--[[-- 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 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 VerticalGroup = require("ui/widget/verticalgroup") local VerticalSpan = require("ui/widget/verticalspan") local util = require("util") local getMenuText = require("ui/widget/menu").getMenuText local _ = require("gettext") local T = require("ffi/util").template local Input = Device.input local Screen = Device.screen --[[ TouchMenuItem widget --]] local TouchMenuItem = InputContainer:new{ 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, }, doc = "Select Menu Item", }, HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen, }, doc = "Hold Menu Item", }, } 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 = CheckMark:new{ checkable = item_checkable, checked = item_checked, enabled = item_enabled, } local checked_widget = CheckMark:new{ -- for layout, to :getSize() checked = true, } -- 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 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, }, 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._underline_container = UnderlineContainer:new{ vertical_align = "center", dimen = self.dimen, 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 end if G_reader_settings:isFalse("flash_ui") then self.menu:onMenuSelect(self.item) else self.item_frame.invert = true UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y) UIManager:setDirty(nil, function() return "fast", self.dimen end) -- yield to main UI loop to invert item UIManager:tickAfterNext(function() self.menu:onMenuSelect(self.item) self.item_frame.invert = false -- NOTE: We can *usually* optimize that repaint away, as most entries in the menu will at least trigger a menu repaint ;). -- But when stuff doesn't repaint the menu and keeps it open, we need to do it. -- Since it's an *un*highlight containing text, we make it "ui" and not "fast", both so it won't mangle text, -- and because "fast" can have some weird side-effects on some devices in this specific instance... if self.item.hold_keep_menu_open or self.item.keep_menu_open then --UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y) UIManager:setDirty(self.show_parent, function() return "ui", self.dimen end) end end) 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 return end if G_reader_settings:isFalse("flash_ui") then self.menu:onMenuHold(self.item) else self.item_frame.invert = true UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y) UIManager:setDirty(nil, function() return "fast", self.dimen end) UIManager:tickAfterNext(function() self.menu:onMenuHold(self.item) end) UIManager:scheduleIn(0.5, function() self.item_frame.invert = false -- NOTE: For some reason, this is finicky (I end up with a solid black bar, i.e., text gets inverted, but not the bg?!) --UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y) UIManager:setDirty(self.show_parent, function() return "ui", self.dimen end) end) end return true end --[[ TouchMenuBar widget --]] local TouchMenuBar = InputContainer:new{ width = Screen:getWidth(), icons = {}, -- 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{ 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:new{ tab_item_table = {}, -- for returning in multi-level menus item_table_stack = 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 = { {"Back"}, doc = "back to upper menu or close touchmenu" } if Device:hasFewKeys() then self.key_events.Back = { {"Left"}, doc = "back to upper menu or close touchmenu" } end self.key_events.NextPage = { {Input.group.PgFwd}, doc = "next page" } self.key_events.PrevPage = { {Input.group.PgBack}, doc = "previous page" } self.key_events.Press = { {"Press"}, doc = "chose selected item" } 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, bordersize = 0, show_parent = self.show_parent, } self.page_info_right_chev = Button:new{ icon = chevron_right, callback = function() self:onNextPage() 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 = TextWidget:new{ text = "", face = self.fface, } 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 UIManager:setDirty(nil, "flashui") 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, } 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 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 = util.secondsToHour(os.time(), G_reader_settings:isTrue("twelve_hour_clock")) local powerd = Device:getPowerDevice() local batt_lvl = powerd:getCapacity() local batt_symbol if powerd:isCharging() then batt_symbol = "" else if batt_lvl >= 100 then batt_symbol = "" elseif batt_lvl >= 90 then batt_symbol = "" elseif batt_lvl >= 80 then batt_symbol = "" elseif batt_lvl >= 70 then batt_symbol = "" elseif batt_lvl >= 60 then batt_symbol = "" elseif batt_lvl >= 50 then batt_symbol = "" elseif batt_lvl >= 40 then batt_symbol = "" elseif batt_lvl >= 30 then batt_symbol = "" elseif batt_lvl >= 20 then batt_symbol = "" elseif batt_lvl >= 10 then batt_symbol = "" else batt_symbol = "" end end if Device:hasBattery() then time_info_txt = BD.wrap(time_info_txt) .. " " .. BD.wrap("⌁") .. BD.wrap(batt_symbol) .. BD.wrap(batt_lvl .. "%") 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.selected = { x = self.cur_tab, y = 1 } -- 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 if self.cur_tab ~= tab_num then -- it's like getting a new menu everytime we switch tab! self.page = 1 -- clear item table stack self.item_table_stack = {} self.cur_tab = tab_num self.item_table = self.tab_item_table[tab_num] self:updateItems() end end function TouchMenu:backToUpperMenu() if #self.item_table_stack ~= 0 then self.item_table = table.remove(self.item_table_stack) self.page = 1 self:updateItems() else 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: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() end end function TouchMenu:onMenuSelect(item) if self.touch_menu_callback then self.touch_menu_callback() 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 -- put stuff in scheduler so we can see -- the effect of inverted menu item UIManager:tickAfterNext(function() -- Provide callback with us, so it can call our -- closemenu() or updateItems() when it sees fit -- (if not providing checked or checked_fund, 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) end else table.insert(self.item_table_stack, self.item_table) self.item_table = sub_item_table self.page = 1 self:updateItems() end end return true end function TouchMenu:onMenuHold(item) 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 UIManager:tickAfterNext(function() -- 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) 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() end if help_text then UIManager:show(InfoMessage:new{ text = help_text, }) end 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 function TouchMenu:onPress() self:getFocusItem():handleEvent(Event:new("TapSelect")) end return TouchMenu