feat(koreader): refine catalog detail layout and widget height calculations

Improve KOReader plugin catalog detail view layout by auto-adjusting widget text heights, fixing series line resolution, and polishing star rating, progress button, and navigation button styling.
This commit is contained in:
neon
2026-07-31 00:22:16 +00:00
parent 40e366a463
commit 5cea03799e
7 changed files with 305 additions and 126 deletions
@@ -1717,7 +1717,8 @@ function BookOrbitCatalog:menuChromeHeight()
if self:dashboardMode() then
-- Reserve a deliberate bottom margin so the single-page dashboard does not
-- run edge-to-edge (the pagination footer that normally fills this is hidden).
bottom_height = Screen:scaleBySize(40)
-- Kept modest: the rest of the page buys its cover sizes out of this.
bottom_height = Screen:scaleBySize(16)
elseif self:detailMode() then
local prev_book, next_book, current_idx = self:getAdjacentDetailBooks()
local parent = self.stack[#self.stack]
@@ -54,7 +54,10 @@ local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton
-- How long the local reading-stats summary is reused before re-querying
-- statistics.sqlite3 (the dashboard re-renders on every row page turn).
local STATS_CACHE_TTL = 120
local SECTION_TARGET_SLOTS = 5
-- Six columns is what makes a two-up Continue reading card exactly three shelf
-- columns wide at any screen size: one for its cover, two for its text. That is
-- also the width the hero sizes its cover from, so the two rows agree.
local SECTION_TARGET_SLOTS = 6
local SECTION_COMPACT_GAP = 6
local STATS_MIN_BODY_HEIGHT = 56
local SECTION_PAGE_PREFIX = "section"
@@ -63,6 +66,10 @@ local SECTION_PAGE_PREFIX = "section"
local SHELF_PAGE_SIZE = 12
-- Cover cards never get narrower than this, however tight the page is.
local SHELF_MIN_CARD_WIDTH = 72
-- Bounds on the Continue reading hero, which is otherwise sized to match the
-- shelf cover.
local HERO_MIN_HEIGHT = 76
local HERO_MAX_HEIGHT = 170
-- Header controls are drawn inside the section header's label row, so the row
-- stays as tall as a header without controls; the tap box reaches into the gap
-- above and below it to stay finger-sized.
@@ -934,8 +941,10 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local function px(n) return Screen:scaleBySize(n) end
local avail = self.available_height
local inner_gap = px(9)
local top_gap = px(4)
local section_gap = px(18)
-- Lets the greeting in the title bar breathe before the first section
-- starts, rather than the stats rule sitting right under it.
local top_gap = px(10)
local section_gap = px(20)
local compact_gap = px(SECTION_COMPACT_GAP)
self.dash_inner_gap = inner_gap
self.dash_used = 0
@@ -945,7 +954,9 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local browse_row_h = px(42)
local browse_cols = 3
local browse_rows = 3
local hero_h = math.min(px(150), math.max(px(76), math.floor(avail * 0.18)))
-- A provisional height: once the shelves have settled their column width the
-- hero is re-sized from their cover, below.
local hero_h = math.min(px(HERO_MAX_HEIGHT), math.max(px(HERO_MIN_HEIGHT), math.floor(avail * 0.18)))
local status_widget
if context.stale and not context.loading and context.dashboard then
@@ -971,8 +982,10 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local fixed = top_gap + status_h
local shelf_count = 0
local max_shelf_books = 0
local visible = 0
for index = 1, DashboardSections.SLOT_COUNT do
if not hidden[index] then
visible = visible + 1
local config = configs[index]
if config.type == "stats" then
stats_widgets[index] = stats_widgets[index] or self:buildDashboardStatsStrip(summary, dashboard)
@@ -993,6 +1006,9 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
end
end
-- The gap separates sections, so the last one does not carry one: that
-- space belongs to the page's bottom margin, not to the section.
if visible > 0 then fixed = fixed - section_gap end
return fixed, shelf_count, max_shelf_books
end
@@ -1025,11 +1041,37 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
end
while not pageFits() do
local drop = slotToDrop()
if not drop then break end
hidden[drop] = true
fixed_h, shelf_count, max_shelf_books = measureFixed()
local function fitPage()
while not pageFits() do
local drop = slotToDrop()
if not drop then break end
hidden[drop] = true
fixed_h, shelf_count, max_shelf_books = measureFixed()
end
end
fitPage()
-- Now that the shelves have settled their column width, grow Continue
-- reading so its cover is the same size as a shelf cover rather than the
-- smallest cover on the page. Only taken when it costs nothing: a page too
-- tight to absorb it keeps the shelf it already has, since a matching hero
-- is not worth a denser row of covers or a dropped section. pageFits() only
-- recomputes the shelf metrics, so trying it here changes nothing else.
if shelf_card_w > 0 then
local settled_slots = shelf_slots
local matched = math.min(px(HERO_MAX_HEIGHT),
math.max(px(HERO_MIN_HEIGHT), CatalogWidgets.dashboardHeroHeightForCoverCard(shelf_card_w)))
if matched ~= hero_h then
local provisional = hero_h
hero_h = matched
fixed_h, shelf_count, max_shelf_books = measureFixed()
if not pageFits() or shelf_slots ~= settled_slots then
hero_h = provisional
fixed_h, shelf_count, max_shelf_books = measureFixed()
pageFits()
end
end
end
self:addDashboardSpacer(top_gap)
@@ -1128,8 +1170,12 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
self.dash_section_bands[page_id] = { y = band_start, h = self.dash_used - band_start }
end
local rendered = 0
for index = 1, DashboardSections.SLOT_COUNT do
if not hidden[index] then
if rendered > 0 then
self:addDashboardSpacer(section_gap)
end
local config = configs[index]
if config.type == "stats" then
renderStats(index)
@@ -1142,7 +1188,7 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
else
renderShelf(index, config)
end
self:addDashboardSpacer(section_gap)
rendered = rendered + 1
end
end
@@ -57,16 +57,27 @@ local shortText = CatalogUtil.shortText
local buildCoverWidget = CatalogWidgets.buildCoverWidget
local BookOrbitDetailRelatedCard = CatalogWidgets.DetailRelatedCard
local BookOrbitDetailTabButton = CatalogWidgets.DetailTabButton
local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton
local BookOrbitDetailRatingStar = CatalogWidgets.DetailRatingStar
-- Max genres/tags shown inline on the detail page before a "+N more" pill.
local DETAIL_TAGS_INLINE = 6
local DETAIL_CACHE_MAX_BOOKS = 80
-- Comfortably more description than fits on any screen the plugin runs on; the
-- overview box does the actual truncating.
local DETAIL_OVERVIEW_MAX_CHARS = 2000
-- Shared spacing scale for the detail page. INSET is the left/right content
-- margin every section aligns to; the gaps are the only vertical rhythm used.
local DETAIL_INSET = Screen:scaleBySize(16)
local DETAIL_RELATED_SHELF_INSET = Screen:scaleBySize(12)
-- The related shelf's paging controls are its outermost elements, so they sit
-- on the same margin as every other section rather than the shelf being nudged
-- out on an inset of its own.
local DETAIL_RELATED_SHELF_INSET = DETAIL_INSET
-- Matches the dashboard's section-header controls, so the two pages share one
-- paging idiom instead of the shelf using a smaller, fainter pair.
local DETAIL_NAV_BOX = Screen:scaleBySize(26)
local DETAIL_NAV_ICON = Screen:scaleBySize(19)
local DETAIL_GAP_XS = Screen:scaleBySize(4)
local DETAIL_GAP_S = Screen:scaleBySize(8)
local DETAIL_GAP_M = Screen:scaleBySize(14)
@@ -101,7 +112,19 @@ function DetailDescriptionWidget:init()
local face = Font:getFace("smallinfofont", 15)
local text_h = math.max(lineHeight(face), self.max_height)
local dimen = Geom:new{ w = self.width, h = text_h }
-- height_adjust lets a short description end where its text ends instead of
-- reserving the full block, so the tabs below it do not float on a band of
-- white; a long one is still capped and ellipsized at max_height.
local box = TextBoxWidget:new{
text = BD.auto(text),
width = text_w,
height = text_h,
height_adjust = true,
height_overflow_show_ellipsis = true,
face = face,
}
local dimen = Geom:new{ w = self.width, h = box:getSize().h }
self.dimen = dimen
local has_desc = description ~= nil
@@ -113,13 +136,7 @@ function DetailDescriptionWidget:init()
self[1] = HorizontalGroup:new{
HorizontalSpan:new{ width = DETAIL_INSET },
TextBoxWidget:new{
text = BD.auto(text),
width = text_w,
height = text_h,
height_overflow_show_ellipsis = true,
face = face,
},
box,
HorizontalSpan:new{ width = DETAIL_INSET },
}
end
@@ -145,7 +162,19 @@ function DetailOverviewWidget:init()
local face = Font:getFace("x_smallinfofont")
local text_h = math.max(lineHeight(face), self.max_height)
local dimen = Geom:new{ w = self.width, h = text_h }
local box = TextBoxWidget:new{
text = table.concat(self.menu:detailOverviewLines(self.detail), "\n"),
width = text_w,
height = text_h,
height_adjust = true,
height_overflow_show_ellipsis = true,
face = face,
}
-- The box shrinks to its text, so the widget must report that height too:
-- reporting the full block would leave the page padded out with white below
-- a short description.
local dimen = Geom:new{ w = self.width, h = box:getSize().h }
self.dimen = dimen
local has_desc = description ~= nil
@@ -157,14 +186,7 @@ function DetailOverviewWidget:init()
self[1] = HorizontalGroup:new{
HorizontalSpan:new{ width = DETAIL_INSET },
TextBoxWidget:new{
text = table.concat(self.menu:detailOverviewLines(self.detail), "\n"),
width = text_w,
height = text_h,
height_adjust = true,
height_overflow_show_ellipsis = true,
face = face,
},
box,
HorizontalSpan:new{ width = DETAIL_INSET },
}
end
@@ -898,7 +920,10 @@ function CatalogDetail:detailOverviewLines(detail)
local description = cleanDescriptionText(detail.description)
if description then
table.insert(lines, _("Description"))
table.insert(lines, shortText(description, 360))
-- A bound on layout work, not a display limit: the overview box gets the
-- whole rest of the page and ellipsizes at its own height, so cutting
-- the text short here only stranded it mid-sentence above empty space.
table.insert(lines, shortText(description, DETAIL_OVERVIEW_MAX_CHARS))
end
if #lines == 0 then
@@ -1102,15 +1127,20 @@ end
function CatalogDetail:buildDetailRating(detail, width)
local rating = tonumber(detail.rating) or 0
local star_w = CatalogWidgets.detailRatingStarWidth()
local star_gap = DETAIL_GAP_XS
local row = HorizontalGroup:new{ align = "center" }
self.detail_rating_stars = {}
for star = 1, 5 do
if star > 1 then
table.insert(row, HorizontalSpan:new{ width = star_gap })
end
local widget = BookOrbitDetailRatingStar:new{
entry = {
rating = star,
filled = rating >= star,
},
dimen = Geom:new{ w = DETAIL_STAR_SIZE, h = DETAIL_STAR_SIZE },
dimen = Geom:new{ w = star_w, h = DETAIL_STAR_SIZE },
menu = self,
}
table.insert(row, widget)
@@ -1118,9 +1148,10 @@ function CatalogDetail:buildDetailRating(detail, width)
end
table.insert(row, HorizontalSpan:new{ width = DETAIL_GAP_S })
local label_face = Font:getFace("xx_smallinfofont", 12)
local stars_w = 5 * star_w + 4 * star_gap
table.insert(row, TextBoxWidget:new{
text = rating > 0 and formatRating(rating) or _("Not rated"),
width = math.max(1, width - 5 * DETAIL_STAR_SIZE - DETAIL_GAP_S),
width = math.max(1, width - stars_w - DETAIL_GAP_S),
height = lineHeight(label_face),
height_overflow_show_ellipsis = true,
face = label_face,
@@ -1199,12 +1230,16 @@ end
-- glanceable and one tap to change, without competing with the action button.
function CatalogDetail:buildDetailProgress(detail, width)
local label_face = Font:getFace("xx_smallinfofont", 12)
-- A caption, not a headline: the chevron carries the affordance, so it sits
-- at the same weight and size as the progress text it shares the line with
-- rather than out-shouting it.
self.detail_status_button = Button:new{
text = (self:readStatusLabel(detail) or _("Unread")) .. " ▾",
bordersize = 0,
margin = 0,
padding = 0,
text_font_size = 13,
text_font_size = 12,
text_font_bold = false,
callback = function()
self:showSetStatusDialog(detail)
end,
@@ -1276,7 +1311,9 @@ function CatalogDetail:buildDetailHeader(detail, width)
local title_face = Font:getFace("cfont", 21)
local author_face = Font:getFace("smallinfofont", 16)
local meta_face = Font:getFace("x_smallinfofont", 13)
local series_line = self:detailSeriesLine(detail)
-- Static helper: calling it with `self:` passes the catalog as the detail
-- and silently yields no series line at all.
local series_line = CatalogDetail.detailSeriesLine(detail)
local meta_line = self:detailHeroMetaLine(detail)
local function buildTop(with_series)
@@ -1441,7 +1478,7 @@ function CatalogDetail:buildDetailRelatedSection(section, width, height)
local content_w = width - 2 * DETAIL_RELATED_SHELF_INSET
local gap = Screen:scaleBySize(10)
local nav_w = Screen:scaleBySize(20)
local nav_w = DETAIL_NAV_BOX
local nav_gap = Screen:scaleBySize(4)
local nav_gutter_w = nav_w + nav_gap
local slots = 4
@@ -1482,19 +1519,21 @@ function CatalogDetail:buildDetailRelatedSection(section, width, height)
local row = HorizontalGroup:new{ align = "center" }
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),
width = nav_w,
bordersize = 0,
show_border = false,
enabled = page > 1,
local function navButton(icon, enabled, delta)
return BookOrbitDashboardIconButton:new{
entry = { icon = icon },
dimen = Geom:new{ x = 0, y = 0, w = nav_w, h = nav_w },
icon_size = DETAIL_NAV_ICON,
enabled = enabled,
callback = function()
self:turnDetailRelatedPage(section_id, -1)
self:turnDetailRelatedPage(section_id, delta)
end,
menu = self,
}
end
if layout.has_nav then
prev_button = navButton("chevron.left", page > 1, -1)
table.insert(row, CenterContainer:new{
dimen = Geom:new{ w = nav_w, h = layout.total_h },
prev_button,
@@ -1541,18 +1580,7 @@ function CatalogDetail:buildDetailRelatedSection(section, width, height)
if layout.has_nav then
table.insert(row, HorizontalSpan:new{ width = nav_gap })
next_button = Button:new{
icon = "chevron.right",
icon_width = Screen:scaleBySize(16),
icon_height = Screen:scaleBySize(16),
width = nav_w,
bordersize = 0,
show_border = false,
enabled = page < page_count,
callback = function()
self:turnDetailRelatedPage(section_id, 1)
end,
}
next_button = navButton("chevron.right", page < page_count, 1)
table.insert(row, CenterContainer:new{
dimen = Geom:new{ w = nav_w, h = layout.total_h },
next_button,
@@ -54,6 +54,12 @@ local READ_STATUS_BADGE_ICONS = {
local CatalogWidgets = {}
-- Mirrors TextBoxWidget's own line height math so text blocks can be sized in
-- whole lines (same helper as the detail page uses).
local function lineHeight(face)
return math.floor(1.3 * face.size + 0.5)
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.
@@ -128,6 +134,19 @@ function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet, borde
local inner_w = math.max(1, width - 2 * Size.padding.default - 2 * bordersize)
local inner_h = math.max(1, height - 2 * Size.padding.default - 2 * bordersize)
if quiet then
local label = TextWidget:new{
text = footer or _("No cover"),
face = Font:getFace("xx_smallinfofont"),
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
max_width = inner_w,
}
-- On a narrow cover the label survives only as an initial and an
-- ellipsis, which reads as damage; an empty frame says "no cover"
-- better than "C..." does.
if label:isTruncated() then
label:free()
label = HorizontalSpan:new{ width = inner_w }
end
return FrameContainer:new{
width = width,
height = height,
@@ -137,47 +156,60 @@ function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet, borde
background = Blitbuffer.COLOR_WHITE,
CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
TextWidget:new{
text = footer or _("No cover"),
face = Font:getFace("xx_smallinfofont"),
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
max_width = inner_w,
},
label,
},
}
end
local title_h = math.floor(inner_h * 0.58)
local author_h = math.floor(inner_h * 0.22)
local footer_h = math.max(1, inner_h - title_h - author_h)
-- Blocks are measured in whole lines and each takes only what it needs, so
-- the group can be centred in the cover box. Splitting the box into fixed
-- proportions instead left a short title floating above a band of white and
-- clipped a two-word author to "Arkady...". Anything that still does not fit
-- is dropped from the bottom up rather than ellipsized into a fragment.
-- Sized explicitly: the face defaults run 22/20/18, so leaving the author
-- and footer unsized set them larger than a title asked for at 16 and stood
-- the hierarchy on its head.
local title_face = Font:getFace("smallinfofont", 17)
local author_face = Font:getFace("x_smallinfofont", 14)
local footer_face = Font:getFace("xx_smallinfofont", 12)
local gap = Size.span.vertical_default
local author = book and firstAuthor(book) or nil
local has_footer = footer ~= nil and footer ~= ""
local content = VerticalGroup:new{ align = "center" }
table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(content, TextBoxWidget:new{
text = BD.auto(shortText(book and book.title or _("Untitled"), 60)),
width = inner_w,
height = title_h,
alignment = "center",
face = Font:getFace("smallinfofont", 16),
height_overflow_show_ellipsis = true,
})
table.insert(content, TextBoxWidget:new{
text = author and BD.auto(shortText(author, 44)) or "",
width = inner_w,
height = author_h,
alignment = "center",
face = Font:getFace("x_smallinfofont"),
height_overflow_show_ellipsis = true,
})
table.insert(content, TextBoxWidget:new{
text = footer or "",
width = inner_w,
height = footer_h,
alignment = "center",
face = Font:getFace("xx_smallinfofont"),
height_overflow_show_ellipsis = true,
})
local function textBlock(text, face, max_lines)
return TextBoxWidget:new{
text = text,
width = inner_w,
height = max_lines * lineHeight(face),
height_adjust = true,
alignment = "center",
face = face,
height_overflow_show_ellipsis = true,
}
end
local function buildContent(with_author, with_footer)
local group = VerticalGroup:new{ align = "center" }
table.insert(group, textBlock(
BD.auto(shortText(book and book.title or _("Untitled"), 60)), title_face, 3))
if with_author then
table.insert(group, VerticalSpan:new{ width = gap })
table.insert(group, textBlock(BD.auto(shortText(author, 44)), author_face, 2))
end
if with_footer then
table.insert(group, VerticalSpan:new{ width = gap })
table.insert(group, textBlock(footer, footer_face, 2))
end
return group
end
local content = buildContent(author ~= nil, has_footer)
if content:getSize().h > inner_h and has_footer then
content = buildContent(author ~= nil, false)
end
if content:getSize().h > inner_h and author then
content = buildContent(false, false)
end
return FrameContainer:new{
width = width,
@@ -232,9 +264,12 @@ function CatalogWidgets.buildCoverWidget(book, width, height, path, state, opts)
end
-- Quiet placeholders are reserved for covers still loading; a cover that is
-- missing or failed keeps the titled fake cover so the book stays
-- identifiable on caption-less shelves.
return CatalogWidgets.buildFakeCover(
book, width, height, footer, opts.quiet_placeholder == true and state == "loading", bordersize)
-- identifiable on caption-less shelves. always_quiet is for callers whose
-- own row already names the book, where a titled placeholder would only
-- repeat it - badly, since those covers are far too narrow to set it in.
local quiet = opts.always_quiet == true
or (opts.quiet_placeholder == true and state == "loading")
return CatalogWidgets.buildFakeCover(book, width, height, footer, quiet, bordersize)
end
-- Badges sit on a small white plate so they stay legible over busy cover art;
@@ -287,8 +322,8 @@ function CatalogWidgets.buildDownloadedBadge(max_width)
return badgeChip(IconWidget:new(opts))
end
function CatalogWidgets.buildCoverWithStateBadges(book, width, height, path, state, downloaded, selected)
local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state)
function CatalogWidgets.buildCoverWithStateBadges(book, width, height, path, state, downloaded, selected, cover_opts)
local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state, cover_opts)
if not downloaded and not selected then return cover end
local group = OverlapGroup:new{
@@ -346,7 +381,10 @@ function MosaicItem:init()
local book = self.entry.book
local show_label = self.menu.mosaic_show_titles == true
local bar_reserve = hasProgress(book) and (PROGRESS_BAR_HEIGHT + Size.span.vertical_default) or 0
-- Reserved for every cell, drawn only where there is progress: sizing the
-- cell from whether this particular book has a bar left covers in the same
-- row sitting a few pixels off each other.
local bar_reserve = PROGRESS_BAR_HEIGHT + Size.span.vertical_default
local label_h = show_label and math.max(Screen:scaleBySize(44), math.floor(self.dimen.h * 0.24)) or 0
local label_gap = show_label and Size.span.vertical_default or 0
local max_cover_w = math.max(1, self.dimen.w - 2 * Size.padding.default)
@@ -361,12 +399,14 @@ function MosaicItem:init()
local content = VerticalGroup:new{ align = "center" }
table.insert(
content,
CatalogWidgets.buildCoverWithStateBadges(book, cover_w, cover_h, path, state, downloaded, selected))
-- With labels on, the cell already names the book below the cover, so a
-- titled placeholder would just say it twice.
CatalogWidgets.buildCoverWithStateBadges(
book, cover_w, cover_h, path, state, downloaded, selected,
{ always_quiet = show_label }))
local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, cover_w)
if bar then
table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(content, bar)
end
table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(content, bar or VerticalSpan:new{ width = PROGRESS_BAR_HEIGHT })
if show_label then
local label_text = shortText(book and book.title or _("Untitled"), 30)
local label_w = math.max(1, self.dimen.w - 2 * Size.padding.tiny)
@@ -442,7 +482,9 @@ function ListItem:init()
local content_h = math.max(1, inner_h - 2 * Size.padding.small)
local cover_h = math.max(Screen:scaleBySize(38), math.min(Screen:scaleBySize(74), content_h))
local cover_w = math.floor(cover_h * 0.68)
local bar_reserve = hasProgress(book) and (PROGRESS_BAR_HEIGHT + Size.span.vertical_default) or 0
-- Reserved on every row so the title and subtitle sit on the same lines
-- whether or not this book has a progress bar under them.
local bar_reserve = PROGRESS_BAR_HEIGHT + Size.span.vertical_default
local left_w = cover_w + 2 * pad
local side_meta_text = self.menu:listSideMetaText(book)
local show_side_meta = side_meta_text ~= "" and self.dimen.w >= Screen:scaleBySize(520)
@@ -492,10 +534,8 @@ function ListItem:init()
local body_col = VerticalGroup:new{ align = "left" }
table.insert(body_col, text_col)
local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, main_w)
if bar then
table.insert(body_col, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(body_col, bar)
end
table.insert(body_col, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(body_col, bar or VerticalSpan:new{ width = PROGRESS_BAR_HEIGHT })
local row_dimen = Geom:new{ w = self.dimen.w, h = inner_h }
local row = OverlapGroup:new{
@@ -504,7 +544,13 @@ function ListItem:init()
dimen = row_dimen:copy(),
CenterContainer:new{
dimen = Geom:new{ w = left_w, h = inner_h },
CatalogWidgets.buildCoverWithStateBadges(book, cover_w, cover_h, path, state, downloaded, selected),
-- The meta column already spells out "On device", so the badge
-- would only repeat it on a cover far too small to spare the
-- room; it stays for the narrow layouts that hide that column.
CatalogWidgets.buildCoverWithStateBadges(
book, cover_w, cover_h, path, state,
downloaded and not show_side_meta, selected,
{ always_quiet = true }),
},
},
LeftContainer:new{
@@ -587,12 +633,6 @@ local function cardFrame(width, height, padding, child)
}
end
-- Mirrors TextBoxWidget's own line height math so single-line boxes can be
-- sized exactly (same helper as the detail page uses).
local function lineHeight(face)
return math.floor(1.3 * face.size + 0.5)
end
-- Caption fonts under captioned dashboard cover cards (title + author/percent).
local CAPTION_TITLE_FONT_SIZE = 13
local CAPTION_SUB_FONT_SIZE = 11
@@ -629,6 +669,16 @@ function CatalogWidgets.coverCardHeight(card_w, with_progress, with_caption)
return cover_h + bar_h + caption_h + 2 * COVER_CARD_PAD + 2 * CARD_BORDER
end
-- The Continue reading hero card height whose cover comes out the same size as
-- a shelf cover card of the given width. The hero is otherwise sized from a
-- share of the page, which left the featured row showing a smaller cover than
-- the browse shelf under it.
function CatalogWidgets.dashboardHeroHeightForCoverCard(card_w)
local cover_h = coverCardCoverHeight(
CatalogWidgets.coverCardHeight(card_w, false, false), false, false)
return cover_h + 2 * Size.padding.default + 2 * CARD_BORDER
end
function CatalogWidgets.detailRelatedCardWidth(card_h)
local cover_h = math.max(Screen:scaleBySize(54), card_h - 2 * COVER_CARD_PAD - 2 * CARD_BORDER)
local cover_w = math.floor(cover_h * COVER_ASPECT)
@@ -1278,6 +1328,22 @@ function DetailTabButton:onHoldSelect()
return self:onTapSelect()
end
local DETAIL_STAR_FONT_SIZE = 22
-- The width a star cell needs to hold its glyph. Sizing cells to the glyph
-- rather than to the row height keeps the first star flush with the title and
-- author above it; a square cell would centre the glyph and indent the row.
function CatalogWidgets.detailRatingStarWidth()
local probe = TextWidget:new{
text = "★",
face = Font:getFace("cfont", DETAIL_STAR_FONT_SIZE),
bold = true,
}
local width = probe:getSize().w
probe:free()
return width + 2 * Size.padding.tiny
end
local DetailRatingStar = InputContainer:extend{
entry = nil,
dimen = nil,
@@ -1299,7 +1365,7 @@ function DetailRatingStar:init()
height = self.dimen.h,
alignment = "center",
bold = true,
face = Font:getFace("cfont", 22),
face = Font:getFace("cfont", DETAIL_STAR_FONT_SIZE),
height_overflow_show_ellipsis = true,
},
}
@@ -180,9 +180,9 @@ catalog = newCatalog{}
catalog.content_w = 500
catalog.dash_inner_gap = 9
local slots, card_w, grid_h = catalog:sectionRowMetrics(12, 6)
assertEqual(slots, 5, "a wide dashboard budgets five cover slots")
assertEqual(card_w, 95, "cover width subtracts only the four inter-card gaps")
assertEqual(grid_h, 115, "grid height follows the measured cover-card height")
assertEqual(slots, 6, "a wide dashboard budgets six cover slots, the column cap")
assertEqual(card_w, 78, "cover width subtracts only the five inter-card gaps")
assertEqual(grid_h, 98, "grid height follows the measured cover-card height")
catalog.content_w = 320
slots, card_w, grid_h = catalog:sectionRowMetrics(12, 6)
@@ -194,8 +194,8 @@ assertEqual(grid_h, 95, "the narrower grid recomputes its card height")
-- always the height those cards need, so covers are never drawn out of aspect.
catalog.content_w = 500
slots, card_w, grid_h = catalog:shelfRowMetrics(12, 6, 999)
assertEqual(slots, 5, "a shelf with room to spare keeps its natural row")
assertEqual(grid_h, 115, "an unconstrained shelf keeps its natural height")
assertEqual(slots, 6, "a shelf with room to spare keeps its natural row")
assertEqual(grid_h, 98, "an unconstrained shelf keeps its natural height")
slots, card_w, grid_h = catalog:shelfRowMetrics(12, 6, 100)
assertEqual(slots, 6, "a tight shelf fits more cards per row")
@@ -77,6 +77,9 @@ package.loaded["bookorbit_catalog_widgets"] = {
coverCardHeight = function(card_w)
return card_w + 20
end,
dashboardHeroHeightForCoverCard = function(card_w)
return card_w + 24
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
@@ -162,7 +165,7 @@ catalog:updateDashboardItems()
assertEqual(#catalog.shelves, 1, "the default dashboard renders its one shelf")
assertEqual(#catalog.headers, 3, "Continue reading, the shelf and Browse each get a header")
assertEqual(catalog.shelves[1].height, catalog.shelves[1].card_w + 20, "a roomy shelf is drawn in aspect")
assertEqual(catalog.shelves[1].cols, 5, "a roomy shelf keeps its natural slot count")
assertEqual(catalog.shelves[1].cols, 6, "a roomy shelf keeps its natural slot count")
assertEqual(catalog.dash_used <= 800, true, "a roomy page stays inside the available height")
-- Four shelves share the page and stay in aspect: the cards get narrower rather
@@ -173,7 +176,7 @@ assertEqual(#catalog.shelves, 4, "four configured shelves all render")
for _, shelf in ipairs(catalog.shelves) do
assertEqual(shelf.height, shelf.card_w + 20, "every shelf is drawn in aspect")
end
assertEqual(catalog.shelves[1].cols > 5, true, "a crowded page fits more cards per row")
assertEqual(catalog.shelves[1].cols, 6, "a crowded page still stops at the column cap")
assertEqual(catalog.dash_used <= 600, true, "four shelves stay inside the available height")
-- Too tight for everything: the stats strip goes before any shelf does.
@@ -59,10 +59,12 @@ package.loaded["ffi/util"] = {
end,
}
package.loaded["gettext"] = setmetatable({}, { __call = function(_, value) return value end })
local widget_stub = stubWidget()
package.loaded["bookorbit_catalog_widgets"] = {
buildCoverWidget = function() end,
buildDetailPill = function() end,
buildDetailProgressBar = function() end,
buildCoverWidget = function() return widget_stub:new() end,
buildDetailPill = function() return widget_stub:new() end,
buildDetailProgressBar = function() return widget_stub:new() end,
detailRatingStarWidth = function() return 20 end,
DetailRelatedCard = stubWidget(),
DetailTabButton = stubWidget(),
DetailRatingStar = stubWidget(),
@@ -199,4 +201,37 @@ assertEqual(menu:downloadButtonLabel({ { format = "epub", sizeBytes = 2411724 }
assertEqual(menu:downloadButtonLabel({ { format = "epub" } }), "Download (EPUB)",
"an unknown size leaves the original label untouched")
-- Hero series line
--
-- Asserted through buildDetailHeader rather than against the helper directly:
-- the helper is a plain function, and the header used to call it as a method,
-- which passed the catalog as the detail and silently dropped the line from
-- every book page. Only the real call path catches that.
local header_menu = setmetatable({
available_height = 800,
inner_dimen = { w = 600, h = 900 },
on_device = {},
current_context = { supported_files = {} },
}, { __index = CatalogDetail })
function header_menu:thumbnailDisplay() return nil, "failed" end
function header_menu:isOnDevice() return false end
function header_menu:readStatusLabel() return nil end
function header_menu:supportedFiles() return {} end
function header_menu:onDeviceFilePath() return nil end
function header_menu:downloadButtonLabel() return "Download" end
header_menu:buildDetailHeader({
title = "The Left Hand of Darkness",
authors = { "Ursula K. Le Guin" },
seriesName = "Hainish Cycle",
seriesIndex = 4,
}, 600)
assertEqual(header_menu.detail_series_line ~= nil, true,
"a book in a series renders its hero series line")
header_menu:buildDetailHeader({ title = "Standalone", authors = { "Someone" } }, 600)
assertEqual(header_menu.detail_series_line, nil,
"a book with no series renders no series line")
print("bookorbit_detail_info_rows_test.lua: ok")