mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -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.
973 lines
38 KiB
Lua
973 lines
38 KiB
Lua
--[[--
|
|
Drawing primitives for the widget dashboard.
|
|
|
|
Sizes are pixels (convert dp with BoardMetrics.px); font sizes are nominal and
|
|
go through the board's density step. Stacks lay children out in a fixed box
|
|
with flexible spacers, which is how the dashboard's tiles get the same
|
|
proportions on every screen.
|
|
|
|
Widgets fit by construction: a widget offers layouts from richest to most
|
|
compact and BoardUI.pick keeps the first one that fits its tile. While a
|
|
candidate is built, the primitives report problems to the active fit check:
|
|
a stack whose fixed content is taller or wider than its box, a line of
|
|
interface text that had to be shortened, or two labels pushed together. Book
|
|
data (titles, authors, quotes) may be shortened; pass data = true for those.
|
|
]]
|
|
|
|
local BD = require("ui/bidi")
|
|
local CenterContainer = require("ui/widget/container/centercontainer")
|
|
local Screen = require("device").screen
|
|
local FrameContainer = require("ui/widget/container/framecontainer")
|
|
local Geom = require("ui/geometry")
|
|
local HorizontalGroup = require("ui/widget/horizontalgroup")
|
|
local HorizontalSpan = require("ui/widget/horizontalspan")
|
|
local LeftContainer = require("ui/widget/container/leftcontainer")
|
|
local OverlapGroup = require("ui/widget/overlapgroup")
|
|
local RenderImage = require("ui/renderimage")
|
|
local RightContainer = require("ui/widget/container/rightcontainer")
|
|
local TextBoxWidget = require("ui/widget/textboxwidget")
|
|
local TextWidget = require("ui/widget/textwidget")
|
|
local VerticalGroup = require("ui/widget/verticalgroup")
|
|
local VerticalSpan = require("ui/widget/verticalspan")
|
|
local Widget = require("ui/widget/widget")
|
|
local WidgetContainer = require("ui/widget/container/widgetcontainer")
|
|
|
|
local BoardIcons = require("bookorbit_board_icons")
|
|
local BoardMetrics = require("bookorbit_board_metrics")
|
|
|
|
local INK = BoardMetrics.INK
|
|
local px = BoardMetrics.px
|
|
|
|
local BoardUI = {}
|
|
BoardUI.INK = INK
|
|
BoardUI.px = px
|
|
|
|
-- KOReader's plain painters turn an RGB colour into its gray; RGB colours
|
|
-- (the board's colour schemes) go through the RGB32 painters instead.
|
|
local Blitbuffer = package.loaded["ffi/blitbuffer"] or select(2, pcall(require, "ffi/blitbuffer"))
|
|
if type(Blitbuffer) ~= "table" then Blitbuffer = {} end
|
|
|
|
-- A truthiness test, never == nil: LuaJIT hands a colour's comparison with
|
|
-- nil to its __eq, which indexes the other side.
|
|
local function isRGB(c)
|
|
if not c or not Blitbuffer.isColor8 then return false end
|
|
return not Blitbuffer.isColor8(c)
|
|
end
|
|
BoardUI.isRGB = isRGB
|
|
|
|
local function paintBorder(bb, x, y, w, h, bw, c, r)
|
|
if isRGB(c) and bb.paintBorderRGB32 then
|
|
bb:paintBorderRGB32(x, y, w, h, bw, c, r)
|
|
else
|
|
bb:paintBorder(x, y, w, h, bw, c, r)
|
|
end
|
|
end
|
|
|
|
local function paintRoundedRect(bb, x, y, w, h, c, r)
|
|
if isRGB(c) and bb.paintRoundedRectRGB32 then
|
|
bb:paintRoundedRectRGB32(x, y, w, h, c, r)
|
|
else
|
|
bb:paintRoundedRect(x, y, w, h, c, r)
|
|
end
|
|
end
|
|
|
|
-- ------------------------------------------------------------- fit checks
|
|
local fit_stack = {}
|
|
|
|
local function problem(kind, detail)
|
|
local check = fit_stack[#fit_stack]
|
|
if check then check.problems = check.problems + 1; check.last = kind; check.detail = detail end
|
|
end
|
|
BoardUI.problem = problem
|
|
|
|
-- Builds each candidate in turn and returns the first that reported no
|
|
-- problem, or the last one.
|
|
local DEBUG = os.getenv("BO_BOARD_DEBUG") ~= nil
|
|
|
|
-- Which candidate won, per widget, size and data. A widget's choice depends
|
|
-- only on those, so a rebuild (a page flip, a tile refresh) builds the
|
|
-- winner directly instead of trying the richer layouts again. Cleared when
|
|
-- the data changes.
|
|
local pick_memo, pick_memo_fp = {}, nil
|
|
local pick_scope = nil
|
|
|
|
-- Called by the tile builder around each widget: key names the widget and
|
|
-- its size, fingerprint the data it was drawn from.
|
|
function BoardUI.pickScope(key, fingerprint)
|
|
if fingerprint ~= pick_memo_fp then
|
|
pick_memo, pick_memo_fp = {}, fingerprint
|
|
end
|
|
pick_scope = key and { key = key, n = 0 } or nil
|
|
end
|
|
|
|
function BoardUI.pick(variants)
|
|
local memo_key
|
|
if pick_scope and #fit_stack == 0 then
|
|
pick_scope.n = pick_scope.n + 1
|
|
memo_key = pick_scope.key .. "#" .. pick_scope.n .. "/" .. #variants
|
|
local won = pick_memo[memo_key]
|
|
if won and variants[won] then return variants[won]() end
|
|
end
|
|
local result, won = nil, #variants
|
|
for index = 1, #variants - 1 do
|
|
table.insert(fit_stack, { problems = 0 })
|
|
-- A candidate that cannot even be built (a box too narrow for its
|
|
-- text) is just one that does not fit.
|
|
local ok, node = pcall(variants[index])
|
|
local check = table.remove(fit_stack)
|
|
if not ok then check.problems, check.last, check.detail, node = 1, "error", tostring(node), nil end
|
|
if check.problems == 0 then
|
|
result, won = node, index
|
|
break
|
|
end
|
|
if DEBUG then require("logger").info("BOARDFIT", BoardUI.debug_widget or "?", "variant", index, "rejected:", check.last, check.detail) end
|
|
if node and node.free then node:free() end
|
|
end
|
|
if memo_key then pick_memo[memo_key] = won end
|
|
return result or variants[#variants]()
|
|
end
|
|
|
|
-- Runs fn under a fit check and reports whether it fitted. For specs and the
|
|
-- fit audit, not for drawing.
|
|
function BoardUI.fits(fn)
|
|
table.insert(fit_stack, { problems = 0 })
|
|
local ok, node = pcall(fn)
|
|
local check = table.remove(fit_stack)
|
|
if not ok then return false, node end
|
|
return check.problems == 0, node, check.last, check.detail
|
|
end
|
|
|
|
-- --------------------------------------------------------------- spacers
|
|
function BoardUI.space(w, h)
|
|
if (w or 0) > 0 and (h or 0) > 0 then
|
|
return VerticalGroup:new{ HorizontalSpan:new{ width = w }, VerticalSpan:new{ width = h } }
|
|
end
|
|
if (w or 0) > 0 then return HorizontalSpan:new{ width = w } end
|
|
return VerticalSpan:new{ width = h or 0 }
|
|
end
|
|
|
|
-- A flexible spacer for vstack/hstack. min is the least it may shrink to.
|
|
function BoardUI.flex(min)
|
|
return { _flex = true, min = min or 0 }
|
|
end
|
|
|
|
-- ------------------------------------------------------------------- text
|
|
local function faceKind(o)
|
|
if o.serif then return o.i and "serif_italic" or "serif" end
|
|
return o.i and "sans_italic" or "sans"
|
|
end
|
|
|
|
-- One line of text in an RGB colour. TextWidget paints glyphs in gray only,
|
|
-- so the line is set white on black once, which is its coverage mask, and
|
|
-- blitted in the colour. (Blitbuffer:invert only flips a flag that the C
|
|
-- blitter ignores when both buffers carry it, as a night-mode screen does.)
|
|
local ColourLine = Widget:extend{ line = nil, color = nil }
|
|
|
|
function ColourLine:getSize()
|
|
return self.line:getSize()
|
|
end
|
|
|
|
function ColourLine:isTruncated()
|
|
return self.line:isTruncated()
|
|
end
|
|
|
|
function ColourLine:paintTo(bb, x, y)
|
|
local size = self.line:getSize()
|
|
self.dimen = Geom:new{ x = x, y = y, w = size.w, h = size.h }
|
|
if not self.mask then
|
|
local canvas = Blitbuffer.new(size.w, size.h, Blitbuffer.TYPE_BB8)
|
|
canvas:fill(Blitbuffer.COLOR_BLACK)
|
|
self.line:paintTo(canvas, 0, 0)
|
|
self.mask = canvas
|
|
end
|
|
if bb.colorblitFromRGB32 then
|
|
bb:colorblitFromRGB32(self.mask, x, y, 0, 0, size.w, size.h, self.color)
|
|
else
|
|
bb:colorblitFrom(self.mask, x, y, 0, 0, size.w, size.h, self.color)
|
|
end
|
|
end
|
|
|
|
function ColourLine:free()
|
|
if self.mask then self.mask:free(); self.mask = nil end
|
|
if self.line and self.line.free then self.line:free() end
|
|
end
|
|
|
|
-- A single line, or o.lines lines when o.lines > 1. o: s (nominal size),
|
|
-- b, i, serif, col, w (box width), align, lines, data.
|
|
function BoardUI.text(str, o)
|
|
o = o or {}
|
|
local size = o.s or 14
|
|
local face = BoardMetrics.face(size, faceKind(o))
|
|
str = BD.auto(tostring(str or ""))
|
|
local lines = o.lines or 1
|
|
-- No room to set any text: report it and hold the space.
|
|
if o.w and o.w < px(8) then
|
|
problem("narrow", "text w=" .. o.w)
|
|
return VerticalGroup:new{ HorizontalSpan:new{ width = math.max(0, o.w) },
|
|
VerticalSpan:new{ width = lines > 1 and lines * BoardMetrics.lh(size) or BoardMetrics.lh1(size) } }
|
|
end
|
|
if lines > 1 then
|
|
local tb = TextBoxWidget:new{
|
|
text = str,
|
|
face = face,
|
|
bold = o.b == true,
|
|
fgcolor = o.col or INK.black,
|
|
-- TextBoxWidget paints its own background; match the surface
|
|
-- it sits on.
|
|
bgcolor = o.bg or BoardUI.surface or INK.white,
|
|
width = o.w,
|
|
height = lines * BoardMetrics.lh(size),
|
|
height_adjust = true,
|
|
height_overflow_show_ellipsis = true,
|
|
alignment = o.align or "left",
|
|
}
|
|
if tb.line_with_ellipsis and not o.data then problem("clamped", str) end
|
|
return tb
|
|
end
|
|
local rgb = isRGB(o.col) and Blitbuffer.TYPE_BB8 ~= nil
|
|
local tw = TextWidget:new{
|
|
text = str,
|
|
padding = 0,
|
|
face = face,
|
|
bold = o.b == true,
|
|
fgcolor = rgb and INK.white or (o.col or INK.black),
|
|
max_width = o.w,
|
|
}
|
|
if o.w and tw:isTruncated() and not o.data then problem("ellipsis", str .. " w=" .. o.w .. " need=" .. tw:getSize().w) end
|
|
if rgb then tw = ColourLine:new{ line = tw, color = o.col } end
|
|
if not o.w then return tw end
|
|
local dimen = Geom:new{ w = o.w, h = tw:getSize().h }
|
|
if o.align == "center" then return CenterContainer:new{ dimen = dimen, tw } end
|
|
if o.align == "right" then return RightContainer:new{ dimen = dimen, tw } end
|
|
return LeftContainer:new{ dimen = dimen, tw }
|
|
end
|
|
|
|
-- The largest size from o.s down to min_s at which str fits one line of w,
|
|
-- so a label in a narrow column steps down before it is cut.
|
|
function BoardUI.fitSize(str, o, w, min_s)
|
|
local size = o.s or 14
|
|
local face_kind = faceKind(o)
|
|
while size > (min_s or size) do
|
|
local tw = TextWidget:new{ text = BD.auto(tostring(str or "")), face = BoardMetrics.face(size, face_kind), bold = o.b == true, padding = 0 }
|
|
local fits = tw:getSize().w <= w
|
|
tw:free()
|
|
if fits then break end
|
|
size = size - 1
|
|
end
|
|
return size
|
|
end
|
|
|
|
-- The largest of `sizes` at which `str` fits in max_lines lines and max_h
|
|
-- pixels, with the line count it takes there.
|
|
local fit_title_cache, fit_title_count = {}, 0
|
|
|
|
function BoardUI.fitTitle(str, o, width, sizes, max_lines, max_h)
|
|
local key = table.concat({ tostring(str), tostring(o and o.b), tostring(o and o.serif), width, table.concat(sizes, ","), max_lines, tostring(max_h), BoardMetrics.get().dp }, "|")
|
|
local hit = fit_title_cache[key]
|
|
if hit then return { s = hit.s, lines = hit.lines } end
|
|
local result = BoardUI._fitTitle(str, o, width, sizes, max_lines, max_h)
|
|
if fit_title_count > 400 then fit_title_cache, fit_title_count = {}, 0 end
|
|
fit_title_cache[key] = result
|
|
fit_title_count = fit_title_count + 1
|
|
return { s = result.s, lines = result.lines }
|
|
end
|
|
|
|
-- The widest single word of str at size s: a box narrower than it breaks
|
|
-- the word across lines.
|
|
local function widestWord(str, o, s)
|
|
local widest = 0
|
|
for word in tostring(str or ""):gmatch("%S+") do
|
|
local tw = TextWidget:new{ text = BD.auto(word), face = BoardMetrics.face(s, faceKind(o or {})), bold = o and o.b == true, padding = 0 }
|
|
widest = math.max(widest, tw:getSize().w)
|
|
tw:free()
|
|
end
|
|
return widest
|
|
end
|
|
|
|
function BoardUI._fitTitle(str, o, width, sizes, max_lines, max_h)
|
|
for index, s in ipairs(sizes) do
|
|
local tb = TextBoxWidget:new{
|
|
text = BD.auto(tostring(str or "")),
|
|
face = BoardMetrics.face(s, faceKind(o or {})),
|
|
bold = o and o.b == true,
|
|
width = width,
|
|
}
|
|
local lines = math.max(1, math.floor(tb:getSize().h / BoardMetrics.lh(s) + 0.5))
|
|
tb:free()
|
|
-- A size that would split a word goes to the next one down.
|
|
if lines <= max_lines and (not max_h or lines * BoardMetrics.lh(s) <= max_h)
|
|
and (index == #sizes or lines == 1 or widestWord(str, o, s) <= width) then
|
|
return { s = s, lines = lines }
|
|
end
|
|
end
|
|
local s = sizes[#sizes]
|
|
local lines = max_h and math.floor(max_h / BoardMetrics.lh(s)) or max_lines
|
|
return { s = s, lines = math.max(1, math.min(max_lines, lines)) }
|
|
end
|
|
|
|
-- One line of styled runs on a shared baseline, e.g. a bold number followed
|
|
-- by a muted unit. parts: strings or { t, b, s, col, serif, i }.
|
|
function BoardUI.runs(parts, o)
|
|
o = o or {}
|
|
local base = o.s or 14
|
|
local widgets = {}
|
|
local max_base = 0
|
|
local total = 0
|
|
local tallest = 0
|
|
for _i, p in ipairs(parts) do
|
|
if type(p) == "string" then p = { t = p } end
|
|
local size = p.s or base
|
|
local tw = TextWidget:new{
|
|
text = BD.auto(p.t),
|
|
padding = 0,
|
|
face = BoardMetrics.face(size, faceKind(p)),
|
|
bold = p.b == true,
|
|
fgcolor = p.col or o.col or INK.black,
|
|
}
|
|
table.insert(widgets, tw)
|
|
max_base = math.max(max_base, tw:getBaseline())
|
|
total = total + tw:getSize().w
|
|
tallest = math.max(tallest, size)
|
|
end
|
|
local avail = o.w
|
|
if avail and total > avail then
|
|
-- Shorten the run that crosses the edge; drop the ones after it.
|
|
local used = 0
|
|
for index, tw in ipairs(widgets) do
|
|
local w = tw:getSize().w
|
|
if used + w > avail then
|
|
local keep = avail - used
|
|
if keep > px(12) then
|
|
tw:setMaxWidth(keep)
|
|
for j = #widgets, index + 1, -1 do table.remove(widgets, j) end
|
|
else
|
|
for j = #widgets, index, -1 do table.remove(widgets, j) end
|
|
end
|
|
break
|
|
end
|
|
used = used + w
|
|
end
|
|
if not o.data then problem("ellipsis") end
|
|
end
|
|
local row_h = BoardMetrics.lh1(tallest)
|
|
local group = HorizontalGroup:new{ align = "top" }
|
|
for _i, tw in ipairs(widgets) do
|
|
local top = max_base - tw:getBaseline()
|
|
if top > 0 then
|
|
table.insert(group, VerticalGroup:new{ align = "left", VerticalSpan:new{ width = top }, tw })
|
|
else
|
|
table.insert(group, tw)
|
|
end
|
|
end
|
|
if not avail then
|
|
return CenterContainer:new{ dimen = Geom:new{ w = group:getSize().w, h = row_h }, group }
|
|
end
|
|
local dimen = Geom:new{ w = avail, h = row_h }
|
|
if o.align == "right" then return RightContainer:new{ dimen = dimen, group } end
|
|
if o.align == "center" then return CenterContainer:new{ dimen = dimen, group } end
|
|
return LeftContainer:new{ dimen = dimen, group }
|
|
end
|
|
|
|
-- ----------------------------------------------------------------- stacks
|
|
local function sizeOf(item)
|
|
if type(item) == "table" and item._flex then return 0, 0 end
|
|
local s = item:getSize()
|
|
return s.w, s.h
|
|
end
|
|
|
|
-- Children top to bottom in a w x h box. Flex spacers share what is left.
|
|
-- o: w, h, align ("left", "center", "right"), pad (number or {t, r, b, l}), strict_padding.
|
|
function BoardUI.vstack(o, items)
|
|
local pt, pr, pb, pl = 0, 0, 0, 0
|
|
if type(o.pad) == "number" then pt, pr, pb, pl = o.pad, o.pad, o.pad, o.pad
|
|
elseif type(o.pad) == "table" then pt, pr, pb, pl = o.pad[1], o.pad[2], o.pad[3], o.pad[4] end
|
|
local widest = 0
|
|
for _i, item in ipairs(items) do
|
|
if item and not (type(item) == "table" and item._flex) then
|
|
widest = math.max(widest, (sizeOf(item)))
|
|
end
|
|
end
|
|
local box_w = o.w or (widest + pl + pr)
|
|
local iw = box_w - pl - pr
|
|
local fixed, flexes, flex_min = 0, 0, 0
|
|
local present = {}
|
|
for _i, item in ipairs(items) do
|
|
if item then
|
|
table.insert(present, item)
|
|
if type(item) == "table" and item._flex then
|
|
flexes = flexes + 1
|
|
flex_min = flex_min + item.min
|
|
else
|
|
local w, h = sizeOf(item)
|
|
fixed = fixed + h
|
|
if w > iw + pr + 1 then problem("overflow-x", "vstack child w=" .. w .. " iw=" .. iw) end
|
|
end
|
|
end
|
|
end
|
|
local ih = o.h and (o.h - pt - pb) or (fixed + flex_min)
|
|
local remaining = ih - fixed
|
|
-- Content may overhang the bottom padding by a text line's own leading
|
|
-- (2dp), never more: a fuller layout loses to a smaller one before a
|
|
-- card's bottom margin looks thinner than its top.
|
|
local padding_slack = o.strict_padding and 0 or math.min(pb, px(2))
|
|
if remaining + padding_slack < flex_min - 0.5 then problem("overflow-y", "vstack need=" .. (fixed + flex_min) .. " have=" .. ih) end
|
|
local left = math.max(0, remaining)
|
|
local share = flexes > 0 and math.floor(left / flexes) or 0
|
|
local extra = flexes > 0 and (left - share * flexes) or 0
|
|
local Container = (o.align == "center" and CenterContainer) or (o.align == "right" and RightContainer) or LeftContainer
|
|
local group = VerticalGroup:new{ align = "left" }
|
|
if pt > 0 then table.insert(group, VerticalSpan:new{ width = pt }) end
|
|
local used = pt
|
|
local seen = 0
|
|
for _i, item in ipairs(present) do
|
|
if type(item) == "table" and item._flex then
|
|
seen = seen + 1
|
|
local h = math.max(item.min, share + (seen == flexes and extra or 0))
|
|
table.insert(group, VerticalSpan:new{ width = h })
|
|
used = used + h
|
|
else
|
|
local _, h = sizeOf(item)
|
|
table.insert(group, Container:new{ dimen = Geom:new{ w = iw, h = h }, item })
|
|
used = used + h
|
|
end
|
|
end
|
|
local box_h = o.h or (used + pb)
|
|
if used < box_h then table.insert(group, VerticalSpan:new{ width = box_h - used }) end
|
|
return LeftContainer:new{
|
|
dimen = Geom:new{ w = box_w, h = box_h },
|
|
HorizontalGroup:new{ align = "top", HorizontalSpan:new{ width = pl }, group },
|
|
}
|
|
end
|
|
|
|
-- Children left to right. o: w, h, gap, valign ("center", "top", "bottom").
|
|
-- A flex spacer's min is the least room it needs; less counts as labels
|
|
-- touching.
|
|
function BoardUI.hstack(o, items)
|
|
local gap = o.gap or 0
|
|
local fixed, flexes, flex_min = 0, 0, 0
|
|
local present = {}
|
|
local tallest = 0
|
|
for _i, item in ipairs(items) do
|
|
if item then
|
|
table.insert(present, item)
|
|
if type(item) == "table" and item._flex then
|
|
flexes = flexes + 1
|
|
flex_min = flex_min + item.min
|
|
else
|
|
local w, h = sizeOf(item)
|
|
fixed = fixed + w
|
|
tallest = math.max(tallest, h)
|
|
end
|
|
end
|
|
end
|
|
local gaps = math.max(0, #present - 1) * gap
|
|
local width = o.w or (fixed + gaps + flex_min)
|
|
local remaining = width - fixed - gaps
|
|
if remaining < flex_min - 0.5 then problem(flexes > 0 and "tight" or "overflow-x", "hstack need=" .. (fixed + gaps + flex_min) .. " have=" .. width) end
|
|
local h = o.h or tallest
|
|
if tallest > h + 1 then problem("overflow-y", "hstack tallest=" .. tallest .. " h=" .. h) end
|
|
local valign = o.valign or "center"
|
|
local group = HorizontalGroup:new{ align = valign }
|
|
local left = math.max(0, remaining)
|
|
local share = flexes > 0 and math.floor(left / flexes) or 0
|
|
local extra = flexes > 0 and (left - share * flexes) or 0
|
|
local seen = 0
|
|
for index, item in ipairs(present) do
|
|
if index > 1 and gap > 0 then table.insert(group, HorizontalSpan:new{ width = gap }) end
|
|
if type(item) == "table" and item._flex then
|
|
seen = seen + 1
|
|
table.insert(group, HorizontalSpan:new{ width = math.max(item.min, share + (seen == flexes and extra or 0)) })
|
|
else
|
|
table.insert(group, item)
|
|
end
|
|
end
|
|
local gh = math.min(h, tallest)
|
|
local above = valign == "top" and 0 or valign == "bottom" and (h - gh) or math.floor((h - gh) / 2)
|
|
return LeftContainer:new{
|
|
dimen = Geom:new{ w = width, h = h },
|
|
VerticalGroup:new{ align = "left", VerticalSpan:new{ width = above }, group, VerticalSpan:new{ width = h - gh - above } },
|
|
}
|
|
end
|
|
|
|
-- Label on the left, an optional widget on the right, and at least 4dp
|
|
-- between them. The header row every tile starts with.
|
|
function BoardUI.spread(w, left, right, h)
|
|
if not right then
|
|
return BoardUI.hstack({ w = w, h = h }, { left, BoardUI.flex(0) })
|
|
end
|
|
return BoardUI.hstack({ w = w, h = h }, { left, BoardUI.flex(px(4)), right })
|
|
end
|
|
|
|
-- ----------------------------------------------------------------- frames
|
|
-- An RGB border painted over a frame of the same size.
|
|
local ColourRing = Widget:extend{ bw = 1, color = nil, radius = 0 }
|
|
|
|
function ColourRing:getSize()
|
|
return self.dimen
|
|
end
|
|
|
|
function ColourRing:paintTo(bb, x, y)
|
|
self.dimen.x, self.dimen.y = x, y
|
|
paintBorder(bb, x, y, self.dimen.w, self.dimen.h, self.bw, self.color, self.radius)
|
|
end
|
|
|
|
-- A fixed box with an optional fill, radius and inner border.
|
|
function BoardUI.box(o, child)
|
|
local border = o.border or 0
|
|
local inner_w, inner_h = o.w - 2 * border, o.h - 2 * border
|
|
if border > 0 and isRGB(o.border_col) then
|
|
local plain = {}
|
|
for k, v in pairs(o) do plain[k] = v end
|
|
plain.border_col = INK.white
|
|
return BoardUI.overlay(o.w, o.h, {
|
|
{ widget = BoardUI.box(plain, child) },
|
|
{ widget = ColourRing:new{ dimen = Geom:new{ w = o.w, h = o.h }, bw = border, color = o.border_col, radius = o.radius or 0 } },
|
|
})
|
|
end
|
|
return FrameContainer:new{
|
|
margin = 0,
|
|
padding = 0,
|
|
bordersize = border,
|
|
color = o.border_col or INK.black,
|
|
radius = o.radius,
|
|
background = o.bg,
|
|
LeftContainer:new{
|
|
dimen = Geom:new{ w = math.max(1, inner_w), h = math.max(1, inner_h) },
|
|
child or HorizontalSpan:new{ width = 0 },
|
|
},
|
|
}
|
|
end
|
|
|
|
-- A progress bar: track, fill and optional rounded ends.
|
|
function BoardUI.bar(w, h, pct, o)
|
|
o = o or {}
|
|
pct = math.max(0, math.min(100, pct or 0))
|
|
local fill_w = math.floor(w * pct / 100 + 0.5)
|
|
if pct > 0 then fill_w = math.max(fill_w, math.max(1, px(1.5))) end
|
|
local r = o.r or 0
|
|
local track = FrameContainer:new{
|
|
margin = 0, padding = 0, bordersize = 0, radius = r,
|
|
background = o.track or INK.gc,
|
|
LeftContainer:new{
|
|
dimen = Geom:new{ w = w, h = h },
|
|
fill_w > 0 and FrameContainer:new{
|
|
margin = 0, padding = 0, bordersize = 0, radius = r,
|
|
background = o.fill or INK.black,
|
|
HorizontalGroup:new{ HorizontalSpan:new{ width = fill_w }, VerticalSpan:new{ width = h } },
|
|
} or HorizontalSpan:new{ width = 0 },
|
|
},
|
|
}
|
|
return track
|
|
end
|
|
|
|
-- A dot: filled, or an outlined ring.
|
|
function BoardUI.dot(size, on, o)
|
|
o = o or {}
|
|
return FrameContainer:new{
|
|
margin = 0, padding = 0,
|
|
bordersize = on and 0 or math.max(1, px(1.25)),
|
|
color = o.off or INK.ga,
|
|
radius = math.floor(size / 2),
|
|
background = on and (o.col or INK.black) or nil,
|
|
HorizontalGroup:new{
|
|
HorizontalSpan:new{ width = size - (on and 0 or 2 * math.max(1, px(1.25))) },
|
|
VerticalSpan:new{ width = size - (on and 0 or 2 * math.max(1, px(1.25))) },
|
|
},
|
|
}
|
|
end
|
|
|
|
-- A rectangle of solid colour.
|
|
function BoardUI.rect(w, h, col, r)
|
|
return FrameContainer:new{
|
|
margin = 0, padding = 0, bordersize = 0, radius = r,
|
|
background = col,
|
|
HorizontalGroup:new{ HorizontalSpan:new{ width = math.max(0, w) }, VerticalSpan:new{ width = math.max(0, h) } },
|
|
}
|
|
end
|
|
|
|
-- An icon. o: col (an INK key), sw, fill.
|
|
function BoardUI.icon(name, size, o)
|
|
o = o or {}
|
|
return BoardIcons.widget(name, size, {
|
|
color = BoardMetrics.INK_HEX[o.col or "black"] or o.col,
|
|
sw = o.sw or 2,
|
|
fill = o.fill,
|
|
})
|
|
end
|
|
|
|
-- A circle of diameter d with an icon centred in it.
|
|
function BoardUI.circle(d, o, child)
|
|
local border = o.border or 0
|
|
return FrameContainer:new{
|
|
margin = 0, padding = 0,
|
|
bordersize = border,
|
|
color = o.border_col or INK.black,
|
|
radius = math.floor(d / 2),
|
|
background = o.bg,
|
|
CenterContainer:new{ dimen = Geom:new{ w = d - 2 * border, h = d - 2 * border }, child },
|
|
}
|
|
end
|
|
|
|
-- --------------------------------------------------------------- hit areas
|
|
-- A transparent wrapper that remembers where it was painted, so a tap on a
|
|
-- tile can go to exactly what was drawn under the finger.
|
|
local HitArea = WidgetContainer:extend{
|
|
action = nil,
|
|
}
|
|
|
|
function HitArea:getSize()
|
|
return self[1]:getSize()
|
|
end
|
|
|
|
function HitArea:paintTo(bb, x, y)
|
|
local size = self[1]:getSize()
|
|
self.dimen = Geom:new{ x = x, y = y, w = size.w, h = size.h }
|
|
self[1]:paintTo(bb, x, y)
|
|
end
|
|
|
|
local hit_sinks = {}
|
|
|
|
-- Marks child as tappable with `action`, recorded on the tile being built.
|
|
function BoardUI.hit(child, action)
|
|
local area = HitArea:new{ action = action, child }
|
|
local sink = hit_sinks[#hit_sinks]
|
|
if sink then table.insert(sink, area) end
|
|
return area
|
|
end
|
|
|
|
-- How many hit areas the current build has made, and dropping the ones made
|
|
-- after a mark: a layout built, measured and then rebuilt another way.
|
|
function BoardUI.hitMark()
|
|
local sink = hit_sinks[#hit_sinks]
|
|
return sink and #sink or 0
|
|
end
|
|
|
|
function BoardUI.hitTrim(mark)
|
|
local sink = hit_sinks[#hit_sinks]
|
|
if not sink then return end
|
|
for i = #sink, mark + 1, -1 do table.remove(sink, i) end
|
|
end
|
|
|
|
-- Runs fn and returns its result plus the hit areas it created.
|
|
function BoardUI.collectHits(fn)
|
|
local sink = {}
|
|
table.insert(hit_sinks, sink)
|
|
local ok, result = pcall(fn)
|
|
table.remove(hit_sinks)
|
|
if not ok then error(result, 0) end
|
|
return result, sink
|
|
end
|
|
|
|
-- Wi-Fi, battery and the clock: the device status both full pages show.
|
|
function BoardUI.statusCluster(d)
|
|
local wifi_on = require("ui/network/manager"):isWifiOn() and require("ui/network/manager"):isConnected()
|
|
local powerd = require("device"):getPowerDevice()
|
|
local items = {}
|
|
table.insert(items, BoardUI.icon(wifi_on and "wifi" or "wifi-off", px(15), { col = wifi_on and "black" or "g4", sw = 2.25 }))
|
|
if require("device"):hasBattery() then
|
|
local capacity = powerd:getCapacity() or 0
|
|
local bw, bh = px(21), px(11)
|
|
local b = math.max(1, px(1.25))
|
|
local inner_pad = px(1.5)
|
|
local fill_w = math.max(1, math.floor((bw - 2 * b - 2 * inner_pad) * capacity / 100 + 0.5))
|
|
local body = BoardUI.box({ w = bw, h = bh, border = b, radius = px(2.5) },
|
|
BoardUI.hstack({ h = bh - 2 * b }, { BoardUI.space(inner_pad, 1), BoardUI.rect(fill_w, bh - 2 * b - 2 * inner_pad, INK.black, px(1)) }))
|
|
local nub = BoardUI.rect(px(2), px(4.5), INK.black)
|
|
table.insert(items, BoardUI.hstack({ gap = px(4) }, { BoardUI.hstack({}, { body, nub }), BoardUI.text(tostring(capacity) .. "%", { s = 12, col = INK.g4 }) }))
|
|
end
|
|
table.insert(items, BoardUI.text(d.clock, { s = 12, b = true }))
|
|
return BoardUI.hstack({ gap = px(7) }, items)
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ covers
|
|
-- Scaled covers are decoded once per size and kept, so flipping pages and
|
|
-- partial refreshes do not decode again. Bounded to keep memory flat.
|
|
local COVER_CACHE_MAX = 48
|
|
local cover_cache = {}
|
|
local cover_order = {}
|
|
|
|
local aspect_cache = {}
|
|
|
|
-- The cover scaled for a w x h box: cropped to fill when its shape is within
|
|
-- tol (12% unless given) of the box, fitted otherwise. One decode serves both
|
|
-- the shape check and the scale.
|
|
local function cachedCover(path, w, h, tol)
|
|
tol = tol or 0.12
|
|
local key = path .. "|" .. w .. "x" .. h .. "|" .. tol
|
|
local hit = cover_cache[key]
|
|
if hit then return hit end
|
|
local ok, native = pcall(RenderImage.renderImageFile, RenderImage, path, false)
|
|
if not ok or not native then return nil end
|
|
local iw, ih = native:getWidth(), native:getHeight()
|
|
aspect_cache[path] = iw / ih
|
|
local box_aspect = w / h
|
|
local crop = math.abs(iw / ih - box_aspect) / box_aspect <= tol
|
|
local scale = crop and math.max(w / iw, h / ih) or math.min(w / iw, h / ih)
|
|
local sw, sh = math.max(1, math.floor(iw * scale + 0.5)), math.max(1, math.floor(ih * scale + 0.5))
|
|
local scaled = RenderImage:scaleBlitBuffer(native, sw, sh, true)
|
|
hit = { bb = scaled, w = sw, h = sh, crop = crop }
|
|
cover_cache[key] = hit
|
|
table.insert(cover_order, key)
|
|
while #cover_order > COVER_CACHE_MAX do
|
|
local old = table.remove(cover_order, 1)
|
|
local entry = cover_cache[old]
|
|
if entry and entry.bb then entry.bb:free() end
|
|
cover_cache[old] = nil
|
|
end
|
|
return hit
|
|
end
|
|
|
|
function BoardUI.clearCoverCache()
|
|
for _i, entry in pairs(cover_cache) do
|
|
if entry.bb then entry.bb:free() end
|
|
end
|
|
cover_cache, cover_order = {}, {}
|
|
end
|
|
|
|
-- The cover's native shape, from the decode the board already did, or a
|
|
-- decode of its own when the cover has not been drawn yet.
|
|
BoardUI.cachedCover = cachedCover
|
|
|
|
function BoardUI.nativeAspect(path)
|
|
if aspect_cache[path] then return aspect_cache[path] end
|
|
local ok, native = pcall(RenderImage.renderImageFile, RenderImage, path, false)
|
|
if not ok or not native then return nil end
|
|
aspect_cache[path] = native:getWidth() / native:getHeight()
|
|
native:free()
|
|
return aspect_cache[path]
|
|
end
|
|
|
|
local BoardCover = Widget:extend{
|
|
path = nil,
|
|
aspect = nil,
|
|
border = 0,
|
|
border_col = nil,
|
|
dim = false,
|
|
-- A lighter film than dim, as an amount for lightenRect (0 to 1).
|
|
lighten = nil,
|
|
valign = "bottom",
|
|
crop_tol = nil,
|
|
plate = false,
|
|
}
|
|
|
|
function BoardCover:getSize()
|
|
return self.dimen
|
|
end
|
|
|
|
function BoardCover:paintTo(bb, x, y)
|
|
local w, h = self.dimen.w, self.dimen.h
|
|
self.dimen.x, self.dimen.y = x, y
|
|
local img = cachedCover(self.path, w, h, self.crop_tol)
|
|
if not img then
|
|
bb:paintRect(x, y, w, h, INK.ge)
|
|
return
|
|
end
|
|
-- A plate: an image of another shape sits centred on a gray box the size
|
|
-- of the cover, so marks drawn on the box land on a defined shape.
|
|
if self.plate and not img.crop then
|
|
bb:paintRect(x, y, w, h, INK.ge)
|
|
local px_, py_ = x + math.floor((w - img.w) / 2), y + math.floor((h - img.h) / 2)
|
|
bb:blitFrom(img.bb, px_, py_, 0, 0, img.w, img.h)
|
|
if self.dim then bb:lightenRect(x, y, w, h, 0.55) end
|
|
if Screen.night_mode then bb:invertRect(px_, py_, img.w, img.h) end
|
|
if self.border > 0 then paintBorder(bb, px_, py_, img.w, img.h, self.border, self.border_col or INK.gb) end
|
|
return
|
|
end
|
|
local dx, dy, dw, dh
|
|
if img.crop then
|
|
local ox, oy = math.floor((img.w - w) / 2), math.floor((img.h - h) / 2)
|
|
bb:blitFrom(img.bb, x, y, ox, oy, w, h)
|
|
dx, dy, dw, dh = x, y, w, h
|
|
else
|
|
-- Square audiobook art and other outliers stand on the box's bottom
|
|
-- edge, or hang from its top when the box sits beside text.
|
|
dx = x + math.floor((w - img.w) / 2)
|
|
dy = self.valign == "top" and y or y + (h - img.h)
|
|
bb:blitFrom(img.bb, dx, dy, 0, 0, img.w, img.h)
|
|
dw, dh = img.w, img.h
|
|
end
|
|
if self.dim then
|
|
bb:lightenRect(dx, dy, dw, dh, 0.55)
|
|
elseif self.lighten then
|
|
bb:lightenRect(dx, dy, dw, dh, self.lighten)
|
|
end
|
|
if Screen.night_mode then
|
|
bb:invertRect(dx, dy, dw, dh)
|
|
end
|
|
if self.border > 0 then
|
|
paintBorder(bb, dx, dy, dw, dh, self.border, self.border_col or INK.gb)
|
|
end
|
|
end
|
|
|
|
-- A book cover at an exact box. o: border (px), border_col, dim, lighten, path,
|
|
-- valign ("top" or the default "bottom") for an image of another shape,
|
|
-- placeholder title when there is no image.
|
|
-- The placeholder title's size: the largest at which every word (split
|
|
-- after hyphens, where lines may break) fits the cover's width, so a narrow
|
|
-- cover never breaks a word in two.
|
|
local PLACEHOLDER_SIZES = { 14, 13, 12, 11, 10, 9 }
|
|
local placeholder_cache, placeholder_count = {}, 0
|
|
|
|
local function placeholderSize(title, w)
|
|
local key = title .. "|" .. w .. "|" .. BoardMetrics.get().dp
|
|
local hit = placeholder_cache[key]
|
|
if hit then return hit end
|
|
local result = PLACEHOLDER_SIZES[#PLACEHOLDER_SIZES]
|
|
for _i, s in ipairs(PLACEHOLDER_SIZES) do
|
|
local face = BoardMetrics.face(s, "serif")
|
|
local fits = true
|
|
for word in title:gmatch("[^%s%-]+%-?") do
|
|
local tw = TextWidget:new{ text = word, face = face, bold = true, padding = 0 }
|
|
local word_w = tw:getSize().w
|
|
tw:free()
|
|
if word_w > w then fits = false; break end
|
|
end
|
|
if fits then result = s; break end
|
|
end
|
|
if placeholder_count > 400 then placeholder_cache, placeholder_count = {}, 0 end
|
|
placeholder_cache[key] = result
|
|
placeholder_count = placeholder_count + 1
|
|
return result
|
|
end
|
|
|
|
function BoardUI.cover(book, w, h, o)
|
|
o = o or {}
|
|
if o.path then
|
|
return BoardCover:new{
|
|
dimen = Geom:new{ w = w, h = h },
|
|
path = o.path,
|
|
border = o.border or 0,
|
|
border_col = o.border_col,
|
|
dim = o.dim == true,
|
|
lighten = o.lighten,
|
|
valign = o.valign or "bottom",
|
|
crop_tol = o.crop_tol,
|
|
plate = o.plate == true,
|
|
}
|
|
end
|
|
-- No image yet: a quiet placeholder with the title, never an empty hole.
|
|
local pad = px(8)
|
|
local frame_b = math.max(1, px(1))
|
|
local inner = {}
|
|
local title = book and book.title or ""
|
|
local tw = w - 2 * pad - 2 * frame_b
|
|
local s = placeholderSize(title, tw)
|
|
-- The title takes the lines the cover has room for, up to four; a cover
|
|
-- too small for one line stays an empty frame.
|
|
local lines = math.min(4, math.floor((h - 2 * frame_b - 2 * pad) / BoardMetrics.lh(s)))
|
|
if o.loading ~= true and w > px(48) and lines >= 1 then
|
|
inner = { BoardUI.flex(0), BoardUI.text(title, { s = s, b = true, serif = true, lines = lines, w = tw, align = "center", data = true, bg = INK.ge }), BoardUI.flex(0) }
|
|
end
|
|
return BoardUI.box({ w = w, h = h, border = frame_b, border_col = INK.black, bg = INK.ge },
|
|
BoardUI.vstack({ w = w - 2 * frame_b, h = h - 2 * frame_b, pad = pad, align = "center" }, inner))
|
|
end
|
|
|
|
-- Many small rounded squares painted in one pass: the heatmap and calendar
|
|
-- grids. A widget per cell would cost a FrameContainer each; this is a loop
|
|
-- of paintRoundedRect calls. cells: { x, y, col, ring } in box coordinates;
|
|
-- ring draws a black outline with a white gap around that cell.
|
|
local CellGrid = Widget:extend{
|
|
cells = nil,
|
|
size = 0,
|
|
radius = 0,
|
|
}
|
|
|
|
function CellGrid:getSize()
|
|
return self.dimen
|
|
end
|
|
|
|
function CellGrid:paintTo(bb, x, y)
|
|
self.dimen.x, self.dimen.y = x, y
|
|
local s, r = self.size, self.radius
|
|
for _i, cell in ipairs(self.cells) do
|
|
if cell.ring then
|
|
local ring, gap = math.max(2, px(2)), math.max(1, px(1))
|
|
bb:paintRoundedRect(x + cell.x - ring, y + cell.y - ring, s + 2 * ring, s + 2 * ring, INK.black, r + ring)
|
|
bb:paintRoundedRect(x + cell.x - gap, y + cell.y - gap, s + 2 * gap, s + 2 * gap, INK.white, r + gap)
|
|
end
|
|
if cell.border and isRGB(cell.col) and bb.paintBorderRGB32 then
|
|
bb:paintBorderRGB32(x + cell.x, y + cell.y, s, s, cell.border, cell.col, r, true)
|
|
elseif cell.border then
|
|
bb:paintBorder(x + cell.x, y + cell.y, s, s, cell.border, cell.col, r, true)
|
|
else
|
|
paintRoundedRect(bb, x + cell.x, y + cell.y, s, s, cell.col, r)
|
|
end
|
|
end
|
|
end
|
|
|
|
function BoardUI.cellGrid(w, h, size, radius, cells)
|
|
return CellGrid:new{ dimen = Geom:new{ w = w, h = h }, cells = cells, size = size, radius = radius }
|
|
end
|
|
|
|
-- Painted bottom to top, but asked about taps top to bottom, so a bar or a
|
|
-- badge drawn over a tile gets its own taps.
|
|
local TopFirstGroup = OverlapGroup:extend{}
|
|
|
|
function TopFirstGroup:propagateEvent(event)
|
|
for index = #self, 1, -1 do
|
|
local widget = self[index]
|
|
if widget and widget:handleEvent(event) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Several widgets painted over one another at given offsets.
|
|
function BoardUI.overlay(w, h, layers)
|
|
local group = TopFirstGroup:new{ dimen = Geom:new{ w = w, h = h } }
|
|
for _i, layer in ipairs(layers) do
|
|
if layer.widget then
|
|
layer.widget.overlap_offset = { layer.x or 0, layer.y or 0 }
|
|
table.insert(group, layer.widget)
|
|
end
|
|
end
|
|
return group
|
|
end
|
|
|
|
-- --------------------------------------------------------------- snapshots
|
|
-- A widget drawn once at full size into an offscreen buffer and scaled down:
|
|
-- the starter layouts and widget previews. Kept until cleared, so repaints
|
|
-- and partial refreshes do not draw them again.
|
|
local snapshot_cache = {}
|
|
|
|
local Snapshot = Widget:extend{ key = nil }
|
|
|
|
function Snapshot:getSize()
|
|
return self.dimen
|
|
end
|
|
|
|
function Snapshot:paintTo(bb, x, y)
|
|
self.dimen.x, self.dimen.y = x, y
|
|
local hit = snapshot_cache[self.key]
|
|
if hit and hit.bb then bb:blitFrom(hit.bb, x, y, 0, 0, hit.bb:getWidth(), hit.bb:getHeight()) end
|
|
end
|
|
|
|
function BoardUI.snapshot(key, full_w, full_h, w, h, build)
|
|
if not snapshot_cache[key] then
|
|
local Screen = require("device").screen
|
|
local Blitbuffer_ = require("ffi/blitbuffer")
|
|
-- KOReader's MuPDF scaler keeps the 16-bit stride when it widens RGB565 and throws, so the
|
|
-- reMarkable's RGB565 screen draws its thumbnails in RGB32 instead.
|
|
local canvas_type = Screen.bb:getType()
|
|
if canvas_type == Blitbuffer_.TYPE_BBRGB16 then canvas_type = Blitbuffer_.TYPE_BBRGB32 end
|
|
local canvas = Blitbuffer_.new(full_w, full_h, canvas_type)
|
|
canvas:fill(INK.white)
|
|
-- Built in a sink of its own: a thumbnail's buttons are not tappable.
|
|
local widget = BoardUI.collectHits(build)
|
|
widget:paintTo(canvas, 0, 0)
|
|
if widget.free then widget:free() end
|
|
snapshot_cache[key] = { bb = RenderImage:scaleBlitBuffer(canvas, w, h, true) }
|
|
end
|
|
return Snapshot:new{ key = key, dimen = Geom:new{ w = w, h = h } }
|
|
end
|
|
|
|
function BoardUI.clearSnapshots(prefix)
|
|
for key, hit in pairs(snapshot_cache) do
|
|
if not prefix or key:sub(1, #prefix) == prefix then
|
|
if hit.bb then hit.bb:free() end
|
|
snapshot_cache[key] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
return BoardUI
|