feat(reader): adapt KOReader catalog layouts for tall screens

Use measured screen surplus to show additional dashboard and related book rows on tall devices.

Preserve compact one-row layouts when space is tight.

Closes #526
This commit is contained in:
neon
2026-07-07 17:51:31 +00:00
parent 0a4dcc192f
commit 2b8b8962fa
3 changed files with 208 additions and 63 deletions
@@ -3,12 +3,12 @@ Dashboard mixin for the BookOrbit catalog browser.
Builds and renders the single-page dashboard: a summary stats band, a
Continue-reading hero row (two hero cards side by side, chevron-paged through
all in-progress books), the Discover row and a compact Browse list, including
all in-progress books), the Discover rows and a compact Browse list, including
the offline cache and the Discover reroll. Installed onto the catalog
controller as regular methods.
Layout is budget-driven: fixed blocks are measured first. When the page gets
too tight, the stats strip is dropped first, then the Discover row itself.
too tight, the stats strip is dropped first, then Discover itself.
]]
local Blitbuffer = require("ffi/blitbuffer")
@@ -46,6 +46,10 @@ local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton
-- statistics.sqlite3 (the dashboard re-renders on every row page turn).
local STATS_CACHE_TTL = 120
local DISCOVER_TARGET_SLOTS = 5
local DISCOVER_COMPACT_GAP = 6
local DASHBOARD_TALL_ASPECT_RATIO = 1.55
local DISCOVER_MAX_ROWS = 2
local STATS_MIN_BODY_HEIGHT = 56
local CatalogDashboard = {}
@@ -322,6 +326,10 @@ function CatalogDashboard:dashboardHeroSlots(count)
return self.content_w >= Screen:scaleBySize(420) and 2 or 1
end
function CatalogDashboard:dashboardTallLayout()
return self.inner_dimen.h / math.max(1, self.inner_dimen.w) >= DASHBOARD_TALL_ASPECT_RATIO
end
-- The Continue-reading hero row: full-width hero cards side by side, paged
-- through all in-progress books via the chevrons in the section header (the
-- e-ink take on a horizontal scroll).
@@ -354,30 +362,32 @@ function CatalogDashboard:addDashboardHeroRow(books, height, slots, page)
self.dash_used = self.dash_used + height
end
function CatalogDashboard:discoverRowMetrics(count)
function CatalogDashboard:discoverRowMetrics(count, gap)
local slots = math.min(DISCOVER_TARGET_SLOTS, count)
local gap = self.dash_inner_gap
gap = gap or self.dash_inner_gap
local min_card_w = Screen:scaleBySize(72)
while slots > 1
and math.floor((self.content_w - (slots + 1) * gap) / slots) < min_card_w do
and math.floor((self.content_w - (slots - 1) * gap) / slots) < min_card_w do
slots = slots - 1
end
local card_w = math.max(min_card_w, math.floor((self.content_w - (slots + 1) * gap) / slots))
local card_w = math.max(min_card_w, math.floor((self.content_w - (slots - 1) * gap) / slots))
return slots, card_w, CatalogWidgets.coverCardHeight(card_w, false, false)
end
-- A paged row of cover cards, evenly distributed across the full
-- content width. Paging is driven by the chevrons in the section header.
function CatalogDashboard:addDashboardCoverRow(
_section_id, books, height, with_progress, with_caption, slots, card_w, page)
local first = (page - 1) * slots + 1
local card_gap = math.floor(math.max(0, self.content_w - slots * card_w) / (slots + 1))
_section_id, books, height, with_progress, with_caption, slots, card_w, page, first_index)
local first = first_index or ((page - 1) * slots + 1)
local card_gap = slots > 1 and math.floor(math.max(0, self.content_w - slots * card_w) / (slots - 1)) or 0
local row = HorizontalGroup:new{ align = "center" }
local focus_row = {}
table.insert(row, HorizontalSpan:new{ width = self.content_inset })
for slot = 1, slots do
table.insert(row, HorizontalSpan:new{ width = card_gap })
if slot > 1 then
table.insert(row, HorizontalSpan:new{ width = card_gap })
end
local book = books[first + slot - 1]
if book then
local card = BookOrbitDashboardCoverCard:new{
@@ -395,7 +405,6 @@ function CatalogDashboard:addDashboardCoverRow(
table.insert(row, HorizontalSpan:new{ width = card_w })
end
end
table.insert(row, HorizontalSpan:new{ width = card_gap })
table.insert(row, HorizontalSpan:new{ width = self.content_inset })
table.insert(self.item_group, row)
if #focus_row > 0 then
@@ -404,6 +413,19 @@ function CatalogDashboard:addDashboardCoverRow(
self.dash_used = self.dash_used + height
end
function CatalogDashboard:addDashboardCoverGrid(
section_id, books, height, with_progress, with_caption, slots, card_w, page, rows)
rows = math.max(1, rows or 1)
local first = (page - 1) * slots * rows + 1
for row = 1, rows do
self:addDashboardCoverRow(section_id, books, height, with_progress, with_caption, slots, card_w, 1, first)
first = first + slots
if row < rows then
self:addDashboardSpacer(self.dash_inner_gap)
end
end
end
-- A friendlier empty/unavailable state than a bare status line: a short
-- centered message with a borderless action button underneath.
function CatalogDashboard:addDashboardEmptyState(text, button_text, callback)
@@ -479,7 +501,7 @@ function CatalogDashboard:buildDashboardStatsStrip(summary, dashboard, height)
end
local line_h = Size.line.thin
local body_h = height and math.max(row:getSize().h, height - 2 * line_h)
or math.max(row:getSize().h, Screen:scaleBySize(64))
or math.max(row:getSize().h, Screen:scaleBySize(STATS_MIN_BODY_HEIGHT))
return VerticalGroup:new{
align = "center",
LineWidget:new{
@@ -580,19 +602,26 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local show_stats = stats_widget ~= nil
local show_discover = #discover_books > 0
local discover_gap = inner_gap
local discover_slots = 0
local discover_card_w = 0
local discover_row_h = 0
local discover_rows = 1
local function updateDiscoverMetrics()
if show_discover then
discover_slots, discover_card_w, discover_row_h = self:discoverRowMetrics(#discover_books)
discover_slots, discover_card_w, discover_row_h = self:discoverRowMetrics(#discover_books, discover_gap)
else
discover_slots, discover_card_w, discover_row_h = 0, 0, 0
end
end
updateDiscoverMetrics()
local function discoverGridHeight()
if not show_discover then return 0 end
return discover_rows * discover_row_h + math.max(0, discover_rows - 1) * inner_gap
end
-- Fixed blocks are measured up front. If the dashboard gets too tight, the
-- stats strip drops first, then Discover.
local function fixedHeight()
@@ -601,7 +630,7 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
+ header_h + inner_gap
+ (has_continue and hero_h or empty_h)
+ section_gap
+ (show_discover and (header_h + inner_gap + discover_row_h + section_gap) or 0)
+ (show_discover and (header_h + inner_gap + discoverGridHeight() + section_gap) or 0)
+ header_h + inner_gap + browse_rows * browse_row_h
+ inner_gap
return fixed
@@ -613,6 +642,35 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
show_discover = false
updateDiscoverMetrics()
end
if show_discover then
local compact_gap = px(DISCOVER_COMPACT_GAP)
if compact_gap < discover_gap then
local previous_gap = discover_gap
local previous_slots, previous_card_w, previous_row_h = discover_slots, discover_card_w, discover_row_h
discover_gap = compact_gap
updateDiscoverMetrics()
if fixedHeight() > avail then
discover_gap = previous_gap
discover_slots, discover_card_w, discover_row_h = previous_slots, previous_card_w, previous_row_h
end
end
end
if show_discover and self:dashboardTallLayout() and discover_slots > 0 then
local full_rows = math.floor(#discover_books / discover_slots)
local remainder = #discover_books % discover_slots
local book_rows = full_rows
if remainder >= math.ceil(discover_slots * 0.5) then
book_rows = book_rows + 1
end
local max_rows = math.min(DISCOVER_MAX_ROWS, math.max(1, book_rows))
while discover_rows < max_rows do
discover_rows = discover_rows + 1
if fixedHeight() > avail then
discover_rows = discover_rows - 1
break
end
end
end
if show_stats then
local extra_stats_h = math.min(px(56), math.max(0, avail - fixedHeight()))
if extra_stats_h > 0 then
@@ -657,7 +715,8 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
if show_discover then
discover_slots = math.min(discover_slots, #discover_books)
local discover_pages = math.max(1, math.ceil(#discover_books / discover_slots))
local discover_page_size = math.max(1, discover_slots * discover_rows)
local discover_pages = math.max(1, math.ceil(#discover_books / discover_page_size))
local discover_page = self:dashboardPage("discover", discover_pages)
local discover_controls = {}
if discover_pages > 1 then
@@ -669,8 +728,8 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
self:addDashboardHeader(_("Discover"), discover_controls)
self:addDashboardSpacer(inner_gap)
self:addDashboardCoverRow("discover", discover_books, discover_row_h, false, false,
discover_slots, discover_card_w, discover_page)
self:addDashboardCoverGrid("discover", discover_books, discover_row_h, false, false,
discover_slots, discover_card_w, discover_page, discover_rows)
self:addDashboardSpacer(section_gap)
end
@@ -75,6 +75,8 @@ local DETAIL_PROGRESS_BAR_HEIGHT = Screen:scaleBySize(7)
local DETAIL_BUTTON_HEIGHT = Screen:scaleBySize(38)
local DETAIL_DESCRIPTION_HEIGHT = Screen:scaleBySize(136)
local DETAIL_COVER_ASPECT = 0.66
local DETAIL_RELATED_TALL_ASPECT_RATIO = 1.55
local DETAIL_RELATED_MAX_ROWS = 2
-- Mirrors TextBoxWidget's own line height math so single/double line boxes
-- can be sized exactly.
@@ -802,6 +804,10 @@ function CatalogDetail:detailRelatedPage(section_id, page_count)
return page
end
function CatalogDetail:detailTallLayout()
return self.inner_dimen.h / math.max(1, self.inner_dimen.w) >= DETAIL_RELATED_TALL_ASPECT_RATIO
end
function CatalogDetail:turnDetailRelatedPage(section_id, delta)
local context = self.current_context or {}
context.related_pages = context.related_pages or {}
@@ -1153,9 +1159,9 @@ function CatalogDetail:buildDetailRelatedTabs(related, width, active_id)
return col, focus_row
end
-- One shelf row of related covers. Card size is derived from the available
-- height first so cards always hug their covers; the row is top-aligned and
-- only as tall as the cards, never stretched to fill leftover space.
-- One or two shelf rows of related covers. Tall screens use a second row only
-- when the measured shelf budget can fit it; compact e-ink layouts keep the
-- original single-row carousel.
function CatalogDetail:buildDetailRelatedSection(section, width, height)
local books = type(section.books) == "table" and section.books or {}
if #books == 0 then
@@ -1176,24 +1182,45 @@ function CatalogDetail:buildDetailRelatedSection(section, width, height)
local gap = Screen:scaleBySize(10)
local nav_w = Screen:scaleBySize(24)
local slots = 4
local has_nav = #books > slots
local covers_w = content_w
if has_nav then
covers_w = content_w - 2 * (nav_w + gap)
end
local card_w = math.floor((covers_w - (slots - 1) * gap) / slots)
local card_h = CatalogWidgets.detailRelatedCardHeight(card_w)
local page_count = math.max(1, math.ceil(#books / slots))
local rows = 1
if self:detailTallLayout() then
local book_rows = math.ceil(#books / slots)
rows = math.min(DETAIL_RELATED_MAX_ROWS, math.max(1, book_rows))
end
local function metrics(row_count)
local page_size = slots * row_count
local has_nav = #books > page_size
local covers_w = has_nav and content_w - 2 * (nav_w + gap) or content_w
local card_w = math.floor((covers_w - (slots - 1) * gap) / slots)
local card_h = CatalogWidgets.detailRelatedCardHeight(card_w)
return {
rows = row_count,
page_size = page_size,
has_nav = has_nav,
card_w = card_w,
card_h = card_h,
total_h = row_count * card_h + math.max(0, row_count - 1) * gap,
}
end
local layout = metrics(rows)
while layout.rows > 1 and layout.total_h > height do
layout = metrics(layout.rows - 1)
end
local page_count = math.max(1, math.ceil(#books / layout.page_size))
local section_id = section.id or tostring(section.title or "related")
local page = self:detailRelatedPage(section_id, page_count)
local first = (page - 1) * slots + 1
local last = math.min(#books, first + slots - 1)
local first = (page - 1) * layout.page_size + 1
local focus_row = {}
local focus_rows = { is_grid = true }
local row = HorizontalGroup:new{ align = "center" }
if has_nav then
local prev_button = Button:new{
local prev_button
local next_button
if layout.has_nav then
prev_button = Button:new{
icon = "chevron.left",
icon_width = Screen:scaleBySize(16),
icon_height = Screen:scaleBySize(16),
@@ -1205,38 +1232,51 @@ function CatalogDetail:buildDetailRelatedSection(section, width, height)
self:turnDetailRelatedPage(section_id, -1)
end,
}
table.insert(row, prev_button)
table.insert(row, CenterContainer:new{
dimen = Geom:new{ w = nav_w, h = layout.total_h },
prev_button,
})
table.insert(row, HorizontalSpan:new{ width = gap })
table.insert(focus_row, prev_button)
end
for index = first, last do
local book = books[index]
local card = BookOrbitDetailRelatedCard:new{
entry = {
text = coverLabel(book),
kind = "dashboard-book",
book_id = book.id,
book = book,
},
dimen = Geom:new{ w = card_w, h = card_h },
menu = self,
}
table.insert(row, card)
table.insert(focus_row, card)
if index < last then
table.insert(row, HorizontalSpan:new{ width = gap })
local grid = VerticalGroup:new{ align = "left" }
for row_index = 1, layout.rows do
local visual_row = HorizontalGroup:new{ align = "center" }
local focus_row = {}
local row_first = first + (row_index - 1) * slots
for slot = 1, slots do
if slot > 1 then
table.insert(visual_row, HorizontalSpan:new{ width = gap })
end
local book = books[row_first + slot - 1]
if book then
local card = BookOrbitDetailRelatedCard:new{
entry = {
text = coverLabel(book),
kind = "dashboard-book",
book_id = book.id,
book = book,
},
dimen = Geom:new{ w = layout.card_w, h = layout.card_h },
menu = self,
}
table.insert(visual_row, card)
table.insert(focus_row, card)
else
table.insert(visual_row, HorizontalSpan:new{ width = layout.card_w })
end
end
if row_index > 1 then
table.insert(grid, VerticalSpan:new{ width = gap })
end
table.insert(grid, visual_row)
table.insert(focus_rows, focus_row)
end
table.insert(row, grid)
if has_nav then
local visible_slots = last - first + 1
for _ = visible_slots + 1, slots do
table.insert(row, HorizontalSpan:new{ width = gap })
table.insert(row, HorizontalSpan:new{ width = card_w })
end
if layout.has_nav then
table.insert(row, HorizontalSpan:new{ width = gap })
local next_button = Button:new{
next_button = Button:new{
icon = "chevron.right",
icon_width = Screen:scaleBySize(16),
icon_height = Screen:scaleBySize(16),
@@ -1248,18 +1288,22 @@ function CatalogDetail:buildDetailRelatedSection(section, width, height)
self:turnDetailRelatedPage(section_id, 1)
end,
}
table.insert(row, next_button)
table.insert(focus_row, next_button)
table.insert(row, CenterContainer:new{
dimen = Geom:new{ w = nav_w, h = layout.total_h },
next_button,
})
table.insert(focus_rows[1], 1, prev_button)
table.insert(focus_rows[1], next_button)
end
return HorizontalGroup:new{
HorizontalSpan:new{ width = DETAIL_INSET },
LeftContainer:new{
dimen = Geom:new{ w = content_w, h = card_h },
dimen = Geom:new{ w = content_w, h = layout.total_h },
row,
},
HorizontalSpan:new{ width = DETAIL_INSET },
}, focus_row
}, focus_rows
end
function CatalogDetail:updateDetailItems(select_number, no_recalculate_dimen)
@@ -1320,7 +1364,13 @@ function CatalogDetail:updateDetailItems(select_number, no_recalculate_dimen)
if widget then
table.insert(self.item_group, widget)
if section_focus and #section_focus > 0 then
table.insert(self.layout, section_focus)
if section_focus.is_grid then
for _, row in ipairs(section_focus) do
if #row > 0 then table.insert(self.layout, row) end
end
else
table.insert(self.layout, section_focus)
end
end
end
end