Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_detail_view.lua
T
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

1383 lines
67 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--[[--
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 math.floor(BoardMetrics.get().W * 1000 + 0.5) / 1000
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
local lo, hi = math.floor(width * 0.5 + 0.5), 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 >= px(600) then return 6 end
if w >= px(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, when the page has room left for them.
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, late = true, 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 >= px(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.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