diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua index 7d77dc59c..b9cb90687 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua @@ -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] diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua index 18bf6c92e..3099dea6a 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua @@ -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 diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua index e7f83cecb..a2bbee477 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua @@ -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, diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua index e126f4d85..c820dce03 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua @@ -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, }, } diff --git a/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua b/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua index 4061f2030..9c189e905 100644 --- a/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua +++ b/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua @@ -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") diff --git a/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua b/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua index 5be4391ac..75b434a1e 100644 --- a/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua +++ b/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua @@ -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. diff --git a/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua b/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua index 6a5b34e62..ffaaf489f 100644 --- a/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua +++ b/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua @@ -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")