mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
267f53fccd
Add a Colour setting to Settings > Home: Accents, the default, colours each widget's marks and draws Continue reading in its cover's deep shade; Tinted also washes every tile in its widget's colour; Off keeps the greys. Black-and- white screens draw as before, colour e-readers get stronger shades, and night mode keeps each colour's hue.
1916 lines
93 KiB
Lua
1916 lines
93 KiB
Lua
--[[--
|
|
The dashboard's widgets. Each takes the board context, a tile size in pixels
|
|
and the widget's props, and returns content of exactly that size.
|
|
|
|
A widget lays itself out for the box it gets: one with several layouts tries
|
|
them from richest to most compact and keeps the first that fits (see
|
|
BoardUI.pick), so every widget works at every size on every screen. The
|
|
dimensions and spacing are in dp and mirror the design mocks one for one.
|
|
]]
|
|
|
|
local datetime = require("datetime")
|
|
local T = require("ffi/util").template
|
|
local _ = require("gettext")
|
|
|
|
local BoardColour = require("bookorbit_board_colour")
|
|
local BoardData = require("bookorbit_board_data")
|
|
local BoardStore = require("bookorbit_board_store")
|
|
local BoardMetrics = require("bookorbit_board_metrics")
|
|
local BoardUI = require("bookorbit_board_ui")
|
|
|
|
local INK = BoardMetrics.INK
|
|
local px, lh1, lh = BoardMetrics.px, BoardMetrics.lh1, BoardMetrics.lh
|
|
local text, runs, vstack, hstack, flex, space = BoardUI.text, BoardUI.runs, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space
|
|
local icon, bar, rect, pick = BoardUI.icon, BoardUI.bar, BoardUI.rect, BoardUI.pick
|
|
|
|
local W = {}
|
|
local BoardWidgets = { W = W }
|
|
|
|
local function clamp(v, lo, hi)
|
|
if v < lo then return lo end
|
|
if v > hi then return hi end
|
|
return v
|
|
end
|
|
|
|
local function thin() return math.max(1, px(0.75)) end
|
|
|
|
local function firstAuthor(book)
|
|
return book and type(book.authors) == "table" and book.authors[1] or nil
|
|
end
|
|
|
|
-- A font size that fills `room` px of height, as the mocks compute it.
|
|
local function fillSize(room, lo, hi)
|
|
return clamp(math.floor(room / 1.36 / BoardMetrics.get().dp), lo, hi)
|
|
end
|
|
|
|
-- The label row every tile starts with: bold label left, an optional widget
|
|
-- right, at least 4dp between them. A label too long for a narrow tile steps
|
|
-- down a size or two before it is cut; a tile's layouts share it, so this
|
|
-- never changes which layout wins.
|
|
local function head(iw, label, right, o)
|
|
o = o or {}
|
|
local rw = right and right:getSize().w or 0
|
|
local room = right and (iw - rw - px(4)) or iw
|
|
local size = 13
|
|
local lab = BoardUI.text(label, { s = size, b = true, col = o.col })
|
|
while lab:getSize().w > room and size > 11 do
|
|
size = size - 1
|
|
lab = BoardUI.text(label, { s = size, b = true, col = o.col })
|
|
end
|
|
if lab:getSize().w > room then
|
|
lab = BoardUI.text(label, { s = size, b = true, col = o.col, w = math.max(1, room) })
|
|
end
|
|
return BoardUI.spread(iw, lab, right, lh1(13))
|
|
end
|
|
|
|
-- An empty widget's hint in the lines it needs, at the largest of `sizes`
|
|
-- that fits `room`, so a short tile neither cuts it nor reserves lines it
|
|
-- leaves empty. o: sizes, i, serif, col.
|
|
local function hint(str, iw, room, o)
|
|
o = o or {}
|
|
local f = BoardUI.fitTitle(str, { i = o.i, serif = o.serif }, iw, o.sizes or { 13, 12, 11 }, 4, room)
|
|
return text(str, { s = f.s, i = o.i, serif = o.serif, col = o.col or INK.g4, lines = f.lines > 1 and f.lines or nil, w = iw })
|
|
end
|
|
|
|
local function dots(flags, size, gap, o)
|
|
local items = {}
|
|
for i = 1, 7 do
|
|
table.insert(items, BoardUI.dot(size, flags[i] == true, o))
|
|
end
|
|
return hstack({ gap = gap }, items)
|
|
end
|
|
|
|
local function cover(ctx, book, w, h, o)
|
|
o = o or {}
|
|
return BoardUI.cover(book, w, h, {
|
|
path = book and book.cover_path,
|
|
loading = book and book.cover_state == "loading",
|
|
border = o.border or thin(),
|
|
border_col = o.border_col or BoardColour.coverEdge(ctx, INK.gb),
|
|
dim = o.dim,
|
|
})
|
|
end
|
|
|
|
local function offlineDim(ctx, book)
|
|
return not ctx.d.online and book and not book.on_device
|
|
end
|
|
|
|
-- ------------------------------------------------------- continue reading
|
|
-- disabled draws it like the full Download pill offline: gray, so it does
|
|
-- not look like a button that works.
|
|
local function roundButton(ctx, d, icon_name, filled, ink, disabled)
|
|
local bg = disabled and INK.g4 or ink and INK.white or INK.black
|
|
return BoardUI.circle(d, { bg = bg }, icon(icon_name, math.floor(d * (filled and 0.36 or 0.42) + 0.5), {
|
|
col = disabled and "g6" or ink and "black" or "white", fill = filled, sw = 2.25,
|
|
}))
|
|
end
|
|
|
|
W.hero = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local book = d.continue_reading[1]
|
|
local total = #d.continue_reading
|
|
local tall = hh >= px(170)
|
|
local pad = px(tall and 16 or 12)
|
|
local iw, ih = w - 2 * pad, hh - 2 * pad
|
|
|
|
if not book then
|
|
local variants = {}
|
|
local copies = { _("Three picks from your library"), _("Pick a book to start"), _("Start here") }
|
|
local picks = d.shelves["random"] or {}
|
|
for n = 3, 0, -1 do
|
|
for ci, copy in ipairs(copies) do
|
|
table.insert(variants, function()
|
|
local ch = tall and math.min(ih - px(4), px(150)) or ih
|
|
local cw = math.floor(ch * 2 / 3 + 0.5)
|
|
local g2 = px(8)
|
|
local btn_d = math.min(px(tall and 48 or 52), ih)
|
|
local covers = {}
|
|
for i = 1, math.min(n, #picks) do
|
|
table.insert(covers, cover(ctx, picks[i], cw, ch, { border = math.max(1, px(1)), border_col = INK.g6 }))
|
|
end
|
|
local tw = math.max(1, iw - (n * cw + (n - 1) * g2) - btn_d - px(28))
|
|
local label = (ci == 1 and n ~= 3) and _("Picks from your library") or copy
|
|
return hstack({ w = w, h = hh }, {
|
|
space(pad, 1),
|
|
vstack({ w = tw }, {
|
|
text(_("Start reading"), { s = 11, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = tw }),
|
|
space(nil, px(3)),
|
|
text(label, { s = 16, b = true, col = INK.white, lines = 2, w = tw }),
|
|
}),
|
|
space(px(14), 1),
|
|
hstack({ gap = g2, valign = "bottom" }, covers),
|
|
space(px(14), 1),
|
|
BoardUI.hit(roundButton(ctx, btn_d, "shuffle", false, true), { kind = "shuffle" }),
|
|
flex(0),
|
|
})
|
|
end)
|
|
end
|
|
end
|
|
if tall then
|
|
local function stacked(n)
|
|
return function()
|
|
local g2 = px(8)
|
|
local btn_h = px(38)
|
|
local ch = math.min(px(120), ih - lh1(11) - px(3) - 2 * lh(16) - px(10) - px(10) - btn_h)
|
|
local cw = math.min(math.floor(ch * 2 / 3 + 0.5), math.floor((iw - (n - 1) * g2) / n))
|
|
local covers = {}
|
|
for i = 1, math.min(n, #picks) do
|
|
table.insert(covers, cover(ctx, picks[i], cw, math.floor(cw * 1.5 + 0.5), { border = math.max(1, px(1)), border_col = INK.g6 }))
|
|
end
|
|
local shuffle = BoardUI.box({ w = BoardUI.runs({ _("Shuffle") }, { s = 14 }):getSize().w + px(16) + px(13) + px(16) + px(8), h = btn_h, radius = math.floor(btn_h / 2), bg = INK.white },
|
|
hstack({ h = btn_h, gap = px(8) }, { space(px(13), 1), icon("shuffle", px(16), { sw = 2.25 }), text(_("Shuffle"), { s = 14, b = true }) }))
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
text(_("Start reading"), { s = 11, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = iw }),
|
|
space(nil, px(3)),
|
|
text(_("Pick a book from your library to begin"), { s = 16, b = true, col = INK.white, lines = 2, w = iw }),
|
|
flex(px(10)),
|
|
hstack({ gap = g2, valign = "bottom" }, covers),
|
|
space(nil, px(10)),
|
|
shuffle,
|
|
})
|
|
end
|
|
end
|
|
table.insert(variants, 1, stacked(3))
|
|
table.insert(variants, stacked(2))
|
|
end
|
|
return pick(variants)
|
|
end
|
|
|
|
local pct = tonumber(book.progressPercentage) or 0
|
|
local play_icon = book.on_device and "play" or "download"
|
|
|
|
if not tall then
|
|
local ch = ih
|
|
local btn_d = math.min(px(56), ch)
|
|
-- The cover gives way before the title and progress: a tall, narrow
|
|
-- tile keeps at least 110dp for them.
|
|
local cw2 = math.min(math.floor(ch * 2 / 3 + 0.5), math.max(px(36), w - 2 * pad - btn_d - px(28) - px(110)))
|
|
local cover_h = math.min(ch, math.floor(cw2 * 1.5 + 0.5))
|
|
local tw2 = math.max(1, w - 2 * pad - cw2 - btn_d - px(28))
|
|
local function label(lv)
|
|
if lv == 2 then
|
|
return total > 1 and T(_("Continue reading · %1 of %2"), 1, total) or _("Continue reading")
|
|
end
|
|
if lv == 1 then return _("Continue reading") end
|
|
end
|
|
local function meta(mv)
|
|
local m = { { t = BoardData.pctLabel(book) or "0%", b = true, col = INK.white } }
|
|
if mv == 2 and book.minutes_left then
|
|
table.insert(m, { t = " · " .. T(_("%1 left"), BoardData.formatDuration(book.minutes_left)), col = BoardColour.onInk(ctx, INK.gc, "hero") })
|
|
elseif mv == 1 and book.minutes_left then
|
|
table.insert(m, { t = " · " .. T(_("%1 left"), BoardData.formatShortDuration(book.minutes_left)), col = BoardColour.onInk(ctx, INK.gc, "hero") })
|
|
end
|
|
return m
|
|
end
|
|
local variants = {}
|
|
for _i, lm in ipairs({ { 2, 2 }, { 1, 2 }, { 2, 1 }, { 1, 1 }, { 1, 0 }, { 0, 1 }, { 0, 0 } }) do
|
|
table.insert(variants, function()
|
|
local lab = label(lm[1])
|
|
local avail = ch - (lab and (lh1(11) + px(3)) or 0) - px(7) - lh1(12)
|
|
local tf = BoardUI.fitTitle(book.title, { b = true }, tw2, { 18, 16, 15 }, 2, avail)
|
|
local m = runs(meta(lm[2]), { s = 12 })
|
|
local mw = math.min(math.floor(tw2 * 0.62 + 0.5), m:getSize().w + px(2), math.max(1, tw2 - px(30)))
|
|
m = runs(meta(lm[2]), { s = 12, w = mw, data = true })
|
|
return hstack({ w = w, h = hh }, {
|
|
space(pad, 1),
|
|
cover(ctx, book, cw2, cover_h, { border = math.max(1, px(1)), border_col = INK.g4 }),
|
|
space(px(14), 1),
|
|
vstack({ w = tw2, h = ch }, {
|
|
flex(0),
|
|
lab and text(lab, { s = 11, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = tw2 }) or false,
|
|
lab and space(nil, px(3)) or false,
|
|
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw2, data = true }),
|
|
space(nil, px(7)),
|
|
hstack({ w = tw2, gap = px(10) }, { bar(math.max(px(20), tw2 - mw - px(10)), px(5), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(2.5) }), m }),
|
|
flex(0),
|
|
}),
|
|
space(px(14), 1),
|
|
BoardUI.hit(roundButton(ctx, btn_d, play_icon, book.on_device, true, not book.on_device and not d.online), { kind = "book", book = book, resume = true, list = { label = _("Continue reading"), books = d.continue_reading } }),
|
|
flex(0),
|
|
})
|
|
end)
|
|
end
|
|
return pick(variants)
|
|
end
|
|
|
|
local offline = not d.online
|
|
local function btn(short)
|
|
local disabled = not book.on_device and offline
|
|
local fg = disabled and INK.g6 or INK.black
|
|
local label = book.on_device and (short and _("Resume") or _("Resume reading")) or _("Download")
|
|
local inner = hstack({ h = px(40), gap = px(8) }, {
|
|
space(px(13) - px(8), 1),
|
|
icon(play_icon, px(book.on_device and 14 or 16), { col = disabled and "g6" or "black", fill = book.on_device, sw = 2.25 }),
|
|
text(label, { s = 14, b = true, col = fg }),
|
|
space(px(16) - px(8), 1),
|
|
})
|
|
return BoardUI.hit(BoardUI.box({ w = inner:getSize().w, h = px(40), radius = px(20), bg = disabled and INK.g4 or INK.white }, inner),
|
|
{ kind = "book", book = book, resume = true, list = { label = _("Continue reading"), books = d.continue_reading } })
|
|
end
|
|
local function ghost()
|
|
return BoardUI.hit(BoardUI.circle(px(40), { border = math.max(1, px(1.5)), border_col = INK.white }, icon("ellipsis", px(18), { col = "white", sw = 2.5 })),
|
|
{ kind = "book-actions", book = book })
|
|
end
|
|
local function pager()
|
|
if total <= 1 then return nil end
|
|
return hstack({ gap = px(2) }, { text(T(_("%1 / %2"), 1, total), { s = 12, col = BoardColour.onInk(ctx, INK.gb, "hero") }), icon("chevron-right", px(15), { col = BoardColour.onInkHex(ctx, "gb", "hero"), sw = 2.5 }) })
|
|
end
|
|
local function meta(full)
|
|
local m = { { t = tostring(math.floor(pct + 0.5)) .. "%", b = true, col = INK.white } }
|
|
if book.minutes_left then
|
|
local dur = full and BoardData.formatDuration(book.minutes_left) or BoardData.formatShortDuration(book.minutes_left)
|
|
table.insert(m, { t = " · " .. T(_("%1 left"), dur), col = BoardColour.onInk(ctx, INK.gc, "hero") })
|
|
end
|
|
return m
|
|
end
|
|
local function side(opt)
|
|
return function()
|
|
local cover_h = ih
|
|
local cover_w = math.min(math.floor(cover_h * 2 / 3 + 0.5), math.floor(iw * 0.4 + 0.5))
|
|
local gutter = px(16)
|
|
local tw = iw - cover_w - gutter
|
|
local label_gap = px(opt.compact and 4 or 8)
|
|
local author_gap = px(opt.compact and 4 or 6)
|
|
local progress_gap = px(opt.compact and 4 or 10)
|
|
local meta_gap = px(opt.compact and 4 or 8)
|
|
local action_gap = px(opt.compact and 8 or 12)
|
|
local fixed = lh1(12) + label_gap + author_gap + lh1(15) + progress_gap + px(6) + meta_gap + lh1(13) + action_gap + px(40)
|
|
local tf = BoardUI.fitTitle(book.title, { b = true }, tw, { 26, 23, 20, 18, 16 }, 3, cover_h - fixed)
|
|
local actions = opt.ghost and hstack({ gap = px(8) }, { btn(opt.short), ghost() }) or btn(opt.short)
|
|
local label = text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero") })
|
|
return hstack({ w = w, h = hh, valign = "top" }, {
|
|
space(pad, 1),
|
|
-- A cover narrower than the column's height is centred in it,
|
|
-- not hung above a band of empty tile.
|
|
vstack({ w = cover_w, h = hh, pad = { pad, 0, pad, 0 } }, { flex(0), cover(ctx, book, cover_w, math.min(cover_h, math.floor(cover_w * 1.5 + 0.5)), { border = math.max(1, px(1)), border_col = INK.g4 }), flex(0) }),
|
|
space(gutter, 1),
|
|
vstack({ w = tw, h = hh, pad = { pad, 0, pad, 0 }, strict_padding = true }, {
|
|
opt.pager and BoardUI.spread(tw, label, pager()) or label,
|
|
space(nil, label_gap),
|
|
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }),
|
|
space(nil, author_gap),
|
|
text(firstAuthor(book) or "", { s = 15, col = BoardColour.onInk(ctx, INK.gd, "hero"), w = tw, data = true }),
|
|
flex(progress_gap),
|
|
bar(tw, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
|
|
space(nil, meta_gap),
|
|
runs(meta(opt.full_meta), { s = 13, w = tw }),
|
|
space(nil, action_gap),
|
|
actions,
|
|
}),
|
|
})
|
|
end
|
|
end
|
|
-- Short and narrow (2x2 on a 6" reader): the cover keeps the full height,
|
|
-- and Resume becomes a round button beside the progress.
|
|
local function sideCompact(opt)
|
|
return function()
|
|
local cover_h = ih
|
|
local cover_w = math.min(math.floor(cover_h * 2 / 3 + 0.5), math.floor(iw * 0.42 + 0.5))
|
|
local gutter = px(14)
|
|
local tw = iw - cover_w - gutter
|
|
local btn_d = px(40)
|
|
local author_h = opt.author and (px(4) + lh1(13)) or 0
|
|
local fixed = lh1(12) + px(6) + px(8) + btn_d
|
|
local tf = BoardUI.fitTitle(book.title, { b = true }, tw, { 18, 16, 15 }, 3, cover_h - fixed - author_h)
|
|
local bar_w = tw - btn_d - px(10)
|
|
return hstack({ w = w, h = hh, valign = "top" }, {
|
|
space(pad, 1),
|
|
-- A cover narrower than the column's height is centred in it,
|
|
-- not hung above a band of empty tile.
|
|
vstack({ w = cover_w, h = hh, pad = { pad, 0, pad, 0 } }, { flex(0), cover(ctx, book, cover_w, math.min(cover_h, math.floor(cover_w * 1.5 + 0.5)), { border = math.max(1, px(1)), border_col = INK.g4 }), flex(0) }),
|
|
space(gutter, 1),
|
|
vstack({ w = tw, h = hh, pad = { pad, 0, pad, 0 }, strict_padding = true }, {
|
|
text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = tw }),
|
|
space(nil, px(6)),
|
|
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }),
|
|
opt.author and space(nil, px(4)) or false,
|
|
opt.author and text(firstAuthor(book) or "", { s = 13, col = BoardColour.onInk(ctx, INK.gd, "hero"), w = tw, data = true }) or false,
|
|
flex(px(8)),
|
|
hstack({ w = tw, gap = px(10) }, {
|
|
vstack({ w = bar_w }, {
|
|
text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }),
|
|
space(nil, px(5)),
|
|
bar(bar_w, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
|
|
}),
|
|
BoardUI.hit(roundButton(ctx, btn_d, play_icon, book.on_device, true, not book.on_device and not d.online), { kind = "book", book = book, resume = true, list = { label = _("Continue reading"), books = d.continue_reading } }),
|
|
}),
|
|
}),
|
|
})
|
|
end
|
|
end
|
|
local function stacked(opt)
|
|
return function()
|
|
local bottom_h = px(6) + px(8) + lh1(13) + px(12) + px(40)
|
|
local top_h = ih - lh1(12) - px(8) - bottom_h - px(8)
|
|
local cover_h = math.max(px(40), math.min(top_h, px(120)))
|
|
local cover_w = math.floor(cover_h * 2 / 3 + 0.5)
|
|
local tw = iw - cover_w - px(12)
|
|
local tf = BoardUI.fitTitle(book.title, { b = true }, tw, { 20, 18, 16, 15 }, 3, cover_h - (opt.author and (lh1(13) + px(4)) or 0))
|
|
local label = text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero") })
|
|
return vstack({ w = w, h = hh, pad = pad, strict_padding = true }, {
|
|
opt.pager and BoardUI.spread(iw, label, pager()) or label,
|
|
space(nil, px(8)),
|
|
hstack({ gap = px(12), valign = "top" }, {
|
|
cover(ctx, book, cover_w, cover_h, { border = math.max(1, px(1)), border_col = INK.g4 }),
|
|
vstack({ w = tw }, {
|
|
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }),
|
|
opt.author and space(nil, px(4)) or false,
|
|
opt.author and text(firstAuthor(book) or "", { s = 13, col = BoardColour.onInk(ctx, INK.gd, "hero"), w = tw, data = true }) or false,
|
|
}),
|
|
}),
|
|
flex(px(8)),
|
|
bar(iw, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
|
|
space(nil, px(8)),
|
|
runs(meta(opt.full_meta), { s = 13, w = iw }),
|
|
space(nil, px(12)),
|
|
opt.ghost and hstack({ gap = px(8) }, { btn(opt.short), ghost() }) or btn(opt.short),
|
|
})
|
|
end
|
|
end
|
|
-- The last resort on a short tile: no cover, the title and the round
|
|
-- button beside the progress.
|
|
local function mini()
|
|
return function()
|
|
local btn_d = px(40)
|
|
local bar_w = iw - btn_d - px(10)
|
|
local tf = BoardUI.fitTitle(book.title, { b = true }, iw, { 18, 16, 15 }, 2, ih - lh1(12) - px(6) - px(8) - btn_d)
|
|
return vstack({ w = w, h = hh, pad = pad, strict_padding = true }, {
|
|
text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = iw }),
|
|
space(nil, px(6)),
|
|
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = iw, data = true }),
|
|
flex(px(8)),
|
|
hstack({ w = iw, gap = px(10) }, {
|
|
vstack({ w = bar_w }, {
|
|
text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }),
|
|
space(nil, px(5)),
|
|
bar(bar_w, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
|
|
}),
|
|
BoardUI.hit(roundButton(ctx, btn_d, play_icon, book.on_device, true, not book.on_device and not d.online), { kind = "book", book = book, resume = true, list = { label = _("Continue reading"), books = d.continue_reading } }),
|
|
}),
|
|
})
|
|
end
|
|
end
|
|
return pick({
|
|
side({ pager = true, ghost = true, full_meta = true }),
|
|
side({ pager = true, ghost = false, short = true, full_meta = true }),
|
|
side({ pager = false, ghost = false, short = true, full_meta = false }),
|
|
side({ pager = false, ghost = false, short = true, full_meta = false, compact = true }),
|
|
sideCompact({ author = true }),
|
|
sideCompact({ author = false }),
|
|
stacked({ pager = true, ghost = true, author = true, full_meta = true }),
|
|
stacked({ pager = true, ghost = false, author = true, full_meta = true }),
|
|
stacked({ pager = false, ghost = false, short = true, author = true, full_meta = false }),
|
|
stacked({ pager = false, ghost = false, short = true, author = false, full_meta = false }),
|
|
mini(),
|
|
})
|
|
end
|
|
|
|
-- -------------------------------------------------------------- statistics
|
|
W.streak = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local n = d.streak.current
|
|
local wide = w > px(200)
|
|
local val_s = fillSize(hh - 2 * pad - lh1(13) - px(18), 20, wide and 40 or 36)
|
|
local function count(width)
|
|
if n > 0 then
|
|
return runs({ { t = tostring(n), b = true, s = val_s }, { t = " " .. (n == 1 and _("day") or _("days")), col = INK.g4 } }, { s = 13, w = width })
|
|
end
|
|
local rs = math.min(val_s, 18)
|
|
if width then rs = BoardUI.fitSize(_("Read today"), { s = rs, b = true }, width, 12) end
|
|
return runs({ { t = _("Read today"), b = true, s = rs } }, { s = 13, w = width })
|
|
end
|
|
local dot_size = px(wide and 11 or 9)
|
|
local dot_gap = wide and px(7) or math.max(px(3), math.floor((iw - 7 * px(9)) / 6))
|
|
return pick({
|
|
function()
|
|
local dot_row = dots(d.streak_week, dot_size, dot_gap, { col = BoardColour.ink(ctx, INK.black) })
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
count(iw),
|
|
flex(0),
|
|
wide and BoardUI.spread(iw, dot_row, text(T(_("best %1"), d.streak.longest), { s = 12, col = INK.g4 })) or dot_row,
|
|
})
|
|
end,
|
|
-- A short row: the week beside the count.
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
BoardUI.spread(iw, count(nil), dots(d.streak_week, dot_size, dot_gap, { col = BoardColour.ink(ctx, INK.black) })),
|
|
})
|
|
end,
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
count(iw),
|
|
})
|
|
end,
|
|
})
|
|
end
|
|
|
|
W.week = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local max = 1
|
|
for _i, v in ipairs(d.week) do max = math.max(max, v) end
|
|
local bar_w = math.min(px(22), math.floor((iw - 6 * px(6)) / 7))
|
|
local gap = math.floor((iw - 7 * bar_w) / 6)
|
|
local base = math.max(1, px(1))
|
|
local function chart(chart_h)
|
|
local bars = {}
|
|
for i, v in ipairs(d.week) do
|
|
local h = v > 0 and math.max(px(3), math.floor((chart_h - px(2)) * v / max + 0.5)) or px(2)
|
|
local col = v > 0 and (i == 7 and BoardColour.ink(ctx, INK.black) or BoardColour.mid(ctx, INK.g8)) or BoardColour.track(ctx, INK.gc)
|
|
table.insert(bars, BoardUI.rect(bar_w, h, col))
|
|
end
|
|
return vstack({ w = iw }, {
|
|
hstack({ w = iw, h = chart_h - base, gap = gap, valign = "bottom" }, bars),
|
|
rect(iw, base, INK.black),
|
|
})
|
|
end
|
|
local function initials()
|
|
local cells = {}
|
|
for i, t in ipairs(d.week_initials) do
|
|
table.insert(cells, text(t, { s = 11, b = i == 7, col = i == 7 and INK.black or INK.g4, w = bar_w, align = "center" }))
|
|
end
|
|
return hstack({ gap = gap }, cells)
|
|
end
|
|
local long = d.week_min > 0 and BoardData.formatDuration(d.week_min) or _("Nothing yet")
|
|
local short = d.week_min > 0 and BoardData.formatShortDuration(d.week_min) or _("Nothing yet")
|
|
-- A layout only counts when its bars get at least this much height.
|
|
local min_chart = px(12)
|
|
local variants = {}
|
|
-- The total beside the title, then the bars over their weekday initials.
|
|
local beside_h = hh - 2 * pad - lh1(13) - px(8) - lh1(11) - px(4)
|
|
for _i, total in ipairs(beside_h >= min_chart and { long, short } or {}) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("This week"), text(total, { s = 13, b = true })),
|
|
space(nil, px(8)),
|
|
chart(beside_h),
|
|
space(nil, px(4)),
|
|
initials(),
|
|
})
|
|
end)
|
|
end
|
|
-- A single column: the total under the title, initials only with room.
|
|
for _i, with_initials in ipairs({ true, false }) do
|
|
local chart_h = hh - 2 * pad - 2 * lh1(13) - px(6) - (with_initials and (px(4) + lh1(11)) or 0)
|
|
if chart_h >= min_chart or not with_initials then
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("This week"), nil),
|
|
text(short, { s = 13, b = true, w = iw }),
|
|
space(nil, px(6)),
|
|
chart(chart_h),
|
|
with_initials and space(nil, px(4)) or false,
|
|
with_initials and initials() or false,
|
|
})
|
|
end)
|
|
end
|
|
end
|
|
return pick(variants)
|
|
end
|
|
|
|
W.today = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local val_s = fillSize(hh - 2 * pad - lh1(13) - px(20), 18, 30)
|
|
local function value()
|
|
return d.today_min > 0
|
|
and runs({ { t = tostring(d.today_min), b = true, s = val_s }, { t = " " .. _("min"), col = INK.g4 } }, { s = 14, w = iw })
|
|
or runs({ { t = _("Not yet"), b = true, s = math.min(val_s, 20) } }, { s = 14, w = iw })
|
|
end
|
|
-- A short row drops the weekly average.
|
|
return pick({
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Today"), icon("clock", px(17), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
value(),
|
|
flex(0),
|
|
text(T(_("avg %1 min"), math.floor(d.week_min / 7 + 0.5)), { s = 12, col = INK.g4, w = iw }),
|
|
})
|
|
end,
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Today"), icon("clock", px(17), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
value(),
|
|
})
|
|
end,
|
|
})
|
|
end
|
|
|
|
-- Goal pace: how the year's goal is tracking, not only the count.
|
|
W.goalpace = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local g = d.goal
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local goal = g.books or 0
|
|
local done = g.done or 0
|
|
if goal <= 0 then
|
|
local function empty(str)
|
|
return function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, T(_("%1 goal"), g.year), icon("target", px(16), BoardColour.icon(ctx, 2.25))),
|
|
space(nil, px(6)),
|
|
hint(str, iw, hh - 2 * pad - lh1(13) - px(6)),
|
|
})
|
|
end
|
|
end
|
|
return pick({ empty(_("Set a reading goal in BookOrbit to track your pace.")), empty(_("Set a goal in BookOrbit.")) })
|
|
end
|
|
local expected = goal * d.day_of_year / 365
|
|
local projected = math.floor(done * 365 / math.max(1, d.day_of_year) + 0.5)
|
|
local met = done >= goal
|
|
local statuses
|
|
if done == 0 then
|
|
statuses = { _("First book starts it"), _("Starting out"), _("New") }
|
|
elseif met then
|
|
statuses = { T(_("Goal met · on pace for %1"), projected), T(_("on pace for %1"), projected), _("Goal met") }
|
|
elseif done >= expected then
|
|
local ahead = math.floor(done - expected + 0.5)
|
|
statuses = { T(_("%1 ahead of pace"), ahead), T(_("%1 ahead"), ahead) }
|
|
else
|
|
local behind = math.floor(expected - done + 0.5)
|
|
statuses = { T(_("%1 behind pace"), behind), T(_("%1 behind"), behind) }
|
|
end
|
|
local behind = not met and done > 0 and done < expected
|
|
local trophy = icon(met and "trophy" or "target", px(16), BoardColour.icon(ctx, 2.25))
|
|
local function goalBar()
|
|
local scale = math.max(goal, projected, done, 1)
|
|
local goal_x = math.floor(iw * goal / scale + 0.5)
|
|
local tick_w = math.max(1, px(2))
|
|
local tick = met
|
|
and BoardUI.box({ w = tick_w + 2 * math.max(1, px(1)), h = px(14) + 2 * math.max(1, px(1)), border = math.max(1, px(1)), bg = INK.white })
|
|
or rect(tick_w, px(14), INK.black)
|
|
return BoardUI.overlay(iw, px(14), {
|
|
{ widget = bar(iw, px(6), math.floor(100 * done / scale + 0.5), { track = BoardColour.track(ctx, INK.gd), fill = BoardColour.ink(ctx, INK.black), r = px(3) }), x = 0, y = px(4) },
|
|
{ widget = tick, x = math.min(iw - px(2), goal_x) - (met and math.max(1, px(1)) or 0), y = met and -math.max(1, px(1)) or 0 },
|
|
})
|
|
end
|
|
local val_s = fillSize(hh - 2 * pad - lh1(13) - lh1(12) - px(10), 20, 32)
|
|
local variants = {}
|
|
for _i, st in ipairs(statuses) do
|
|
for _i, unit in ipairs({ T(_(" of %1 books"), goal), T(_(" of %1"), goal) }) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, T(_("%1 goal"), g.year), trophy),
|
|
flex(0),
|
|
BoardUI.spread(iw, runs({ { t = tostring(done), b = true, s = 24 }, { t = unit, col = INK.g4 } }, { s = 13 }),
|
|
text(st, { s = 12, b = behind, col = behind and INK.black or INK.g4 })),
|
|
space(nil, px(4)),
|
|
goalBar(),
|
|
})
|
|
end)
|
|
end
|
|
end
|
|
for _i, st in ipairs(statuses) do
|
|
for _i, unit in ipairs({ T(_(" of %1 books"), goal), T(_(" of %1"), goal) }) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, T(_("%1 goal"), g.year), trophy),
|
|
flex(0),
|
|
runs({ { t = tostring(done), b = true, s = val_s }, { t = unit, col = INK.g4 } }, { s = 13, w = iw }),
|
|
space(nil, px(5)),
|
|
text(st, { s = 12, b = behind, col = behind and INK.black or INK.g4, w = iw }),
|
|
})
|
|
end)
|
|
end
|
|
end
|
|
-- A short row: the count with its status beside it, then the count alone.
|
|
for _i, st in ipairs(statuses) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, T(_("%1 goal"), g.year), trophy),
|
|
flex(0),
|
|
BoardUI.spread(iw, runs({ { t = tostring(done), b = true, s = 20 }, { t = T(_(" of %1"), goal), col = INK.g4 } }, { s = 13 }),
|
|
text(st, { s = 12, b = behind, col = behind and INK.black or INK.g4 })),
|
|
})
|
|
end)
|
|
end
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, T(_("%1 goal"), g.year), trophy),
|
|
flex(0),
|
|
runs({ { t = tostring(done), b = true, s = 20 }, { t = T(_(" of %1"), goal), col = INK.g4 } }, { s = 13, w = iw }),
|
|
})
|
|
end)
|
|
return pick(variants)
|
|
end
|
|
|
|
-- A year of reading days, one square a day, shaded by minutes read.
|
|
W.heatmap = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local g = math.max(1, px(1.5))
|
|
local legend_h = lh1(11)
|
|
local month_h = lh1(11)
|
|
local grid_h = hh - 2 * pad - lh1(13) - px(8) - month_h - px(3) - px(8) - legend_h
|
|
local weeks = 52
|
|
local cell = math.min(math.floor((iw - (weeks - 1) * g) / weeks), math.floor((grid_h - 6 * g) / 7))
|
|
if cell < px(6.5) then
|
|
cell = math.max(px(4), math.min(px(9), math.floor((grid_h - 6 * g) / 7)))
|
|
weeks = math.min(52, math.floor((iw + g) / (cell + g)))
|
|
end
|
|
local offset = (52 - weeks) * 7
|
|
local total, days_read, best, best_i = 0, 0, 0, nil
|
|
for i = offset + 1, #d.heatmap do
|
|
local v = d.heatmap[i] or 0
|
|
total = total + v
|
|
if v > 0 then days_read = days_read + 1 end
|
|
if v > best then best, best_i = v, i end
|
|
end
|
|
local ramp = BoardColour.ramp(ctx, { INK.ge, INK.gb, INK.g8, INK.g4, INK.black })
|
|
local function level(v)
|
|
if v == nil then return nil end
|
|
if v <= 0 then return ramp[1] end
|
|
if v < 30 then return ramp[2] end
|
|
if v < 60 then return ramp[3] end
|
|
if v < 120 then return ramp[4] end
|
|
return ramp[5]
|
|
end
|
|
local grid_w = weeks * cell + (weeks - 1) * g
|
|
local radius = math.max(1, px(1.5))
|
|
local today_i = nil
|
|
for i = 1, #d.heatmap do if d.heatmap[i] ~= nil then today_i = i end end
|
|
local function gridEl()
|
|
local cells = {}
|
|
for wk = 0, weeks - 1 do
|
|
for day = 0, 6 do
|
|
local i = offset + wk * 7 + day + 1
|
|
local col = level(d.heatmap[i])
|
|
if col then
|
|
table.insert(cells, { x = wk * (cell + g), y = day * (cell + g), col = col, ring = i == today_i })
|
|
end
|
|
end
|
|
end
|
|
return BoardUI.cellGrid(grid_w, 7 * cell + 6 * g, cell, radius, cells)
|
|
end
|
|
local function monthsEl()
|
|
local layers = {}
|
|
local prev_m, prev_x = nil, -999
|
|
for wk = 0, weeks - 1 do
|
|
local t = d.heatmap_start + (offset + wk * 7) * 86400
|
|
local m = tonumber(os.date("%m", t))
|
|
local x = wk * (cell + g)
|
|
if prev_m and m ~= prev_m and x - prev_x >= px(26) and x + px(24) <= grid_w then
|
|
table.insert(layers, { widget = text(BoardData.shortMonth(t), { s = 11, col = INK.g4 }), x = x, y = 0 })
|
|
prev_x = x
|
|
end
|
|
prev_m = m
|
|
end
|
|
return BoardUI.overlay(grid_w, month_h, layers)
|
|
end
|
|
local sw = math.max(px(8), math.min(cell, px(11)))
|
|
local function swatches(words)
|
|
local items = {}
|
|
if words then table.insert(items, text(_("Less"), { s = 11, col = INK.g4 })); table.insert(items, space(px(2), 1)) end
|
|
for _i, v in ipairs({ 0, 20, 45, 90, 200 }) do table.insert(items, rect(sw, sw, level(v), radius)) end
|
|
if words then table.insert(items, space(px(2), 1)); table.insert(items, text(_("More"), { s = 11, col = INK.g4 })) end
|
|
return hstack({ gap = px(3) }, items)
|
|
end
|
|
local function bestEl()
|
|
if not best_i then return text(_("Your reading days fill in here"), { s = 11, col = INK.g4 }) end
|
|
local t = d.heatmap_start + (best_i - 1) * 86400
|
|
return runs({ { t = _("Best day") .. " " }, { t = BoardData.formatShortDuration(best), b = true, col = INK.black },
|
|
{ t = ", " .. BoardData.shortWeekday(t) .. " " .. tonumber(os.date("%d", t)) .. " " .. BoardData.shortMonth(t) } }, { s = 11, col = INK.g4 })
|
|
end
|
|
local function legend(lv)
|
|
if lv == -1 then return space(grid_w, legend_h) end
|
|
if lv == 0 then return hstack({ w = grid_w, h = legend_h }, { flex(0), swatches(true) }) end
|
|
return BoardUI.spread(grid_w, bestEl(), swatches(lv == 2), legend_h)
|
|
end
|
|
local label = weeks >= 52 and _("Year in reading") or T(_("Past %1 months"), math.floor(weeks / 4.35 + 0.5))
|
|
local used = lh1(13) + px(8) + month_h + px(3) + 7 * cell + 6 * g + px(8) + legend_h
|
|
local figs = {
|
|
{ T(_("%1 h"), math.floor(total / 60 + 0.5)), _("read") },
|
|
{ tostring(days_read), _("days") },
|
|
{ tostring(d.streak.longest), _("best streak") },
|
|
{ T(_("%1 min"), math.floor(total / math.max(1, days_read) + 0.5)), _("a reading day") },
|
|
}
|
|
local function build(lv, nf, head_totals)
|
|
return function()
|
|
local fig_row = nil
|
|
if nf > 0 then
|
|
local items = {}
|
|
for i = 1, nf do
|
|
if i > 1 then table.insert(items, flex(0)) end
|
|
table.insert(items, vstack({}, { text(figs[i][1], { s = 20, b = true }), text(figs[i][2], { s = 11, col = INK.g4 }) }))
|
|
end
|
|
fig_row = hstack({ w = grid_w }, items)
|
|
end
|
|
local totals = nil
|
|
if head_totals and total > 0 then
|
|
totals = runs({ { t = T(_("%1 h"), math.floor(total / 60 + 0.5)), b = true }, { t = " · " .. T(_("%1 days"), days_read), col = INK.g4 } }, { s = 13 })
|
|
end
|
|
return vstack({ w = w, h = hh, pad = pad, align = "center" }, {
|
|
head(iw, label, totals),
|
|
space(nil, px(8)),
|
|
monthsEl(), space(nil, px(3)), gridEl(),
|
|
flex(px(8)),
|
|
fig_row or false,
|
|
fig_row and flex(px(8)) or false,
|
|
legend(lv),
|
|
})
|
|
end
|
|
end
|
|
local room_for_figs = total > 0 and hh - 2 * pad - used >= lh1(20) + lh1(11) + px(6)
|
|
local variants = {}
|
|
for _i, nf in ipairs(room_for_figs and { 4, 3, 0 } or { 0 }) do
|
|
for _i, lv in ipairs({ 2, 1, 0, -1 }) do table.insert(variants, build(lv, nf, nf == 0)) end
|
|
end
|
|
table.insert(variants, build(-1, 0, false))
|
|
return pick(variants)
|
|
end
|
|
|
|
W.calendar = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local now = d.now
|
|
local t = os.date("*t", now)
|
|
local first = os.time({ year = t.year, month = t.month, day = 1, hour = 12 })
|
|
local lead = tonumber(os.date("%w", first))
|
|
local days = tonumber(os.date("%d", os.time({ year = t.year, month = t.month + 1, day = 0, hour = 12 })))
|
|
local today = t.day
|
|
-- Which days of this month had reading, from the heatmap's minutes.
|
|
local read = {}
|
|
for day = 1, today do
|
|
local key_t = os.time({ year = t.year, month = t.month, day = day, hour = 12 })
|
|
local idx = math.floor((key_t - d.heatmap_start) / 86400 + 0.5) + 1
|
|
read[day] = (d.heatmap[idx] or 0) > 0
|
|
end
|
|
local read_count = 0
|
|
for _i, v in pairs(read) do if v then read_count = read_count + 1 end end
|
|
local gap = px(3)
|
|
local month_rows = math.ceil((lead + days) / 7)
|
|
local head_row_h = lh1(11) + px(3)
|
|
local avail_h = hh - 2 * pad - lh1(13) - px(8)
|
|
local month_cell = math.min(math.floor((avail_h - (month_rows - 1) * gap) / month_rows), math.floor((iw - 6 * gap) / 7))
|
|
local numbered = month_cell >= px(24)
|
|
if numbered then
|
|
avail_h = avail_h - head_row_h
|
|
month_cell = math.min(math.floor((avail_h - (month_rows - 1) * gap) / month_rows), math.floor((iw - 6 * gap) / 7))
|
|
end
|
|
local strip = month_cell < px(14)
|
|
local per_row = strip and 16 or 7
|
|
local rows = strip and 2 or month_rows
|
|
local offset = strip and 0 or lead
|
|
local cell = strip and math.min(math.floor((iw - 15 * gap) / 16), math.floor((avail_h - gap) / 2)) or month_cell
|
|
local grid_w = per_row * cell + (per_row - 1) * gap
|
|
local radius = px(numbered and 4 or 2)
|
|
local cells = {}
|
|
local labels = {}
|
|
local outline = math.max(1, px(1))
|
|
for i = 0, rows * per_row - 1 do
|
|
local day = i - offset + 1
|
|
if day >= 1 and day <= days then
|
|
local x, y = (i % per_row) * (cell + gap), math.floor(i / per_row) * (cell + gap)
|
|
local is_read = read[day] == true
|
|
if is_read then
|
|
table.insert(cells, { x = x, y = y, col = day == today and BoardColour.deep(ctx, INK.black) or BoardColour.ink(ctx, INK.g6) })
|
|
else
|
|
table.insert(cells, { x = x, y = y, col = day > today and INK.gd or INK.gb, border = outline })
|
|
end
|
|
if numbered and not strip then
|
|
table.insert(labels, { widget = text(tostring(day), { s = 11, b = day == today, col = is_read and INK.white or (day > today and INK.g8 or INK.g4), w = cell, align = "center" }),
|
|
x = x, y = y + math.floor((cell - lh1(11)) / 2) })
|
|
end
|
|
end
|
|
end
|
|
local grid_h = rows * cell + (rows - 1) * gap
|
|
table.insert(labels, 1, { widget = BoardUI.cellGrid(grid_w, grid_h, cell, radius, cells) })
|
|
local grid = BoardUI.overlay(grid_w, grid_h, labels)
|
|
local header = nil
|
|
if numbered and not strip then
|
|
local items = {}
|
|
for i = 1, 7 do
|
|
local wd_t = os.time({ year = 2026, month = 10, day = 3 + i, hour = 12 }) -- Sunday 4 Oct 2026
|
|
table.insert(items, text(BoardData.shortWeekday(wd_t):sub(1, 1), { s = 11, col = INK.g4, w = cell, align = "center" }))
|
|
end
|
|
header = vstack({ w = grid_w, h = head_row_h }, { hstack({ gap = gap }, items) })
|
|
end
|
|
return vstack({ w = w, h = hh, pad = pad, align = "center" }, {
|
|
head(iw, BoardData.longMonth(now), text(T(_("%1 days read"), read_count), { s = 12, col = INK.g4 })),
|
|
space(nil, px(8)),
|
|
header or false,
|
|
grid,
|
|
flex(0),
|
|
})
|
|
end
|
|
|
|
-- ------------------------------------------------------------------- lists
|
|
W.also = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local books = {}
|
|
for i = 2, #d.continue_reading do table.insert(books, d.continue_reading[i]) end
|
|
local avail = hh - 2 * pad - lh1(13) - px(6)
|
|
local n = math.max(0, math.min(#books, math.floor(avail / px(46))))
|
|
local row_h = n > 0 and math.min(px(64), math.floor(avail / n)) or px(56)
|
|
local cover_h = math.min(px(44), row_h - px(8))
|
|
local cover_w = math.floor(cover_h * 2 / 3 + 0.5)
|
|
local tw = iw - cover_w - px(10)
|
|
local rows = {}
|
|
for i = 1, n do
|
|
local b = books[i]
|
|
local pct = tonumber(b.progressPercentage) or 0
|
|
local sep = i > 1 and rect(iw, math.max(1, px(0.75)), INK.gc) or nil
|
|
local row_inner_h = row_h - (sep and math.max(1, px(0.75)) or 0)
|
|
-- At least 38dp, wider when a cloud and "100%" need it; the bar gives way.
|
|
local pct_text = text(BoardData.pctLabel(b) or "0%", { s = 12, b = true })
|
|
local content_w = pct_text:getSize().w + ((not b.on_device) and (px(12) + px(3)) or 0)
|
|
local right_w = math.max(px(38), content_w)
|
|
local right = hstack({ w = right_w }, {
|
|
flex(0),
|
|
(not b.on_device) and icon("cloud", px(12), { col = "g4", sw = 2.25 }) or false,
|
|
(not b.on_device) and space(px(3), 1) or false,
|
|
pct_text,
|
|
})
|
|
table.insert(rows, sep or false)
|
|
table.insert(rows, BoardUI.hit(hstack({ w = iw, h = row_inner_h, gap = px(10) }, {
|
|
cover(ctx, b, cover_w, cover_h, { border = thin(), dim = offlineDim(ctx, b) }),
|
|
vstack({ w = tw }, {
|
|
text(b.title, { s = 14, b = true, w = tw, data = true }),
|
|
space(nil, px(5)),
|
|
hstack({ w = tw, gap = px(8) }, { bar(tw - right_w - px(8), px(4), pct, { track = BoardColour.track(ctx, INK.gc), fill = BoardColour.ink(ctx, INK.black), r = px(2) }), right }),
|
|
}),
|
|
}), { kind = "book", book = b, resume = ctx.tap_resumes, list = { label = _("Continue reading"), books = d.continue_reading } }))
|
|
end
|
|
local all = hstack({ gap = px(2) }, { text(T(_("All %1"), d.counts.in_progress or #d.continue_reading), { s = 13, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) })
|
|
local body = n > 0 and vstack({ w = iw }, rows) or text(_("Books you start show up here."), { s = 13, col = INK.g4, w = iw })
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Also reading"), all),
|
|
space(nil, px(6)),
|
|
body,
|
|
flex(0),
|
|
})
|
|
end
|
|
|
|
W.highlight = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local hl = d.highlight
|
|
local pad = px(14)
|
|
local iw = w - 2 * pad
|
|
if not hl then
|
|
local str = _("Your highlights come back here, one a day.")
|
|
local o = { sizes = { 15, 14, 13, 12 }, i = true, serif = true, col = INK.g3 }
|
|
return pick({
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
icon("quote", px(20), { fill = true, sw = 1, col = BoardColour.artHex(ctx, "#000000", "deep") }),
|
|
space(nil, px(8)),
|
|
hint(str, iw, hh - 2 * pad - px(20) - px(8), o),
|
|
})
|
|
end,
|
|
function() return vstack({ w = w, h = hh, pad = pad }, { hint(str, iw, hh - 2 * pad, o) }) end,
|
|
})
|
|
end
|
|
local tall = hh > px(200)
|
|
local p3 = hh < px(130) and px(12) or pad
|
|
local iw3 = w - 2 * p3
|
|
local attr_h = lh1(12) + (tall and (px(2) + lh1(12)) or 0)
|
|
local qs = hh < px(130) and 14 or 15
|
|
local function quote(budget)
|
|
local lines = math.max(1, math.floor(budget / lh(qs)))
|
|
return text(hl.text, { s = qs, i = true, serif = true, lines = lines > 1 and lines or nil, w = iw3, col = INK.g1, data = true })
|
|
end
|
|
local label = function()
|
|
return hstack({ w = iw3, h = lh1(12), gap = px(6) }, { icon("quote", px(14), { fill = true, sw = 1, col = BoardColour.artHex(ctx, "#000000", "deep") }), text(_("Highlight of the day"), { s = 12, b = true, col = INK.g4 }), flex(0) })
|
|
end
|
|
return pick({
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = p3 }, {
|
|
label(),
|
|
space(nil, px(6)),
|
|
quote(hh - 2 * p3 - lh1(12) - px(6) - px(6) - attr_h),
|
|
flex(px(6)),
|
|
text(hl.bookTitle or "", { s = 12, b = true, w = iw3, data = true }),
|
|
tall and space(nil, px(2)) or false,
|
|
tall and text(hl.chapterTitle or "", { s = 12, col = INK.g4, w = iw3, data = true }) or false,
|
|
})
|
|
end,
|
|
-- A short row: the quote and its book, without the label.
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = p3 }, {
|
|
quote(hh - 2 * p3 - px(6) - lh1(12)),
|
|
flex(px(6)),
|
|
text(hl.bookTitle or "", { s = 12, b = true, w = iw3, data = true }),
|
|
})
|
|
end,
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = p3 }, { flex(0), quote(hh - 2 * p3), flex(0) })
|
|
end,
|
|
})
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- shelves
|
|
local SHELF_TITLES = {
|
|
["want-to-read"] = function() return _("Want to read") end,
|
|
["up-next-in-series"] = function() return _("Up next in series") end,
|
|
["random"] = function() return _("Discover") end,
|
|
["recently-added"] = function() return _("Recently added") end,
|
|
["in-progress"] = function() return _("In progress") end,
|
|
["pick-back-up"] = function() return _("Pick back up") end,
|
|
["finished"] = function() return _("Finished this year") end,
|
|
}
|
|
local SHELF_EMPTY = {
|
|
["want-to-read"] = function() return _("Mark books Want to read and they wait here.") end,
|
|
["pick-back-up"] = function() return _("Books you put down a while ago show up here.") end,
|
|
["finished"] = function() return _("Books you finish this year gather here.") end,
|
|
}
|
|
-- Shelves with no catalog list to open: no See all.
|
|
local SHELF_NO_LIST = { ["want-to-read"] = true }
|
|
BoardWidgets.shelfTitle = function(props)
|
|
if props.title then return props.title end
|
|
local f = SHELF_TITLES[props.source]
|
|
return f and f() or _("Shelf")
|
|
end
|
|
|
|
W.shelf = function(ctx, w, hh, props, item)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local fetched = d.shelves[BoardStore.shelfKey(props)]
|
|
local books = fetched or {}
|
|
local title_size = 11
|
|
local cap_h = px(6) + lh1(title_size)
|
|
local cover_h = math.max(1, hh - 2 * pad - lh1(13) - px(10) - cap_h)
|
|
local cover_w = math.min(iw, math.floor(cover_h * 2 / 3 + 0.5))
|
|
cover_h = math.min(cover_h, math.floor(cover_w * 1.5 + 0.5))
|
|
local min_gap = px(12)
|
|
-- A half-width shelf holds three covers; one as wide as the board takes
|
|
-- as many as fit, so a two-column board does not spread three apart.
|
|
local geometry = ctx.menu and ctx.menu._board_geometry
|
|
local full_width = item and geometry and item.cw >= geometry.cols
|
|
local max_books = (not full_width and (item and item.cw or 2) <= 2) and 3 or math.huge
|
|
local n = math.min(max_books, math.max(1, math.floor((iw + min_gap) / (cover_w + min_gap))))
|
|
-- A gap of a quarter of a cover takes one more, smaller cover instead.
|
|
if n < max_books and iw - (n * cover_w + (n - 1) * min_gap) > cover_w * 0.25 then
|
|
n = n + 1
|
|
cover_w = math.floor((iw - (n - 1) * min_gap) / n)
|
|
cover_h = math.min(cover_h, math.floor(cover_w * 1.5 + 0.5))
|
|
end
|
|
local gap = n > 1 and math.floor((iw - n * cover_w) / (n - 1)) or 0
|
|
local finished = props.source == "finished" and not props.title
|
|
local title = finished and T(_("Finished in %1"), d.goal.year) or BoardWidgets.shelfTitle(props)
|
|
local right
|
|
if props.shuffle then
|
|
right = BoardUI.hit(hstack({ w = px(36), h = lh1(13) }, { flex(0), icon("shuffle", px(17), { sw = BoardColour.icon(ctx, 2.25).sw, col = d.online and BoardColour.artHex(ctx, "#000000") or "ga" }) }),
|
|
d.online and { kind = "shuffle" } or { kind = "noop" })
|
|
elseif finished then
|
|
right = hstack({ gap = px(2) }, { text(tostring(d.goal.done or 0), { s = 13, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) })
|
|
elseif not SHELF_NO_LIST[props.source] then
|
|
right = hstack({ gap = px(2) }, { text(_("See all"), { s = 13, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) })
|
|
end
|
|
if #books == 0 then
|
|
-- A shelf added since the last refresh has no books yet, which is not
|
|
-- the same as an empty one.
|
|
local message
|
|
if d.loading or (not fetched and d.online) then
|
|
message = _("Loading…")
|
|
elseif not fetched then
|
|
message = _("Connect to Wi-Fi to fill this shelf.")
|
|
else
|
|
local empty = SHELF_EMPTY[props.source]
|
|
message = empty and empty() or _("Nothing here yet.")
|
|
end
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, title, nil),
|
|
space(nil, px(8)),
|
|
text(message, { s = 13, col = INK.g4, lines = 3, w = iw }),
|
|
flex(0),
|
|
})
|
|
end
|
|
local cards = {}
|
|
for i = 1, math.min(n, #books) do
|
|
local b = books[i]
|
|
table.insert(cards, BoardUI.hit(vstack({ w = cover_w }, {
|
|
cover(ctx, b, cover_w, cover_h, { border = thin(), dim = offlineDim(ctx, b) }),
|
|
space(nil, px(6)),
|
|
text(b.title, { s = title_size, b = true, w = cover_w, data = true }),
|
|
}), { kind = "detail", book_id = b.id, list = { label = title, books = books } }))
|
|
end
|
|
return vstack({ w = w, h = hh, pad = pad, strict_padding = true }, {
|
|
head(iw, title, right),
|
|
space(nil, px(10)),
|
|
hstack({ gap = gap, valign = "top" }, cards),
|
|
flex(0),
|
|
})
|
|
end
|
|
|
|
-- --------------------------------------------------------------- shortcuts
|
|
local LINKS = {
|
|
library = { "book-copy", function() return _("Library") end, function(d) return d.counts.library end, function() return _("books") end },
|
|
series = { "layers-2", function() return _("Series") end, function(d) return d.counts.series end, function() return _("series") end },
|
|
authors = { "feather", function() return _("Authors") end, function(d) return d.counts.authors end, function() return _("authors") end },
|
|
collections = { "folder-open", function() return _("Collections") end, function(d) return d.counts.collections end,
|
|
function(n) return n == 1 and _("collection") or _("collections") end },
|
|
scopes = { "aperture", function() return _("SmartScopes") end, function(d) return d.counts.scopes end, function() return _("scopes") end },
|
|
device = { "tablet", function() return _("On device") end, function(d) return d.counts.device end,
|
|
function(n) return _.ngettext("book here", "books here", n) end },
|
|
search = { "search", function() return _("Search") end, function() return nil end, function() return "" end },
|
|
}
|
|
BoardWidgets.LINKS = LINKS
|
|
|
|
local function fanWidth(cover_h, count)
|
|
local cw = math.floor(cover_h * 2 / 3 + 0.5)
|
|
return cw + math.floor(cw * 0.6 + 0.5) * (math.min(3, count) - 1)
|
|
end
|
|
|
|
local function fan(ctx, books, cover_h)
|
|
local cw = math.floor(cover_h * 2 / 3 + 0.5)
|
|
local step = math.floor(cw * 0.6 + 0.5)
|
|
local list = {}
|
|
for i = 1, math.min(3, #books) do table.insert(list, books[i]) end
|
|
if #list == 0 then return space(1, cover_h) end
|
|
local ring = math.max(1, px(1.5))
|
|
local width = cw + step * (#list - 1)
|
|
-- The first book sits on top at the right. Each cover carries a white ring
|
|
-- drawn outside its box, so the fan measures exactly its covers.
|
|
local layers = {}
|
|
for i = #list, 1, -1 do
|
|
local x = (#list - i) * step
|
|
table.insert(layers, { widget = rect(cw + 2 * ring, cover_h + 2 * ring, BoardColour.halo(ctx, INK.white)), x = x - ring, y = -ring })
|
|
table.insert(layers, { widget = cover(ctx, list[i], cw, cover_h, { border = thin(), border_col = BoardColour.coverEdge(ctx, INK.g9) }), x = x, y = 0 })
|
|
end
|
|
return BoardUI.overlay(width, cover_h, layers)
|
|
end
|
|
|
|
local function formatCount(n)
|
|
if not n then return "" end
|
|
local s = tostring(math.floor(n))
|
|
local out = s:reverse():gsub("(%d%d%d)", "%1,"):reverse()
|
|
return (out:gsub("^,", ""))
|
|
end
|
|
|
|
W.link = function(ctx, w, hh, props)
|
|
local d = ctx.d
|
|
local spec = LINKS[props.link] or LINKS.library
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local ih = hh - 2 * pad
|
|
local n = spec[3](d)
|
|
local label = spec[2]()
|
|
-- The name stays whole: the chevron gives way first, then the icon, then
|
|
-- the name steps down a size or two.
|
|
local function headEl()
|
|
local need = text(label, { s = 13, b = true }):getSize().w
|
|
local icon_w = px(15) + px(5)
|
|
local chevron = w >= px(150) and icon("chevron-right", px(14), { sw = 2.5, col = "g6" }) or nil
|
|
local cw = chevron and (chevron:getSize().w + px(5)) or 0
|
|
if chevron and need > iw - icon_w - cw then chevron, cw = nil, 0 end
|
|
local with_icon = need <= iw - icon_w
|
|
local lab_w = iw - (with_icon and icon_w or 0) - cw
|
|
local size = 13
|
|
while need > lab_w and size > 11 do
|
|
size = size - 1
|
|
need = text(label, { s = size, b = true }):getSize().w
|
|
end
|
|
return hstack({ w = iw, h = lh1(13), gap = px(5) }, {
|
|
with_icon and icon(spec[1], px(15), BoardColour.icon(ctx, 2.25)) or false,
|
|
text(label, { s = size, b = true, w = lab_w }),
|
|
chevron or false,
|
|
})
|
|
end
|
|
local room = ih - lh1(13)
|
|
local val_s = fillSize(room - lh1(11) - px(4), 18, 30)
|
|
local unit = spec[4](n)
|
|
if props.link == "device" then
|
|
local storage = d.storage
|
|
local function count()
|
|
local cs = math.min(val_s, 24)
|
|
local parts = function(size) return { { t = tostring(n or 0), b = true, s = size }, { t = " " .. unit, col = INK.g4 } } end
|
|
while cs > 16 and runs(parts(cs), { s = 11 }):getSize().w > iw do cs = cs - 2 end
|
|
return runs(parts(cs), { s = 11, w = iw })
|
|
end
|
|
-- The storage bar only on a row with room for it.
|
|
return pick({
|
|
function()
|
|
local items = { headEl(), flex(0), count() }
|
|
if storage then
|
|
local label2 = text(T(_("%1 GB free"), string.format("%.1f", storage.free_gb):gsub("%.0$", "")), { s = 11, col = INK.g4 })
|
|
local used = math.floor(100 * (storage.total_gb - storage.free_gb) / storage.total_gb + 0.5)
|
|
table.insert(items, space(nil, px(5)))
|
|
table.insert(items, hstack({ w = iw, gap = px(8) }, { bar(iw - label2:getSize().w - px(8), px(6), used, { track = BoardColour.track(ctx, INK.gd), fill = BoardColour.ink(ctx, INK.black), r = px(3) }), label2 }))
|
|
end
|
|
return vstack({ w = w, h = hh, pad = pad }, items)
|
|
end,
|
|
function() return vstack({ w = w, h = hh, pad = pad }, { headEl(), flex(0), count() }) end,
|
|
})
|
|
end
|
|
if props.link == "search" then
|
|
local recents = table.concat(d.recent_searches or {}, ", ")
|
|
local border = math.max(1, px(1.25))
|
|
local variants = {}
|
|
-- Recent searches go first when the tile is short; the field keeps its
|
|
-- placeholder only while it is tall enough to hold the text.
|
|
for _i, with_recents in ipairs(recents ~= "" and { true, false } or { false }) do
|
|
for _j, ph in ipairs({ _("Title, author"), _("Books"), false }) do
|
|
table.insert(variants, function()
|
|
local below = with_recents and (px(6) + lh1(11)) or 0
|
|
local field_h = math.min(px(34), room - below - px(4))
|
|
-- Below 28dp the field is a sliver, not a control.
|
|
if field_h < px(28) then BoardUI.problem("short", "search field h=" .. field_h) end
|
|
local field = BoardUI.box({ w = iw, h = field_h, radius = math.floor(field_h / 2), border = border, border_col = INK.g6 },
|
|
hstack({ w = iw - 2 * border, h = field_h - 2 * border, gap = px(6) }, {
|
|
space(px(10) - px(6), 1),
|
|
icon("search", math.min(px(14), field_h - 2 * border), { sw = 2.5, col = "g4" }),
|
|
ph and text(ph, { s = 12, col = INK.g4 }) or false,
|
|
flex(0),
|
|
}))
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
headEl(), flex(0), field,
|
|
with_recents and space(nil, px(6)) or false,
|
|
with_recents and text(recents, { s = 11, i = true, col = INK.g4, w = iw, data = true }) or false,
|
|
})
|
|
end)
|
|
end
|
|
end
|
|
-- A short row: the hint (or the recent searches) without a field.
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
headEl(), flex(0),
|
|
text(recents ~= "" and recents or _("Title, author"), { s = 12, i = recents ~= "", col = INK.g4, w = iw, data = true }),
|
|
})
|
|
end)
|
|
return pick(variants)
|
|
end
|
|
local names
|
|
if props.link == "collections" then names = d.names.collections end
|
|
if props.link == "scopes" then names = d.names.scopes end
|
|
if props.link == "authors" then
|
|
names = {}
|
|
local seen = {}
|
|
local pool = {}
|
|
for _i, src in ipairs({ "recently-added", "random" }) do
|
|
for _i, b in ipairs(d.shelves[src] or {}) do table.insert(pool, b) end
|
|
end
|
|
for _i, b in ipairs(pool) do
|
|
local a = firstAuthor(b)
|
|
if a then
|
|
local last = a:match("(%S+)%s*$") or a
|
|
if not seen[last] then seen[last] = true; table.insert(names, last) end
|
|
end
|
|
if #names >= 4 then break end
|
|
end
|
|
end
|
|
if names then
|
|
local function count()
|
|
local cs = math.min(val_s, 24)
|
|
local parts = function(size) return { { t = formatCount(n or 0), b = true, s = size }, { t = " " .. unit, col = INK.g4 } } end
|
|
while cs > 16 and runs(parts(cs), { s = 11 }):getSize().w > iw do cs = cs - 2 end
|
|
return runs(parts(cs), { s = 11, w = iw })
|
|
end
|
|
-- The names only on a row with room for them.
|
|
return pick({
|
|
function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
headEl(), flex(0), count(),
|
|
#names > 0 and space(nil, px(4)) or false,
|
|
#names > 0 and text(table.concat(names, ", "), { s = 12, i = true, serif = true, col = INK.g3, w = iw, data = true }) or false,
|
|
})
|
|
end,
|
|
function() return vstack({ w = w, h = hh, pad = pad }, { headEl(), flex(0), count() }) end,
|
|
})
|
|
end
|
|
local books = props.link == "series" and (d.shelves["up-next-in-series"] or {}) or (d.shelves["recently-added"] or d.shelves["random"] or {})
|
|
local count_col = vstack({}, { text(formatCount(n or 0), { s = val_s, b = true }), text(unit, { s = 11, col = INK.g4 }) })
|
|
local fan_room = iw - count_col:getSize().w - px(8)
|
|
local fh = math.max(px(30), math.min(px(66), room - px(6), math.floor(fan_room / 2.2 * 1.5)))
|
|
-- Rounding can make the fan a pixel wider than its room; past the
|
|
-- smallest covers it holds fewer books, then none.
|
|
local shown = {}
|
|
for i = 1, math.min(3, #books) do shown[i] = books[i] end
|
|
while fh > px(30) and #shown > 0 and fanWidth(fh, #shown) > fan_room do fh = fh - 1 end
|
|
while #shown > 0 and fanWidth(fh, #shown) > fan_room do table.remove(shown) end
|
|
books = shown
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
headEl(), flex(0),
|
|
hstack({ w = iw, valign = "bottom" }, { count_col, flex(px(4)), fan(ctx, books, fh) }),
|
|
})
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ device
|
|
W.clock = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
return pick({
|
|
function()
|
|
local val_s = fillSize(hh - 2 * pad - lh1(13) * 2 - px(6), 20, 40)
|
|
while val_s > 20 and text(d.clock, { s = val_s, b = true }):getSize().w > iw do val_s = val_s - 2 end
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
text(d.clock, { s = val_s, b = true, w = iw, col = INK.white }),
|
|
flex(0),
|
|
text(d.weekday, { s = 13, b = true, w = iw, col = INK.white }),
|
|
text(d.day_month, { s = 13, w = iw, col = BoardColour.onInk(ctx, INK.gc) }),
|
|
})
|
|
end,
|
|
-- A short row: the time over the date.
|
|
function()
|
|
local val_s = fillSize(hh - 2 * pad - lh1(13) - px(4), 20, 40)
|
|
while val_s > 20 and text(d.clock, { s = val_s, b = true }):getSize().w > iw do val_s = val_s - 2 end
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
text(d.clock, { s = val_s, b = true, w = iw, col = INK.white }),
|
|
flex(0),
|
|
text(d.day_month, { s = 13, b = true, w = iw, col = INK.white }),
|
|
})
|
|
end,
|
|
})
|
|
end
|
|
|
|
W.sync = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local on = d.online
|
|
local when = d.last_sync_at and BoardData.relativeDay(os.date("!%Y-%m-%dT%H:%M:%SZ", d.last_sync_at), d.now) or _("not yet")
|
|
local title = on and (d.last_sync_at and _("Synced") or _("Sync")) or _("Offline")
|
|
local opts = on and { { when, 16 }, { when, 14 }, { when, 12 } } or { { T(_("saved %1"), d.updated or ""), 16 }, { T(_("saved %1"), d.updated or ""), 14 }, { d.updated or "", 16 }, { d.updated or "", 13 } }
|
|
local variants = {}
|
|
for _i, o in ipairs(opts) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, title, icon(on and "cloud-check" or "cloud-off", px(17), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
text(o[1], { s = o[2], b = true, w = iw }),
|
|
space(nil, px(6)),
|
|
hstack({ gap = px(5) }, { icon(on and "refresh-cw" or "wifi", px(14), BoardColour.icon(ctx, 2.5)), text(on and _("Sync now") or _("Connect"), { s = 12, b = true }) }),
|
|
})
|
|
end)
|
|
end
|
|
-- A short row: the tile is still the button.
|
|
for _i, o in ipairs(opts) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, title, icon(on and "cloud-check" or "cloud-off", px(17), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
text(o[1], { s = o[2], b = true, w = iw }),
|
|
})
|
|
end)
|
|
end
|
|
return pick(variants)
|
|
end
|
|
|
|
-- Quick settings without opening KOReader's menu.
|
|
-- When in the day this reader reads: minutes started in each local hour
|
|
-- over the last 90 days, and the part of the day that holds the most.
|
|
local DAY_PARTS = {
|
|
{ function() return _("Mornings") end, 5, 12 },
|
|
{ function() return _("Afternoons") end, 12, 17 },
|
|
{ function() return _("Evenings") end, 17, 22 },
|
|
{ function() return _("Late nights") end, 22, 29 },
|
|
}
|
|
local function dayPart(hours)
|
|
local best, best_min = nil, 0
|
|
for _i, part in ipairs(DAY_PARTS) do
|
|
local m = 0
|
|
for hr = part[2], part[3] - 1 do m = m + (hours[hr % 24 + 1] or 0) end
|
|
if m > best_min then best, best_min = part[1](), m end
|
|
end
|
|
return best
|
|
end
|
|
|
|
W.hours = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local hours = d.hours or {}
|
|
local part = dayPart(hours)
|
|
local function sunMoon() return icon("sun-moon", px(17), BoardColour.icon(ctx, 2.25)) end
|
|
if not part then
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("When you read"), sunMoon()),
|
|
space(nil, px(6)),
|
|
text(_("Read on this device and your hours show up here."), { s = 13, col = INK.g4, lines = 3, w = iw }),
|
|
})
|
|
end
|
|
local max, peak = 1, 0
|
|
for i = 1, 24 do max = math.max(max, hours[i] or 0) end
|
|
for i = 2, 24 do
|
|
if (hours[i] or 0) > (hours[peak + 1] or 0) then peak = i - 1 end
|
|
end
|
|
local gap = math.max(1, px(2))
|
|
local bar_w = math.max(1, math.floor((iw - 23 * gap) / 24))
|
|
local chart_w = 24 * bar_w + 23 * gap
|
|
local cell_w = 6 * (bar_w + gap)
|
|
local base = math.max(1, px(1))
|
|
local function chart(chart_h)
|
|
local bars = {}
|
|
for i = 1, 24 do
|
|
local v = hours[i] or 0
|
|
if v > 0 then
|
|
local h = math.max(px(2), math.floor((chart_h - px(2)) * v / max + 0.5))
|
|
table.insert(bars, BoardUI.rect(bar_w, h, i - 1 == peak and BoardColour.ink(ctx, INK.black) or BoardColour.mid(ctx, INK.g8)))
|
|
else
|
|
table.insert(bars, space(bar_w, 0))
|
|
end
|
|
end
|
|
return vstack({ w = chart_w }, {
|
|
hstack({ w = chart_w, h = chart_h - base, gap = gap, valign = "bottom" }, bars),
|
|
rect(chart_w, base, INK.black),
|
|
})
|
|
end
|
|
local function axis()
|
|
local cells = {}
|
|
for i, t in ipairs({ "0", "6", "12", "18" }) do
|
|
table.insert(cells, text(t, { s = 11, col = INK.g4, w = i < 4 and cell_w or chart_w - 3 * cell_w }))
|
|
end
|
|
return hstack({ w = chart_w }, cells)
|
|
end
|
|
local peak_label = T(_("Most at %1"), datetime.secondsToHour(BoardData.dayStart(d.now or os.time()) + peak * 3600,
|
|
G_reader_settings:isTrue("twelve_hour_clock")))
|
|
local min_chart = px(12)
|
|
local box = { w = w, h = hh, pad = pad }
|
|
local variants = {}
|
|
-- Tall: the part of the day large, the peak hour under it, then the bars.
|
|
local tall_h = hh - 2 * pad - lh1(13) - px(4) - lh1(20) - px(2) - lh1(12) - px(10) - px(4) - lh1(11)
|
|
if tall_h >= px(40) then
|
|
table.insert(variants, function()
|
|
return vstack(box, {
|
|
head(iw, _("When you read"), sunMoon()),
|
|
space(nil, px(4)),
|
|
text(part, { s = 20, b = true, w = iw }),
|
|
space(nil, px(2)),
|
|
text(peak_label, { s = 12, col = INK.g4, w = iw }),
|
|
space(nil, px(10)),
|
|
chart(tall_h),
|
|
space(nil, px(4)),
|
|
axis(),
|
|
})
|
|
end)
|
|
end
|
|
-- One row: the part of the day beside the title.
|
|
local beside_h = hh - 2 * pad - lh1(13) - px(8) - px(4) - lh1(11)
|
|
if beside_h >= min_chart then
|
|
table.insert(variants, function()
|
|
return vstack(box, {
|
|
head(iw, _("When you read"), text(part, { s = 13, b = true })),
|
|
space(nil, px(8)),
|
|
chart(beside_h),
|
|
space(nil, px(4)),
|
|
axis(),
|
|
})
|
|
end)
|
|
end
|
|
table.insert(variants, function()
|
|
return vstack(box, {
|
|
head(iw, _("When you read"), text(part, { s = 13, b = true })),
|
|
space(nil, px(8)),
|
|
chart(math.max(min_chart, hh - 2 * pad - lh1(13) - px(8))),
|
|
})
|
|
end)
|
|
return pick(variants)
|
|
end
|
|
|
|
-- Session records from this device's statistics.
|
|
W.records = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local r = d.records
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local function award() return icon("award", px(17), BoardColour.icon(ctx, 2.25)) end
|
|
if not r then
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Reading records"), award()),
|
|
space(nil, px(6)),
|
|
hint(_("Your records appear as you read on this device."), iw, hh - 2 * pad - lh1(13) - px(6)),
|
|
})
|
|
end
|
|
local function stats(short)
|
|
local dur = short and BoardData.formatShortDuration or BoardData.formatDuration
|
|
return {
|
|
{ dur(r.longest_min), _("longest session") },
|
|
{ tostring(r.pages_per_hour), _("pages an hour") },
|
|
{ dur(r.avg_min), _("average session") },
|
|
{ formatCount(r.sessions), _("sessions") },
|
|
}
|
|
end
|
|
local function cell(st, cw, vs)
|
|
return vstack({ w = cw }, {
|
|
text(st[1], { s = vs, b = true, w = cw }),
|
|
space(nil, px(2)),
|
|
text(st[2], { s = 11, col = INK.g4, w = cw }),
|
|
})
|
|
end
|
|
local g = px(12)
|
|
local half = math.floor((iw - g) / 2)
|
|
local variants = {}
|
|
-- Two by two when the tile is tall enough, else one row of two.
|
|
for _i, v in ipairs({ { 22, false }, { 20, true }, { 18, true } }) do
|
|
table.insert(variants, function()
|
|
local st = stats(v[2])
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Reading records"), award()),
|
|
flex(px(8)),
|
|
hstack({ gap = g, valign = "top" }, { cell(st[1], half, v[1]), cell(st[2], iw - half - g, v[1]) }),
|
|
flex(px(10)),
|
|
hstack({ gap = g, valign = "top" }, { cell(st[3], half, v[1]), cell(st[4], iw - half - g, v[1]) }),
|
|
})
|
|
end)
|
|
end
|
|
for _i, v in ipairs({ { 20, false }, { 18, true }, { 16, true }, { 14, true }, { 13, true } }) do
|
|
table.insert(variants, function()
|
|
local st = stats(v[2])
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Reading records"), award()),
|
|
flex(px(v[1] <= 14 and 4 or 6)),
|
|
hstack({ gap = g, valign = "top" }, { cell(st[1], half, v[1]), cell(st[2], iw - half - g, v[1]) }),
|
|
})
|
|
end)
|
|
end
|
|
return pick(variants)
|
|
end
|
|
|
|
W.toggles = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local pad = px(10)
|
|
local iw, ih = w - 2 * pad, hh - 2 * pad
|
|
local small = w < px(200)
|
|
-- A row of five whose labels do not fit even at 10 is drawn as the grid.
|
|
local label_s
|
|
if not small then
|
|
local names = { _("Wi-Fi"), _("Light"), _("Night"), _("Rotate"), _("Sync") }
|
|
local cw5 = math.floor(iw / (d.light ~= nil and 5 or 4))
|
|
for _i, size in ipairs({ 12, 11, 10 }) do
|
|
local widest = 0
|
|
for _j, name in ipairs(names) do
|
|
local t = text(name, { s = size, b = true })
|
|
widest = math.max(widest, t:getSize().w)
|
|
if t.free then t:free() end
|
|
end
|
|
if widest <= cw5 - px(2) then label_s = size; break end
|
|
end
|
|
small = label_s == nil
|
|
end
|
|
-- Light opens KOReader's brightness dialog. In the 2x2 grid it takes
|
|
-- Rotate's place, the least used of the five.
|
|
local has_light = d.light ~= nil
|
|
local items = { { "wifi", _("Wi-Fi"), d.online, "wifi" } }
|
|
if has_light then table.insert(items, { "sun", _("Light"), false, "frontlight" }) end
|
|
table.insert(items, { "moon", _("Night"), G_reader_settings:isTrue("night_mode"), "night" })
|
|
if not (has_light and small) then table.insert(items, { "rotate-cw-square", _("Rotate"), false, "rotate" }) end
|
|
table.insert(items, { "refresh-cw", _("Sync"), false, "sync" })
|
|
local function circleBtn(size, it)
|
|
local on = it[3]
|
|
return BoardUI.hit(BoardUI.circle(size, { bg = on and BoardColour.ink(ctx, INK.black) or INK.white, border = on and 0 or math.max(1, px(1.5)) },
|
|
icon(it[1], math.floor(size * 0.46 + 0.5), { col = on and "white" or "black", sw = 2.25 })), { kind = it[4] })
|
|
end
|
|
if small then
|
|
local bd = math.min(math.floor((iw - px(8)) / 2), math.floor((ih - px(8)) / 2))
|
|
return vstack({ w = w, h = hh, align = "center" }, {
|
|
flex(0),
|
|
hstack({ gap = px(8) }, { circleBtn(bd, items[1]), circleBtn(bd, items[2]) }),
|
|
space(nil, px(8)),
|
|
hstack({ gap = px(8) }, { circleBtn(bd, items[3]), circleBtn(bd, items[4]) }),
|
|
flex(0),
|
|
})
|
|
end
|
|
local cw = math.floor(iw / #items)
|
|
local bd = math.min(px(44), ih - lh1(12) - px(6), cw - px(8))
|
|
local cells = {}
|
|
for _i, it in ipairs(items) do
|
|
table.insert(cells, vstack({ w = cw, align = "center" }, {
|
|
circleBtn(bd, it),
|
|
space(nil, px(5)),
|
|
text(it[2], { s = label_s, b = it[3], w = cw, align = "center" }),
|
|
}))
|
|
end
|
|
return hstack({ w = w, h = hh }, { space(pad, 1), hstack({}, cells), flex(0) })
|
|
end
|
|
|
|
-- Frontlight and warmth, set without leaving the dashboard.
|
|
local function stepButton(d, name)
|
|
return BoardUI.circle(d, { bg = INK.white, border = math.max(1, px(1.5)) }, icon(name, math.floor(d * 0.5 + 0.5), { sw = 2.75 }))
|
|
end
|
|
BoardWidgets.stepButton = stepButton
|
|
|
|
W.light = function(ctx, w, hh)
|
|
local lt = ctx.d.light
|
|
local pad = px(12)
|
|
local iw, ih = w - 2 * pad, hh - 2 * pad
|
|
if not lt then
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Light"), icon("sun", px(16), BoardColour.icon(ctx, 2.25))),
|
|
space(nil, px(6)),
|
|
hint(_("This device has no frontlight."), iw, hh - 2 * pad - lh1(13) - px(6), { sizes = { 12, 11 } }),
|
|
})
|
|
end
|
|
local function compact(bd, s)
|
|
return function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Light"), icon("sun", px(16), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
hstack({ w = iw }, {
|
|
BoardUI.hit(stepButton(bd, "minus"), { kind = "light", what = "brightness", delta = -5 }),
|
|
flex(px(4)), text(lt.brightness .. "%", { s = s, b = true }), flex(px(4)),
|
|
BoardUI.hit(stepButton(bd, "plus"), { kind = "light", what = "brightness", delta = 5 }),
|
|
}),
|
|
})
|
|
end
|
|
end
|
|
local function full(with_label)
|
|
return function()
|
|
local rows_n = lt.warmth and 2 or 1
|
|
local avail = ih - lh1(13) - px(6)
|
|
local rh = math.min(px(38), math.floor((avail - (rows_n - 1) * px(5)) / rows_n))
|
|
local bd = math.min(px(32), rh)
|
|
local function stepper(name, val, what, fam)
|
|
local vw = px(36)
|
|
local bw = iw - px(18) - 2 * bd - vw - 4 * px(8)
|
|
return hstack({ w = iw, h = rh, gap = px(8) }, {
|
|
icon(name, px(18), BoardColour.icon(ctx, 2.25, fam)),
|
|
BoardUI.hit(stepButton(bd, "minus"), { kind = "light", what = what, delta = -5 }),
|
|
bar(math.max(px(16), bw), px(8), val, { track = BoardColour.track(ctx, INK.gd, fam), fill = BoardColour.ink(ctx, INK.black, fam), r = px(4) }),
|
|
BoardUI.hit(stepButton(bd, "plus"), { kind = "light", what = what, delta = 5 }),
|
|
text(tostring(val), { s = 13, b = true, w = vw, align = "right" }),
|
|
})
|
|
end
|
|
local label = rows_n == 2 and _("Brightness · Warmth") or _("Brightness")
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Light"), with_label and text(label, { s = 12, col = INK.g4 }) or nil),
|
|
flex(0),
|
|
stepper("sun", lt.brightness, "brightness", "gold"),
|
|
rows_n == 2 and space(nil, px(5)) or false,
|
|
rows_n == 2 and stepper("thermometer-sun", lt.warmth, "warmth", "orange") or false,
|
|
})
|
|
end
|
|
end
|
|
return pick({ full(true), full(false), compact(px(32), 20), compact(px(28), 17), compact(px(24), 15), compact(px(22), 13) })
|
|
end
|
|
|
|
-- ------------------------------------------------------------ new widgets
|
|
-- Finish forecast: when the current book ends at the reader's own pace.
|
|
W.forecast = function(ctx, w, hh)
|
|
local d = ctx.d
|
|
local f = d.forecast
|
|
local pad = px(12)
|
|
local iw, ih = w - 2 * pad, hh - 2 * pad
|
|
local tall = hh >= px(160)
|
|
if not f then
|
|
local variants = {}
|
|
for _i, long in ipairs({ true, false }) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, long and _("Finish forecast") or _("Finish"), icon("hourglass", px(16), BoardColour.icon(ctx, 2.25))),
|
|
space(nil, px(6)),
|
|
text(long and _("Read a little on this device and a finish date shows up here.") or _("Read a little"), { s = 13, col = INK.g4, lines = 3, w = iw }),
|
|
})
|
|
end)
|
|
end
|
|
return pick(variants)
|
|
end
|
|
local function strip(tw)
|
|
local n = math.min(8, f.days_out + 1)
|
|
local g = px(4)
|
|
local cw = math.floor((tw - (n - 1) * g) / n)
|
|
local cells = {}
|
|
for i = 1, n do
|
|
local is_end, is_today = i == n, i == 1
|
|
local t = d.now + (i - 1) * 86400
|
|
table.insert(cells, vstack({ w = cw, align = "center" }, {
|
|
rect(cw, px(8), is_end and BoardColour.ink(ctx, INK.black) or (is_today and BoardColour.mid(ctx, INK.g6) or BoardColour.track(ctx, INK.gc)), px(4)),
|
|
space(nil, px(3)),
|
|
text(BoardData.shortWeekday(t):sub(1, 1), { s = 11, b = is_end or is_today, col = (is_end or is_today) and INK.black or INK.g4, w = cw, align = "center" }),
|
|
}))
|
|
end
|
|
return hstack({ gap = g }, cells)
|
|
end
|
|
local function bottomText(level)
|
|
if level == 2 then return T(_("%1 left at ~%2 min a day"), BoardData.formatDuration(f.minutes_left), math.floor(f.per_day + 0.5)) end
|
|
if level == 1 then return T(_("%1 left"), BoardData.formatDuration(f.minutes_left)) end
|
|
if level == 0 then return T(_("%1 left"), BoardData.formatShortDuration(f.minutes_left)) end
|
|
end
|
|
local function dayEl(fmt, tw)
|
|
if fmt == "long" then
|
|
return runs({ { t = f.day, b = true, s = tall and 26 or 22 }, { t = " " .. f.date, col = INK.g4 } }, { s = 13, w = tw })
|
|
elseif fmt == "mid" then
|
|
return text(f.day, { s = 22, b = true, w = tw })
|
|
end
|
|
return text(f.short .. " " .. f.date:match("^(%d+)"), { s = 20, b = true, w = tw })
|
|
end
|
|
local function build(cfg)
|
|
return function()
|
|
local bottom = cfg.bottom >= 0 and text(bottomText(cfg.bottom), { s = 12, col = INK.g4, w = iw }) or nil
|
|
if cfg.cover == "side" then
|
|
local cover_w = math.floor(ih * 2 / 3 + 0.5)
|
|
local tw = iw - cover_w - px(12)
|
|
return hstack({ w = w, h = hh, valign = "top" }, {
|
|
space(pad, 1),
|
|
vstack({ w = tw, h = hh, pad = { pad, 0, pad, 0 } }, {
|
|
head(tw, cfg.label, nil),
|
|
flex(0),
|
|
dayEl(cfg.day, tw),
|
|
cfg.strip and space(nil, px(8)) or false, cfg.strip and strip(tw) or false,
|
|
cfg.bottom >= 0 and space(nil, px(4)) or false,
|
|
cfg.bottom >= 0 and text(bottomText(cfg.bottom), { s = 12, col = INK.g4, w = tw }) or false,
|
|
}),
|
|
space(px(12), 1),
|
|
vstack({ w = cover_w, h = hh, pad = { pad, 0, pad, 0 } }, { cover(ctx, f.book, cover_w, ih) }),
|
|
flex(0),
|
|
})
|
|
end
|
|
if cfg.cover == "stack" then
|
|
local cover_h = math.min(px(96), math.floor(ih * 0.48 + 0.5))
|
|
local cover_w = math.floor(cover_h * 2 / 3 + 0.5)
|
|
local tw = iw - cover_w - px(12)
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, cfg.label, icon("hourglass", px(16), BoardColour.icon(ctx, 2.25))),
|
|
space(nil, px(8)),
|
|
hstack({ gap = px(12), valign = "bottom" }, {
|
|
cover(ctx, f.book, cover_w, cover_h),
|
|
vstack({ w = tw }, { text(f.day, { s = 24, b = true, w = tw }), text(f.date, { s = 13, col = INK.g4, w = tw }) }),
|
|
}),
|
|
flex(px(6)),
|
|
cfg.strip and strip(iw) or false,
|
|
bottom and space(nil, px(6)) or false,
|
|
bottom or false,
|
|
})
|
|
end
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, cfg.label, icon("hourglass", px(16), BoardColour.icon(ctx, 2.25))),
|
|
flex(0),
|
|
dayEl(cfg.day, iw),
|
|
cfg.strip and space(nil, px(8)) or false, cfg.strip and strip(iw) or false,
|
|
bottom and space(nil, px(4)) or false,
|
|
bottom or false,
|
|
})
|
|
end
|
|
end
|
|
local L1, L2 = _("Finish forecast"), _("Finish")
|
|
local cfgs = {
|
|
{ cover = "side", strip = tall, day = "long", bottom = 2, label = L1 },
|
|
{ cover = "side", strip = tall, day = "long", bottom = 1, label = L1 },
|
|
{ cover = "stack", strip = tall, day = "long", bottom = 2, label = L1 },
|
|
{ cover = "stack", strip = tall, day = "long", bottom = 1, label = L1 },
|
|
{ cover = "none", strip = tall, day = "long", bottom = 2, label = L1 },
|
|
{ cover = "none", strip = tall, day = "long", bottom = 1, label = L1 },
|
|
{ cover = "side", strip = false, day = "mid", bottom = 1, label = L2 },
|
|
{ cover = "none", strip = tall, day = "mid", bottom = 0, label = L2 },
|
|
{ cover = "none", strip = false, day = "short", bottom = 0, label = L2 },
|
|
{ cover = "none", strip = false, day = "short", bottom = -1, label = L2 },
|
|
}
|
|
local variants = {}
|
|
for _i, cfg in ipairs(cfgs) do
|
|
if cfg.cover ~= "stack" or tall then table.insert(variants, build(cfg)) end
|
|
end
|
|
return pick(variants)
|
|
end
|
|
|
|
-- A word looked up while reading, with the sentence it came from.
|
|
W.word = function(ctx, w, hh)
|
|
local wd = ctx.d.word
|
|
local pad = px(12)
|
|
local iw = w - 2 * pad
|
|
local word_icon = icon("whole-word", px(17), BoardColour.icon(ctx, 2.25))
|
|
if not wd then
|
|
local variants = {}
|
|
for _i, long in ipairs({ true, false }) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("Word of the day"), word_icon),
|
|
space(nil, px(6)),
|
|
text(long and _("Look up a word while reading and one comes back here each day.") or _("Look up a word while reading."), { s = 13, col = INK.g4, lines = 3, w = iw }),
|
|
})
|
|
end)
|
|
end
|
|
return pick(variants)
|
|
end
|
|
local context = "\u{2026}" .. (wd.before or "") .. wd.word .. (wd.after or "") .. "\u{2026}"
|
|
local tall = hh >= px(170)
|
|
local function build(cfg)
|
|
return function()
|
|
local items = {
|
|
head(iw, _("Word of the day"), word_icon),
|
|
space(nil, px(cfg.big and 10 or 4)),
|
|
text(wd.word, { s = cfg.ws, b = true, serif = true, w = iw, data = true, col = BoardColour.ink(ctx, INK.black) }),
|
|
}
|
|
if cfg.ctx_lines > 0 then
|
|
table.insert(items, space(nil, px(4)))
|
|
table.insert(items, text(context, { s = 14, i = true, serif = true, lines = cfg.ctx_lines, w = iw, col = INK.g2, data = true }))
|
|
end
|
|
table.insert(items, flex(px(cfg.source and 6 or 0)))
|
|
if cfg.source and wd.book then
|
|
table.insert(items, text(wd.book, { s = 11, col = INK.g4, w = iw, data = true }))
|
|
end
|
|
return vstack({ w = w, h = hh, pad = pad }, items)
|
|
end
|
|
end
|
|
local cfgs = tall and {
|
|
{ big = true, ws = 28, ctx_lines = 5, source = true },
|
|
{ big = true, ws = 28, ctx_lines = 4, source = true },
|
|
{ big = false, ws = 24, ctx_lines = 3, source = true },
|
|
{ big = false, ws = 24, ctx_lines = 2, source = false },
|
|
{ big = false, ws = 22, ctx_lines = 0, source = false },
|
|
} or {
|
|
{ ws = 22, ctx_lines = 2, source = false },
|
|
{ ws = 22, ctx_lines = 1, source = false },
|
|
{ ws = 20, ctx_lines = 1, source = false },
|
|
{ ws = 20, ctx_lines = 0, source = false },
|
|
}
|
|
local variants = {}
|
|
for _i, cfg in ipairs(cfgs) do table.insert(variants, build(cfg)) end
|
|
return pick(variants)
|
|
end
|
|
|
|
-- What you were reading this time last year.
|
|
W.yearago = function(ctx, w, hh)
|
|
local y = ctx.d.year_ago
|
|
local pad = px(12)
|
|
local iw, ih = w - 2 * pad, hh - 2 * pad
|
|
local cal = icon("calendar-clock", px(17), BoardColour.icon(ctx, 2.25))
|
|
if not y then
|
|
local variants = {}
|
|
for _i, long in ipairs({ true, false }) do
|
|
table.insert(variants, function()
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("A year ago"), cal),
|
|
space(nil, px(6)),
|
|
text(long and _("Next year, this shows what you were reading today.") or _("Check back next year."), { s = 13, col = INK.g4, lines = 3, w = iw }),
|
|
})
|
|
end)
|
|
end
|
|
return pick(variants)
|
|
end
|
|
local tall = hh >= px(170)
|
|
local function short(detail)
|
|
return function()
|
|
local cover_w = math.floor(ih * 2 / 3 + 0.5)
|
|
local tw = iw - cover_w - px(12)
|
|
local lines = {
|
|
[2] = y.stopped_at and (T(_("Stopped at %1%"), y.stopped_at) .. " · " .. BoardData.formatShortDuration(y.minutes)) or BoardData.formatShortDuration(y.minutes),
|
|
[1] = y.stopped_at and T(_("Stopped at %1%"), y.stopped_at) or BoardData.formatShortDuration(y.minutes),
|
|
[0] = y.stopped_at and T(_("%1% read"), y.stopped_at) or "",
|
|
}
|
|
return hstack({ w = w, h = hh }, {
|
|
space(pad, 1),
|
|
cover(ctx, y.book, cover_w, ih),
|
|
space(px(12), 1),
|
|
vstack({ w = tw, h = ih }, {
|
|
head(tw, _("A year ago"), nil),
|
|
flex(0),
|
|
text(y.book.title, { s = 15, b = true, w = tw, data = true }),
|
|
text(lines[detail], { s = 12, col = INK.g4, w = tw }),
|
|
}),
|
|
flex(0),
|
|
})
|
|
end
|
|
end
|
|
if not tall then return pick({ short(2), short(1), short(0) }) end
|
|
local function big(cfg)
|
|
return function()
|
|
local body_h = ih - lh1(13) - px(10)
|
|
local cover_h = math.min(body_h, px(cfg.cover_max))
|
|
local cover_w = math.floor(cover_h * 2 / 3 + 0.5)
|
|
local tw = iw - cover_w - px(14)
|
|
local btn_h = px(34)
|
|
local btn_inner = hstack({ h = btn_h, gap = px(6) }, { space(px(14) - px(6), 1), icon("book-open", px(15), { sw = 2.25 }), text(_("Pick it up"), { s = 13, b = true }), space(px(14) - px(6), 1) })
|
|
local btn = BoardUI.box({ w = btn_inner:getSize().w, h = btn_h, radius = math.floor(btn_h / 2), border = math.max(1, px(1.5)) }, btn_inner)
|
|
local story = cfg.story and T(_.ngettext("%1 over %2 day, then you stopped at %3%.", "%1 over %2 days, then you stopped at %3%.", y.days), BoardData.formatDuration(y.minutes), y.days, y.stopped_at or 0)
|
|
or T(_("Stopped at %1%."), y.stopped_at or 0)
|
|
return vstack({ w = w, h = hh, pad = pad }, {
|
|
head(iw, _("A year ago"), cfg.when and y.when and text(y.when, { s = 12, col = INK.g4 }) or nil),
|
|
space(nil, px(10)),
|
|
hstack({ w = iw, h = body_h, gap = px(14), valign = "top" }, {
|
|
cover(ctx, y.book, cover_w, cover_h),
|
|
vstack({ w = tw, h = body_h }, {
|
|
text(y.book.title, { s = 17, b = true, lines = 2, w = tw, data = true }),
|
|
text(firstAuthor(y.book) or "", { s = 13, col = INK.g4, w = tw, data = true }),
|
|
space(nil, px(8)),
|
|
text(story, { s = 13, lines = 3, w = tw }),
|
|
flex(px(8)),
|
|
cfg.button and btn or false,
|
|
}),
|
|
}),
|
|
})
|
|
end
|
|
end
|
|
return pick({
|
|
big({ cover_max = 150, when = true, story = true, button = true }),
|
|
big({ cover_max = 130, when = true, story = true, button = true }),
|
|
big({ cover_max = 130, when = false, story = true, button = true }),
|
|
big({ cover_max = 120, when = false, story = false, button = true }),
|
|
big({ cover_max = 120, when = false, story = false, button = false }),
|
|
short(1),
|
|
})
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ chrome
|
|
-- Tile styles: Outline (thin gray, the default), Soft (gray fills) and Ink
|
|
-- (black outlines). Black fill stays reserved for Continue reading.
|
|
function BoardWidgets.chrome(tiles, chrome)
|
|
local r = px(12)
|
|
if chrome == "ink" then return { bg = INK.black, radius = r, border = 0, fg = "white" } end
|
|
if chrome == "fill" then return { bg = tiles == "soft" and INK.gd or INK.ge, radius = r, border = 0 } end
|
|
if tiles == "soft" then return { bg = INK.ge, radius = r, border = 0 } end
|
|
if tiles == "ink" then return { bg = INK.white, radius = r, border = math.max(1, px(1.5)), border_col = INK.black } end
|
|
return { bg = INK.white, radius = r, border = math.max(1, px(1)), border_col = INK.g8 }
|
|
end
|
|
|
|
-- The tile: chrome around a widget's content, built at exactly w x h. The
|
|
-- content gets the whole tile and the outline is drawn over its padding, so
|
|
-- the outline never costs the content any room.
|
|
function BoardWidgets.tile(ctx, item, w, h)
|
|
local fn = W[item.type]
|
|
ctx.tile_key = BoardColour.keyFor(item.type, item.props)
|
|
local ch = BoardColour.chrome(ctx, item.chrome, ctx.tile_key, BoardWidgets.chrome(ctx.tiles, item.chrome), math.max(1, px(1)), math.max(1, px(1.5)))
|
|
local content
|
|
if fn then
|
|
BoardUI.debug_widget = item.type .. " " .. w .. "x" .. h
|
|
local props = item.props or {}
|
|
local sig = BoardWidgets.SIG[item.type]
|
|
local sig_ok, extra = true, ""
|
|
if sig and ctx.d then sig_ok, extra = pcall(sig, ctx) end
|
|
BoardUI.pickScope(item.type .. ":" .. tostring(props.source or props.link or props.animal or "") .. ":" .. w .. "x" .. h .. ":" .. tostring(ch.bg)
|
|
.. ":" .. (sig_ok and tostring(extra) or ""), ctx.d and ctx.d.fingerprint)
|
|
local surface = BoardUI.surface
|
|
BoardUI.surface = ch.bg
|
|
local ok, result = pcall(fn, ctx, w, h, props, item)
|
|
BoardUI.surface = surface
|
|
BoardUI.pickScope(nil, ctx.d and ctx.d.fingerprint)
|
|
content = ok and result or text(_("Could not draw this widget."), { s = 12, col = INK.g4, w = w - 2 * px(12) })
|
|
if not ok then require("logger").warn("BookOrbit: widget", item.type, "failed:", result) end
|
|
end
|
|
local layers = {
|
|
{ widget = BoardUI.box({ w = w, h = h, radius = ch.radius, bg = ch.bg }) },
|
|
{ widget = content },
|
|
}
|
|
if ch.border > 0 then
|
|
table.insert(layers, { widget = BoardUI.box({ w = w, h = h, radius = ch.radius, border = ch.border, border_col = ch.border_col }) })
|
|
end
|
|
return BoardUI.overlay(w, h, layers)
|
|
end
|
|
|
|
-- ------------------------------------------------------------------- decor
|
|
-- The decor widgets (bookorbit_board_decor) load the first time a board
|
|
-- draws or offers one.
|
|
local DECOR_TYPES = { buddy = true, analog = true, moon = true, odometer = true, glyph = true, rings = true, chapters = true, badge = true, dust = true }
|
|
local function decor()
|
|
local Decor = require("bookorbit_board_decor")
|
|
if not rawget(W, "buddy") then Decor.install(W, { head = head, formatCount = formatCount }) end
|
|
return Decor
|
|
end
|
|
setmetatable(W, { __index = function(t, k)
|
|
if DECOR_TYPES[k] then
|
|
decor()
|
|
return rawget(t, k)
|
|
end
|
|
end })
|
|
BoardWidgets.SIG = setmetatable({}, { __index = function(_t, k)
|
|
if DECOR_TYPES[k] then return decor().SIG[k] end
|
|
end })
|
|
|
|
return BoardWidgets
|