fix(koreader): make the catalog navigable without a touchscreen

Kindle 3 and 4 drive the catalog entirely through KOReader's FocusManager, but none of the
plugin's own widgets answered a Focus event. The cursor moved invisibly, so Press looked
like it activated something at random.

Give every tappable widget a focus box. Draw it as an inner border: these pages lay
themselves out from fixed dimensions, and a cursor that changed a widget's size would
reflow the page under it. Menu marks its own rows with a hairline that upstream declines
to thicken because that shifts the row's text; the same inner border gives those rows a
visible cursor without that cost.

Keep the cursor across the rebuilds these pages do constantly. The dashboard and detail
pages replace their whole widget tree on a page turn, a reroll or a status change, and
Menu's bookkeeping cannot place a cursor on rows of unequal width. Remember the footer by
name rather than by row index: its controls are what cause the rebuild, so it has to
survive even a move to another book. Record a page's cursor on the navigation stack as it
is left, so returning lands where the reader was.

Reach what was unreachable. Menu skips the title bar on Kindle keypads because its own
title bar actions have hardware keys, but the search and refresh icons are this plugin's
and have none. The footer paginator and return arrow join the layout too; on the detail
page those chevrons step through books, which no key covers. Back walks up a level and
closes only at the root, the rule Menu already applies to its own stack. The detail header
walks vertically like the stack it draws, instead of eleven Right presses to reach
Download, and opens on the button the page was opened for.

Two of the fixes here are not specific to the D-Pad. The search and refresh icons were
missing from every section page for all users: TitleBar clears its children and re-inits
whenever a shrink-to-fit title changes, and a run-once guard stopped them being rebuilt.
And a footer chevron hidden while focused kept an inverted frame, painting as a black
block.

