--[[-- TouchMenu widget for hierarchical menus. ]] local BD = require("ui/bidi") local Blitbuffer = require("ffi/blitbuffer") local Button = require("ui/widget/button") local CenterContainer = require("ui/widget/container/centercontainer") local CheckMark = require("ui/widget/checkmark") local Device = require("device") local Event = require("ui/event") local FocusManager = require("ui/widget/focusmanager") local Font = require("ui/font") local FrameContainer = require("ui/widget/container/framecontainer") local Geom = require("ui/geometry") local GestureRange = require("ui/gesturerange") local HorizontalGroup = require("ui/widget/horizontalgroup") local HorizontalSpan = require("ui/widget/horizontalspan") local IconButton = require("ui/widget/iconbutton") local InfoMessage = require("ui/widget/infomessage") local InputContainer = require("ui/widget/container/inputcontainer") local LeftContainer = require("ui/widget/container/leftcontainer") local LineWidget = require("ui/widget/linewidget") local RadioMark = require("ui/widget/radiomark") local RightContainer = require("ui/widget/container/rightcontainer") local Size = require("ui/size") local TextWidget = require("ui/widget/textwidget") local UIManager = require("ui/uimanager") local UnderlineContainer = require("ui/widget/container/underlinecontainer") local Utf8Proc = require("ffi/utf8proc") local VerticalGroup = require("ui/widget/verticalgroup") local VerticalSpan = require("ui/widget/verticalspan") local datetime = require("datetime") local getMenuText = require("ui/widget/menu").getMenuText local _ = require("gettext") local ffiUtil = require("ffi/util") local util = require("util") local T = ffiUtil.template local Input = Device.input local Screen = Device.screen local DGENERIC_ICON_SIZE = G_defaults:readSetting("DGENERIC_ICON_SIZE") --[[ TouchMenuItem widget --]] local TouchMenuItem = InputContainer:extend{ menu = nil, vertical_align = "center", item = nil, dimen = nil, face = Font:getFace("smallinfofont"), show_parent = nil, } function TouchMenuItem:init() self.ges_events = { TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen, }, }, HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen, }, }, } local item_enabled = self.item.enabled if self.item.enabled_func then item_enabled = self.item.enabled_func() end local item_checkable = false local item_checked = self.item.checked if self.item.checked_func then item_checkable = true item_checked = self.item.checked_func() end local checkmark_widget if self.item.radio then checkmark_widget = RadioMark:new{ checkable = item_checkable, checked = item_checked, enabled = item_enabled, } else checkmark_widget = CheckMark:new{ checkable = item_checkable, checked = item_checked, enabled = item_enabled, } end local checked_widget = CheckMark:new{ -- for layout, to :getSize() checked = true, } self.checkmark_tap_width = checked_widget:getSize().w + 2*Size.padding.default -- text_max_width should be the TouchMenuItem width minus the below -- FrameContainer default paddings minus the checked widget width local text_max_width = self.dimen.w - 2*Size.padding.default - checked_widget:getSize().w local text = getMenuText(self.item) local face = self.face local forced_baseline, forced_height if self.item.font_func then -- A font_func() may be provided by ReaderFont to have each font name -- displayed in its own font: we must tell TextWidget to use the default -- font baseline and height for items to be correctly aligned without -- variations due to each font different metrics. face = self.item.font_func(self.face.orig_size) if face then local w = TextWidget:new{ text = "", face = self.face } forced_baseline = w:getBaseline() forced_height = w:getSize().h w:free() else face = self.face end end local text_widget = TextWidget:new{ text = text, max_width = text_max_width, fgcolor = item_enabled ~= false and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_DARK_GRAY, face = face, forced_baseline = forced_baseline, forced_height = forced_height, } self.text_truncated = text_widget:isTruncated() self.item_frame = FrameContainer:new{ width = self.dimen.w, bordersize = 0, color = Blitbuffer.COLOR_BLACK, HorizontalGroup:new { align = "center", CenterContainer:new{ dimen = Geom:new{ w = checked_widget:getSize().w }, checkmark_widget, }, text_widget, }, } self._underline_container = UnderlineContainer:new{ vertical_align = "center", dimen = self.dimen:copy(), self.item_frame, } self[1] = self._underline_container function self:isEnabled() return item_enabled ~= false and true end end function TouchMenuItem:onFocus() self._underline_container.color = Blitbuffer.COLOR_BLACK return true end function TouchMenuItem:onUnfocus() self._underline_container.color = Blitbuffer.COLOR_WHITE return true end function TouchMenuItem:onTapSelect(arg, ges) local enabled = self.item.enabled if self.item.enabled_func then enabled = self.item.enabled_func() end if enabled == false then return true end -- don't propagate local tap_on_checkmark = false if ges and ges.pos and ges.pos.x then local tap_x = BD.mirroredUILayout() and self.dimen.w - ges.pos.x - 1 or ges.pos.x if tap_x <= self.checkmark_tap_width then tap_on_checkmark = true end end -- If the menu hasn't actually been drawn yet, don't do anything (as it's confusing, and the coordinates may be wrong). if not self.item_frame.dimen then return true end if G_reader_settings:isFalse("flash_ui") then self.menu:onMenuSelect(self.item, tap_on_checkmark) else -- c.f., ui/widget/iconbutton for the canonical documentation about the flash_ui code flow -- The item frame's width stops at the text width, but we want it to match the menu's length instead local highlight_dimen = self.item_frame.dimen highlight_dimen.w = self.item_frame.width -- Highlight -- self.item_frame.invert = true UIManager:widgetInvert(self.item_frame, highlight_dimen.x, highlight_dimen.y, highlight_dimen.w) UIManager:setDirty(nil, "fast", highlight_dimen) UIManager:forceRePaint() UIManager:yieldToEPDC() -- Unhighlight -- self.item_frame.invert = false -- NOTE: If the menu is going to be closed, we can safely drop that. if self.item.keep_menu_open then UIManager:widgetInvert(self.item_frame, highlight_dimen.x, highlight_dimen.y, highlight_dimen.w) UIManager:setDirty(nil, "ui", highlight_dimen) end -- Callback -- self.menu:onMenuSelect(self.item, tap_on_checkmark) UIManager:forceRePaint() end return true end function TouchMenuItem:onHoldSelect(arg, ges) local enabled = self.item.enabled if self.item.enabled_func then enabled = self.item.enabled_func() end if enabled == false then -- Allow help_text to be displayed even if menu item disabled if self.item.help_text or type(self.item.help_text_func) == "function" then local help_text = self.item.help_text if self.item.help_text_func then help_text = self.item.help_text_func(self) end if help_text then UIManager:show(InfoMessage:new{ text = help_text, }) end end return true -- don't propagate end if not self.item_frame.dimen then return true end if G_reader_settings:isFalse("flash_ui") then self.menu:onMenuHold(self.item, self.text_truncated) else -- c.f., ui/widget/iconbutton for the canonical documentation about the flash_ui code flow -- The item frame's width stops at the text width, but we want it to match the menu's length instead local highlight_dimen = self.item_frame.dimen highlight_dimen.w = self.item_frame.width -- Highlight -- self.item_frame.invert = true UIManager:widgetInvert(self.item_frame, highlight_dimen.x, highlight_dimen.y, highlight_dimen.w) UIManager:setDirty(nil, "fast", highlight_dimen) UIManager:forceRePaint() UIManager:yieldToEPDC() -- Unhighlight -- self.item_frame.invert = false -- NOTE: If the menu is going to be closed, we can safely drop that. -- (This field defaults to nil, meaning keep the menu open, hence the negated test) if self.item.hold_keep_menu_open ~= false then UIManager:widgetInvert(self.item_frame, highlight_dimen.x, highlight_dimen.y, highlight_dimen.w) UIManager:setDirty(nil, "ui", highlight_dimen) end -- Callback -- self.menu:onMenuHold(self.item, self.text_truncated) UIManager:forceRePaint() end return true end --[[ TouchMenuBar widget --]] local TouchMenuBar = InputContainer:extend{ width = Screen:getWidth(), icons = nil, -- array, mandatory -- touch menu that holds the bar, used for trigger repaint on icons show_parent = nil, menu = nil, } function TouchMenuBar:init() local icon_sep_width = Size.span.vertical_default local icons_sep_width = icon_sep_width * (#self.icons + 1) -- we assume all icons are of the same width local icon_width = Screen:scaleBySize(DGENERIC_ICON_SIZE) local icon_height = icon_width -- content_width is the width of all the icon images local content_width = icon_width * #self.icons + icons_sep_width local spacing_width = (self.width - content_width)/(#self.icons*2) local icon_padding = math.min(spacing_width, Screen:scaleBySize(16)) self.height = icon_height + 2*Size.padding.default self.show_parent = self.show_parent or self self.bar_icon_group = HorizontalGroup:new{} -- build up image widget for menu icon bar self.icon_widgets = {} -- hold icon seperators self.icon_seps = {} -- the start_seg for first icon_widget should be 0 -- we asign negative here to offset it in the loop local start_seg = -icon_sep_width local end_seg = start_seg -- self.width is the screen width -- content_width is the width of all the icon images -- (2 * icon_padding * #self.icons) is the combined width of icons paddings local stretch_width = self.width - content_width - (2 * icon_padding * #self.icons) + icon_sep_width for k, v in ipairs(self.icons) do local ib = IconButton:new{ show_parent = self.show_parent, icon = v, width = icon_width, height = icon_height, callback = nil, padding_left = icon_padding, padding_right = icon_padding, menu = self.menu, } table.insert(self.icon_widgets, ib) table.insert(self.menu.layout, ib) -- for the focusmanager -- we have to use local variable here for closure callback local _start_seg = end_seg + icon_sep_width local _end_seg = _start_seg + self.icon_widgets[k]:getSize().w end_seg = _end_seg -- for next loop _start_seg if BD.mirroredUILayout() then _start_seg, _end_seg = self.width - _end_seg, self.width - _start_seg end if k == 1 then self.bar_sep = LineWidget:new{ dimen = Geom:new{ w = self.width, h = Size.line.thick, }, empty_segments = { { s = _start_seg, e = _end_seg } }, } end local icon_sep = LineWidget:new{ style = k == 1 and "solid" or "none", dimen = Geom:new{ w = icon_sep_width, h = self.height, } } -- no separator on the right if k < #self.icons then table.insert(self.icon_seps, icon_sep) end -- callback to set visual style ib.callback = function() self.bar_sep.empty_segments = { { s = _start_seg, e = _end_seg } } for i, sep in ipairs(self.icon_seps) do local current_icon, last_icon if k == #self.icons then current_icon = false last_icon = i == k else current_icon = i == k - 1 or i == k last_icon = false end -- if the active icon is the last icon then the empty bar segment has -- to move over to the right by the width of a separator and the stretch width if last_icon then local _start_last_seg = icon_sep_width + stretch_width + _start_seg local _end_last_seg = icon_sep_width + stretch_width + _end_seg if BD.mirroredUILayout() then _start_last_seg = _start_seg - icon_sep_width - stretch_width _end_last_seg = _end_seg - icon_sep_width - stretch_width end self.bar_sep.empty_segments = { { s = _start_last_seg, e = _end_last_seg } } sep.style = "solid" -- regular behavior else sep.style = current_icon and "solid" or "none" end end self.menu:switchMenuTab(k) end table.insert(self.bar_icon_group, self.icon_widgets[k]) table.insert(self.bar_icon_group, icon_sep) -- if we're at the before-last icon, add an extra span and the final separator if k == #self.icons - 1 then table.insert(self.bar_icon_group, HorizontalSpan:new{ width = stretch_width }) -- need to create a new LineWidget otherwise it's just a reference to the same instance local icon_sep_duplicate = LineWidget:new{ style = "none", dimen = Geom:new{ w = icon_sep_width, h = self.height, } } table.insert(self.icon_seps, icon_sep_duplicate) table.insert(self.bar_icon_group, icon_sep_duplicate) end end self[1] = FrameContainer:new{ bordersize = 0, padding = 0, VerticalGroup:new{ align = "left", -- bar icons self.bar_icon_group, -- horizontal separate line self.bar_sep }, } self.dimen = Geom:new{ x = 0, y = 0, w = self.width, h = self.height } end function TouchMenuBar:switchToTab(index) -- a little safety check -- don't auto-activate a non-existent index if index > #self.icon_widgets then index = #self.icon_widgets end if self.menu.tab_item_table[index] and self.menu.tab_item_table[index].remember == false then -- Don't auto-activate those that should not be -- remembered (FM plus menu on non-touch devices) index = 1 end self.icon_widgets[index].callback() end --[[ TouchMenu widget for hierarchical menus --]] local TouchMenu = FocusManager:extend{ tab_item_table = nil, -- mandatory -- for returning in multi-level menus item_table_stack = nil, parent_id = nil, item_table = nil, item_height = Size.item.height_large, bordersize = Size.border.window, padding = Size.padding.default, -- (not used at top) fface = Font:getFace("ffont"), width = nil, height = nil, page = 1, max_per_page_default = 10, -- for UIManager:setDirty show_parent = nil, cur_tab = -1, close_callback = nil, is_fresh = true, } function TouchMenu:init() -- We won't include self.bordersize in our width calculations, so that -- borders are pushed off-(screen-)width and so not visible. -- We'll then be similar to bottom menu ConfigDialog (where this -- nice effect is caused by some width calculations bug). if not self.dimen then self.dimen = Geom:new() end self.show_parent = self.show_parent or self if not self.close_callback then self.close_callback = function() UIManager:close(self.show_parent) end end self.layout = {} self.ges_events.TapCloseAllMenus = { GestureRange:new{ ges = "tap", range = Geom:new{ x = 0, y = 0, w = Screen:getWidth(), h = Screen:getHeight(), } } } self.ges_events.Swipe = { GestureRange:new{ ges = "swipe", range = self.dimen, } } self.key_events.Back = { { Input.group.Back } } if Device:hasFewKeys() then self.key_events.Back = { { "Left" } } end self.key_events.NextPage = { { Input.group.PgFwd } } self.key_events.PrevPage = { { Input.group.PgBack } } local icons = {} for _, v in ipairs(self.tab_item_table) do table.insert(icons, v.icon) end self.bar = TouchMenuBar:new{ width = self.width, -- will impose width and push left and right borders offscreen icons = icons, show_parent = self.show_parent, menu = self, } self.item_group = VerticalGroup:new{ align = "center", } -- group for page info local chevron_left = "chevron.left" local chevron_right = "chevron.right" if BD.mirroredUILayout() then chevron_left, chevron_right = chevron_right, chevron_left end self.page_info_left_chev = Button:new{ icon = chevron_left, callback = function() self:onPrevPage() end, hold_callback = function() self:onFirstPage() end, bordersize = 0, show_parent = self.show_parent, } self.page_info_right_chev = Button:new{ icon = chevron_right, callback = function() self:onNextPage() end, hold_callback = function() self:onLastPage() end, bordersize = 0, show_parent = self.show_parent, } self.page_info_left_chev:hide() self.page_info_right_chev:hide() self.page_info_text = TextWidget:new{ text = "", face = self.fface, } self.page_info = HorizontalGroup:new{ self.page_info_left_chev, self.page_info_text, self.page_info_right_chev } -- group for device info self.time_info = Button:new{ text = "", face = self.fface, text_font_bold = false, callback = function() UIManager:show(InfoMessage:new{ text = datetime.secondsToDateTime(nil, nil, true), }) end, hold_callback = function() UIManager:broadcastEvent(Event:new("ShowBatteryStatistics")) end, bordersize = 0, show_parent = self.show_parent, } self.device_info = HorizontalGroup:new{ self.time_info, -- Add some span to balance up_button image included padding HorizontalSpan:new{width = Size.span.horizontal_default}, } local footer_width = self.width - self.padding*2 local up_button = IconButton:new{ icon = "chevron.up", show_parent = self.show_parent, padding_left = math.floor(footer_width*0.33*0.1), padding_right = math.floor(footer_width*0.33*0.1), callback = function() self:backToUpperMenu() end, } local footer_height = up_button:getSize().h + Size.line.thick self.footer = HorizontalGroup:new{ LeftContainer:new{ dimen = Geom:new{ w = math.floor(footer_width*0.33), h = footer_height}, up_button, }, CenterContainer:new{ dimen = Geom:new{ w = math.floor(footer_width*0.33), h = footer_height}, self.page_info, }, RightContainer:new{ dimen = Geom:new{ w = math.floor(footer_width*0.33), h = footer_height}, self.device_info, } } self.menu_frame = FrameContainer:new{ padding = self.padding, padding_top = 0, -- ensured by TouchMenuBar bordersize = self.bordersize, background = Blitbuffer.COLOR_WHITE, -- menubar and footer will be inserted in -- item_group in updateItems self.item_group, } -- This CenterContainer will make the left and right borders drawn -- off-screen self[1] = CenterContainer:new{ dimen = Screen:getSize(), ignore = "height", self.menu_frame } self.item_width = self.width - self.padding*2 self.split_line = HorizontalGroup:new{ -- pad with 10 pixel to align with the up arrow in footer HorizontalSpan:new{width = Size.span.horizontal_default}, LineWidget:new{ background = Blitbuffer.COLOR_GRAY, dimen = Geom:new{ w = self.item_width - 2*Size.span.horizontal_default, h = Size.line.medium, } }, HorizontalSpan:new{width = Size.span.horizontal_default}, } self.footer_top_margin = VerticalSpan:new{width = Size.span.vertical_default} self.bar:switchToTab(self.last_index or 1) end function TouchMenu:onCloseWidget() -- NOTE: We don't pass a region in order to ensure a full-screen flash to avoid ghosting, -- but we only need to do that if we actually have a FM or RD below us. -- Don't do anything when we're switching between the two, or if we don't actually have a live instance of 'em... local FileManager = require("apps/filemanager/filemanager") local ReaderUI = require("apps/reader/readerui") if (FileManager.instance and not FileManager.instance.tearing_down) or (ReaderUI.instance and not ReaderUI.instance.tearing_down) then UIManager:setDirty(nil, "flashui") end end function TouchMenu:_recalculatePageLayout() local content_height -- content == item_list + footer local bar_height = self.bar:getSize().h local footer_height = self.footer:getSize().h if self.height then content_height = self.height - bar_height else content_height = #self.item_table * self.item_height + footer_height -- split line height content_height = content_height + (#self.item_table - 1) content_height = content_height + self.footer_top_margin:getSize().h end if content_height + bar_height > Screen:getHeight() then content_height = Screen:getHeight() - bar_height end local item_list_content_height = content_height - footer_height self.perpage = math.floor(item_list_content_height / self.item_height) local max_per_page = self.item_table.max_per_page or self.max_per_page_default if self.perpage > max_per_page then self.perpage = max_per_page end self.page_num = math.ceil(#self.item_table / self.perpage) end function TouchMenu:updateItems() local old_dimen = self.dimen and self.dimen:copy() self:_recalculatePageLayout() self.item_group:clear() self.layout = {} table.insert(self.item_group, self.bar) table.insert(self.layout, self.bar.icon_widgets) -- for the focusmanager for c = 1, self.perpage do -- calculate index in item_table local i = (self.page - 1) * self.perpage + c if i <= #self.item_table then local item = self.item_table[i] local item_tmp = TouchMenuItem:new{ item = item, menu = self, dimen = Geom:new{ w = self.item_width, h = self.item_height, }, show_parent = self.show_parent, item_visible_index = c, } table.insert(self.item_group, item_tmp) if item_tmp:isEnabled() then table.insert(self.layout, {[self.cur_tab] = item_tmp}) -- for the focusmanager end if item.separator and c ~= self.perpage and i ~= #self.item_table then -- insert split line table.insert(self.item_group, self.split_line) end else -- item not enough to fill the whole page, break out of loop break end -- if i <= self.items end -- for c=1, self.perpage table.insert(self.item_group, self.footer_top_margin) table.insert(self.item_group, self.footer) if self.page_num > 1 then -- @translators %1 is the current page. %2 is the total number of pages. In some languages a good translation might need to reverse this order, for instance: "Total %2, page %1". self.page_info_text:setText(T(_("Page %1 of %2"), self.page, self.page_num)) else self.page_info_text:setText("") end self.page_info_left_chev:showHide(self.page_num > 1) self.page_info_right_chev:showHide(self.page_num > 1) self.page_info_left_chev:enableDisable(self.page > 1) self.page_info_right_chev:enableDisable(self.page < self.page_num) local time_info_txt = datetime.secondsToHour(os.time(), G_reader_settings:isTrue("twelve_hour_clock")) local powerd = Device:getPowerDevice() if Device:hasBattery() then local batt_lvl = powerd:getCapacity() local batt_symbol = powerd:getBatterySymbol(powerd:isCharged(), powerd:isCharging(), batt_lvl) time_info_txt = BD.wrap(time_info_txt) .. " " .. BD.wrap("⌁") .. BD.wrap(batt_symbol) .. BD.wrap(batt_lvl .. "%") if Device:hasAuxBattery() and powerd:isAuxBatteryConnected() then local aux_batt_lvl = powerd:getAuxCapacity() local aux_batt_symbol = powerd:getBatterySymbol(powerd:isAuxCharged(), powerd:isAuxCharging(), aux_batt_lvl) time_info_txt = time_info_txt .. " " .. BD.wrap("+") .. BD.wrap(aux_batt_symbol) .. BD.wrap(aux_batt_lvl .. "%") end end self.time_info:setText(time_info_txt) -- recalculate dimen based on new layout self.dimen.w = self.width self.dimen.h = self.item_group:getSize().h + self.bordersize*2 + self.padding -- (no padding at top) self:moveFocusTo(self.cur_tab, 1, FocusManager.NOT_FOCUS) -- reset the position of the focusmanager -- NOTE: We use a slightly ugly hack to detect a brand new menu vs. a tab switch, -- in order to optionally flash on initial menu popup... -- NOTE: Also avoid repainting what's underneath us on initial popup. -- NOTE: And we also only need to repaint what's behind us when switching to a smaller menu... local keep_bg = old_dimen and self.dimen.h >= old_dimen.h UIManager:setDirty((self.is_fresh or keep_bg) and self.show_parent or "all", function() local refresh_dimen = old_dimen and old_dimen:combine(self.dimen) or self.dimen local refresh_type = "ui" if self.is_fresh then refresh_type = "flashui" -- Drop the region, too, to make it full-screen? May help when starting from a "small" menu. --refresh_dimen = nil self.is_fresh = false end return refresh_type, refresh_dimen end) end function TouchMenu:switchMenuTab(tab_num) if self.tab_item_table[tab_num].remember ~= false then self.last_index = tab_num end if self.touch_menu_callback then self.touch_menu_callback() end if self.tab_item_table[tab_num].callback then self.tab_item_table[tab_num].callback() end -- It's like getting a new menu everytime we switch tab! -- Also, switching to the _same_ tab resets the stack and takes us back to -- the top of the menu tree self.page = 1 -- clear item table stack self.item_table_stack = {} self.parent_id = nil self.cur_tab = tab_num self.item_table = self.tab_item_table[tab_num] self:updateItems() end function TouchMenu:backToUpperMenu(no_close) if #self.item_table_stack ~= 0 then self.item_table = table.remove(self.item_table_stack) -- Allow a menu table to refresh itself when going up (ie. from a setting -- submenu that may want to have its parent menu updated). if self.item_table.needs_refresh and self.item_table.refresh_func then self.item_table = self.item_table.refresh_func() end self.page = 1 if self.parent_id then self:_recalculatePageLayout() -- we need an accurate self.perpage for i = 1, #self.item_table do if self.item_table[i].menu_item_id == self.parent_id then self.page = math.floor( (i - 1) / self.perpage ) + 1 break end end self.parent_id = nil end self:updateItems() elseif not no_close then self:closeMenu() end end function TouchMenu:closeMenu() self.close_callback() end function TouchMenu:onNextPage() if self.page < self.page_num then self.page = self.page + 1 elseif self.page == self.page_num then self.page = 1 end self:updateItems() return true end function TouchMenu:onPrevPage() if self.page > 1 then self.page = self.page - 1 elseif self.page == 1 then self.page = self.page_num end self:updateItems() return true end function TouchMenu:onFirstPage() self.page = 1 self:updateItems() return true end function TouchMenu:onLastPage() self.page = self.page_num self:updateItems() return true end function TouchMenu:onGotoPage(nb) if nb > self.page_num then self.page = self.page_num elseif nb < 1 then self.page = 1 else self.page = nb end self:updateItems() return true end function TouchMenu:onSwipe(arg, ges_ev) local direction = BD.flipDirectionIfMirroredUILayout(ges_ev.direction) if direction == "west" then self:onNextPage() elseif direction == "east" then self:onPrevPage() elseif direction == "north" then self:closeMenu() elseif direction == "south" then -- We don't allow the menu to be closed (this is also necessary as -- a swipe south will be emitted when done opening the menu with -- swipe, as the event handled for that is pan south). self:backToUpperMenu(true) end end function TouchMenu:onMenuSelect(item, tap_on_checkmark) if self.touch_menu_callback then self.touch_menu_callback() end if tap_on_checkmark and item.checkmark_callback then item.checkmark_callback() self:updateItems() return true end if item.tap_input or type(item.tap_input_func) == "function" then if not item.keep_menu_open then self:closeMenu() end if item.tap_input then self:onInput(item.tap_input) else self:onInput(item.tap_input_func()) end else local sub_item_table = item.sub_item_table if item.sub_item_table_func then sub_item_table = item.sub_item_table_func() end if sub_item_table == nil then -- keep menu opened if this item is a check option local callback, refresh = item.callback, item.checked or item.checked_func if item.callback_func then callback = item.callback_func() end if callback then -- Provide callback with us, so it can call our -- closemenu() or updateItems() when it sees fit -- (if not providing checked or checked_func, caller -- must set keep_menu_open=true if that is wished) callback(self) if refresh then self:updateItems() elseif not item.keep_menu_open then self:closeMenu() end end else table.insert(self.item_table_stack, self.item_table) self.parent_id = item.menu_item_id self.item_table = sub_item_table self.page = 1 if self.item_table.open_on_menu_item_id_func then self:_recalculatePageLayout() -- we need an accurate self.perpage local open_id = self.item_table.open_on_menu_item_id_func() for i = 1, #self.item_table do if self.item_table[i].menu_item_id == open_id then self.page = math.floor( (i - 1) / self.perpage ) + 1 break end end end self:updateItems() end end return true end function TouchMenu:onMenuHold(item, text_truncated) if self.touch_menu_callback then self.touch_menu_callback() end if item.hold_input or type(item.hold_input_func) == "function" then if item.hold_keep_menu_open == false then self:closeMenu() end if item.hold_input then self:onInput(item.hold_input) else self:onInput(item.hold_input_func()) end elseif item.hold_callback or type(item.hold_callback_func) == "function" then local callback = item.hold_callback if item.hold_callback_func then callback = item.hold_callback_func() end if callback then -- With hold, the default is to keep menu open, as we're -- most often showing a ConfirmBox that can be cancelled -- (provide hold_keep_menu_open=false to override) if item.hold_keep_menu_open == false then self:closeMenu() end -- Provide callback with us, so it can call our -- closemenu() or updateItems() when it sees fit callback(self) end elseif item.help_text or type(item.help_text_func) == "function" then local help_text = item.help_text if item.help_text_func then help_text = item.help_text_func(self) end if help_text then UIManager:show(InfoMessage:new{ text = help_text, }) end elseif text_truncated then UIManager:show(InfoMessage:new{ text = getMenuText(item), show_icon = false, }) end return true end function TouchMenu:onTapCloseAllMenus(arg, ges_ev) if ges_ev.pos:notIntersectWith(self.dimen) then self:closeMenu() end end function TouchMenu:onClose() self:closeMenu() end function TouchMenu:onBack() self:backToUpperMenu() end -- Menu search feature function TouchMenu:search(search_for) local found_menu_items = {} local MAX_MENU_DEPTH = 10 -- our menu max depth is currently 6 local function recurse(item_table, path, text, icon, depth) if item_table.ignored_by_menu_search then return end depth = depth + 1 if depth > MAX_MENU_DEPTH then return end for i, v in ipairs(item_table) do if type(v) == "table" and not v.ignored_by_menu_search then local entry_text = v.text_func and v.text_func() or v.text local indent = text and ((" "):rep(math.min(depth-1, 6)) .. "→ ") or "→ " -- all spaces here are Hair Space U+200A local walk_text = text and (text .. "\n" .. indent .. entry_text) or (indent .. entry_text) local walk_path = path .. "." .. i if Utf8Proc.lowercase(entry_text):find(search_for, 1, true) then table.insert(found_menu_items, {entry_text, icon, walk_path, walk_text}) end local sub_item_table = v.sub_item_table if v.sub_item_table_func then sub_item_table = v.sub_item_table_func() end if sub_item_table and not sub_item_table.ignored_by_menu_search then recurse(sub_item_table, walk_path, walk_text, icon, depth) end end end end -- recurse -- Initial call of recurse, for each tab for i = 1, #self.tab_item_table do recurse(self.tab_item_table[i], i, nil, self.tab_item_table[i].icon, 0) end return found_menu_items end function TouchMenu:openMenu(path, with_animation) local parts = {} for part in util.gsplit(path, "%.", false) do -- path is ie. "2.3.3.1" table.insert(parts, tonumber(part)) end util.arrayReverse(parts) -- so we can just table.remove() and pop them from end local function highlightWidget(widget, unhighlight) if not widget then return end local highlight_dimen = widget.dimen if highlight_dimen.w == 0 then highlight_dimen.w = widget.width end if unhighlight then widget.invert = false UIManager:widgetInvert(widget, highlight_dimen.x, highlight_dimen.y, highlight_dimen.w) UIManager:setDirty(nil, "ui", highlight_dimen) else widget.invert = true UIManager:widgetInvert(widget, highlight_dimen.x, highlight_dimen.y, highlight_dimen.w) UIManager:setDirty(nil, "fast", highlight_dimen) end end -- Steps/state among consecutive calls to walkStep() local STEPS = { START = 0, TARGET_TAB_HIGHLIGHT_ICON = 1, TARGET_TAB_OPEN = 2, TARGET_PAGE_OR_HIGHLIGHT_NEXT_PREV = 3, TARGET_PAGE_OR_NAVIGATE_NEXT_PREV = 4, MENU_ITEM_HIGHLIGHT = 5, -- intermediate or final menu item MENU_ITEM_ENTER = 6, -- intermediate menu item only DONE = 7, } local step = STEPS.START local tab_nb local item_nb local walkStep_scheduled local trap_widget local function walkStep() walkStep_scheduled = false -- Default delay if not overriden (-1 means no scheduleIn() so no refresh, 0 means nextTick) local next_delay = with_animation and 1 or -1 if step == STEPS.START then -- Ensure some initial delay so search dialog and result list can be closed and refreshed next_delay = with_animation and 1 or 0 step = STEPS.TARGET_TAB_HIGHLIGHT_ICON elseif step == STEPS.TARGET_TAB_HIGHLIGHT_ICON then tab_nb = table.remove(parts) if with_animation then highlightWidget(self.bar.icon_widgets[tab_nb].image) end step = STEPS.TARGET_TAB_OPEN elseif step == STEPS.TARGET_TAB_OPEN then -- The tab icon wouldn't be unhighligted by any other action. -- Animation may have been cancelled, so unhighlight if it was. if self.bar.icon_widgets[tab_nb].image.invert then highlightWidget(self.bar.icon_widgets[tab_nb].image, true) end self:switchMenuTab(tab_nb) self.bar:switchToTab(tab_nb) item_nb = table.remove(parts) step = STEPS.TARGET_PAGE_OR_HIGHLIGHT_NEXT_PREV elseif step == STEPS.TARGET_PAGE_OR_HIGHLIGHT_NEXT_PREV or step == STEPS.TARGET_PAGE_OR_NAVIGATE_NEXT_PREV then local target_page = math.floor((item_nb - 1) / self.perpage) + 1 local pages_diff = target_page - self.page if pages_diff == 0 then -- we are on the right menu page step = STEPS.MENU_ITEM_HIGHLIGHT next_delay = -1 -- we paused before, no need for more pause if not with_animation and #parts == 0 then -- Except if no animation and we are on the final menu that -- we want to highlight: this final highlight needs to be -- delayed for it to be drawn after the final menu page is. next_delay = 1 end elseif step == STEPS.TARGET_PAGE_OR_HIGHLIGHT_NEXT_PREV then -- No need to highlight chevrons if no animation if with_animation then if pages_diff > 0 then highlightWidget(self.page_info_right_chev) else highlightWidget(self.page_info_left_chev) end if pages_diff > 1 or pages_diff < -1 then -- Change pages quicker if more than one needed, but slow on the last one next_delay = 0.5 end end step = STEPS.TARGET_PAGE_OR_NAVIGATE_NEXT_PREV else -- STEPS.TARGET_PAGE_OR_NAVIGATE_NEXT_PREV if pages_diff > 0 then self:onNextPage() else self:onPrevPage() end step = STEPS.TARGET_PAGE_OR_HIGHLIGHT_NEXT_PREV if with_animation and (pages_diff > 1 or pages_diff < -1) then -- Change pages quicker if more than one needed, but slow on the last one next_delay = 0.5 end end elseif step == STEPS.MENU_ITEM_HIGHLIGHT then if with_animation or #parts == 0 then -- Even if no animation, highlight the final item (and don't unhighlight it) local item_visible_index = (item_nb - 1) % self.perpage + 1 local item_widget for i, w in ipairs(self.item_group) do if w.item_visible_index == item_visible_index then item_widget = w break end end if item_widget then highlightWidget(item_widget) end end if #parts == 0 then step = STEPS.DONE else step = STEPS.MENU_ITEM_ENTER end elseif step == STEPS.MENU_ITEM_ENTER then self:onMenuSelect(self.item_table[item_nb]) item_nb = table.remove(parts) step = STEPS.TARGET_PAGE_OR_HIGHLIGHT_NEXT_PREV else -- STEPS.DONE if trap_widget then UIManager:close(trap_widget) trap_widget = nil end return end if next_delay >= 0 then walkStep_scheduled = true UIManager:scheduleIn(next_delay, walkStep) else walkStep() end end -- We use an invisible TrapWidget when no animation, so we can -- cancel the delayed final highlight local TrapWidget = require("ui/widget/trapwidget") trap_widget = TrapWidget:new{ text = with_animation and _("Walking you there…") or nil, dismiss_callback = function() trap_widget = nil if walkStep_scheduled then UIManager:unschedule(walkStep) if with_animation then -- continue walking as if no animation, so we immediately -- reach the requested menu item. We need a new invisible -- TrapWidget for the reason explained above in case a -- second tap happens. with_animation = false trap_widget = TrapWidget:new{ text = nil, dismiss_callback = function() trap_widget = nil if walkStep_scheduled then UIManager:unschedule(walkStep) end end, resend_event = true, } UIManager:show(trap_widget) walkStep() end end end, resend_event = not with_animation, -- if not animation, don't eat the tap } UIManager:show(trap_widget) -- catch taps during animaton -- Call it: it will reschedule itself if animation; if not, it will -- just execute itself without pause until done. -- If tap while animating, it will switch to the non-animation -- behaviour, to reach the requested menu item immediately. walkStep() end function TouchMenu:onShowMenuSearch() local InputDialog = require("ui/widget/inputdialog") local ConfirmBox = require("ui/widget/confirmbox") local Menu = require("ui/widget/menu") local function show_search_results(search_string) local found_menu_items = self:search(search_string) local function get_current_search_results() local function open_menu(i, animate) UIManager:close(self.results_menu_container) UIManager:setDirty(nil, "ui") self:openMenu(found_menu_items[i][3], animate) end local function item_callback(i) local confirm_box confirm_box = ConfirmBox:new{ text = found_menu_items[i][4], icon = found_menu_items[i][2], ok_text = _("Open"), ok_callback = function() UIManager:close(confirm_box) open_menu(i) end, other_buttons = {{ { text = _("Walk me there"), callback = function() UIManager:close(confirm_box) open_menu(i, true) end, }, }}, } UIManager:show(confirm_box) end local result_items = {} for i = 1, #found_menu_items do table.insert(result_items, { text = found_menu_items[i][1], callback = function() item_callback(i) end, hold_callback = function() open_menu(i) end, } ) end return result_items end -- get_current_search_results() if #found_menu_items > 0 then local results_menu = Menu:new{ title = _("Search results"), item_table = get_current_search_results(), width = math.floor(Screen:getWidth() * 0.9), height = math.floor(Screen:getHeight() * 0.9), single_line = true, items_per_page = 10, items_font_size = Menu.getItemFontSize(10), onMenuSelect = function(item, pos) if pos.callback then pos.callback() end end, onMenuHold = function(item, pos) if pos.hold_callback then pos.hold_callback() end end, close_callback = function() UIManager:close(self.results_menu_container) end } -- build container self.results_menu_container = CenterContainer:new{ dimen = Screen:getSize(), results_menu, } results_menu.show_parent = self.results_menu_container UIManager:show(self.results_menu_container) else UIManager:show(InfoMessage:new{ text = T(_("No menus containing '%1' found."), search_string), }) end end -- show_search_results() local search_dialog search_dialog = InputDialog:new{ title = _("Search menu entry"), description = _("Search for a menu entry containing the following text (case insensitive)."), input = G_reader_settings:readSetting("menu_search_string", _("Help")), buttons = { { { text = _("Cancel"), id = "close", callback = function() UIManager:close(search_dialog) end, }, { text = _("Search"), is_enter_default = true, callback = function() local search_for = search_dialog:getInputText() search_for = Utf8Proc.lowercase(search_for) G_reader_settings:saveSetting("menu_search_string", search_for) UIManager:close(search_dialog) show_search_results(search_for) end, }, } }, } UIManager:show(search_dialog) search_dialog:onShowKeyboard() end return TouchMenu