--[[-- 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