Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_detail_view.lua

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
--[[--
The book detail page, drawn from the view model in bookorbit_detail_data.

Two pages. The main page is the jacket (the cover beside the book's series,
title, author, rating and status, where the reader stands, and the action),
then the key facts, the description, genres, the series path and a shelf.
More details holds everything else. Below the jacket, sections are fitted to
the screen by priority and the description takes the room that is left, so
a page never scrolls and never ends in a hole.

Sizes are the design mock's dp values through BoardMetrics, so the page and
the mock lay out with the same pixels. Every control is its own tappable,
focusable region: a D-pad press is a tap at the focused region's centre.
]]

local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local InputContainer = require("ui/widget/container/inputcontainer")
local TextBoxWidget = require("ui/widget/textboxwidget")
local Widget = require("ui/widget/widget")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local BD = require("ui/bidi")
local T = require("ffi/util").template
local _ = require("gettext")

local BoardMetrics = require("bookorbit_board_metrics")
local BoardUI = require("bookorbit_board_ui")
local CatalogWidgets = require("bookorbit_catalog_widgets")
local DetailData = require("bookorbit_detail_data")

local INK = BoardMetrics.INK
local px, lh1, lh = BoardMetrics.px, BoardMetrics.lh1, BoardMetrics.lh
local text, runs, vstack, hstack, flex, space, icon = BoardUI.text, BoardUI.runs, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space, BoardUI.icon

local DetailView = {}

local DEBUG = os.getenv("BO_DETAIL_DEBUG") ~= nil

local function hair() return math.max(1, px(0.75)) end
local function line1() return math.max(1, px(1)) end

local function clamp(v, lo, hi)
    if v < lo then return lo end
    if v > hi then return hi end
    return v
end

-- The screen's width in dp. KOReader rounds its scale up at the sixth
-- decimal, so a 600 dp screen measures 599.9994; thresholds compare the
-- rounded value, as the design mock does.
local function screenW()
    return BoardMetrics.layoutW()
end

local function wOf(widget) return widget:getSize().w end
local function hOf(widget) return widget:getSize().h end

-- ----------------------------------------------------------------- regions
-- A tappable, focusable piece of the page. Built inside a view build, it is
-- recorded in the focus row it belongs to.
local Region = InputContainer:extend{
    action = nil,
    run = nil,
}

function Region:init()
    local size = self[1]:getSize()
    self.dimen = Geom:new{ w = size.w, h = size.h }
    self.ges_events = {
        TapSelect = { GestureRange:new{ ges = "tap", range = function() return self.dimen end } },
        HoldSelect = { GestureRange:new{ ges = "hold", range = function() return self.dimen end } },
    }
end

function Region:handleEvent(event)
    if event.handler == "onTapSelect" or event.handler == "onHoldSelect" then
        return Widget.handleEvent(self, event)
    end
    return InputContainer.handleEvent(self, event)
end

function Region:onTapSelect()
    if self.action and self.run then self.run(self.action, self) end
    return true
end

function Region:onHoldSelect()
    if self.action and self.run then self.run(self.action, self, true) end
    return true
end

local build_ctx = nil

-- Wraps widget as a control. row: the focus row it joins (rows are walked top
-- to bottom by D-pad); key: a name to find it again (partial refreshes).
local function region(widget, action, row, key)
    local r = Region:new{ action = action, run = build_ctx and build_ctx.run, CatalogWidgets.focusable(widget) }
    if build_ctx and row then
        local rows = build_ctx.rows
        rows[row] = rows[row] or {}
        table.insert(rows[row], r)
        if not build_ctx.row_order[row] then
            build_ctx.row_order[row] = true
            table.insert(build_ctx.row_list, row)
        end
    end
    if build_ctx and key then build_ctx.regions[key] = r end
    return r
end

-- A transparent wrapper that remembers where it was painted, for the parity
-- dump. Only built when the dump is on.
local MARKS = os.getenv("BO_DETAIL_DUMP") ~= nil
local Mark = WidgetContainer:extend{ key = nil }
function Mark:getSize() return self[1]:getSize() end
function Mark:paintTo(bb, x, y)
    local size = self[1]:getSize()
    self.dimen = Geom:new{ x = x, y = y, w = size.w, h = size.h }
    self[1]:paintTo(bb, x, y)
end
function Mark:free() if self[1].free then self[1]:free() end end
local function mark(key, widget)
    if not MARKS or not build_ctx or not widget then return widget end
    local m = Mark:new{ key = key, widget }
    build_ctx.regions[key] = m
    return m
end
DetailView.mark = mark

-- -------------------------------------------------------------- text tools
local lines_cache, lines_count = {}, 0

-- How many lines a text block takes at a width (TextBoxWidget wraps the
-- same way it will paint).
local function linesNeeded(str, o, width)
    local key = table.concat({ tostring(str), o.s or 14, tostring(o.b), tostring(o.serif), width, BoardMetrics.get().dp }, "|")
    if lines_cache[key] then return lines_cache[key] end
    local face_kind = o.serif and (o.i and "serif_italic" or "serif") or (o.i and "sans_italic" or "sans")
    local tb = TextBoxWidget:new{
        text = BD.auto(tostring(str or "")),
        face = BoardMetrics.face(o.s or 14, face_kind),
        bold = o.b == true,
        width = width,
    }
    local n = math.max(1, math.floor(tb:getSize().h / lh(o.s or 14) + 0.5))
    tb:free()
    if lines_count > 300 then lines_cache, lines_count = {}, 0 end
    lines_cache[key] = n
    lines_count = lines_count + 1
    return n
end
DetailView.linesNeeded = linesNeeded

-- The narrowest box that keeps the same line count: a title never strands
-- one word on its last line.
local function balancedWidth(str, o, width, lines)
    if lines <= 1 then return width end
    -- Never narrower than the widest word with its punctuation, or a line
    -- would start on the comma after it.
    local widest = 0
    for word in tostring(str or ""):gmatch("%S+") do
        local w = text(word, o)
        widest = math.max(widest, wOf(w))
        if w.free then w:free() end
    end
    local lo, hi = math.min(width, math.max(math.floor(width * 0.5 + 0.5), widest + px(2))), width
    while hi - lo > 4 do
        local mid = math.floor((lo + hi) / 2 + 0.5)
        if linesNeeded(str, o, mid) <= lines then hi = mid else lo = mid end
    end
    return hi
end

local function textWidth(str, o)
    local w = text(str, o)
    local width = wOf(w)
    if w.free then w:free() end
    return width
end

