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,