mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user