Closes #880
This commit is contained in:
neon
2026-08-16 20:27:22 +00:00
parent eba1640df4
commit 21b7eb1e18
9 changed files with 1296 additions and 53 deletions
@@ -49,6 +49,7 @@ local CatalogDownload = require("bookorbit_catalog_download")
local CatalogBulkDownload = require("bookorbit_catalog_bulk_download")
local CatalogDashboard = require("bookorbit_catalog_dashboard")
local CatalogDetail = require("bookorbit_catalog_detail")
local CatalogFocus = require("bookorbit_catalog_focus")
local CatalogThumbnails = require("bookorbit_catalog_thumbnails")
local Screen = Device.screen
@@ -139,10 +140,28 @@ local BookOrbitTitleBar = TitleBar:extend{
refresh_icon_enabled = true,
}
-- Drops any previously appended buttons from the OverlapGroup, so init can
-- rebuild them without leaving a stale copy behind.
function BookOrbitTitleBar:_clearExtraButtons()
for index = #self, 1, -1 do
if self[index] == self.search_button or self[index] == self.refresh_button then
table.remove(self, index)
end
end
self.search_button = nil
self.refresh_button = nil
end
function BookOrbitTitleBar:init()
TitleBar.init(self)
if self._bookorbit_extra_buttons then return end
self._bookorbit_extra_buttons = true
-- These are appended after TitleBar has built its own children, so they only
-- survive as long as that child list does. TitleBar:setTitle re-inits the
-- whole bar when the title can shrink to fit - which this catalog asks for -
-- and clears the group on the way, taking the appended buttons with it.
-- Rebuilding them on every init is what keeps them on screen; skipping the
-- rebuild left the *fields* pointing at buttons that were no longer drawn,
-- so the D-Pad walked onto controls that were not there.
self:_clearExtraButtons()
local icon_size = Screen:scaleBySize(DGENERIC_ICON_SIZE * (self.left_icon_size_ratio or 0.6))
local button_padding = self.button_padding or 0
@@ -194,21 +213,24 @@ function BookOrbitTitleBar:init()
OverlapGroup.init(self)
end
local TITLE_BAR_BUTTONS = { "left_button", "search_button", "refresh_button", "right_button" }
-- The two this plugin appends itself, and so the two that can go missing.
local TITLE_BAR_APPENDED = { search_button = true, refresh_button = true }
function BookOrbitTitleBar:_drawnButtons()
return CatalogFocus.drawnTitleBarButtons(self, TITLE_BAR_BUTTONS, TITLE_BAR_APPENDED)
end
function BookOrbitTitleBar:generateHorizontalLayout()
local row = {}
if self.left_button then table.insert(row, self.left_button) end
if self.search_button then table.insert(row, self.search_button) end
if self.refresh_button then table.insert(row, self.refresh_button) end
if self.right_button then table.insert(row, self.right_button) end
local row = self:_drawnButtons()
return #row > 0 and { row } or {}
end
function BookOrbitTitleBar:generateVerticalLayout()
local layout = {}
if self.left_button then table.insert(layout, { self.left_button }) end
if self.search_button then table.insert(layout, { self.search_button }) end
if self.refresh_button then table.insert(layout, { self.refresh_button }) end
if self.right_button then table.insert(layout, { self.right_button }) end
for _, button in ipairs(self:_drawnButtons()) do
table.insert(layout, { button })
end
return layout
end
@@ -222,6 +244,7 @@ CatalogDownload.install(BookOrbitCatalog)
CatalogBulkDownload.install(BookOrbitCatalog)
CatalogDashboard.install(BookOrbitCatalog)
CatalogDetail.install(BookOrbitCatalog)
CatalogFocus.install(BookOrbitCatalog)
CatalogThumbnails.install(BookOrbitCatalog)
local Menu_recalculateDimen = Menu._recalculateDimen
@@ -231,6 +254,7 @@ local Menu_onNextPage = Menu.onNextPage
local Menu_onPrevPage = Menu.onPrevPage
local Menu_onFirstPage = Menu.onFirstPage
local Menu_onLastPage = Menu.onLastPage
local Menu_onClose = Menu.onClose
function BookOrbitCatalog:init()
self.client = BookOrbitApi.new(self.api)
@@ -739,6 +763,9 @@ function BookOrbitCatalog:switchTo(title, item_table, context, push)
subtitle = self.current_context.subtitle,
item_table = self.item_table,
context = self.current_context,
-- Where the cursor was when this page was left, so returning to it
-- lands back on the row the reader was working in.
focus = self:captureFocus(),
})
end
self:updateReturnPath()
@@ -1957,13 +1984,28 @@ function BookOrbitCatalog:updateItems(select_number, no_recalculate_dimen)
elseif self:detailMode() then
return self:updateDetailItems(select_number, no_recalculate_dimen)
end
return Menu_updateItems(self, select_number, no_recalculate_dimen)
-- A plain Menu page does not go through prepareCustomUpdate, so drop the
-- remembered cursor here: coming back to the dashboard later must not
-- restore coordinates that belong to this list.
-- Menu builds its own layout and merges the title bar; the footer, and
-- putting the cursor back on it, are ours. saveCustomFocus also clears the
-- remembered page key, so returning to the dashboard later cannot restore
-- coordinates that belong to this list.
self:saveCustomFocus()
local result = Menu_updateItems(self, select_number, no_recalculate_dimen)
self:makeMenuItemsFocusable()
local footer_row = self:footerFocusRow()
if footer_row then table.insert(self.layout, footer_row) end
self:restoreCustomFocus()
self:refocusCurrentRow()
return result
end
function BookOrbitCatalog:prepareCustomUpdate(no_recalculate_dimen)
local old_dimen = self.dimen and self.dimen:copy()
local context = self.current_context or {}
self:resetTitleBar(context.title or self.title, context.subtitle or "")
self:saveCustomFocus()
self.layout = {}
self.item_group:clear()
self.page_info:resetLayout()
@@ -1975,7 +2017,11 @@ end
function BookOrbitCatalog:finishCustomUpdate(old_dimen, select_number)
self:updatePageInfo(select_number)
Menu.mergeTitleBarIntoLayout(self)
self:mergeTitleBarIntoLayout()
-- After the title bar, so the footer stays the last row the cursor reaches.
local footer_row = self:footerFocusRow()
if footer_row then table.insert(self.layout, footer_row) end
self:restoreCustomFocus()
-- A view may narrow the e-ink refresh to the region it actually changed
-- (the dashboard does for row page turns); everything else refreshes the
-- whole menu as before.
@@ -2310,11 +2356,21 @@ function BookOrbitCatalog:onMenuHoldSelect(item)
return self:onMenuSelect(item)
end
-- Back walks up the browsing stack on a device that cannot reach the footer's
-- return arrow; see CatalogFocus:shouldReturnOnBack for why.
function BookOrbitCatalog:onClose()
if self:shouldReturnOnBack() then
return self:onReturn()
end
return Menu_onClose(self)
end
function BookOrbitCatalog:onReturn()
self:cancelThumbnailJobs()
local previous = table.remove(self.stack)
local dirty = false
if previous then
self:restoreFocusOnNextUpdate(previous.focus)
self.current_context = previous.context
dirty = self.current_context.dirty == true
self.current_context.dirty = nil
@@ -468,6 +468,10 @@ function CatalogDashboard:addDashboardHeader(text, controls, focus_controls)
self:addDashboardInset(CatalogWidgets.buildDashboardSectionHeader(text, self.content_w, right))
local focus_row = focus_controls or controls
if focus_row and #focus_row > 0 then
-- Paging chevrons and the reroll are chrome for the shelf below them, so
-- a D-Pad cursor opening the page skips past to the books themselves
-- rather than starting on a chevron that is disabled on page one.
focus_row.is_chrome = true
table.insert(self.layout, focus_row)
end
end
@@ -134,11 +134,11 @@ function DetailDescriptionWidget:init()
}
end
self[1] = HorizontalGroup:new{
self[1] = CatalogWidgets.focusable(HorizontalGroup:new{
HorizontalSpan:new{ width = DETAIL_INSET },
box,
HorizontalSpan:new{ width = DETAIL_INSET },
}
})
end
function DetailDescriptionWidget:onTapSelect()
@@ -184,11 +184,11 @@ function DetailOverviewWidget:init()
}
end
self[1] = HorizontalGroup:new{
self[1] = CatalogWidgets.focusable(HorizontalGroup:new{
HorizontalSpan:new{ width = DETAIL_INSET },
box,
HorizontalSpan:new{ width = DETAIL_INSET },
}
})
end
function DetailOverviewWidget:onTapSelect()
@@ -212,7 +212,7 @@ function DetailPillWidget:init()
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = dimen } },
}
self[1] = pill
self[1] = CatalogWidgets.focusable(pill)
end
function DetailPillWidget:onTapSelect()
@@ -244,7 +244,7 @@ function DetailTapLineWidget:init()
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = dimen } },
}
self[1] = box
self[1] = CatalogWidgets.focusable(box)
end
function DetailTapLineWidget:onTapSelect()
@@ -1169,6 +1169,7 @@ function CatalogDetail:buildDetailPillRow(items, width, title, more_button_key)
local row = HorizontalGroup:new{ align = "center" }
local used = 0
local shown = 0
local first_pill
if more_button_key then self[more_button_key] = nil end
for i = 1, #items do
@@ -1199,6 +1200,7 @@ function CatalogDetail:buildDetailPillRow(items, width, title, more_button_key)
used = used + DETAIL_GAP_S
end
table.insert(row, pill)
first_pill = first_pill or pill
used = used + pill_w
shown = shown + 1
end
@@ -1218,6 +1220,13 @@ function CatalogDetail:buildDetailPillRow(items, width, title, more_button_key)
if more_button_key then self[more_button_key] = more end
end
end
-- Every pill opens the same full list, so only one of them needs to be in the
-- focus layout, and the overflow "+N" pill is the natural pick. When nothing
-- overflowed there is no "+N", so fall back to the first pill: otherwise the
-- list has no reachable trigger at all on a device that cannot tap.
if more_button_key and not self[more_button_key] then
self[more_button_key] = first_pill
end
return row
end
@@ -1604,6 +1613,37 @@ function CatalogDetail:buildDetailRelatedSection(section, width, height)
}, focus_rows
end
-- The header is a stack of lines on screen - series, metadata, rating, genres,
-- progress, action - so the cursor moves through it a line at a time. Left and
-- Right are reserved for what genuinely sits side by side, which is only the
-- rating stars. Packing the lot into one row meant up to eleven Right presses
-- to reach the download button, along an axis the layout never suggested.
function CatalogDetail:detailHeaderFocusRows()
local rows = {}
local function addRow(...)
local row = {}
for index = 1, select("#", ...) do
local widget = select(index, ...)
if widget then table.insert(row, widget) end
end
if #row == 0 then return nil end
table.insert(rows, row)
return row
end
addRow(self.detail_series_line)
addRow(self.detail_meta_line)
addRow(unpack(self.detail_rating_stars or {}))
addRow(self.detail_more_pills_button)
addRow(self.detail_status_button)
-- Read and Download are the same slot: a book on the device shows one, a
-- book still on the server the other. It is also why the page was opened,
-- so the cursor starts here instead of five rows above it.
local action_row = addRow(self.detail_read_button, self.detail_download_button)
if action_row then action_row.is_primary = true end
return rows
end
function CatalogDetail:updateDetailItems(select_number, no_recalculate_dimen)
local old_dimen = self:prepareCustomUpdate(no_recalculate_dimen)
local detail = self.current_context.detail
@@ -1621,19 +1661,7 @@ function CatalogDetail:updateDetailItems(select_number, no_recalculate_dimen)
table.insert(self.item_group, header)
local used = DETAIL_GAP_S + header:getSize().h
local focus_row = {}
if self.detail_series_line then table.insert(focus_row, self.detail_series_line) end
if self.detail_meta_line then table.insert(focus_row, self.detail_meta_line) end
for _, star in ipairs(self.detail_rating_stars or {}) do
table.insert(focus_row, star)
end
if self.detail_more_pills_button then
table.insert(focus_row, self.detail_more_pills_button)
end
if self.detail_status_button then table.insert(focus_row, self.detail_status_button) end
if self.detail_read_button then table.insert(focus_row, self.detail_read_button) end
if self.detail_download_button then table.insert(focus_row, self.detail_download_button) end
self.layout = { focus_row }
self.layout = self:detailHeaderFocusRows()
if active_related then
local desc_max = DETAIL_DESCRIPTION_HEIGHT
@@ -0,0 +1,303 @@
--[[--
D-Pad focus bookkeeping for the catalog's custom pages.
A device with a D-Pad but no touchscreen (Kindle 3/4 and friends) drives the
whole catalog through KOReader's FocusManager, which needs two things this
plugin's pages cannot get from Menu alone: a cursor that survives a rebuild,
and a reachable title bar. Both are installed onto the catalog widget by
`install`, alongside the other catalog mixins.
]]
local Device = require("device")
local Event = require("ui/event")
local FocusManager = require("ui/widget/focusmanager")
local CatalogWidgets = require("bookorbit_catalog_widgets")
local CatalogFocus = {}
local FOOTER_ROW_ID = "footer"
--- Filters a title bar's buttons down to the ones it actually draws.
---
--- A focus layout may only name widgets that are on screen: a button reachable
--- by the cursor but absent from the paint is worse than one that cannot be
--- reached at all. TitleBar clears its own children and re-inits whenever a
--- shrink-to-fit title changes, which drops any button appended afterwards while
--- leaving the field pointing at it, so membership of the group is the only
--- trustworthy test for those.
---
--- `order` lists the button fields left to right. `appended` names the ones added
--- as direct children; TitleBar nests its own inside containers, so those are
--- taken on trust.
function CatalogFocus.drawnTitleBarButtons(bar, order, appended)
local drawn = {}
for _, child in ipairs(bar) do
drawn[child] = true
end
local buttons = {}
for _, field in ipairs(order) do
local button = bar[field]
if button and (not appended[field] or drawn[button]) then
table.insert(buttons, button)
end
end
return buttons
end
-- Identifies the page the cursor is currently on, so a rebuild can tell "the
-- same page refreshed" from "a different page opened".
function CatalogFocus:customFocusKey()
local context = self.current_context or {}
if context.kind == "dashboard" then
return "dashboard"
elseif context.kind == "detail" then
return "detail:" .. tostring(context.detail and context.detail.id)
end
return nil
end
-- The custom pages rebuild their whole widget tree on every refresh - a shelf
-- page turn, a Discover reroll, a status change - which throws away the widget
-- the cursor was sitting on. Menu's own bookkeeping cannot put it back: it
-- derives a position from the selected item number over a uniform grid, and
-- these pages are rows of unequal width whose first row is a cluster of header
-- controls. Remember the coordinates before the layout is discarded.
function CatalogFocus:saveCustomFocus()
self.custom_focus = nil
if not CatalogWidgets.focusNavigation() then return end
local key = self:customFocusKey()
-- Returning to a page hands back the cursor it was left at, whatever page
-- the reader wandered off to in between.
if self.pending_focus then
self.custom_focus = self.pending_focus
self.pending_focus = nil
self.custom_focus_key = key
return
end
local row_id = self:focusRowId()
-- A named row is remembered whatever page comes next. The footer is the same
-- strip of controls on every page, and its controls are what *cause* the
-- rebuild: pressing "next page" or "next book" from there has to leave the
-- cursor on the button just pressed, or the reader cannot press it twice.
if row_id or (key and key == self.custom_focus_key) then
self.custom_focus = { x = self.selected.x, y = self.selected.y, row_id = row_id }
end
self.custom_focus_key = key
end
-- The id of the row the cursor is in, when that row has one.
function CatalogFocus:focusRowId()
local row = (self.layout or {})[self.selected.y]
return row and row.id
end
-- The cursor a page is being left at, stored on the navigation stack entry so
-- returning to the page puts it back. Pages are pushed and popped together with
-- their contexts, so this scopes and expires with them.
function CatalogFocus:captureFocus()
if not CatalogWidgets.focusNavigation() then return nil end
return { x = self.selected.x, y = self.selected.y, row_id = self:focusRowId() }
end
-- Hands a remembered cursor to the next rebuild, used when popping the stack.
function CatalogFocus:restoreFocusOnNextUpdate(focus)
if focus and CatalogWidgets.focusNavigation() then
self.pending_focus = focus
end
end
-- The row a cursor should land on when the page is opened rather than rebuilt.
--
-- A page may nominate the row it exists for - the detail page marks its Read or
-- Download button - and the cursor opens there. Otherwise it takes the first row
-- that is neither a merged title bar row nor a section header's controls, and
-- falls back to the first row when a page is nothing but chrome.
function CatalogFocus:firstContentFocusRow()
local layout = self.layout or {}
local start = (self.title_bar_layout_rows or 0) + 1
for index = start, #layout do
if layout[index].is_primary then return index end
end
local y = start
while layout[y] and layout[y].is_chrome do
y = y + 1
end
if not layout[y] then
return math.min((self.title_bar_layout_rows or 0) + 1, math.max(1, #layout))
end
return y
end
-- Puts the cursor back, clamped into the layout that just replaced the old
-- one: a shelf that lost a row, or a header that lost its paging chevrons,
-- leaves the remembered coordinates pointing past the end.
function CatalogFocus:restoreCustomFocus()
if not CatalogWidgets.focusNavigation() then return end
local layout = self.layout
if not layout or #layout == 0 then return end
local target = self.custom_focus
self.custom_focus = nil
local function focusAt(x, y)
y = math.max(1, math.min(y, #layout))
while y > 1 and #layout[y] == 0 do
y = y - 1
end
if #layout[y] == 0 then return end
self:moveFocusTo(math.max(1, math.min(x, #layout[y])), y,
FocusManager.FOCUS_ONLY_ON_NT)
end
-- A cursor we remembered is honoured on every page, including the mosaic and
-- list grids we otherwise leave to Menu: a named row is found again wherever
-- it landed this time, and anything else falls back to its coordinates.
if target then
if target.row_id then
for index = 1, #layout do
if layout[index].id == target.row_id and #layout[index] > 0 then
return focusAt(target.x, index)
end
end
end
return focusAt(target.x, target.y)
end
-- Nothing remembered, so this page is being opened rather than rebuilt. Only
-- the pages we own pick their own opening row; the uniform grids are the
-- ones Menu's own bookkeeping was written for.
if not self.custom_focus_key then return end
focusAt(1, self:firstContentFocusRow())
end
-- Menu skips the title bar on Kindle-style keypads because its own title bar
-- actions all have hardware keys. Ours do not: the search and refresh icons are
-- this plugin's, with no key mapping behind them, so skipping would strand them
-- on exactly the devices that cannot tap. Kindle 3/4 have a full 5-way pad, so
-- the buttons go in as one row and cost a single Down press to leave.
--
-- The D-Pad check is ours too: a focus layout is only ever walked by a D-Pad
-- (FocusManager binds no keys without one), so on a device that has none these
-- rows would be dead entries that only shift the cursor's row index.
function CatalogFocus:mergeTitleBarIntoLayout()
self.title_bar_layout_rows = 0
if not self.title_bar or not Device:hasDPad() then return end
-- A few-keys device spends "Right" on the context menu, so its title bar
-- has to stack vertically to stay reachable.
local title_bar_layout = Device:hasFewKeys()
and self.title_bar:generateVerticalLayout()
or self.title_bar:generateHorizontalLayout()
for index, row in ipairs(title_bar_layout) do
table.insert(self.layout, index, row)
end
self.title_bar_layout_rows = #title_bar_layout
self.selected.y = self.selected.y + self.title_bar_layout_rows
end
-- The footer's paginator and return arrow are Buttons that Menu never puts in
-- the focus layout, on the assumption that the hardware page-turn and Back keys
-- cover them. They mostly do, but those keys are easy to miss, and on the detail
-- page the same chevrons step through *books* rather than pages, which no key
-- covers at all.
--
-- So append whatever the footer is currently showing as the last row. It is
-- marked chrome, so a page opened fresh still starts on its content, and only
-- visible controls go in: the plugin hides the chevrons outright on pages that
-- do not page.
function CatalogFocus:footerFocusRow()
if not CatalogWidgets.focusNavigation() then return nil end
-- Screen order: the return arrow sits bottom-left, the paginator centred.
local candidates = {
self.page_return_arrow,
self.page_info_first_chev,
self.page_info_left_chev,
self.page_info_text,
self.page_info_right_chev,
self.page_info_last_chev,
}
local row, included = {}, {}
for _, widget in ipairs(candidates) do
local shown = not widget.hidden
-- The page indicator is a button in its own right - it opens "go to
-- page" - but it is emptied rather than hidden when there is nothing to
-- page through.
if widget == self.page_info_text then
shown = (widget.text or "") ~= ""
end
if shown then
table.insert(row, widget)
included[widget] = true
end
end
-- A control that has just been hidden has to be told it is no longer
-- focused. It is not in the layout any more, so no Unfocus would reach it,
-- and a hidden Button still paints its frame: left inverted, it shows up as
-- a black block where the chevron used to be.
for _, widget in ipairs(candidates) do
if not included[widget] then
widget:handleEvent(Event:new("Unfocus"))
end
end
if #row == 0 then return nil end
row.is_chrome = true
-- Named, so the cursor can be put back on it after a rebuild without relying
-- on a row index that shifts with however much content the page has.
row.id = FOOTER_ROW_ID
return row
end
-- The Series, Authors, Libraries and Collections pages are drawn by plain Menu,
-- whose rows mark focus with a hairline that is invisible in a list of rows that
-- already have separators. Give them the catalog's own focus box.
--
-- Menu has already placed the cursor by the time this runs, so the box has to be
-- re-asserted afterwards or the row the page opens on stays unmarked until the
-- reader moves.
function CatalogFocus:makeMenuItemsFocusable()
if not CatalogWidgets.focusNavigation() then return end
for _, row in ipairs(self.layout or {}) do
for _, widget in ipairs(row) do
-- The underline container is what identifies a Menu row; the title
-- bar's buttons and our own widgets bring their own focus styling.
if type(widget) == "table" and widget._underline_container then
CatalogWidgets.focusableMenuItem(widget)
end
end
end
end
-- Redraws the focus box on whatever is currently selected.
function CatalogFocus:refocusCurrentRow()
if not CatalogWidgets.focusNavigation() then return end
local layout = self.layout
if not layout or not layout[self.selected.y] then return end
if not layout[self.selected.y][self.selected.x] then return end
self:moveFocusTo(self.selected.x, self.selected.y, FocusManager.FOCUS_ONLY_ON_NT)
end
-- Whether the Back key should walk up a level rather than close the catalog.
--
-- Menu closes on Back because its own back affordance is the footer's return
-- arrow, and that arrow is a Button it never puts in the focus layout: a device
-- with no touchscreen cannot press it, leaving Back as the only key here and
-- closing the whole catalog from however deep the reader had browsed. Menu
-- already has the right rule for its own stack - go up a level, close only at
-- the root - so we apply it to ours.
--
-- Touch devices keep closing on Back: they can still tap the arrow, and a
-- multiswipe to close should not turn into one swipe per level.
function CatalogFocus:shouldReturnOnBack()
return CatalogWidgets.focusNavigation() and #(self.stack or {}) > 0
end
function CatalogFocus.install(Catalog)
for name, fn in pairs(CatalogFocus) do
if name ~= "install" then
Catalog[name] = fn
end
end
end
return CatalogFocus
@@ -60,6 +60,55 @@ local function lineHeight(face)
return math.floor(1.3 * face.size + 0.5)
end
-- A device with a D-Pad but no touchscreen (Kindle 3/4 and friends) drives the
-- catalog entirely through FocusManager, so every tappable widget has to be
-- able to draw the cursor that says where the next Press will land. Touch
-- devices never show it, so they never pay for the extra frame.
local FOCUS_NAV = Device:hasDPad() and not Device:isTouchDevice()
local FOCUS_BORDER = Screen:scaleBySize(3)
--- Wraps a widget's root container in a frame that paints a focus box while the
--- FocusManager has it selected. The box is an *inner* border, drawn within the
--- frame's own bounds, so the widget measures the same focused or not: the
--- dashboard and detail pages lay themselves out from fixed dimensions, and a
--- cursor that changed a widget's size would reflow the page under it.
function CatalogWidgets.focusable(child)
if not FOCUS_NAV then return child end
return FrameContainer:new{
margin = 0,
padding = 0,
bordersize = 0,
focusable = true,
focus_inner_border = true,
focus_border_size = FOCUS_BORDER,
child,
}
end
--- Whether the running device navigates by focus rather than by touch. Callers
--- use it to skip focus bookkeeping that would be dead weight on a touchscreen.
function CatalogWidgets.focusNavigation()
return FOCUS_NAV
end
--- Gives a stock KOReader menu row the same focus box as the widgets above.
---
--- Menu marks its focused row with a hairline underneath it. Upstream calls that
--- line "really, really, really thin" and declines to thicken it because doing
--- so shifts the row's text; on a list of rows that already have separators it
--- is not a cursor a reader can find. An inner border is as visible as the boxes
--- everywhere else in the catalog and changes no geometry, so it does not carry
--- that cost. Applied to the item's own frame, which answers the Focus event
--- before the row does and so replaces the underline rather than adding to it.
function CatalogWidgets.focusableMenuItem(item)
if not FOCUS_NAV then return end
local frame = item and item[1]
if type(frame) ~= "table" or frame.focusable then return end
frame.focusable = true
frame.focus_inner_border = true
frame.focus_border_size = FOCUS_BORDER
end
-- Absolute paths to the plugin's own SVG icons, resolved once by the catalog
-- (widgets cannot know the plugin directory). A missing entry falls back to
-- the stock KOReader glyph named alongside it.
@@ -427,7 +476,7 @@ function MosaicItem:init()
dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
content,
}
self[1] = FrameContainer:new{
self[1] = CatalogWidgets.focusable(FrameContainer:new{
width = self.dimen.w,
height = self.dimen.h,
margin = 0,
@@ -435,7 +484,7 @@ function MosaicItem:init()
bordersize = 0,
background = selectedBackground(selected),
body,
}
})
end
function MosaicItem:onTapSelect()
@@ -587,7 +636,7 @@ function ListItem:init()
dimen = Geom:new{ w = self.dimen.w, h = separator_h },
})
self[1] = FrameContainer:new{
self[1] = CatalogWidgets.focusable(FrameContainer:new{
width = self.dimen.w,
height = self.dimen.h,
margin = 0,
@@ -595,7 +644,7 @@ function ListItem:init()
bordersize = 0,
background = selectedBackground(selected),
content,
}
})
end
function ListItem:onTapSelect()
@@ -1024,10 +1073,10 @@ function DashboardCoverCard:init()
})
end
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
self[1] = CatalogWidgets.focusable(cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
col,
})
}))
end
function DashboardCoverCard:onTapSelect()
@@ -1119,12 +1168,12 @@ function DashboardHeroCard:init()
table.insert(right, VerticalSpan:new{ width = flex })
table.insert(right, bottom)
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, HorizontalGroup:new{
self[1] = CatalogWidgets.focusable(cardFrame(self.dimen.w, self.dimen.h, pad, HorizontalGroup:new{
align = "top",
CatalogWidgets.buildDashboardCoverWidget(book, cover_w, cover_h, path, state, downloaded),
HorizontalSpan:new{ width = gap },
right,
})
}))
end
function DashboardHeroCard:onTapSelect()
@@ -1204,10 +1253,10 @@ function DashboardHighlightCard:init()
local pad = Size.padding.default
local inner_w = math.max(1, self.dimen.w - 2 * pad - 2 * CARD_BORDER)
local inner_h = math.max(1, self.dimen.h - 2 * pad - 2 * CARD_BORDER)
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, LeftContainer:new{
self[1] = CatalogWidgets.focusable(cardFrame(self.dimen.w, self.dimen.h, pad, LeftContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
CatalogWidgets.buildDashboardHighlightContent(self.entry.highlight, inner_w),
})
}))
end
function DashboardHighlightCard:onTapSelect()
@@ -1245,11 +1294,11 @@ function DetailRelatedCard:init()
local path, state = self.menu:thumbnailDisplay(book)
local downloaded = self.menu:isOnDevice(book)
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
self[1] = CatalogWidgets.focusable(cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
CatalogWidgets.buildDashboardCoverWidget(
book, inner_w, inner_h, path, state, downloaded, { no_border = true }),
})
}))
end
function DetailRelatedCard:onTapSelect()
@@ -1308,7 +1357,7 @@ function DetailTabButton:init()
table.insert(col, VerticalSpan:new{ width = underline_h })
end
self[1] = FrameContainer:new{
self[1] = CatalogWidgets.focusable(FrameContainer:new{
width = w,
height = h,
margin = 0,
@@ -1316,7 +1365,7 @@ function DetailTabButton:init()
bordersize = 0,
background = Blitbuffer.COLOR_WHITE,
col,
}
})
end
function DetailTabButton:onTapSelect()
@@ -1357,7 +1406,7 @@ function DetailRatingStar:init()
}
local text = self.entry.filled and "★" or "☆"
self[1] = CenterContainer:new{
self[1] = CatalogWidgets.focusable(CenterContainer:new{
dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
TextBoxWidget:new{
text = text,
@@ -1368,7 +1417,7 @@ function DetailRatingStar:init()
face = Font:getFace("cfont", DETAIL_STAR_FONT_SIZE),
height_overflow_show_ellipsis = true,
},
}
})
end
function DetailRatingStar:onTapSelect()
@@ -1455,14 +1504,14 @@ function DashboardBrowseRow:init()
})
end
self[1] = VerticalGroup:new{
self[1] = CatalogWidgets.focusable(VerticalGroup:new{
align = "left",
row,
LineWidget:new{
background = Blitbuffer.COLOR_LIGHT_GRAY,
dimen = Geom:new{ w = self.dimen.w, h = separator_h },
},
}
})
end
function DashboardBrowseRow:onTapSelect()
@@ -1515,10 +1564,10 @@ function DashboardIconButton:init()
icon_opts.width = icon_size
icon_opts.height = icon_size
icon_opts.dim = not self.enabled
self[1] = CenterContainer:new{
self[1] = CatalogWidgets.focusable(CenterContainer:new{
dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
IconWidget:new(icon_opts),
}
})
end
function DashboardIconButton:onTapSelect()
+1 -1
View File
@@ -48,7 +48,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "1.4.0"
local PLUGIN_VERSION = "1.5.0"
local SYNC_STRATEGY = {
PROMPT = 1,
@@ -0,0 +1,162 @@
-- D-Pad reachability on the book detail page: that its header walks vertically
-- like the stack it draws, and that every pill row keeps one reachable trigger.
--
-- Every pill in a detail row opens the same full list, so exactly one of them is
-- registered for D-Pad focus. The overflow "+N" pill is the natural pick, but a
-- row short enough to fit has no "+N" - and without a fallback the list becomes
-- unreachable on a device that cannot tap a pill.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local function identity(value) return value end
local function stubWidget(width)
local widget = {}
widget.__index = widget
-- Mirrors KOReader's own Widget:new, which runs init(): a pill derives its
-- size there, and without it every pill measures zero and nothing overflows.
function widget:new(opts)
local instance = setmetatable(opts or {}, self)
if instance.init then instance:init() end
return instance
end
function widget:extend(opts)
local child = setmetatable(opts or {}, { __index = self })
child.__index = child
child.new = widget.new
child.extend = widget.extend
return child
end
-- A pill widget sizes itself from the dimen it derived in init, so honour
-- that first; otherwise fall back to the stub's own fixed width.
function widget:getSize()
if self.dimen then return self.dimen end
return { w = self.width or width or 0, h = 0 }
end
return widget
end
package.loaded["ui/bidi"] = { auto = identity, dirpath = identity, filepath = identity }
package.loaded["ffi/blitbuffer"] = { COLOR_GRAY = 0 }
for _, name in ipairs({
"ui/widget/button", "ui/widget/buttondialog", "ui/widget/container/centercontainer",
"ui/widget/container/leftcontainer", "ui/widget/container/inputcontainer",
"ui/widget/horizontalgroup", "ui/widget/horizontalspan", "ui/widget/verticalgroup",
"ui/widget/verticalspan", "ui/widget/linewidget", "ui/widget/textboxwidget",
"ui/widget/textviewer", "ui/widget/infomessage", "ui/widget/keyvaluepage",
"ui/geometry", "ui/gesturerange",
}) do
package.loaded[name] = stubWidget()
end
package.loaded["ui/font"] = { getFace = function() return { size = 12 } end }
package.loaded["ui/size"] = { line = { medium = 1 } }
package.loaded["ui/network/manager"] = { isConnected = function() return true end }
package.loaded["device"] = { screen = { scaleBySize = function(_, value) return value end } }
package.loaded["ui/uimanager"] = { show = function() end, close = function() end }
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
package.loaded["util"] = {
trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end,
fixUtf8 = identity,
htmlToPlainTextIfHtml = identity,
}
package.loaded["ffi/util"] = {
template = function(pattern, a) return (pattern:gsub("%%1", tostring(a))) end,
}
package.loaded["gettext"] = setmetatable({}, { __call = function(_, value) return value end })
-- Each pill reports a fixed width so overflow can be forced by narrowing the row.
local PILL_W = 100
package.loaded["bookorbit_catalog_widgets"] = {
buildCoverWidget = function() return stubWidget(PILL_W):new() end,
buildDetailPill = function() return stubWidget(PILL_W):new{ width = PILL_W } end,
buildDetailProgressBar = function() return stubWidget():new() end,
detailRatingStarWidth = function() return 20 end,
DetailRelatedCard = stubWidget(),
DetailTabButton = stubWidget(),
DetailRatingStar = stubWidget(),
focusable = identity,
focusNavigation = function() return true end,
}
local CatalogDetail = require("bookorbit_catalog_detail")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
end
end
local function buildRow(items, width)
local page = {}
page.showFullList = function() end
page.buildDetailPillRow = CatalogDetail.buildDetailPillRow
page:buildDetailPillRow(items, width, "Genres", "focus_pill")
return page
end
-- A row wide enough for every pill has no overflow button, so the first pill
-- carries the focus instead of nothing at all.
local page = buildRow({ "NOVELS" }, 1000)
assertEqual(page.focus_pill ~= nil, true, "a single pill is reachable by focus")
assertEqual(page.focus_pill.text, "NOVELS", "and it is the pill that was drawn")
page = buildRow({ "NOVELS", "FICTION", "CLASSICS" }, 1000)
assertEqual(page.focus_pill ~= nil, true, "a row that fits entirely is still reachable")
assertEqual(page.focus_pill.text, "NOVELS", "via its first pill")
-- A row too narrow overflows, and the "+N" pill keeps the focus: it is the one
-- that says how much is hidden.
page = buildRow({ "NOVELS", "FICTION", "CLASSICS", "ESSAYS" }, 250)
assertEqual(page.focus_pill ~= nil, true, "an overflowing row is reachable")
assertEqual(page.focus_pill.text:match("^%+") ~= nil, true,
"via its overflow pill, not the first one")
-- No pills at all means nothing to reach, and no stale button left behind.
page = {}
page.showFullList = function() end
page.focus_pill = "stale"
page.buildDetailPillRow = CatalogDetail.buildDetailPillRow
local row = page:buildDetailPillRow({}, 1000, "Genres", "focus_pill")
assertEqual(row, nil, "an empty list builds no row")
-- The header is a vertical stack on screen, so the cursor walks it with Up and
-- Down. Only the rating stars share a row, because only they sit side by side.
local function rows(page)
page.detailHeaderFocusRows = CatalogDetail.detailHeaderFocusRows
return page:detailHeaderFocusRows()
end
local full = rows{
detail_series_line = "series",
detail_meta_line = "meta",
detail_rating_stars = { "s1", "s2", "s3", "s4", "s5" },
detail_more_pills_button = "pill",
detail_status_button = "status",
detail_download_button = "download",
}
assertEqual(#full, 6, "each header line gets its own focus row")
assertEqual(full[1][1], "series", "series line first")
assertEqual(full[2][1], "meta", "then the metadata line")
assertEqual(#full[3], 5, "the rating stars share one row")
assertEqual(full[4][1], "pill", "then the genre pill")
assertEqual(full[5][1], "status", "then the status button")
assertEqual(full[6][1], "download", "and the action button last")
for _, row in ipairs(full) do
if row ~= full[3] then
assertEqual(#row, 1, "every row but the stars holds a single control")
end
end
-- Read and Download are the same slot, never both.
local on_device = rows{ detail_meta_line = "meta", detail_read_button = "read" }
assertEqual(#on_device, 2, "a book on the device drops the rows it has no controls for")
assertEqual(on_device[2][1], "read", "and offers Read in the action slot")
-- A sparse page must not leave empty rows for the cursor to fall into.
local sparse = rows{ detail_download_button = "download" }
assertEqual(#sparse, 1, "a header with one control builds one row")
assertEqual(sparse[1][1], "download", "holding that control")
assertEqual(#rows{}, 0, "a header with no controls builds no rows")
print("bookorbit_detail_focus_test.lua: ok")
@@ -0,0 +1,125 @@
-- Proves the focus chrome the catalog's tappable widgets draw for a D-Pad
-- cursor: that it exists at all on a keyboard-only device, that it is an inner
-- border so adding it cannot resize a widget and reflow the page around it,
-- that a touchscreen pays nothing for it, and that no tappable widget is left
-- without it.
local function stubWidget()
local class = {}
class.new = function(_, opts) return opts or {} end
class.extend = function(_, spec)
spec = spec or {}
spec.new = class.new
spec.extend = class.extend
return spec
end
return class
end
local function anyNumber()
return setmetatable({}, { __index = function() return 1 end })
end
package.loaded["ui/bidi"] = { auto = function(text) return text end }
package.loaded["ffi/blitbuffer"] = setmetatable({}, { __index = function() return 0 end })
package.loaded["ui/font"] = { getFace = function() return { size = 10 } end }
package.loaded["ui/geometry"] = stubWidget()
package.loaded["ui/gesturerange"] = stubWidget()
package.loaded["ui/size"] = setmetatable({}, { __index = function() return anyNumber() end })
package.loaded["gettext"] = function(text) return text end
package.loaded["util"] = { fixUtf8 = function(value) return value end }
package.loaded["ffi/util"] = { template = function(value) return value end }
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
for _, name in ipairs({
"ui/widget/container/centercontainer",
"ui/widget/container/framecontainer",
"ui/widget/container/inputcontainer",
"ui/widget/container/leftcontainer",
"ui/widget/container/rightcontainer",
"ui/widget/horizontalgroup",
"ui/widget/horizontalspan",
"ui/widget/iconwidget",
"ui/widget/imagewidget",
"ui/widget/linewidget",
"ui/widget/overlapgroup",
"ui/widget/progresswidget",
"ui/widget/textboxwidget",
"ui/widget/textwidget",
"ui/widget/verticalgroup",
"ui/widget/verticalspan",
}) do
package.loaded[name] = stubWidget()
end
local touch = false
package.loaded["device"] = {
screen = { scaleBySize = function(_, value) return value end },
hasDPad = function() return true end,
isTouchDevice = function() return touch end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local function loadWidgets()
package.loaded["bookorbit_catalog_widgets"] = nil
package.loaded["bookorbit_catalog_util"] = nil
return require("bookorbit_catalog_widgets")
end
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
-- A D-Pad with no touchscreen is the case that has to draw a cursor.
local CatalogWidgets = loadWidgets()
assertEqual(CatalogWidgets.focusNavigation(), true, "a keyboard-only device navigates by focus")
local child = { "content" }
local frame = CatalogWidgets.focusable(child)
assertEqual(frame ~= child, true, "the widget's root is wrapped in a focus frame")
assertEqual(frame[1], child, "the frame carries the original root untouched")
assertEqual(frame.focusable, true, "the frame answers Focus events")
assertEqual(frame.focus_inner_border, true, "the cursor is drawn inside the frame's own bounds")
assertEqual(frame.focus_border_size > 0, true, "with a visible border width")
-- The inner border is the whole point: the dashboard and detail pages size
-- themselves from fixed dimensions, so a cursor that grew a widget would push
-- the rest of the page around every time it moved.
assertEqual(frame.bordersize, 0, "the resting frame adds no border of its own")
assertEqual(frame.padding, 0, "nor any padding")
assertEqual(frame.margin, 0, "nor any margin")
-- A touchscreen never shows the cursor, so it should not pay for the frame.
touch = true
CatalogWidgets = loadWidgets()
assertEqual(CatalogWidgets.focusNavigation(), false, "a touch device does not navigate by focus")
assertEqual(CatalogWidgets.focusable(child), child, "and gets its root back unwrapped")
touch = false
-- Every tappable widget in these two files sets its root as `self[1]`, and each
-- one has to route through the helper: a new widget added without it would be
-- invisible to a D-Pad user, which is exactly the bug this fixes.
local function read(path)
local file = assert(io.open(path, "rb"))
local content = file:read("*a")
file:close()
return content
end
local plugin_dir = "koreader-plugin/bookorbit.koplugin/"
for _, name in ipairs({ "bookorbit_catalog_widgets.lua", "bookorbit_catalog_detail.lua" }) do
local source = read(plugin_dir .. name)
local roots, wrapped = 0, 0
for assignment in source:gmatch("self%[1%] = ([%w_.]+)") do
roots = roots + 1
if assignment == "CatalogWidgets.focusable" then
wrapped = wrapped + 1
end
end
assertEqual(roots > 0, true, name .. " defines widget roots")
assertEqual(wrapped, roots, "every widget root in " .. name .. " draws a focus cursor")
end
print("bookorbit_focus_highlight_test.lua: ok")
@@ -0,0 +1,516 @@
-- Proves the D-Pad bookkeeping the custom catalog pages need on a device with
-- no touchscreen: that the title bar joins the focus layout on the Kindle
-- keypads Menu skips, and that the cursor survives a rebuild instead of being
-- teleported by Menu's uniform-grid focus math.
local device = {
hasDPad = function() return true end,
hasFewKeys = function() return false end,
}
package.loaded["device"] = device
package.loaded["ui/widget/focusmanager"] = { FOCUS_ONLY_ON_NT = 0 }
package.loaded["ui/event"] = {
new = function(_, name, ...) return { handler = "on" .. name, name, ... } end,
}
local focus_navigation = true
package.loaded["bookorbit_catalog_widgets"] = {
focusNavigation = function() return focus_navigation end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local CatalogFocus = require("bookorbit_catalog_focus")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function titleBar(buttons)
return {
generateHorizontalLayout = function()
return #buttons > 0 and { buttons } or {}
end,
generateVerticalLayout = function()
local rows = {}
for _, button in ipairs(buttons) do
table.insert(rows, { button })
end
return rows
end,
}
end
local function newCatalog(opts)
opts = opts or {}
local catalog = {
layout = opts.layout or {},
selected = { x = opts.x or 1, y = opts.y or 1 },
current_context = opts.context,
title_bar = opts.title_bar,
title_bar_layout_rows = opts.title_bar_layout_rows,
custom_focus_key = opts.custom_focus_key,
custom_focus = opts.custom_focus,
}
function catalog:moveFocusTo(x, y, flags)
self.moved = { x = x, y = y, flags = flags }
self.selected.x, self.selected.y = x, y
end
CatalogFocus.install(catalog)
return catalog
end
-- The title bar goes in as one row on a full keypad, and the cursor's row
-- index shifts to stay on the widget it was already on.
local catalog = newCatalog{
layout = { { "hero" }, { "a", "b" } },
title_bar = titleBar{ "back", "search", "refresh", "menu" },
y = 1,
}
catalog:mergeTitleBarIntoLayout()
assertEqual(#catalog.layout, 3, "the title bar adds a single row on a full keypad")
assertEqual(#catalog.layout[1], 4, "every title bar button shares that row")
assertEqual(catalog.layout[1][2], "search", "the plugin's own search icon is reachable")
assertEqual(catalog.title_bar_layout_rows, 1, "the merged row count is recorded")
assertEqual(catalog.selected.y, 2, "the cursor follows its widget down the layout")
-- A few-keys device spends "Right" on the context menu, so its title bar has to
-- stack instead.
device.hasFewKeys = function() return true end
catalog = newCatalog{
layout = { { "hero" } },
title_bar = titleBar{ "back", "search" },
y = 1,
}
catalog:mergeTitleBarIntoLayout()
assertEqual(#catalog.layout, 3, "a few-keys title bar stacks one button per row")
assertEqual(catalog.title_bar_layout_rows, 2, "both stacked rows are recorded")
assertEqual(catalog.selected.y, 3, "the cursor shifts by both rows")
device.hasFewKeys = function() return false end
-- Without a D-Pad there is no cursor to place, and a page with no title bar
-- must not claim rows it did not add.
catalog = newCatalog{ layout = { { "a" } }, title_bar = titleBar{ "back" } }
device.hasDPad = function() return false end
catalog:mergeTitleBarIntoLayout()
assertEqual(#catalog.layout, 1, "a device with no D-Pad skips the merge")
assertEqual(catalog.title_bar_layout_rows, 0, "and records no merged rows")
device.hasDPad = function() return true end
catalog = newCatalog{ layout = { { "a" } } }
catalog:mergeTitleBarIntoLayout()
assertEqual(catalog.title_bar_layout_rows, 0, "a page with no title bar merges nothing")
-- The two devices this exists for. Kindle 3 and Kindle 4 are the models the bug
-- was reported on, and they are exactly the ones upstream Menu refuses to merge
-- a title bar for (K3 has a Sym key, K4 an on-screen keyboard). Neither sets
-- hasFewKeys, so both take the single-row title bar.
for _, profile in ipairs({
{ model = "Kindle3", has_sym_key = true, has_screen_kb = false },
{ model = "Kindle4", has_sym_key = false, has_screen_kb = true },
}) do
device.hasSymKey = function() return profile.has_sym_key end
device.hasScreenKB = function() return profile.has_screen_kb end
catalog = newCatalog{
layout = { { "hero" } },
title_bar = titleBar{ "back", "search", "refresh" },
y = 1,
}
catalog:mergeTitleBarIntoLayout()
assertEqual(catalog.title_bar_layout_rows, 1,
profile.model .. " gets its title bar as one row")
assertEqual(#catalog.layout[1], 3,
profile.model .. " can reach every title bar button")
assertEqual(catalog.selected.y, 2,
profile.model .. " shifts the cursor past the merged row")
end
device.hasSymKey = nil
device.hasScreenKB = nil
-- Opening a page for the first time records its key but has nothing to restore.
catalog = newCatalog{ context = { kind = "dashboard" }, x = 3, y = 4 }
catalog:saveCustomFocus()
assertEqual(catalog.custom_focus, nil, "a page opened fresh has no remembered cursor")
assertEqual(catalog.custom_focus_key, "dashboard", "but its key is recorded for the next rebuild")
-- Refreshing the same page keeps the cursor.
catalog:saveCustomFocus()
assertEqual(catalog.custom_focus.x, 3, "a rebuild of the same page remembers the column")
assertEqual(catalog.custom_focus.y, 4, "and the row")
-- Opening a different page does not.
catalog.current_context = { kind = "detail", detail = { id = 7 } }
catalog:saveCustomFocus()
assertEqual(catalog.custom_focus, nil, "opening another page starts its cursor from scratch")
assertEqual(catalog.custom_focus_key, "detail:7", "and the detail key names the book")
catalog.current_context = { kind = "detail", detail = { id = 8 } }
catalog:saveCustomFocus()
assertEqual(catalog.custom_focus, nil, "another book is another page")
-- A rebuilt layout can be shorter than the one the cursor was remembered
-- against: a shelf that lost a row, or a header that lost its paging chevrons.
catalog = newCatalog{
layout = { { "a", "b" }, { "c" }, { "d", "e", "f", "g" } },
custom_focus_key = "dashboard",
custom_focus = { x = 5, y = 9 },
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 3, "a row past the end clamps to the last row")
assertEqual(catalog.moved.x, 4, "and a column past the end clamps to that row's width")
assertEqual(catalog.custom_focus, nil, "the remembered cursor is consumed by the restore")
catalog = newCatalog{
layout = { { "a", "b", "c" }, { "d" } },
custom_focus_key = "dashboard",
custom_focus = { x = 2, y = 1 },
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.x, 2, "a cursor still inside the layout is left alone")
assertEqual(catalog.moved.y, 1, "on both axes")
-- An empty row is skipped rather than focused.
catalog = newCatalog{
layout = { { "a" }, { "b" }, {} },
custom_focus_key = "dashboard",
custom_focus = { x = 1, y = 3 },
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 2, "an empty row hands the cursor to the one above it")
-- A page opened fresh starts below the title bar, on content.
catalog = newCatalog{
layout = { { "back", "search" }, { "hero" }, { "a", "b" } },
custom_focus_key = "dashboard",
title_bar_layout_rows = 1,
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 2, "a fresh page opens on its first content row")
assertEqual(catalog.moved.x, 1, "at the start of that row")
-- Section headers carry paging chevrons that are disabled on page one, so a
-- fresh cursor skips past them to the books rather than opening on a dead
-- control.
local chrome = { "prev", "next" }
chrome.is_chrome = true
catalog = newCatalog{
layout = { { "back" }, chrome, { "book" } },
custom_focus_key = "dashboard",
title_bar_layout_rows = 1,
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 3, "a fresh page skips a section header's controls")
-- Chrome is only skipped when opening; a remembered cursor may well be sitting
-- on a chevron the reader was paging with.
catalog = newCatalog{
layout = { { "back" }, chrome, { "book" } },
custom_focus_key = "dashboard",
custom_focus = { x = 2, y = 2 },
title_bar_layout_rows = 1,
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 2, "a remembered cursor stays on its chevron")
assertEqual(catalog.moved.x, 2, "in the column it was paging from")
-- A page that is nothing but chrome still has to put the cursor somewhere.
local only_chrome = { "prev", "next" }
only_chrome.is_chrome = true
catalog = newCatalog{
layout = { { "back" }, only_chrome },
custom_focus_key = "dashboard",
title_bar_layout_rows = 1,
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 2, "a page with no content row falls back to its first row")
-- The mosaic and list views are the uniform grids Menu already handles.
catalog = newCatalog{ layout = { { "a" } } }
catalog:restoreCustomFocus()
assertEqual(catalog.moved, nil, "a plain book grid is left to Menu's own bookkeeping")
-- A touchscreen has no cursor to keep.
focus_navigation = false
catalog = newCatalog{ context = { kind = "dashboard" }, layout = { { "a" } }, x = 2, y = 2 }
catalog:saveCustomFocus()
assertEqual(catalog.custom_focus_key, nil, "a touch device tracks no page key")
assertEqual(catalog.custom_focus, nil, "and remembers no cursor")
catalog.custom_focus_key = "dashboard"
catalog.custom_focus = { x = 2, y = 2 }
catalog:restoreCustomFocus()
assertEqual(catalog.moved, nil, "and never moves the cursor")
focus_navigation = true
-- The footer's return arrow is a Button Menu never puts in the focus layout, so
-- Back is the only way up a level on a device that cannot tap it.
catalog = newCatalog{}
catalog.stack = { "dashboard", "books" }
assertEqual(catalog:shouldReturnOnBack(), true, "Back walks up while there is a level to return to")
catalog.stack = {}
assertEqual(catalog:shouldReturnOnBack(), false, "Back closes the catalog at the root")
catalog.stack = nil
assertEqual(catalog:shouldReturnOnBack(), false, "a catalog with no stack yet closes on Back")
-- A touch device can tap the arrow, so Back keeps closing outright: a multiswipe
-- to leave should not turn into one swipe per level browsed.
focus_navigation = false
catalog = newCatalog{}
catalog.stack = { "dashboard", "books" }
assertEqual(catalog:shouldReturnOnBack(), false, "a touch device still closes on Back")
focus_navigation = true
-- A page may nominate the row it exists for. The detail page marks its Read or
-- Download button, so the cursor opens on the thing the reader came to press
-- rather than five rows above it.
local primary = { "download" }
primary.is_primary = true
local header_chrome = { "prev", "next" }
header_chrome.is_chrome = true
catalog = newCatalog{
layout = { { "back" }, header_chrome, { "meta" }, primary },
custom_focus_key = "detail:7",
title_bar_layout_rows = 1,
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 4, "a fresh page opens on the row it nominated")
-- A remembered cursor still wins: the reader was somewhere for a reason.
catalog = newCatalog{
layout = { { "back" }, { "meta" }, primary },
custom_focus_key = "detail:7",
custom_focus = { x = 1, y = 2 },
title_bar_layout_rows = 1,
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 2, "a rebuild keeps the cursor where it was")
-- The footer's paginator and return arrow join the layout as a last row, so a
-- reader who never learns the page-turn keys can still reach them. Only what is
-- actually on screen goes in: the plugin hides the chevrons on pages that do
-- not page.
local function footerCatalog(opts)
local c = newCatalog{}
for k, v in pairs(opts) do
-- Model a real widget: the footer tells hidden controls they lost focus.
if type(v) == "table" and not v.handleEvent then
v.handleEvent = function(self, event)
if event.handler == "onUnfocus" then self.focused = false end
return true
end
end
c[k] = v
end
return c
end
local chevrons = footerCatalog{
page_return_arrow = { name = "return" },
page_info_first_chev = { name = "first" },
page_info_left_chev = { name = "prev" },
page_info_text = { name = "text", text = "Page 1 of 15" },
page_info_right_chev = { name = "next" },
page_info_last_chev = { name = "last" },
}
local footer = chevrons:footerFocusRow()
assertEqual(#footer, 6, "every visible footer control is reachable")
assertEqual(footer[1].name, "return", "the return arrow comes first, as drawn")
assertEqual(footer[4].name, "text", "the page indicator sits between the chevrons")
assertEqual(footer.is_chrome, true, "the footer is chrome, so a fresh page skips it")
-- A page that does not page hides its chevrons and empties the indicator.
local dashboard_footer = footerCatalog{
page_return_arrow = { name = "return", hidden = true },
page_info_first_chev = { name = "first", hidden = true },
page_info_left_chev = { name = "prev", hidden = true },
page_info_text = { name = "text", text = "" },
page_info_right_chev = { name = "next", hidden = true },
page_info_last_chev = { name = "last", hidden = true },
}
assertEqual(dashboard_footer:footerFocusRow(), nil, "a page with no footer controls adds no row")
-- A return arrow with nothing beside it is still worth reaching.
local only_return = footerCatalog{
page_return_arrow = { name = "return" },
page_info_text = { name = "text", text = "" },
}
local lone = only_return:footerFocusRow()
assertEqual(#lone, 1, "a lone return arrow still gets a row")
assertEqual(lone[1].name, "return", "holding the arrow")
-- A touch device taps the footer directly.
focus_navigation = false
assertEqual(chevrons:footerFocusRow(), nil, "a touch device adds no footer row")
focus_navigation = true
-- The footer is the one row that has to survive a change of page: its controls
-- are what cause the rebuild, so pressing "next page" or "next book" from there
-- must leave the cursor on the button just pressed.
local function footerRow(...)
local row = { ... }
row.id = "footer"
row.is_chrome = true
return row
end
-- The row index shifts with however much content the page has; the id does not.
catalog = newCatalog{
layout = { { "a" }, { "b" }, { "c" }, footerRow("return", "prev", "next") },
custom_focus_key = "books",
custom_focus = { x = 3, y = 4, row_id = "footer" },
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 4, "the footer is found at its new row index")
assertEqual(catalog.moved.x, 3, "on the same control")
catalog = newCatalog{
layout = { { "a" }, footerRow("return", "prev", "next") },
custom_focus_key = "books",
custom_focus = { x = 3, y = 9, row_id = "footer" },
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved.y, 2, "a shorter page still finds the footer")
assertEqual(catalog.moved.x, 3, "keeping the control that caused the rebuild")
-- Even where the page has no key of its own: the book grid is Menu's to manage,
-- but its footer is ours.
catalog = newCatalog{
layout = { { "a" }, footerRow("return", "next") },
custom_focus = { x = 2, y = 2, row_id = "footer" },
}
catalog:restoreCustomFocus()
assertEqual(catalog.moved ~= nil, true, "a grid with no page key still restores its footer")
assertEqual(catalog.moved.x, 2, "on the pressed control")
-- Resting on the footer is remembered whatever page comes next.
catalog = newCatalog{
layout = { { "a" }, footerRow("return", "next") },
context = { kind = "detail", detail = { id = 1 } },
custom_focus_key = "detail:99",
x = 2, y = 2,
}
catalog:saveCustomFocus()
assertEqual(catalog.custom_focus ~= nil, true, "the footer cursor survives moving to another book")
assertEqual(catalog.custom_focus.row_id, "footer", "and remembers it was the footer")
-- Off the footer, a change of page still starts the new one fresh.
catalog = newCatalog{
layout = { { "a" }, { "b" } },
context = { kind = "detail", detail = { id = 1 } },
custom_focus_key = "detail:99",
x = 1, y = 2,
}
catalog:saveCustomFocus()
assertEqual(catalog.custom_focus, nil, "an ordinary row does not follow the reader to another page")
-- Returning to a page restores the cursor it was left at, across any number of
-- pages visited in between and whether or not the page keys its own focus.
catalog = newCatalog{ layout = { { "a", "b" }, { "c" } }, x = 2, y = 1 }
local left_at = catalog:captureFocus()
assertEqual(left_at.x, 2, "leaving a page captures the column")
assertEqual(left_at.y, 1, "and the row")
catalog = newCatalog{ layout = { { "a", "b" }, { "c" } } }
catalog:restoreFocusOnNextUpdate(left_at)
catalog:saveCustomFocus()
catalog:restoreCustomFocus()
assertEqual(catalog.moved.x, 2, "returning puts the cursor back in its column")
assertEqual(catalog.moved.y, 1, "and its row")
-- A touch device captures nothing to restore.
focus_navigation = false
catalog = newCatalog{ layout = { { "a" } }, x = 1, y = 1 }
assertEqual(catalog:captureFocus(), nil, "a touch device captures no cursor")
catalog:restoreFocusOnNextUpdate({ x = 1, y = 1 })
assertEqual(catalog.pending_focus, nil, "and accepts none")
focus_navigation = true
-- A focus layout may only name buttons the title bar actually draws. TitleBar
-- clears its children and re-inits whenever a shrink-to-fit title changes, which
-- drops any button appended afterwards while leaving the field pointing at it.
-- Trusting the field sent the cursor to controls that were not on screen - and
-- the same drop hid those buttons from touch users too.
local ORDER = { "left_button", "search_button", "refresh_button", "right_button" }
local APPENDED = { search_button = true, refresh_button = true }
local function titleBar(fields, drawn)
local b = {}
for k, v in pairs(fields) do b[k] = v end
for _, child in ipairs(drawn or {}) do table.insert(b, child) end
return b
end
local left, search, refresh = { "left" }, { "search" }, { "refresh" }
-- All three drawn: all three reachable.
local drawnButtons = CatalogFocus.drawnTitleBarButtons(
titleBar({ left_button = left, search_button = search, refresh_button = refresh },
{ search, refresh }), ORDER, APPENDED)
assertEqual(#drawnButtons, 3, "every drawn button is reachable")
assertEqual(drawnButtons[2], search, "in left-to-right order")
-- Re-init dropped them from the group but left the fields set.
drawnButtons = CatalogFocus.drawnTitleBarButtons(
titleBar({ left_button = left, search_button = search, refresh_button = refresh }, {}),
ORDER, APPENDED)
assertEqual(#drawnButtons, 1, "buttons dropped from the group are not reachable")
assertEqual(drawnButtons[1], left, "leaving only the bar's own button")
-- One of the two survived.
drawnButtons = CatalogFocus.drawnTitleBarButtons(
titleBar({ left_button = left, search_button = search, refresh_button = refresh },
{ refresh }), ORDER, APPENDED)
assertEqual(#drawnButtons, 2, "a partially rebuilt bar reports only what it drew")
assertEqual(drawnButtons[2], refresh, "and keeps their order")
-- TitleBar's own buttons are nested rather than direct children, so they are
-- taken on trust and must not be filtered out.
drawnButtons = CatalogFocus.drawnTitleBarButtons(
titleBar({ left_button = left, right_button = { "right" } }, {}), ORDER, APPENDED)
assertEqual(#drawnButtons, 2, "the bar's own buttons are trusted without membership")
assertEqual(#CatalogFocus.drawnTitleBarButtons(titleBar({}, {}), ORDER, APPENDED), 0,
"a bar with no buttons reports none")
-- A footer control that has just been hidden is no longer in the layout, so no
-- Unfocus would ever reach it - and a hidden Button still paints its frame, so a
-- stale inverted one shows up as a black block where the chevron used to be.
local function footerWidget(name, hidden)
return {
name = name, hidden = hidden, focused = not not hidden,
handleEvent = function(self, event)
if event.handler == "onUnfocus" or event[1] == "Unfocus" then
self.focused = false
end
return true
end,
}
end
local shown_chev = footerWidget("next", false)
local hidden_chev = footerWidget("last", true)
local page_text = footerWidget("text", false)
page_text.text = "Page 1 of 3"
catalog = newCatalog{}
catalog.page_return_arrow = footerWidget("return", false)
catalog.page_info_first_chev = footerWidget("first", true)
catalog.page_info_left_chev = footerWidget("prev", true)
catalog.page_info_text = page_text
catalog.page_info_right_chev = shown_chev
catalog.page_info_last_chev = hidden_chev
local built = catalog:footerFocusRow()
assertEqual(#built, 3, "only the controls actually on screen are reachable")
assertEqual(hidden_chev.focused, false, "a hidden chevron is told it lost focus")
assertEqual(catalog.page_info_first_chev.focused, false, "every hidden control is")
assertEqual(shown_chev.focused, false, "a shown one is left alone to be focused normally")
-- An emptied page indicator is excluded too: it is blanked rather than hidden.
page_text.text = ""
built = catalog:footerFocusRow()
for _, w in ipairs(built) do
assertEqual(w ~= page_text, true, "an empty page indicator is not reachable")
end
print("bookorbit_focus_navigation_test.lua: ok")