-- The first string in `options` whose single line fits `width`.
local function firstFitting(options, o, width)
    for _i, option in ipairs(options) do
        if option and textWidth(option, o) <= width then return option end
    end
    return options[#options]
end

-- ----------------------------------------------------------------- pieces
local function rule(w, col)
    return BoardUI.rect(w, hair(), col or INK.gc)
end

-- A pill button. o: kind ("solid", "outline", "disabled"), icon, label, w,
-- h, s, icon_size, pad_r, after (a trailing widget), ring.
local function pill(o)
    local h = o.h or px(46)
    local solid, off = o.kind == "solid", o.kind == "disabled"
    local fg = solid and "white" or off and "g6" or "black"
    local fg_ink = solid and INK.white or off and INK.g6 or INK.black
    local items = {}
    if o.icon then
        table.insert(items, icon(o.icon, px(o.icon_size or 18), { col = fg, sw = 2.25, fill = o.icon == "play" }))
    end
    if o.label then
        local text_w = o.text_w
        table.insert(items, text(o.label, { s = o.s or 15, b = true, col = fg_ink, w = text_w }))
    end
    if o.after then table.insert(items, o.after) end
    local ring = (solid or off) and 0 or math.max(1, px(o.ring or 1.5))
    local inner = hstack({ gap = px(8) }, items)
    local pad_l = o.label and px(o.icon and 16 or 20) or 0
    local pad_r = o.label and px(o.pad_r or 20) or 0
    local w = o.w or (o.label and (pad_l + wOf(inner) + pad_r) or h)
    local content = hstack({ w = w - 2 * ring, h = h - 2 * ring }, { flex(0), inner, flex(0) })
    return BoardUI.box({ w = w, h = h, radius = math.floor(h / 2), border = ring, border_col = INK.black,
        bg = solid and INK.black or off and INK.ge or INK.white }, content)
end
DetailView.pill = pill

local STATUS_ICONS = { read = "book-check", reading = "book-open", rereading = "book-open", want_to_read = "bookmark",
    on_hold = "circle-pause", abandoned = "x", unread = "bookmark" }

local function statusPill(d, o)
    o = o or {}
    return pill({ kind = "outline", icon = not o.no_icon and (STATUS_ICONS[d.status] or "bookmark") or nil,
        label = d.status_label, after = icon("chevron-down", px(16), { sw = 2.5 }), h = o.h, s = o.s or 14,
        icon_size = 16, pad_r = 14 })
end

local function starsRow(d, size, gap, row_key)
    local items = {}
    for i = 1, 5 do
        local on = i <= (d.rating or 0)
        local star = icon("star", size, { sw = 1.75, col = on and "black" or "g8", fill = on })
        table.insert(items, row_key and region(star, { kind = "rate", value = i }, row_key) or star)
    end
    return hstack({ gap = gap }, items)
end

-- Stars, then the status pill; the stars shrink and the pill loses its icon
-- before anything collides.
local function verdictRow(d, w)
    local function build(star, no_icon, live)
        local stars = starsRow(d, px(star), px(star >= 20 and 4 or 3), live and "verdict" or nil)
        local status = statusPill(d, { h = px(36), s = 13, no_icon = no_icon })
        if live then status = region(status, { kind = "verdict" }, "verdict", "status-pill") end
        return stars, status
    end
    local choice = { 16, true }
    for _i, variant in ipairs({ { 20, false }, { 20, true }, { 17, true } }) do
        local stars, status = build(variant[1], variant[2], false)
        if wOf(stars) + wOf(status) + 2 * px(10) <= w then choice = variant; break end
    end
    local stars, status = build(choice[1], choice[2], true)
    return hstack({ w = w, gap = px(10) }, { stars, flex(0), status })
end

local function primaryButton(d, o)
    o = o or {}
    local p = d.primary
    local h = o.h or px(46)
    local w = o.w
    if p.kind == "downloading" then
        w = w or px(260)
        local ring = math.max(1, px(1.5))
        local fill = math.floor(w * (p.pct or 0) / 100 + 0.5)
        local lead = p.pct and (tostring(p.pct) .. "%") or _("Downloading")
        local label = runs({ { t = lead, b = true }, { t = p.sub and (" " .. p.sub) or "", col = INK.g3 } }, { s = 14 })
        local cancel = BoardUI.circle(px(32), { border = line1(), bg = INK.white }, icon("x", px(16), { sw = 2.5 }))
        local row = hstack({ w = w - 2 * ring, h = h - 2 * ring }, { space(px(18) - ring, 1), label, flex(0), cancel, space(px(8) - ring, 1) })
        local track = BoardUI.overlay(w - 2 * ring, h - 2 * ring, {
            { widget = BoardUI.rect(math.max(0, fill - ring), h - 2 * ring, INK.gc), x = 0, y = 0 },
            { widget = row, x = 0, y = 0 },
        })
        return BoardUI.box({ w = w, h = h, radius = math.floor(h / 2), border = ring, border_col = INK.black, bg = INK.white }, track)
    end
    local kind = (p.kind == "offline" or p.kind == "none") and "disabled" or "solid"
    local labels = p.labels or { "" }
    local label = labels[1]
    if w then
        local room = w - px(16) - px(20) - (p.icon and (px(18) + px(8)) or 0)
        label = firstFitting(labels, { s = o.s or 15, b = true }, room)
    end
    return pill({ kind = kind, icon = p.icon, label = label, w = w, h = h, s = o.s })
end

-- Redraws the action button in place at its current size (download progress).
function DetailView.refreshPrimary(primary_region, primary)
    local size = primary_region.dimen
    primary_region[1] = CatalogWidgets.focusable(primaryButton({ primary = primary }, { w = size.w, h = size.h, s = 14 }))
end

local function detailsButton(short, h)
    return pill({ kind = "outline", icon = "info", label = not short and _("Details") or nil, h = h or px(46), s = 14, icon_size = 18, pad_r = 16 })
end

local function sectionHead(w, label, right, s)
    s = s or 14
    local left = text(label, { s = s, b = true, data = true })
    local right_w = nil
    if right then
        right_w = hstack({ gap = px(2) }, { text(right, { s = 13, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) })
    end
    local max_left = w - (right_w and (wOf(right_w) + px(4)) or 0)
    if wOf(left) > max_left then left = text(label, { s = s, b = true, w = max_left, data = true }) end
    return hstack({ w = w, h = lh1(s) }, { left, flex(0), right_w })
end

-- --------------------------------------------------------------- the header
function DetailView.header(d, w, o)
    o = o or {}
    local box = px(44)
    local back_inner = hstack({ h = box, gap = px(2) }, { icon("chevron-left", px(24), { sw = 2.25 }), text(o.back or d.back or _("Back"), { s = 15, b = true, w = nil, data = true }), space(px(8), 1) })
    local back_w = math.min(wOf(back_inner), math.floor(w * 0.45))
    if wOf(back_inner) > back_w then
        back_inner = hstack({ h = box, gap = px(2) }, { icon("chevron-left", px(24), { sw = 2.25 }),
            text(o.back or d.back or _("Back"), { s = 15, b = true, w = back_w - px(24) - px(2) - px(8), data = true }), space(px(8), 1) })
    end
    local back = region(back_inner, { kind = "back" }, "header", "back")
    local more = region(hstack({ w = box, h = box }, { flex(0), icon("ellipsis", px(24), { sw = 2.25 }), flex(0) }), { kind = "menu" }, "header", "menu")
    local status = DetailView.statusRegion(d)
    -- The tap areas reach into the margin so the glyphs sit on it.
    local row = hstack({ w = w + px(8) + px(10), h = px(48) }, { back, flex(0), status, space(px(10), 1), more })
    local layers = { { widget = row, x = 0, y = 0 } }
    if o.center then
        local c = text(o.center, { s = 15, b = true })
        table.insert(layers, { widget = c, x = math.floor((w + px(8) + px(10) - wOf(c)) / 2), y = math.floor((px(48) - hOf(c)) / 2) })
    end
    return BoardUI.overlay(w + px(8) + px(10), px(48), layers)
end

-- The device status in a region of its own, so the clock can be redrawn
-- without the page.
function DetailView.statusRegion(d)
    local widget = BoardUI.statusCluster(d)
    local r = Region:new{ widget }
    if build_ctx then build_ctx.regions.status = r end
    return r
end

function DetailView.refreshStatus(status_region, d)
    status_region[1] = BoardUI.statusCluster(d)
end

function DetailView.offlineNotice(d, w)
    local inner = hstack({ h = px(32), gap = px(8) }, {
        space(px(12) - px(8), 1),
        icon("cloud-off", px(16), { sw = 2.25, col = "g3" }),
        runs({ { t = _("Offline"), b = true, col = INK.black }, { t = " · " .. (d.saved_note or _("Changes sync when you're back.")), col = INK.g3 } }, { s = 12, w = w - px(48) }),
    })
    return BoardUI.box({ w = w, h = px(32), radius = px(16), bg = INK.ge }, inner)
end

-- ---------------------------------------------------------------- the pager
-- ‹ previous book · 3 of 7 · next book ›
function DetailView.pager(d, w)
    local p = d.pager
    if not p then return nil end
    local fh = px(48)
    local label = p.label or ""
    local mid_w = math.floor(clamp(textWidth(label, { s = 11 }) + px(12), px(92), w * 0.36) + 0.5)
    local side_w = math.floor((w - mid_w) / 2)
    local function side(dir, title)
        if not title then return space(side_w, 1) end
        local chev = icon(dir == "l" and "chevron-left" or "chevron-right", px(20), { sw = 2.5 })
        local room = side_w - px(20) - px(4)
        local t = text(title, { s = 13, b = true, data = true })
        if wOf(t) > room then t = text(title, { s = 13, b = true, w = room, data = true }) end
        local items = dir == "l" and { chev, t, flex(0) } or { flex(0), t, chev }
        return region(hstack({ w = side_w, h = fh, gap = px(4) }, items), { kind = "page", delta = dir == "l" and -1 or 1 }, "pager")
    end
    local mid = vstack({ w = mid_w, align = "center" }, {
        text(T(_("%1 of %2"), p.index, p.total), { s = 13, b = true }),
        text(label, { s = 11, col = INK.g4, w = mid_w, align = "center", data = true }),
    })
    local row = hstack({ w = w, h = fh - hair() }, { side("l", p.prev), mid, side("r", p.next) })
    return vstack({ w = w }, { rule(w, INK.gb), row })
end

-- ------------------------------------------------------------- the jacket
local function kicker(d, w)
    if not d.kicker then return nil end
    local chev = icon("chevron-right", px(14), { sw = 2.5, col = "g3" })
    local t = text(d.kicker, { s = 13, b = true, col = INK.g3, data = true })
    if wOf(t) + px(2) + px(14) > w then t = text(d.kicker, { s = 13, b = true, col = INK.g3, w = w - px(2) - px(14), data = true }) end
    return region(hstack({ w = w }, { t, space(px(2), 1), chev, flex(0) }), { kind = "series" }, "jacket")
end

local function authorLine(d, w, s)
    local lines = d.author_lines or {}
    if #lines == 0 then return text(_("Author unknown"), { s = s, col = INK.g5, i = true, w = w }) end
    local t = firstFitting(lines, { s = s }, w)
    return text(t, { s = s, col = INK.g2, w = w, data = true })
end

-- Where the reader stands, sized for the column beside the cover: a big
-- percentage with time left and the finish day, or how it ended, or what
-- starting would take.
local function columnStanding(d, w)
    local p = d.progress
    local function two(a, b)
        return vstack({ w = w }, { text(a, { s = 14, b = true, w = w }), b and text(b, { s = 12, col = INK.g3, w = w }) or nil })
    end
    if d.status == "read" then
        if not d.finished or not d.finished.date then return nil end
        local j = d.journal
        local tail = j and firstFitting({ T(_("%1 over %2 days"), j.time, j.days), j.time }, { s = 12 }, w) or nil
        return two(T(_("Finished %1"), d.finished.date), tail)
    end
    if not p then
        if not d.not_started then return nil end
        local ns = d.not_started
        local options = {}
        if ns.estimate then
            table.insert(options, T(_("about %1 at your pace"), ns.estimate))
            table.insert(options, T(_("about %1"), ns.estimate))
        end
        table.insert(options, T(_("%1 pages"), ns.pages))
        return two(_("Not started"), firstFitting(options, { s = 12 }, w))
    end
    local big = text(p.label, { s = 26, b = true })
    local rw = w - wOf(big) - px(12)
    local left = p.left or (d.journal and d.journal.last_read and T(_("Last read %1"), d.journal.last_read)) or _("In progress")
    local lines
    if p.finish then
        lines = {
            { left, T(_("Finish %1, %2"), p.finish.day, p.finish.date) },
            { left, T(_("Finish %1"), p.finish.day) },
            { p.left_short or left, T(_("Done %1"), p.finish.day) },
            { p.left_short or left, nil },
        }
    else
        lines = { { left, p.page_short }, { p.left_short or left, nil } }
    end
    local pick = lines[#lines]
    for _i, l in ipairs(lines) do
        if textWidth(l[1], { s = 14, b = true }) <= rw and (not l[2] or textWidth(l[2], { s = 12 }) <= rw) then pick = l; break end
    end
    local right = vstack({ w = rw }, { text(pick[1], { s = 14, b = true, w = rw }), pick[2] and text(pick[2], { s = 12, col = INK.g3, w = rw }) or nil })
    return vstack({ w = w }, {
        hstack({ w = w, gap = px(12) }, { big, right }),
        space(nil, px(8)),
        BoardUI.bar(w, px(6), p.pct, { track = INK.gd, fill = INK.black, r = px(3) }),
    })
end

-- The action beside Details at full size: the spread's left page.
local function wideActions(d, w)
    local gap = px(10)
    local det = detailsButton(false, px(46))
    local dw = wOf(det)
    local primary = region(primaryButton(d, { w = w - dw - gap, h = px(46) }), { kind = "primary" }, "actions", "primary")
    return hstack({ w = w, gap = gap }, { primary, region(det, { kind = "details" }, "actions", "details") })
end

-- The action and Details side by side; Details folds to an icon when the
-- column is narrow.
local function columnActions(d, w)
    local gap = px(8)
    local h = px(44)
    local full = detailsButton(false, h)
    local probe = primaryButton(d, { h = h, s = 14 })
    local short = w - wOf(full) - gap < math.min(wOf(probe), px(200))
    local det = short and detailsButton(true, h) or full
    local dw = wOf(det)
    local primary = region(primaryButton(d, { w = w - dw - gap, h = h, s = 14 }), { kind = "primary" }, "actions", "primary")
    local details = region(det, { kind = "details" }, "actions", "details")
    return hstack({ w = w, gap = gap }, { primary, details })
end

-- The cover box: the same size on every book, so paging never moves the
-- frame. A cover of another shape keeps its own and sits inside, centred
-- across and hung from the top.
local function coverBox(d, box_w, box_h)
    return BoardUI.cover({ title = d.title }, box_w, box_h, {
        path = d.cover_path, border = line1(), border_col = INK.g6, loading = d.cover_state == "loading", valign = "top",
    })
end

local function jacketColumn(d, tw)
    local tf = BoardUI.fitTitle(d.title, { b = true }, tw, { 24, 22, 20, 18, 17 }, 3)
    local title_w = balancedWidth(d.title, { b = true, s = tf.s }, tw, tf.lines)
    local st = columnStanding(d, tw)
    local items = {}
    local k = kicker(d, tw)
    if k then table.insert(items, mark("kicker", k)); table.insert(items, space(nil, px(5))) end
    table.insert(items, mark("title", text(d.title, { s = tf.s, b = true, lines = tf.lines > 1 and tf.lines or nil, w = title_w, data = true })))
    if d.subtitle and tf.lines < 3 then
        table.insert(items, space(nil, px(2)))
        table.insert(items, text(d.subtitle, { s = 13, col = INK.g3, w = tw, data = true }))
    end
    table.insert(items, space(nil, px(4)))
    table.insert(items, mark("author", authorLine(d, tw, 15)))
    table.insert(items, space(nil, px(10)))
    table.insert(items, mark("verdict-row", verdictRow(d, tw)))
    -- Room the cover box leaves goes here: what the book is stays at the top,
    -- where you stand and what to do next sit on the box's bottom edge.
    table.insert(items, flex(px(12)))
    if st then table.insert(items, mark("standing", st)); table.insert(items, space(nil, px(12))) end
    table.insert(items, columnActions(d, tw))
    return items
end

-- The jacket: the cover box beside everything about the book's state. The
-- column is at least as tall as the box, so the actions meet its bottom edge.
function DetailView.jacket(d, w)
    local gut = px(16)
    local box_w = math.min(px(160), math.floor(w * 0.36 + 0.5))
    local box_h = math.floor(box_w * 1.5 + 0.5)
    local tw = w - box_w - gut
    -- The measuring pass builds without registering any controls.
    local saved = build_ctx
    build_ctx = nil
    local col_h = hOf(vstack({ w = tw }, jacketColumn(d, tw)))
    build_ctx = saved
    local column = vstack({ w = tw, h = math.max(box_h, col_h) }, jacketColumn(d, tw))
    return hstack({ w = w, valign = "top", gap = gut }, { region(coverBox(d, box_w, box_h), { kind = "cover" }, nil, "cover"), column })
end

-- -------------------------------------------------------------- the facts
local function specStrip(d, w)
    local items = d.spec or {}
    if #items == 0 then return nil end
    local gap, pad = px(12), px(12)
    local function need(v, l)
        return math.max(textWidth(v, { s = 15, b = true }), textWidth(l, { s = 12 })) + pad + hair()
    end
    local per = #items
    local cw = math.floor((w - (per - 1) * gap) / per)
    for _i, item in ipairs(items) do
        if need(item[1], item[2]) > cw then per = 2; break end
    end
    cw = math.floor((w - (per - 1) * gap) / per)
    local function cell(item, first)
        local inner_w = cw - (first and 0 or (pad + hair()))
        local body = vstack({ w = inner_w }, { text(item[1], { s = 15, b = true, w = inner_w, data = true }), text(item[2], { s = 12, col = INK.g4, w = inner_w }) })
        if first then return body end
        return hstack({}, { BoardUI.rect(hair(), hOf(body), INK.gc), space(pad, 1), body })
    end
    local rows = {}
    for k = 1, #items, per do
        local cells = {}
        for j = k, math.min(#items, k + per - 1) do table.insert(cells, cell(items[j], j == k)) end
        table.insert(rows, hstack({ w = w, gap = gap, valign = "top" }, cells))
        if k + per <= #items then table.insert(rows, space(nil, px(12))) end
    end
    return vstack({ w = w }, rows)
end

-- Genres and tags on `max_lines` lines; the last slot becomes +N.
local function chipRows(d, w, max_lines, row_key)
    local items = d.chips or {}
    if #items == 0 then return nil end
    local h, gap, s = px(30), px(8), 13
    local max_chip = math.floor(w * 0.6)
    local function chip(t, more)
        local tw = textWidth(t, { s = s, b = more })
        local clip = not more and tw > max_chip - 2 * px(12)
        local label = text(t, { s = s, b = more, col = more and INK.black or INK.g2, w = clip and (max_chip - 2 * px(12)) or nil, data = true })
        local w_ = wOf(label) + 2 * px(12)
        return BoardUI.box({ w = w_, h = h, radius = math.floor(h / 2), border = more and 0 or line1(), border_col = INK.g8, bg = more and INK.ge or INK.white },
            hstack({ w = w_ - 2 * (more and 0 or line1()), h = h - 2 * (more and 0 or line1()) }, { flex(0), label, flex(0) }))
    end
    local function widthOf(t, more) return math.min(textWidth(t, { s = s, b = more }), max_chip - 2 * px(12)) + 2 * px(12) end
    local lines, cur, used, i = {}, {}, 0, 1
    while i <= #items and #lines < max_lines do
        local cw = widthOf(items[i])
        local left = #items - i
        local is_last = #lines == max_lines - 1
        local reserve = (is_last and left > 0) and (gap + widthOf("+" .. left, true)) or 0
        if #cur > 0 and used + gap + cw + reserve > w then
            if is_last then break end
            table.insert(lines, cur); cur, used = {}, 0
        else
            table.insert(cur, items[i]); used = used + (#cur > 1 and gap or 0) + cw; i = i + 1
        end
    end
    if #cur > 0 then table.insert(lines, cur) end
    local rest = #items - (i - 1)
    local out = {}
    for li, ln in ipairs(lines) do
        local row = {}
        for _j, t in ipairs(ln) do table.insert(row, chip(t)) end
        if li == #lines and rest > 0 then table.insert(row, chip("+" .. rest, true)) end
        if li > 1 then table.insert(out, space(nil, gap)) end
        table.insert(out, hstack({ gap = gap }, row))
    end
    return region(vstack({ w = w }, out), { kind = "tags" }, row_key or "chips")
end

-- ------------------------------------------------------------ series path
local function seriesPath(s, w, o)
    o = o or {}
    local books = s.books
    local cur = s.current or 1
    local max_n = math.max(5, math.min(12, math.floor(w / px(46))))
    local first, last = 1, #books
    if #books > max_n then
        first = math.max(1, math.min(#books - max_n + 1, cur - math.floor(max_n / 2)))
        last = first + max_n - 1
    end
    local n = last - first + 1
    local D = math.min(px(o.dia or 34), math.floor((w - (n - 1) * px(8)) / n))
    local step = n > 1 and math.min((w - D) / (n - 1), px(84)) or 0
    local lh_ = px(3)
    local caps = o.caps ~= false
    local cap_h = caps and (lh1(11) + px(6)) or 0
    local height = D + px(8) + cap_h
    local cy = px(4) + D / 2
    local layers = {}
    for i = first, last - 1 do
        local done = books[i].status == "read" and books[i + 1].status == "read"
        local x = math.floor((i - first) * step + D / 2 + 0.5)
        table.insert(layers, { widget = BoardUI.rect(math.floor(step + 0.5), lh_, done and INK.black or INK.gc), x = x, y = math.floor(cy - lh_ / 2 + 0.5) })
    end
    local dp = BoardMetrics.get().dp
    for i = first, last do
        local b = books[i]
        local x = math.floor((i - first) * step + 0.5)
        local read = b.status == "read"
        local started = not read and ((b.pct or 0) > 0 or b.status == "reading" or b.status == "rereading")
        local ring = read and 0 or (started and math.max(2, px(2.5)) or math.max(1, px(1.25)))
        local child
        if read then
            child = icon("check", math.floor(D * 0.5), { col = "white", sw = 3 })
        else
            child = text(b.index or "?", { s = math.max(11, math.floor(D / dp * 0.42 + 0.5)), b = true, col = started and INK.black or INK.g5 })
        end
        local node = BoardUI.circle(D, { border = ring, border_col = started and INK.black or INK.g8, bg = read and INK.black or INK.white }, child)
        if b.current then
            local outer = px(5)
            local gap_ring = px(3)
            table.insert(layers, { widget = BoardUI.circle(D + 2 * outer, { bg = INK.black }, space(1, 1)), x = x - outer, y = px(4) - outer })
            table.insert(layers, { widget = BoardUI.circle(D + 2 * gap_ring, { bg = INK.white }, space(1, 1)), x = x - gap_ring, y = px(4) - gap_ring })
            table.insert(layers, { widget = node, x = x, y = px(4) })
        else
            table.insert(layers, { widget = region(node, { kind = "detail", book_id = b.id }, "series"), x = x, y = px(4) })
        end
        if caps and (b.current or (s.next and b.id == s.next.id)) then
            local cap = text(b.current and _("This book") or _("Next"), { s = 11, b = b.current, col = b.current and INK.black or INK.g4 })
            local cw = wOf(cap)
            table.insert(layers, { widget = cap, x = math.floor(clamp(x + D / 2 - cw / 2, 0, w - cw)), y = px(4) + D + px(8) })
        end
    end
    return BoardUI.overlay(w, height, layers)
end

local function seriesSummary(d, w, compact)
    local s = d.series
    local nxt = s.next
    local titles = {}
    for _i, b in ipairs(s.books) do table.insert(titles, b.title) end
    local next_t = nxt and nxt.index and T(_("Next: Book %1, %2"), nxt.index, DetailData.pagerTitle(nxt.title, titles) or "") or nil
    local lead = compact and T(_("%1: %2 of %3 read"), s.name, s.read, s.total) or T(_("%1 of %2 read"), s.read, s.total)
    local options = {}
    if next_t then table.insert(options, { lead, " · " .. next_t }) end
    if nxt and nxt.index then table.insert(options, { lead, " · " .. T(_("Next: Book %1"), nxt.index) }) end
    table.insert(options, { lead, nil })
    local pick = options[#options]
    for _i, o in ipairs(options) do
        if textWidth(o[1], { s = 13, b = true }) + (o[2] and textWidth(o[2], { s = 13 }) or 0) <= w then pick = o; break end
    end
    local parts = { { t = pick[1], b = true } }
    if pick[2] then table.insert(parts, { t = pick[2], col = INK.g3 }) end
    return runs(parts, { s = 13, w = w, data = true })
end

local function seriesBlock(d, w, compact)
    return vstack({ w = w }, { seriesPath(d.series, w, { caps = not compact, dia = compact and 30 or 34 }), space(nil, px(8)), seriesSummary(d, w, compact) })
end

-- ----------------------------------------------------------------- shelves
-- `count` covers filling the width, captioned or not.
local function shelf(books, w, count, captions, row_key)
    local gap0 = px(12)
    local cover_w = math.floor((w - (count - 1) * gap0) / count)
    local cover_h = math.floor(cover_w * 1.5 + 0.5)
    local gap = count > 1 and math.floor((w - count * cover_w) / (count - 1)) or 0
    local items = {}
    for i = 1, math.min(count, #books) do
        local b = books[i]
        local cover = BoardUI.cover(b, cover_w, cover_h, { path = b.cover_path, border = hair(), border_col = INK.gb, loading = b.cover_state == "loading" })
        local cell = cover
        if captions then
            local author = b.authors and b.authors[1] or ""
            cell = vstack({ w = cover_w }, { cover, space(nil, px(6)), text(b.title or "", { s = 12, b = true, w = cover_w, data = true }), text(author, { s = 11, col = INK.g4, w = cover_w, data = true }) })
        end
        table.insert(items, region(cell, { kind = "detail", book_id = b.id }, row_key or "shelf"))
    end
    return hstack({ gap = gap, valign = "top" }, items)
end

-- The shelf's heading: one label, or Similar books and More by <author> as
-- tabs, the chosen one inked and underlined. All opens the author's books.
local function shelfHead(d, w)
    local right
    if d.shelf.kind == "author" then
        right = region(hstack({ h = px(32), gap = px(2) }, { text(_("All"), { s = 13, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) }),
            { kind = "author" }, "shelf-head", "shelf-all")
    end
    local right_w = right and (wOf(right) + px(12)) or 0
    local tabs = d.shelf_tabs or {}
    if #tabs < 2 then
        local left = text(d.shelf.label, { s = 14, b = true, w = w - right_w, data = true })
        return hstack({ w = w, h = right and px(32) or lh1(14) }, { left, flex(0), right or false })
    end
    local gap = px(20)
    local avail = w - right_w - gap * (#tabs - 1)
    local widths, total = {}, 0
    for i, tab in ipairs(tabs) do
        widths[i] = wOf(text(tab.label, { s = 14, b = true }))
        total = total + widths[i]
    end
    local items = {}
    for i, tab in ipairs(tabs) do
        local label = tab.label
        local lw = widths[i]
        -- A long author name gives way to "More by this author", then is cut.
        if total > avail and tab.short then
            local short_w = wOf(text(tab.short, { s = 14, b = true }))
            total = total - lw + short_w
            label, lw = tab.short, short_w
        end
        if total > avail then lw = math.max(px(40), lw - (total - avail)) end
        local on = tab.kind == d.shelf.kind
        -- A 32dp target; the label and its rule sit at the bottom, by the covers.
        local cell = vstack({ w = lw, h = px(32) }, {
            flex(0),
            text(label, { s = 14, b = true, col = on and INK.black or INK.g4, w = lw, data = true }),
            space(nil, px(4)),
            BoardUI.rect(lw, px(2), on and INK.black or INK.white),
        })
        table.insert(items, region(cell, { kind = "shelf-tab", value = tab.kind }, "shelf-head", "shelf-tab-" .. tab.kind))
        if i < #tabs then table.insert(items, space(gap, 1)) end
    end
    table.insert(items, flex(0))
    if right then table.insert(items, right) end
    return hstack({ w = w, h = px(32), valign = "bottom" }, items)
end

local function shelfCount(w)
    if w >= BoardMetrics.lpx(600) then return 6 end
    if w >= BoardMetrics.lpx(480) then return 5 end
    return 4
end

-- ------------------------------------------------------------ the fill
-- Required parts first, then optional ones by value while they fit, then
-- elastic parts grow (in phases) into what is left, then late parts take
-- any remainder. Every part reports its height for a size.
local function fill(avail, gap, parts)
    local chosen, in_chosen = {}, {}
    local function size0(p) return p.min end
    local used = 0
    -- Only the first required part stays whatever its size; the others
    -- step aside rather than run off the page.
    for _i, p in ipairs(parts) do
        if p.required then
            local ph = p.h(size0(p)) + (#chosen > 0 and gap or 0)
            if #chosen == 0 or used + ph <= avail then
                table.insert(chosen, p); in_chosen[p] = true
                used = used + ph
            end
        end
    end
    local optional = {}
    for _i, p in ipairs(parts) do if not p.required and not p.late then table.insert(optional, p) end end
    table.sort(optional, function(a, b) return (a.value or 0) > (b.value or 0) end)
    for _i, p in ipairs(optional) do
        local ph = p.h(size0(p))
        if used + gap + ph <= avail then table.insert(chosen, p); in_chosen[p] = true; used = used + gap + ph end
    end
    local sizes = {}
    for _i, p in ipairs(chosen) do sizes[p] = p.min end
    local phases = {}
    for _i, p in ipairs(chosen) do
        if p.grow then
            for _j, ph in ipairs(p.phases or { { to = p.max, gv = p.grow_value or 0 } }) do
                table.insert(phases, { p = p, to = ph.to, gv = ph.gv })
            end
        end
    end
    table.sort(phases, function(a, b) return a.gv > b.gv end)
    for _i, ph in ipairs(phases) do
        local p = ph.p
        local best = sizes[p]
        local steps = {}
        if p.steps then
            for _j, v in ipairs(p.steps) do if v > best and v <= ph.to then table.insert(steps, v) end end
        else
            local v = best + (p.step or 1)
            while v <= ph.to do table.insert(steps, v); v = v + (p.step or 1) end
        end
        for _j, v in ipairs(steps) do
            local delta = p.h(v) - p.h(best)
            if used + delta <= avail then used = used + delta; best = v else break end
        end
        sizes[p] = best
    end
    for _i, p in ipairs(parts) do
        if p.late and not in_chosen[p] then
            local ph = p.h(p.min)
            if used + gap + ph <= avail then table.insert(chosen, p); in_chosen[p] = true; sizes[p] = p.min; used = used + gap + ph end
        end
    end
    local order = {}
    for _i, p in ipairs(parts) do if in_chosen[p] then table.insert(order, p) end end
    return { order = order, sizes = sizes, used = used }
end
DetailView.fill = fill

-- Measures a part without keeping its regions.
local function measured(build)
    return function(v)
        local saved = build_ctx
        build_ctx = nil
        local widget = build(v)
        build_ctx = saved
        local h = hOf(widget)
        if widget.free then widget:free() end
        return h
    end
end

local function renderFill(f, gap, rule_between, w)
    local items = {}
    for i, p in ipairs(f.order) do
        if i > 1 then
            if rule_between then
                local top = math.floor(gap / 2)
                table.insert(items, space(nil, top))
                table.insert(items, rule(w))
                table.insert(items, space(nil, gap - top - hair()))
            else
                table.insert(items, space(nil, gap))
            end
        end
        table.insert(items, mark("part-" .. p.id, p.node(f.sizes[p])))
    end
    return items
end

-- ------------------------------------------------------- main page parts
local function mainParts(d, w, o)
    o = o or {}
    local parts = {}
    local head = function(label, right) return sectionHead(w, label, right, 14) end
    local excerpt = d.excerpt
    local total = excerpt and linesNeeded(excerpt, { s = 14 }, w) or 1
    -- The turned layout heads the description; the upright one does not.
    local head_h = o.about_head and (lh1(14) + px(8)) or 0
    local function about(n)
        local body
        if not excerpt then
            body = text(_("No description yet."), { s = 14, col = INK.g4, w = w })
        else
            local lines = math.min(n, total)
            body = region(text(excerpt, { s = 14, col = INK.g1, lines = lines > 1 and lines or nil, w = w, data = true }), { kind = "about" }, "about")
        end
        if not o.about_head then return body end
        return vstack({ w = w }, { head(_("About this book")), space(nil, px(8)), body })
    end
    -- The description's first six lines come before the shelf's captions;
    -- the rest of it only after them.
    table.insert(parts, { id = "about", required = true, grow = true, grow_value = 10,
        phases = { { to = math.min(total, math.max(6, o.about_min or 3)), gv = 10 }, { to = math.min(total, 30), gv = 7 } },
        min = math.min(total, o.about_min or 3), max = math.min(total, 30), node = about,
        h = function(v) return head_h + (excerpt and BoardMetrics.textBoxHeight(math.min(v, total), 14) or lh1(14)) end })
    local spec = specStrip(d, w) and function() return specStrip(d, w) end or nil
    if spec then table.insert(parts, { id = "spec", value = 90, node = spec, h = measured(spec), min = 0 }) end
    if #(d.chips or {}) > 0 then
        local function chips() return chipRows(d, w, 1) end
        table.insert(parts, { id = "chips", value = 45, node = chips, h = measured(chips), min = 0 })
    end
    if d.series then
        local function series(v)
            if v == 1 then
                return vstack({ w = w }, { head(d.series.name, T(_("All %1"), d.series.total)), space(nil, px(10)), seriesBlock(d, w, false) })
            end
            return seriesBlock(d, w, true)
        end
        table.insert(parts, { id = "series", value = 80, grow = true, grow_value = 6, min = 0, max = 1, step = 1, node = series, h = measured(series) })
    end
    if d.shelf then
        local count = shelfCount(w)
        local function shelf_part(v)
            return vstack({ w = w }, { shelfHead(d, w), space(nil, px(10)),
                shelf(d.shelf.books, w, v == 0 and math.min(6, count + 1) or count, v == 2) })
        end
        table.insert(parts, { id = "similar", required = true, value = 50, grow = true, min = count >= 6 and 1 or 0, max = 2, step = 1,
            phases = { { to = 1, gv = 20 }, { to = 2, gv = 8 } }, node = shelf_part, h = measured(shelf_part) })
    end
    -- Display order: facts, description, genres, series, shelf.
    local order = { spec = 1, about = 2, chips = 3, series = 4, similar = 5 }
    table.sort(parts, function(a, b) return order[a.id] < order[b.id] end)
    return parts
end

-- --------------------------------------------------- More details parts
local function ledger(rows, w, cols, row_h)
    local gap_x = px(24)
    local cw = math.floor((w - (cols - 1) * gap_x) / cols)
    local lab_w = math.floor(cw * 0.36 + 0.5)
    local per = math.ceil(#rows / cols)
    local columns = {}
    for k = 0, cols - 1 do
        local items = {}
        for i = k * per + 1, math.min(#rows, (k + 1) * per) do
            if i > k * per + 1 then table.insert(items, rule(cw)) end
            local h = px(row_h) - ((i > k * per + 1) and hair() or 0)
            table.insert(items, hstack({ w = cw, h = h, gap = px(10) }, {
                text(rows[i][1], { s = 13, col = INK.g4, w = lab_w }),
                text(rows[i][2], { s = 14, w = cw - lab_w - px(10), data = true }),
            }))
        end
        table.insert(columns, vstack({ w = cw }, items))
    end
    return hstack({ w = w, gap = gap_x, valign = "top" }, columns)
end

local function fileRow(entry, w, first, offline)
    local row_h = px(52)
    local right
    if entry.on_device then
        right = hstack({ gap = px(5) }, { icon("circle-check", px(17), { sw = 2.25 }), text(_("On device"), { s = 13, b = true }) })
    elseif offline then
        right = text(_("Needs Wi-Fi"), { s = 12, col = INK.g4 })
    else
        right = region(pill({ kind = "outline", icon = "download", label = _("Get"), h = px(34), s = 13, icon_size = 15, pad_r = 14, ring = 1.25 }),
            { kind = "get", file = entry.file }, "formats")
    end
    local rw = wOf(right)
    local tw = w - px(50) - rw - 2 * px(12)
    local subs = DetailData.fileSubs(entry)
    local sub = firstFitting(subs, { s = 12 }, math.max(1, tw))
    local chip = BoardUI.box({ w = math.max(px(50), textWidth(entry.format, { s = 12, b = true }) + 2 * px(7)), h = px(24), radius = px(5), border = math.max(1, px(1.25)), border_col = INK.black },
        hstack({ w = math.max(px(50), textWidth(entry.format, { s = 12, b = true }) + 2 * px(7)) - 2 * math.max(1, px(1.25)), h = px(24) - 2 * math.max(1, px(1.25)) }, { flex(0), text(entry.format, { s = 12, b = true }), flex(0) }))
    local body = hstack({ w = w, h = row_h - (first and 0 or hair()), gap = px(12) }, {
        hstack({ w = px(50) }, { chip, flex(0) }),
        vstack({ w = math.max(1, tw) }, { text(entry.title, { s = 14, b = true, w = math.max(1, tw) }), sub ~= "" and text(sub, { s = 12, col = INK.g4, w = math.max(1, tw) }) or nil }),
        right,
    })
    if first then return body end
    return vstack({ w = w }, { rule(w), body })
end

local function otherFilesRow(other, w, first)
    local row_h = px(52)
    local chips = {}
    for _i, entry in ipairs(other) do
        local cw = math.max(px(40), textWidth(entry.format, { s = 12, b = true }) + 2 * px(7))
        local b = math.max(1, px(1.25))
        table.insert(chips, BoardUI.box({ w = cw, h = px(24), radius = px(5), border = b, border_col = INK.g8 },
            hstack({ w = cw - 2 * b, h = px(24) - 2 * b }, { flex(0), text(entry.format, { s = 12, b = true, col = INK.g5 }), flex(0) })))
    end
    local chip_row = hstack({ gap = px(4) }, chips)
    local tw = w - wOf(chip_row) - px(12)
    local sub = firstFitting(DetailData.otherFilesSubs(other), { s = 12 }, tw)
    local body = hstack({ w = w, h = row_h - (first and 0 or hair()), gap = px(12) }, {
        chip_row,
        vstack({ w = tw }, { text(_("Also in BookOrbit"), { s = 14, b = true, col = INK.g3, w = tw }), text(sub, { s = 12, col = INK.g4, w = tw }) }),
    })
    if first then return body end
    return vstack({ w = w }, { rule(w), body })
end

-- The reading path: progress through the book by day since it was started,
-- a stepped area drawn from plain rectangles.
local PathChart = Widget:extend{ values = nil, pct = 0, started = nil }

function PathChart:getSize() return self.dimen end

function PathChart:paintTo(bb, x, y)
    self.dimen.x, self.dimen.y = x, y
    local w, h = self.dimen.w, self.dimen.h
    local axis_h = lh1(11) + px(4)
    local ch = h - axis_h
    local values = self.values
    local n = #values
    local edge = math.max(2, px(2))
    local top_pct = math.min(100, math.max(10, math.ceil(self.pct * 1.25 / 5) * 5))
    local function yOf(p) return math.floor(ch - (ch - lh1(12) - px(6)) * p / top_pct + 0.5) end
    bb:paintRect(x, y + ch, w, hair(), INK.g8)
    bb:paintRect(x, y + yOf(self.pct), w, hair(), INK.ge)
    local prev_top = ch
    for i = 1, n do
        local top = yOf(values[i])
        local x0 = math.floor((i - 1) * w / n + 0.5)
        local x1 = math.floor(i * w / n + 0.5)
        bb:paintRect(x + x0, y + top, x1 - x0, ch - top, INK.gd)
        bb:paintRect(x + x0, y + top - math.floor(edge / 2), x1 - x0, edge, INK.black)
        if top ~= prev_top then
            bb:paintRect(x + math.max(0, x0 - math.floor(edge / 2)), y + math.min(top, prev_top) - math.floor(edge / 2), edge, math.abs(top - prev_top) + edge, INK.black)
        end
        prev_top = top
    end
end

local function pathChart(j, pct, w, h)
    local axis = hstack({ w = w }, { text(j.started or "", { s = 11, col = INK.g4 }), flex(0), text(_("Today"), { s = 11, col = INK.g4 }) })
    local chart = PathChart:new{ dimen = Geom:new{ w = w, h = h }, values = j.path, pct = pct }
    local label = text(DetailData.pctLabel(pct) or "", { s = 12, b = true })
    local axis_h = lh1(11) + px(4)
    local ch = h - axis_h
    local top_pct = math.min(100, math.max(10, math.ceil(pct * 1.25 / 5) * 5))
    local y_pct = math.floor(ch - (ch - lh1(12) - px(6)) * pct / top_pct + 0.5)
    return BoardUI.overlay(w, h, {
        { widget = chart, x = 0, y = 0 },
        { widget = axis, x = 0, y = ch + px(4) },
        { widget = BoardUI.box({ w = wOf(label) + px(4), h = hOf(label), bg = INK.white }, hstack({}, { space(px(4), 1), label })), x = w - wOf(label) - px(4), y = math.max(0, y_pct - lh1(12) - px(5)) },
    })
end

local function stat(value, label, w)
    return vstack({ w = w }, { text(value or "", { s = 16, b = true, w = w, data = true }), text(label, { s = 12, col = INK.g4, w = w }) })
end

local function quote(hl, w, max_lines)
    local bar = px(3)
    local pad = px(14)
    local tw = w - bar - pad
    local body = vstack({ w = tw }, {
        text(hl.text or "", { s = 14, i = true, serif = true, lines = max_lines, w = tw, data = true }),
        space(nil, px(6)),
        runs({ { t = hl.chapter or "", b = true, col = INK.g2 }, { t = hl.when and (" · " .. hl.when) or "", col = INK.g4 } }, { s = 12, w = tw, data = true }),
    })
    return hstack({ w = w, valign = "top" }, { BoardUI.rect(bar, hOf(body), INK.black, math.floor(bar / 2)), space(pad, 1), body })
end

local function detailsParts(d, w, lw, rw, two_col)
    local parts = {}
    local wide = screenW() >= 600 or two_col
    local head = function(width, label, right) return sectionHead(width, label, right, 15) end
    -- About: one row to the full text; lines only with room to spare.
    local desc = d.description
    local total = desc and linesNeeded(desc, { s = 14 }, lw) or 0
    local function about(n)
        local body
        if not desc then
            body = text(_("No description yet."), { s = 14, col = INK.g4, w = lw })
        elseif n == 0 then
            body = region(hstack({ gap = px(6) }, { icon("file-text", px(16), { sw = 2.25 }), text(_("Read the full description"), { s = 14, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) }), { kind = "about" }, "details-about")
        else
            local items = { text(desc, { s = 14, lines = math.min(n, total) > 1 and math.min(n, total) or nil, w = lw, data = true }) }
            if n < total then
                table.insert(items, space(nil, px(6)))
                table.insert(items, region(hstack({ gap = px(2) }, { text(_("Read all"), { s = 13, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) }), { kind = "about" }, "details-about"))
            end
            body = vstack({ w = lw }, items)
        end
        return vstack({ w = lw }, { head(lw, _("About this book")), space(nil, px(10)), body })
    end
    local steps = { 0 }
    if desc then for v = 3, math.min(total, 30) do table.insert(steps, v) end end
    table.insert(parts, { id = "about", col = "l", required = true, grow = true, grow_value = 10, min = 0, max = steps[#steps], steps = steps, node = about, h = measured(about) })
    -- Genres and tags sit under About; the description shortens to keep them,
    -- so they show at every interface size.
    if #(d.chips or {}) > 0 then
        local function chips_part(v) return vstack({ w = lw }, { head(lw, _("Genres and tags")), space(nil, px(10)), chipRows(d, lw, v or 2, "details-chips") }) end
        table.insert(parts, { id = "chips", col = "l", value = 40, min = 2, node = chips_part, h = measured(chips_part) })
    end
    -- Your reading: time on this device and highlights.
    local hl = d.highlights
    local j = d.journal
    if j or hl then
        local jw = two_col and rw or w
        local done = d.status == "read"
        local n4 = jw >= BoardMetrics.lpx(480) and 4 or 3
        local sw = math.floor((jw - (n4 - 1) * px(12)) / n4)
        local stats = {}
        if j then
            if done and j.finished_on then
                stats = { { j.finished_on, _("Finished") }, { j.time, _("Time read") }, { tostring(j.days), _("Reading days") }, { j.pace_short or "–", _("Pace") } }
            else
                stats = { { j.started, _("Started") }, { j.time, _("Time read") }, { tostring(j.days), _("Reading days") }, { j.last_read, _("Last read") } }
            end
        end
        local function one_line()
            local tail_opts = done and { T(_(" over %1 days, finished %2"), j.days, j.finished_on or ""), T(_(" over %1 days"), j.days) }
                or { T(_(" over %1 days since %2, last read %3"), j.days, j.started or "", j.last_read or ""), T(_(" over %1 days since %2"), j.days, j.started or ""), T(_(" over %1 days"), j.days) }
            local tail = tail_opts[#tail_opts]
            for _i, t in ipairs(tail_opts) do
                if textWidth(j.time, { s = 13, b = true }) + textWidth(t, { s = 13 }) <= jw then tail = t; break end
            end
            return runs({ { t = j.time, b = true }, { t = tail, col = INK.g3 } }, { s = 13, w = jw })
        end
        local chartable = j and d.progress and not done and j.path and #j.path > 0
        local right = hl and (hl.count == 1 and _("1 highlight") or T(_("%1 highlights"), hl.count)) or nil
        local function yours(v)
            local items = {}
            local h_ = sectionHead(jw, j and _("Your reading on this device") or _("Your highlights"), right, 15)
            if hl then h_ = region(h_, { kind = "highlights" }, "yours") end
            table.insert(items, h_)
            table.insert(items, space(nil, px(10)))
            if j then
                if v >= 2 then
                    local cells = {}
                    for i = 1, n4 do if stats[i] then table.insert(cells, stat(stats[i][1], stats[i][2], sw)) end end
                    table.insert(items, hstack({ w = jw, gap = px(12), valign = "top" }, cells))
                else
                    table.insert(items, one_line())
                end
            end
            if hl and (v >= 1 or not j) then
                if j then table.insert(items, space(nil, px(14))) end
                table.insert(items, quote(hl.latest, jw, 2))
            end
            if v >= 3 and chartable then
                table.insert(items, space(nil, px(14)))
                table.insert(items, pathChart(j, d.progress.pct, jw, px(110)))
            end
            return vstack({ w = jw }, items)
        end
        local max = not j and 0 or (chartable and 3 or 2)
        local steps_y = nil
        if not hl then steps_y = {} for _i, v in ipairs({ 0, 2, 3 }) do if v <= max then table.insert(steps_y, v) end end end
        table.insert(parts, { id = "yours", col = two_col and "r" or "l", required = true, grow = true, grow_value = 25, min = 0, max = max, step = 1, steps = steps_y, node = yours, h = measured(yours) })
    end
    local function ledger_part() return vstack({ w = rw }, { head(rw, _("Details")), space(nil, px(4)), ledger(d.facts, rw, (wide and not two_col) and 2 or 1, 32) }) end
    if #(d.facts or {}) > 0 then table.insert(parts, { id = "ledger", col = "r", required = true, node = ledger_part, h = measured(ledger_part), min = 0 }) end
    local function formats_part()
        local items = { head(rw, _("Formats")), space(nil, px(2)) }
        for i, entry in ipairs(d.usable) do table.insert(items, fileRow(entry, rw, i == 1, d.offline)) end
        if #d.other > 0 then table.insert(items, otherFilesRow(d.other, rw, #d.usable == 0)) end
        return vstack({ w = rw }, items)
    end
    if #d.usable + #d.other > 0 then table.insert(parts, { id = "formats", col = "r", required = true, node = formats_part, h = measured(formats_part), min = 0 }) end
    -- The author's other books, when the main page's shelf was Similar books.
    if d.author_books then
        local aw = two_col and lw or w
        local count = shelfCount(aw)
        local function author_part(v)
            return vstack({ w = aw }, { head(aw, T(_("More by %1"), d.author_first or "")), space(nil, px(10)), shelf(d.author_books, aw, count, v == 1, "author") })
        end
        table.insert(parts, { id = "author", col = "l", value = 30, grow = true, grow_value = 2, min = 0, max = 1, step = 1, node = author_part, h = measured(author_part) })
    end
    return parts
end

-- ---------------------------------------------------------------- pages
local function mainPage(d, w, body_h, m, full_h)
    if m.w > m.h then
        -- Turned: a spread. The left page is the cover box with the actions
        -- under it; the right page reads like the portrait column, then the
        -- rest. The box is sized as if the pager were showing, so it is the
        -- same on every book.
        local lw = math.floor(w * 0.36 + 0.5)
        local spine = px(44)
        local rw = w - lw - spine
        local actions = wideActions(d, lw)
        local room = math.min(body_h, full_h - px(48) - px(8)) - hOf(actions) - px(20)
        local box_w = math.min(lw, math.floor(room * 2 / 3))
        local box_h = math.floor(box_w * 1.5 + 0.5)
        local left = vstack({ w = lw, h = body_h }, {
            hstack({ w = lw }, { flex(0), region(coverBox(d, box_w, box_h), { kind = "cover" }, nil, "cover"), flex(0) }),
            flex(px(20)),
            actions,
        })
        local tf = BoardUI.fitTitle(d.title, { b = true }, rw, { 24, 22, 20, 18, 17 }, 3)
        local title_w = balancedWidth(d.title, { b = true, s = tf.s }, rw, tf.lines)
        local st = columnStanding(d, rw)
        local head = {}
        local k = kicker(d, rw)
        if k then table.insert(head, mark("kicker", k)); table.insert(head, space(nil, px(6))) end
        table.insert(head, mark("title", text(d.title, { s = tf.s, b = true, lines = tf.lines > 1 and tf.lines or nil, w = title_w, data = true })))
        if d.subtitle and tf.lines < 3 then
            table.insert(head, space(nil, px(2)))
            table.insert(head, text(d.subtitle, { s = 14, col = INK.g3, w = rw, data = true }))
        end
        table.insert(head, space(nil, px(4)))
        table.insert(head, mark("author", authorLine(d, rw, 16)))
        table.insert(head, space(nil, px(12)))
        table.insert(head, mark("verdict-row", verdictRow(d, rw)))
        if st then table.insert(head, space(nil, px(16))); table.insert(head, mark("standing", st)) end
        local head_block = vstack({ w = rw }, head)
        local gap = px(22)
        local f = fill(body_h - hOf(head_block) - gap, gap, mainParts(d, rw, { about_head = true }))
        local right = { head_block, space(nil, gap) }
        for _i, item in ipairs(renderFill(f, gap, true, rw)) do table.insert(right, item) end
        return hstack({ w = w, h = body_h, valign = "top" }, {
            left, space(math.floor(spine / 2), 1), BoardUI.rect(hair(), body_h, INK.gb), space(spine - math.floor(spine / 2) - hair(), 1),
            vstack({ w = rw, h = body_h }, right),
        })
    end
    local top = DetailView.jacket(d, w)
    local top_h = hOf(top) + px(20)
    local about_min = (d.series and d.shelf) and 2 or 3
    local f = fill(body_h - top_h, px(18), mainParts(d, w, { about_min = about_min }))
    local items = { top, space(nil, px(20)) }
    for _i, item in ipairs(renderFill(f, px(18), true, w)) do table.insert(items, item) end
    if DEBUG then
        local parts = {}
        for _i, p in ipairs(f.order) do table.insert(parts, p.id .. "=" .. tostring(f.sizes[p]) .. ":" .. p.h(f.sizes[p]) .. "/" .. hOf(p.node(f.sizes[p]))) end
        local real = 0
        for _i, item in ipairs(items) do if not (type(item) == "table" and item._flex) then real = real + hOf(item) end end
        require("logger").info("DETAILFIT body_h", body_h, "top_h", top_h, "used", f.used, "real", real, table.concat(parts, " "))
    end
    return vstack({ w = w, h = body_h }, items)
end

local function detailsPage(d, w, body_h, m)
    local gap = px(22)
    local two_col = m.w > m.h
    local lw = two_col and math.floor((w - px(40)) * 0.52) or w
    local rw = two_col and (w - lw - px(40)) or w
    local parts = detailsParts(d, w, lw, rw, two_col)
    if not two_col then
        local f = fill(body_h, gap, parts)
        return vstack({ w = w, h = body_h }, renderFill(f, gap, false, w))
    end
    local function side(which)
        local list = {}
        for _i, p in ipairs(parts) do if p.col == which or (which == "l" and p.col == "b") then table.insert(list, p) end end
        return list
    end
    local fl, fr = fill(body_h, gap, side("l")), fill(body_h, gap, side("r"))
    return hstack({ w = w, h = body_h, valign = "top", gap = px(40) }, {
        vstack({ w = lw }, renderFill(fl, gap, false, lw)),
        vstack({ w = rw }, renderFill(fr, gap, false, rw)),
    })
end

-- ------------------------------------------------------------- overlays
-- Lightens what is under a sheet, so the page stays visible behind it.
local Scrim = Widget:extend{}
function Scrim:getSize() return self.dimen end
function Scrim:paintTo(bb, x, y)
    self.dimen.x, self.dimen.y = x, y
    bb:lightenRect(x, y, self.dimen.w, self.dimen.h, 0.55)
end

-- The verdict sheet: rating and status in one place.
local function verdictSheet(d, screen_w, screen_h, mx)
    local w = screen_w - 2 * mx
    local pad = px(18)
    local opts = {
        { id = "want_to_read", icon = "bookmark" }, { id = "reading", icon = "book-open" }, { id = "on_hold", icon = "circle-pause" },
        { id = "read", icon = "book-check" }, { id = "abandoned", icon = "x" }, { id = "unread", icon = "rotate-ccw" },
    }
    local two = screenW() >= 600
    local opt_h = px(52)
    local cw = two and math.floor((w - px(10)) / 2) or w
    local star_d = px(44)
    local stars = {}
    for i = 1, 5 do
        local on = i <= (d.rating or 0)
        table.insert(stars, region(hstack({ w = star_d, h = star_d }, { flex(0), icon("star", px(34), { sw = 1.75, col = on and "black" or "g8", fill = on }), flex(0) }), { kind = "rate", value = i, from_sheet = true }, "sheet-stars"))
    end
    local clear = d.rating and d.rating > 0 and region(text(_("Clear"), { s = 14, b = true }), { kind = "rate", value = nil, from_sheet = true }, "sheet-stars") or text(_("Tap a star"), { s = 13, col = INK.g4 })
    local star_row = hstack({ w = w, gap = px(6) }, { hstack({ gap = px(6) }, stars), flex(0), clear })
    local cells = {}
    for _i, o in ipairs(opts) do
        local on = o.id == d.status or (o.id == "reading" and d.status == "rereading")
        local label = DetailData.statusLabel(o.id)
        local inner = hstack({ w = cw - 2 * px(14), h = opt_h, gap = px(12) }, {
            icon(o.icon, px(19), { sw = 2.25, col = on and "white" or "black" }),
            text(label, { s = 15, b = true, col = on and INK.white or INK.black }),
            flex(0),
            on and icon("check", px(18), { sw = 2.75, col = "white" }) or nil,
        })
        local cell = BoardUI.box({ w = cw, h = opt_h, radius = px(12), border = on and 0 or line1(), border_col = INK.g8, bg = on and INK.black or INK.white },
            hstack({ w = cw - 2 * (on and 0 or line1()), h = opt_h - 2 * (on and 0 or line1()) }, { space(px(14) - (on and 0 or line1()), 1), inner }))
        table.insert(cells, region(cell, { kind = "status", value = o.id }, "sheet-status-" .. math.ceil(_i / (two and 2 or 1))))
    end
    local grid = {}
    local per = two and 2 or 1
    for k = 1, #cells, per do
        local row = {}
        for j = k, math.min(#cells, k + per - 1) do table.insert(row, cells[j]) end
        if k > 1 then table.insert(grid, space(nil, px(8))) end
        table.insert(grid, hstack({ gap = px(10) }, row))
    end
    local close = region(BoardUI.circle(px(40), { border = math.max(1, px(1.5)), bg = INK.white }, icon("x", px(20), { sw = 2.5 })), { kind = "close-sheet" }, "sheet-head")
    local head_row = hstack({ w = w, valign = "top" }, {
        vstack({ w = w - px(56) }, { text(_("Your verdict"), { s = 20, b = true }), text(d.title, { s = 13, col = INK.g4, w = w - px(56), data = true }) }),
        flex(0), close,
    })
    local body = vstack({ w = w }, {
        head_row, space(nil, px(14)),
        text(_("Rating"), { s = 13, b = true, col = INK.g4 }), space(nil, px(10)), star_row,
        space(nil, px(22)),
        text(_("Status"), { s = 13, b = true, col = INK.g4 }), space(nil, px(10)),
        vstack({ w = w }, grid),
    })
    local sheet_h = math.min(screen_h - px(60), pad + hOf(body) + px(24))
    -- Rounded on top only: the box runs a radius past the screen's bottom
    -- edge, which clips it. A black box one rule taller behind it draws the
    -- top edge along the curve.
    local r = px(18)
    local edge = math.max(1, px(2))
    local sheet = BoardUI.box({ w = screen_w, h = sheet_h + r, radius = r, bg = INK.white },
        vstack({ w = screen_w, h = sheet_h + r, pad = { pad, mx, 0, mx } }, { body }))
    local backing = BoardUI.box({ w = screen_w, h = sheet_h + r + edge, radius = r, bg = INK.black }, space(1, 1))
    return { widget = sheet, backing = backing, edge = edge, h = sheet_h }
end

-- The ⋯ menu, anchored under the header's menu button: a white panel with a
-- black edge and a hard shadow below, rows of icon and label.
local function menuPanel(screen_w, mx, items)
    local mw = math.min(px(300), screen_w - 2 * mx)
    local ih = px(50)
    local b = math.max(1, px(1.5))
    local iw = mw - 2 * b
    local rows = {}
    for i, item in ipairs(items) do
        local sep = item.separator and i > 1
        local content_h = ih - (sep and hair() or 0)
        local row = hstack({ w = iw, h = content_h }, {
            space(px(16) - b, 1),
            icon(item.icon, px(19), { sw = 2.25 }),
            space(px(14), 1),
            text(item.label, { s = 15, w = mw - px(16 + 14 + 19 + 16), data = true }),
            flex(0),
        })
        if sep then row = vstack({ w = iw }, { BoardUI.rect(iw, hair(), INK.gc), row }) end
        table.insert(rows, region(row, item.action, "menu-" .. i))
    end
    local body = vstack({ w = iw }, { space(nil, px(6) - b), vstack({ w = iw }, rows), space(nil, px(6) - b) })
    local panel_h = hOf(body) + 2 * b
    local panel = BoardUI.box({ w = mw, h = panel_h, radius = px(14), border = b, border_col = INK.black, bg = INK.white }, body)
    local shadow = BoardUI.box({ w = mw, h = panel_h, radius = px(14), bg = INK.black }, space(1, 1))
    return BoardUI.overlay(mw, panel_h + px(3), { { widget = shadow, x = 0, y = px(3) }, { widget = panel, x = 0, y = 0 } }), mw
end

-- ------------------------------------------------------------- the screen
-- ctx: d (view model), page (1 main, 2 details), sheet ("verdict", "menu"),
-- menu_items, run (action handler), screen_w, screen_h.
-- Returns the screen widget, the focus rows (top to bottom) and the
-- regions by key.
function DetailView.build(ctx)
    BoardMetrics.refresh()
    local m = BoardMetrics.get()
    local d = ctx.d
    local screen_w, screen_h = ctx.screen_w, ctx.screen_h
    local W = math.floor(screen_w / m.layout_dp * 1000 + 0.5) / 1000
    local mx = px(W >= 900 and 28 or W >= 700 and 24 or W >= 560 and 20 or 16)
    local w = screen_w - 2 * mx
    local top = px(6)
    build_ctx = { rows = {}, row_order = {}, row_list = {}, regions = {}, run = ctx.run }
    local ok, result = pcall(function()
        local page = ctx.page == 2 and 2 or 1
        local header = DetailView.header(d, w, page == 2 and { back = _("Book"), center = _("More details") } or nil)
        local pager = page == 1 and DetailView.pager(d, w) or nil
        local off_h = d.offline and (px(32) + px(10)) or 0
        local pager_h = pager and (px(48) + px(8)) or 0
        local body_h = screen_h - top - px(48) - px(10) - off_h - pager_h - px(8)
        local metrics = { w = screen_w, h = screen_h, W = W, H = screen_h / m.dp, dp = m.dp }
        local body = page == 1 and mainPage(d, w, body_h, metrics, body_h + off_h + pager_h) or detailsPage(d, w, body_h, metrics)
        local items = {
            BoardUI.overlay(w, px(48), { { widget = header, x = -px(8), y = 0 } }),
            space(nil, px(10)),
        }
        if d.offline then table.insert(items, DetailView.offlineNotice(d, w)); table.insert(items, space(nil, px(10))) end
        table.insert(items, body)
        table.insert(items, flex(px(8)))
        if pager then table.insert(items, mark("pager", pager)) end
        local screen = vstack({ w = screen_w, h = screen_h, pad = { top, mx, px(4), mx } }, items)
        -- The header's tap areas reach px(8) into the left margin.
        local layers = { { widget = screen, x = 0, y = 0 } }
        -- With a sheet or the menu open, a tap anywhere outside it closes it
        -- and reaches nothing underneath.
        if ctx.sheet then
            table.insert(layers, { widget = region(space(screen_w, screen_h), { kind = "close-sheet" }), x = 0, y = 0 })
        end
        if ctx.sheet == "verdict" then
            local sh = verdictSheet(d, screen_w, screen_h, mx)
            table.insert(layers, { widget = Scrim:new{ dimen = Geom:new{ w = screen_w, h = screen_h } }, x = 0, y = 0 })
            table.insert(layers, { widget = sh.backing, x = 0, y = screen_h - sh.h - sh.edge })
            table.insert(layers, { widget = sh.widget, x = 0, y = screen_h - sh.h })
        elseif ctx.sheet == "menu" then
            local panel, mw = menuPanel(screen_w, mx, ctx.menu_items or {})
            table.insert(layers, { widget = panel, x = screen_w - mx - mw, y = top + px(48) })
        end
        return BoardUI.overlay(screen_w, screen_h, layers)
    end)
    local out = build_ctx
    build_ctx = nil
    if not ok then error(result, 0) end
    local rows = {}
    for _i, key in ipairs(out.row_list) do
        local list = out.rows[key]
        if list and #list > 0 then
            list.id = key
            table.insert(rows, list)
        end
    end
    return result, rows, out.regions
end

return DetailView