Files
bookorbit/koreader-plugin/spec/bookorbit_focus_navigation_test.lua
T
neon 21b7eb1e18 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
2026-08-16 20:27:22 +00:00

517 lines
21 KiB
Lua

-- 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")