mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
80244ea4e2
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.
1383 lines
67 KiB
Lua
1383 lines
67 KiB
Lua
--[[--
|
||
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
|