feat(koreader): colour the widget board on colour screens

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.
This commit is contained in:
neon
2026-10-27 15:27:39 +00:00
parent 0b89e5a4a1
commit 267f53fccd
11 changed files with 1017 additions and 162 deletions
@@ -263,7 +263,13 @@ function GlyphWidget:paintTo(bb, x, y)
self.dimen.x, self.dimen.y = x, y
local g = renderGlyph(self.file, self.codepoint, self.size)
if not g then return end
bb:colorblitFrom(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), self.color or Blitbuffer.COLOR_BLACK)
local color = self.color or Blitbuffer.COLOR_BLACK
-- The plain colour blit keeps only gray; an RGB colour takes the RGB one.
if Blitbuffer.isColor8 and not Blitbuffer.isColor8(color) and bb.colorblitFromRGB32 then
bb:colorblitFromRGB32(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), color)
else
bb:colorblitFrom(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), color)
end
end
-- One character's bitmap at a pixel size: { bb, l, t }, or nil. Drawn by
@@ -0,0 +1,437 @@
--[[--
Colour for the widget board on colour screens.
The Colour setting chooses how much of the board is coloured:
accents the default. Each widget's marks (bars, dots, chart cells, the
switched-on control, its header icon) take the widget's own
colour, and Continue reading takes the deep shade of its cover.
tinted accents, with every tile washed in its widget's colour; the
tile style draws the edge in that colour, and the black clock
and moon tiles take the deep shade.
off the sixteen greys.
Black-and-white screens always draw the greys: every colour here is asked
for together with the grey it replaces.
Text stays black or white and controls stay black, so nothing depends on
colour to be read. Kaleido 3 shows colour at half the resolution of black
and about half its saturation, so a colour e-reader gets every shade with its
chroma pushed out (KOReader's highlight colours are fully saturated for the
same reason) and icons drawn a third heavier. Night mode inverts the screen,
so each colour is painted with its hue turned half way round: it shows in its
own hue at the inverse lightness.
]]
local Colour = {}
Colour.SCHEMES = { "accents", "tinted", "off" }
Colour.DEFAULT = "accents"
-- ink: bars, dots, icons. deep: a filled tile with white text on it.
-- tint: tracks and pale marks. wash: a whole tile's background.
local FAM = {
red = { ink = "#de4127", deep = "#8c2716", tint = "#f8c9bd", wash = "#fbdcd3" },
orange = { ink = "#ee8410", deep = "#8a4806", tint = "#fbd6a6", wash = "#fde5c6" },
gold = { ink = "#d9a40c", deep = "#6b5006", tint = "#f6e3a0", wash = "#faedc2" },
green = { ink = "#1e9857", deep = "#135a37", tint = "#c2e8d1", wash = "#d9f1e2" },
teal = { ink = "#12908b", deep = "#0c5653", tint = "#bfe6e3", wash = "#d7f0ee" },
blue = { ink = "#2b61d6", deep = "#1b397d", tint = "#c8d7f6", wash = "#dde6f9" },
violet = { ink = "#7848c8", deep = "#45277c", tint = "#dbcff2", wash = "#e8e0f7" },
rose = { ink = "#d0386f", deep = "#801f46", tint = "#f4c8d8", wash = "#f8dbe5" },
brown = { ink = "#99602f", deep = "#57381c", tint = "#e8d3bf", wash = "#f1e4d7" },
yellow = { ink = "#e9b800", deep = "#6b5500", tint = "#f9e57f", wash = "#fcefae" },
night = { ink = "#7f8bd6", deep = "#1c2147", tint = "#c9cdee", wash = "#dfe1f5" },
}
Colour.FAM = FAM
-- Which family each widget wears. Tiles that touch on a starter page, on any
-- screen, never share a family or a look-alike wash (gold beside yellow, red
-- beside rose, green beside teal); a dark ink tile may sit beside a pale tile
-- of its own family.
local HUE = {
hero = "cover", also = "blue", streak = "orange", week = "green", today = "green", goalpace = "rose",
toggles = "violet", highlight = "yellow", heatmap = "green", forecast = "blue", hours = "violet", records = "gold",
yearago = "blue", word = "rose", sync = "orange", clock = "blue", light = "gold", calendar = "red",
buddy = "violet", analog = "blue", moon = "night", odometer = "red", glyph = "red", rings = "brown",
chapters = "cover", badge = "violet", dust = "green",
["link:library"] = "orange", ["link:authors"] = "rose", ["link:series"] = "violet", ["link:device"] = "gold",
["link:collections"] = "orange", ["link:scopes"] = "teal", ["link:search"] = "blue",
["shelf:random"] = "brown", ["shelf:recently-added"] = "teal", ["shelf:pick-back-up"] = "gold",
["shelf:up-next-in-series"] = "violet", ["shelf:want-to-read"] = "red", ["shelf:finished"] = "teal",
["shelf:in-progress"] = "blue",
}
Colour.HUE = HUE
local RARITY = { common = "green", rare = "blue", epic = "violet", legendary = "gold", ribbon = "red" }
-- -------------------------------------------------------------- colour math
local function hexRGB(hex)
return tonumber(hex:sub(2, 3), 16), tonumber(hex:sub(4, 5), 16), tonumber(hex:sub(6, 7), 16)
end
local function clampByte(v)
v = math.floor(v + 0.5)
if v < 0 then return 0 end
if v > 255 then return 255 end
return v
end
local function rgbHex(r, g, b)
return string.format("#%02x%02x%02x", clampByte(r), clampByte(g), clampByte(b))
end
function Colour.mix(a, b, t)
local ar, ag, ab = hexRGB(a)
local br, bg, bb = hexRGB(b)
return rgbHex(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t)
end
-- Hue, lightness, saturation in 0..1, as Python's colorsys has them.
local function rgbToHls(r, g, b)
local maxc, minc = math.max(r, g, b), math.min(r, g, b)
local l = (minc + maxc) / 2
if maxc == minc then return 0, l, 0 end
local s = l <= 0.5 and (maxc - minc) / (maxc + minc) or (maxc - minc) / (2 - maxc - minc)
local rc, gc, bc = (maxc - r) / (maxc - minc), (maxc - g) / (maxc - minc), (maxc - b) / (maxc - minc)
local h
if r == maxc then h = bc - gc
elseif g == maxc then h = 2 + rc - bc
else h = 4 + gc - rc end
return (h / 6) % 1, l, s
end
Colour.rgbToHls = rgbToHls
local function hueValue(m1, m2, hue)
hue = hue % 1
if hue < 1 / 6 then return m1 + (m2 - m1) * hue * 6 end
if hue < 0.5 then return m2 end
if hue < 2 / 3 then return m1 + (m2 - m1) * (2 / 3 - hue) * 6 end
return m1
end
local function hlsToRgb(h, l, s)
if s == 0 then return l, l, l end
local m2 = l <= 0.5 and l * (1 + s) or l + s - l * s
local m1 = 2 * l - m2
return hueValue(m1, m2, h + 1 / 3), hueValue(m1, m2, h), hueValue(m1, m2, h - 1 / 3)
end
local function hlsHex(h, l, s)
local r, g, b = hlsToRgb(h, l, s)
return rgbHex(r * 255, g * 255, b * 255)
end
-- Chroma pushed out from the colour's own luma.
function Colour.boost(hex, k)
local r, g, b = hexRGB(hex)
local y = 0.2126 * r + 0.7152 * g + 0.0722 * b
return rgbHex(y + (r - y) * k, y + (g - y) * k, y + (b - y) * k)
end
-- What to paint so the inverted screen shows `hex`'s hue at the inverse
-- lightness: the same lightness and saturation, the hue turned half way.
function Colour.forNight(hex)
local r, g, b = hexRGB(hex)
local h, l, s = rgbToHls(r / 255, g / 255, b / 255)
return hlsHex(h + 0.5, l, s)
end
-- ----------------------------------------------------------- cover colours
-- One family per cover: pixels that are nearly grey, nearly black or nearly
-- white are dropped and the rest vote by hue, weighted by saturation. The
-- winning hue gives the deep, ink and tint shades. A jacket with too few
-- coloured pixels has none. pixels: a list of { r, g, b } in 0..255.
function Colour.familyFromPixels(pixels)
local BINS = 36
local bins, sats = {}, {}
for i = 0, BINS - 1 do bins[i], sats[i] = 0, {} end
local total = #pixels
if total == 0 then return nil end
for _i, p in ipairs(pixels) do
local h, l, s = rgbToHls(p[1] / 255, p[2] / 255, p[3] / 255)
if s >= 0.28 and l >= 0.12 and l <= 0.9 then
local k = math.floor(h * BINS) % BINS
bins[k] = bins[k] + s * (1 - math.abs(l - 0.5))
table.insert(sats[k], s)
end
end
-- Neighbouring bins vote together, so a hue split across a bin edge wins.
local best, best_v = 0, -1
for i = 0, BINS - 1 do
local v = bins[(i - 1) % BINS] * 0.5 + bins[i] + bins[(i + 1) % BINS] * 0.5
if v > best_v then best, best_v = i, v end
end
if best_v / total < 0.06 then return nil end
local hue = (best + 0.5) / BINS
local list = sats[best]
table.sort(list)
local sat = #list > 0 and list[math.floor(#list / 2) + 1] or 0.5
local tint = hlsHex(hue, 0.85, 0.7)
return {
deep = hlsHex(hue, 0.22, math.min(0.7, math.max(0.4, sat))),
ink = hlsHex(hue, 0.44, math.min(0.85, math.max(0.6, sat))),
tint = tint,
wash = Colour.mix(tint, "#ffffff", 0.45),
}
end
local cover_cache, cover_count = {}, 0
local NO_COVER = false
-- The family of the cover at `path`, worked out once per cover and kept.
function Colour.coverFamily(path)
if not path then return nil end
local hit = cover_cache[path]
if hit ~= nil then return hit or nil end
local fam = nil
local ok, bb = pcall(function() return require("ui/renderimage"):renderImageFile(path, false, 24, 36) end)
if ok and bb then
local pixels = {}
for y = 0, bb:getHeight() - 1 do
for x = 0, bb:getWidth() - 1 do
local c = bb:getPixel(x, y):getColorRGB32()
table.insert(pixels, { c.r, c.g, c.b })
end
end
bb:free()
fam = Colour.familyFromPixels(pixels)
end
if cover_count > 200 then cover_cache, cover_count = {}, 0 end
cover_cache[path] = fam or NO_COVER
cover_count = cover_count + 1
return fam
end
-- ---------------------------------------------------------- the screen
-- Specs set Colour._env to { colour, eink, night } instead of a device.
local function env()
if Colour._env then return Colour._env end
local Device = require("device")
local Screen = Device.screen
local Blitbuffer = require("ffi/blitbuffer")
return {
-- Older KOReader builds cannot paint RGB fills and borders.
colour = Screen:isColorEnabled() and Blitbuffer.isColor8 ~= nil and Blitbuffer.ColorRGB32 ~= nil,
eink = Device:hasEinkScreen() and true or false,
night = Screen.night_mode and true or false,
}
end
Colour.env = env
function Colour.normalize(value)
for _i, s in ipairs(Colour.SCHEMES) do
if s == value then return value end
end
return Colour.DEFAULT
end
function Colour.scheme(ctx)
return ctx and ctx.colour or "off"
end
-- Colour reaches only screens that show it.
function Colour.on(ctx)
return Colour.scheme(ctx) ~= "off" and env().colour == true
end
function Colour.tinted(ctx)
return Colour.on(ctx) and ctx.colour == "tinted"
end
-- The key a tile's colour is looked up by.
function Colour.keyFor(type_, props)
if type_ == "link" and props and props.link then return "link:" .. props.link end
if type_ == "shelf" and props and props.source then return "shelf:" .. props.source end
return type_
end
local boosted = {}
local function forPanel(fam)
if not fam or not env().eink then return fam end
local hit = boosted[fam]
if hit then return hit end
hit = {}
for k, v in pairs(fam) do hit[k] = Colour.boost(v, 1.4) end
boosted[fam] = hit
return hit
end
local function leadCover(ctx)
local book = ctx and ctx.d and ctx.d.continue_reading and ctx.d.continue_reading[1]
return book and Colour.coverFamily(book.cover_path) or nil
end
-- The family for a widget key (the tile's own when nil) or a family name.
function Colour.fam(ctx, key)
key = key or (ctx and ctx.tile_key)
if key == "hero" or key == "chapters" then return forPanel(leadCover(ctx) or FAM.blue) end
if FAM[key] then return forPanel(FAM[key]) end
return forPanel(FAM[HUE[key] or "blue"])
end
function Colour.rarity(rarity)
return forPanel(FAM[RARITY[rarity] or "violet"])
end
-- -------------------------------------------------------- what is painted
local paint_cache = {}
-- A hex colour as the screen should be given it: night mode turns its hue.
function Colour.hex(hex)
if env().night then return Colour.forNight(hex) end
return hex
end
-- A hex colour as a Blitbuffer colour, one object per colour, so a tile's
-- chrome fingerprints the same way on every rebuild.
function Colour.paint(hex)
hex = Colour.hex(hex)
local hit = paint_cache[hex]
if hit then return hit end
local Blitbuffer = require("ffi/blitbuffer")
local r, g, b = hexRGB(hex)
hit = Blitbuffer.ColorRGB32(r, g, b, 0xFF)
paint_cache[hex] = hit
return hit
end
-- Each shade, or the grey given for a screen without colour.
function Colour.ink(ctx, grey, key)
if not Colour.on(ctx) then return grey end
return Colour.paint(Colour.fam(ctx, key).ink)
end
function Colour.deep(ctx, grey, key)
if not Colour.on(ctx) then return grey end
return Colour.paint(Colour.fam(ctx, key).deep)
end
function Colour.tint(ctx, grey, key)
if not Colour.on(ctx) then return grey end
return Colour.paint(Colour.fam(ctx, key).tint)
end
-- Between tint and ink: the other days beside today's full-colour bar.
function Colour.mid(ctx, grey, key)
if not Colour.on(ctx) then return grey end
local f = Colour.fam(ctx, key)
return Colour.paint(Colour.mix(f.tint, f.ink, 0.45))
end
-- A bar's empty part: the pale shade on white, white on a washed tile.
function Colour.track(ctx, grey, key)
if not Colour.on(ctx) then return grey end
if Colour.tinted(ctx) then return Colour.paint("#ffffff") end
return Colour.paint(Colour.fam(ctx, key).tint)
end
-- A five-step ramp from none to most (Year in reading).
function Colour.ramp(ctx, greys, key)
if not Colour.on(ctx) then return greys end
local f = Colour.fam(ctx, key)
local empty = Colour.tinted(ctx) and Colour.paint(Colour.mix(f.wash, "#ffffff", 0.55)) or greys[1]
return { empty, Colour.paint(f.tint), Colour.paint(Colour.mix(f.tint, f.ink, 0.5)), Colour.paint(f.ink), Colour.paint(f.deep) }
end
-- An icon's options: the widget's colour, heavier on a colour e-reader.
function Colour.icon(ctx, sw, key)
if not Colour.on(ctx) then return { sw = sw } end
return { sw = env().eink and math.floor(sw * 135 + 0.5) / 100 or sw, col = Colour.hex(Colour.fam(ctx, key).ink) }
end
-- A hex shade for SVG art, or the grey's hex.
function Colour.artHex(ctx, grey_hex, shade, key)
if not Colour.on(ctx) then return grey_hex end
return Colour.hex(Colour.fam(ctx, key)[shade or "ink"])
end
-- Ink tiles (Continue reading, the clocks, the moon) keep black in accents,
-- except Continue reading, which takes its cover's deep shade; tinted gives
-- every one its deep shade. A jacket without colour keeps black.
function Colour.inkFill(ctx, key)
key = key or (ctx and ctx.tile_key)
local Blitbuffer = require("ffi/blitbuffer")
if not Colour.on(ctx) then return Blitbuffer.COLOR_BLACK end
if key == "hero" then
local lead = leadCover(ctx)
return lead and Colour.paint(forPanel(lead).deep) or Blitbuffer.COLOR_BLACK
end
if Colour.tinted(ctx) then return Colour.paint(Colour.fam(ctx, key).deep) end
return Blitbuffer.COLOR_BLACK
end
local function inkFillHex(ctx, key)
if not Colour.on(ctx) then return nil end
if key == "hero" then
local lead = leadCover(ctx)
return lead and forPanel(lead).deep or nil
end
return Colour.tinted(ctx) and Colour.fam(ctx, key).deep or nil
end
-- Quiet text on an ink tile: the grey on black, a pale shade on colour.
function Colour.onInk(ctx, grey, key)
key = key or (ctx and ctx.tile_key)
if not inkFillHex(ctx, key) then return grey end
return Colour.paint(Colour.mix(Colour.fam(ctx, key).tint, "#ffffff", 0.35))
end
-- The same for an icon: an INK key on black, the pale shade's hex on colour.
function Colour.onInkHex(ctx, grey_key, key)
key = key or (ctx and ctx.tile_key)
if not inkFillHex(ctx, key) then return grey_key end
return Colour.hex(Colour.mix(Colour.fam(ctx, key).tint, "#ffffff", 0.35))
end
-- A bar's track on an ink tile.
function Colour.inkTrack(ctx, grey, key)
key = key or (ctx and ctx.tile_key)
local fill = inkFillHex(ctx, key)
return fill and Colour.paint(Colour.mix(fill, "#ffffff", 0.3)) or grey
end
-- Tile chrome. base is the tile style's own; tinted keeps its edge, drawn in
-- the widget's colour: Outline a hairline a step darker than the wash, Soft no
-- edge, Ink a firm edge in the deep shade. Highlight of the day is yellow,
-- the colour KOReader highlights in, in both schemes.
function Colour.chrome(ctx, chrome, key, base, hairline, firm)
if not Colour.on(ctx) or chrome == "open" then return base end
local out = {}
for k, v in pairs(base) do out[k] = v end
if chrome == "ink" then
out.bg = Colour.inkFill(ctx, key)
return out
end
local tinted = Colour.tinted(ctx)
if not tinted and key ~= "highlight" then return base end
local f = Colour.fam(ctx, key)
out.bg = Colour.paint(f.wash)
out.border, out.border_col = 0, nil
if tinted then
local tiles = ctx.tiles or "outline"
if tiles == "outline" then
out.border, out.border_col = hairline, Colour.paint(Colour.mix(f.wash, f.ink, 0.4))
elseif tiles == "ink" then
out.border, out.border_col = firm, Colour.paint(f.deep)
end
end
return out
end
-- Whatever sits on a tile and assumed white or grey (the halo between fanned
-- covers, a cover's hairline) takes the tile's own colour on a washed tile.
function Colour.halo(ctx, grey)
if not Colour.tinted(ctx) then return grey end
return Colour.paint(Colour.fam(ctx).wash)
end
function Colour.coverEdge(ctx, grey)
if not Colour.tinted(ctx) then return grey end
local f = Colour.fam(ctx)
return Colour.paint(Colour.mix(f.wash, f.deep, 0.3))
end
function Colour.clearCache()
cover_cache, cover_count, boosted, paint_cache = {}, 0, {}, {}
end
return Colour
@@ -23,6 +23,7 @@ local T = require("ffi/util").template
local _ = require("gettext")
local BoardArt = require("bookorbit_board_art")
local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data")
local BoardIcons = require("bookorbit_board_icons")
local BoardMetrics = require("bookorbit_board_metrics")
@@ -38,6 +39,12 @@ local Decor = {}
-- Set by install: the board's own header row and number formatting.
local head, formatCount
-- A hex for decor art: `colour` on a screen showing the Colour setting, the
-- grey otherwise.
local function artHex(ctx, grey, colour)
return BoardColour.on(ctx) and BoardColour.hex(colour) or grey
end
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
@@ -135,10 +142,11 @@ local function bubble(w, lines, s, tail, second_is_data)
local bw = math.max(1, px(1.5))
local pad_x, pad_y = px(10), px(7)
local tw = w - 2 * pad_x - 2 * bw
local items = { text(lines[1], { s = s, b = true, w = tw, lines = 2 }) }
-- The bubble is white whatever the tile under it is, and so is its text's ground.
local items = { text(lines[1], { s = s, b = true, w = tw, lines = 2, bg = INK.white }) }
if lines[2] then
table.insert(items, space(nil, px(2)))
table.insert(items, text(lines[2], { s = math.max(11, s - 2), col = INK.g4, w = tw, lines = 2, data = second_is_data }))
table.insert(items, text(lines[2], { s = math.max(11, s - 2), col = INK.g4, w = tw, lines = 2, data = second_is_data, bg = INK.white }))
end
local inner = vstack({ w = tw }, items)
local h = inner:getSize().h + 2 * pad_y + 2 * bw
@@ -270,7 +278,7 @@ function Decor.buddy(ctx, w, hh, props)
end
-- ================================================================ analog clock
local function clockFace(dia, hour, minute)
local function clockFace(dia, hour, minute, accent)
local r = dia / 2
local cx, cy = r, r
local body = { '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(r - 0.5) .. '" fill="#ffffff"/>' }
@@ -280,7 +288,7 @@ local function clockFace(dia, hour, minute)
local r1 = r - px(major and 4 or 5)
local r2 = r - (major and r * 0.2 or r * 0.12) - px(3)
table.insert(body, '<line x1="' .. n(cx + r1 * math.sin(a)) .. '" y1="' .. n(cy - r1 * math.cos(a)) .. '" x2="' .. n(cx + r2 * math.sin(a))
.. '" y2="' .. n(cy - r2 * math.cos(a)) .. '" stroke="' .. (major and "#000000" or "#555555") .. '" stroke-width="'
.. '" y2="' .. n(cy - r2 * math.cos(a)) .. '" stroke="' .. (major and accent or "#555555") .. '" stroke-width="'
.. n(major and math.max(2, px(2.5)) or math.max(1, px(1.25))) .. '" stroke-linecap="round"/>')
end
local ah = ((hour % 12) + minute / 60) * math.pi / 6
@@ -291,13 +299,14 @@ local function clockFace(dia, hour, minute)
end
table.insert(body, hand(ah, r * 0.5, math.max(3, px(4.5)), r * 0.08))
table.insert(body, hand(am, r * 0.76, math.max(2, px(2.75)), r * 0.1))
table.insert(body, '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(math.max(3, px(4))) .. '" fill="#000000"/>')
table.insert(body, '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(math.max(3, px(4))) .. '" fill="' .. accent .. '"/>')
table.insert(body, '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(math.max(1, px(1.25))) .. '" fill="#ffffff"/>')
return svg(round(dia), round(dia), table.concat(body))
end
function Decor.analog(ctx, w, hh)
local d = ctx.d
local accent = BoardColour.artHex(ctx, "#000000", "ink", "red")
local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad
local now = d.now or os.time()
@@ -311,12 +320,12 @@ function Decor.analog(ctx, w, hh)
if tw < px(70) then return skip("no room for the date") end
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, {
clockFace(dia, hour, minute),
clockFace(dia, hour, minute, accent),
vstack({ w = tw }, {
text(d.weekday, { s = s, b = true, w = tw, col = INK.white }),
text(d.day_month, { s = 13, w = tw, col = INK.gc }),
text(d.day_month, { s = 13, w = tw, col = BoardColour.onInk(ctx, INK.gc) }),
space(nil, px(4)),
text(d.clock, { s = 13, b = true, w = tw, col = INK.gc }),
text(d.clock, { s = 13, b = true, w = tw, col = BoardColour.onInk(ctx, INK.gc) }),
}),
}),
})
@@ -324,7 +333,7 @@ function Decor.analog(ctx, w, hh)
end
end
table.insert(variants, function()
return padded(w, hh, { flex(0), clockFace(math.max(1, math.min(iw, ih)), hour, minute), flex(0) }, "center")
return padded(w, hh, { flex(0), clockFace(math.max(1, math.min(iw, ih)), hour, minute, accent), flex(0) }, "center")
end)
return pick(variants)
end
@@ -343,10 +352,10 @@ end
-- Procellarum and Nubium, drawn only where wholly in the light.
local MARIA = { { -0.26, -0.36, 0.24, 0.17, -0.3 }, { 0.2, -0.3, 0.13, 0.12, 0 }, { 0.32, -0.02, 0.15, 0.12, 0.4 }, { -0.5, 0.06, 0.15, 0.3, 0.1 }, { -0.08, 0.42, 0.15, 0.09, 0.2 } }
local function moonDisc(dia, m)
local function moonDisc(dia, m, ctx)
local R = dia / 2 - math.max(1, px(1))
local cx, cy = dia / 2, dia / 2
local body = { '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(R) .. '" fill="#222222" stroke="#666666" stroke-width="' .. n(math.max(1, px(1))) .. '"/>' }
local body = { '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(R) .. '" fill="' .. artHex(ctx, "#222222", "#2a3062") .. '" stroke="' .. artHex(ctx, "#666666", "#5a628f") .. '" stroke-width="' .. n(math.max(1, px(1))) .. '"/>' }
if m.lit > 0.004 then
local k = math.cos(2 * math.pi * m.p)
local waxing = m.p < 0.5
@@ -356,7 +365,7 @@ local function moonDisc(dia, m)
if waxing then term_sweep = k > 0 and 0 or 1 else term_sweep = k > 0 and 1 or 0 end
local trx = math.max(0.01, math.abs(k) * R)
table.insert(body, '<path d="M' .. top .. ' A' .. n(R) .. ' ' .. n(R) .. ' 0 0 ' .. limb_sweep .. ' ' .. bottom .. ' A' .. n(trx) .. ' ' .. n(R)
.. ' 0 0 ' .. term_sweep .. ' ' .. top .. ' Z" fill="#ffffff"/>')
.. ' 0 0 ' .. term_sweep .. ' ' .. top .. ' Z" fill="' .. artHex(ctx, "#ffffff", "#f7efcf") .. '"/>')
for _i, mare in ipairs(MARIA) do
local mx, my, ex, ey, rot = mare[1], mare[2], mare[3], mare[4], mare[5]
local ok = true
@@ -368,7 +377,7 @@ local function moonDisc(dia, m)
end
if ok then
table.insert(body, '<ellipse cx="' .. n(cx + mx * R) .. '" cy="' .. n(cy + my * R) .. '" rx="' .. n(ex * R) .. '" ry="' .. n(ey * R)
.. '" transform="rotate(' .. n(rot * 57.3) .. ' ' .. n(cx + mx * R) .. ' ' .. n(cy + my * R) .. ')" fill="#dddddd"/>')
.. '" transform="rotate(' .. n(rot * 57.3) .. ' ' .. n(cx + mx * R) .. ' ' .. n(cy + my * R) .. ')" fill="' .. artHex(ctx, "#dddddd", "#e2d3a2") .. '"/>')
end
end
end
@@ -415,13 +424,13 @@ function Decor.moon(ctx, w, hh)
if tw < px(80) then return skip("no room for words") end
local words = vstack({ w = tw }, {
text(m.name, { s = v[1], b = true, w = tw, col = INK.white }),
text(T(_("%1% lit"), lit), { s = 13, w = tw, col = INK.gc }),
text(T(_("%1% lit"), lit), { s = 13, w = tw, col = BoardColour.onInk(ctx, INK.gc, "moon") }),
space(nil, px(4)),
text(m.next, { s = 12, w = tw, col = INK.gc }),
text(m.next, { s = 12, w = tw, col = BoardColour.onInk(ctx, INK.gc, "moon") }),
})
return overlay(w, hh, {
{ widget = sky(w, hh, { x = pad + dia / 2, y = hh / 2, r = dia / 2 }, { x = pad + dia, y = 0, w = w, h = hh }) },
{ widget = moonDisc(dia, m), x = pad, y = round((hh - dia) / 2) },
{ widget = moonDisc(dia, m, ctx), x = pad, y = round((hh - dia) / 2) },
{ widget = vstack({ w = tw, h = hh }, { flex(0), words, flex(0) }), x = pad + dia + px(16), y = 0 },
})
end)
@@ -436,7 +445,7 @@ function Decor.moon(ctx, w, hh)
local top = pad + (ih - cap_h - dia) / 2
local layers = {
{ widget = sky(w, hh, { x = w / 2, y = top + dia / 2, r = dia / 2 }, caption and { x = 0, y = hh - pad - lh1(12), w = w, h = lh1(12) } or nil) },
{ widget = moonDisc(dia, m), x = round((w - dia) / 2), y = round(top) },
{ widget = moonDisc(dia, m, ctx), x = round((w - dia) / 2), y = round(top) },
}
if caption then
table.insert(layers, { widget = text(m.name, { s = 12, b = true, w = iw, col = INK.white, align = "center" }), x = pad, y = hh - pad - lh1(12) })
@@ -450,7 +459,7 @@ end
-- ================================================================ odometer
-- The last wheel, caught mid-turn: the bottom of its digit and the top of
-- the next, both drawn into the wheel's own box so neither spills out.
local RollingWheel = Widget:extend{ digit = 0, size = 10, roll = 0, pitch = 0 }
local RollingWheel = Widget:extend{ digit = 0, size = 10, roll = 0, pitch = 0, color = nil }
function RollingWheel:getSize()
return self.dimen
@@ -467,7 +476,7 @@ function RollingWheel:paintTo(bb, x, y)
local gy = y + round(center_y - gh / 2)
local top, bottom = math.max(gy, y), math.min(gy + gh, y + h)
if bottom <= top then return end
bb:colorblitFrom(g.bb, gx, top, 0, top - gy, gw, bottom - top, INK.black)
bb:colorblitFrom(g.bb, gx, top, 0, top - gy, gw, bottom - top, self.color or INK.black)
end
draw(self.digit, h / 2 - self.roll)
if self.roll > 0 then draw((self.digit + 1) % 10, h / 2 - self.roll + self.pitch) end
@@ -482,14 +491,15 @@ function Decor.odometer(ctx, w, hh)
local digits = string.format("%06d", total)
local count = #digits
local g = px(3)
local hot = BoardColour.on(ctx)
local function drums(dw, dh)
local fs = math.floor(dh * 0.62 / BoardMetrics.get().dp)
local items = {}
for i = 1, count do
local ch = digits:sub(i, i)
local shade_col = i < count and INK.g3 or INK.gd
local shade_col = i < count and INK.g3 or (hot and BoardColour.paint(BoardColour.mix(BoardColour.fam(ctx).ink, "#ffffff", 0.3)) or INK.gd)
local layers = {
{ widget = BoardUI.box({ w = dw, h = dh, radius = px(3), bg = i < count and INK.black or INK.white }) },
{ widget = BoardUI.box({ w = dw, h = dh, radius = px(3), bg = i < count and INK.black or BoardColour.ink(ctx, INK.white) }) },
{ widget = BoardUI.rect(dw, math.max(1, px(1)), shade_col), y = round(dh * 0.16) },
{ widget = BoardUI.rect(dw, math.max(1, px(1)), shade_col), y = round(dh * 0.84) },
}
@@ -499,8 +509,10 @@ function Decor.odometer(ctx, w, hh)
local pitch = dh * 0.8
-- Nothing read today: the wheel rests on its digit.
local roll = today > 0 and round(pitch * 0.3) or 0
table.insert(layers, { widget = RollingWheel:new{ digit = tonumber(ch), size = BoardMetrics.fs(fs), roll = roll, pitch = pitch, dimen = Geom:new{ w = dw, h = dh } } })
table.insert(layers, { widget = BoardUI.box({ w = dw, h = dh, radius = px(3), border = math.max(1, px(1.5)), border_col = INK.black }) })
table.insert(layers, { widget = RollingWheel:new{ digit = tonumber(ch), size = BoardMetrics.fs(fs), roll = roll, pitch = pitch, color = hot and INK.white or INK.black, dimen = Geom:new{ w = dw, h = dh } } })
if not hot then
table.insert(layers, { widget = BoardUI.box({ w = dw, h = dh, radius = px(3), border = math.max(1, px(1.5)), border_col = INK.black }) })
end
end
table.insert(items, overlay(dw, dh, layers))
end
@@ -531,7 +543,7 @@ function Decor.odometer(ctx, w, hh)
local dw_fit = math.min(dw, round(dh / 1.32))
local right = nil
if mode == "beside" then right = todayRuns(12)
elseif mode ~= "bare" then right = icon("book-open", px(17), { sw = 2.25 }) end
elseif mode ~= "bare" then right = icon("book-open", px(17), BoardColour.icon(ctx, 2.25)) end
return padded(w, hh, {
head(iw, _("Pages turned"), right),
flex(0),
@@ -545,8 +557,10 @@ function Decor.odometer(ctx, w, hh)
end
-- ================================================================ glyph
local function glyphArt(g, max_w, max_h)
return BoardArt.glyph(g.font, g.cp, math.max(1, max_w), math.max(1, max_h))
local function glyphArt(g, max_w, max_h, col)
local art = BoardArt.glyph(g.font, g.cp, math.max(1, max_w), math.max(1, max_h))
if art then art.color = col end
return art
end
-- A line of the alphabet in the glyph's own font.
@@ -573,7 +587,7 @@ function Decor.glyph(ctx, w, hh)
for _i, s in ipairs({ 15, 14 }) do
table.insert(variants, function()
local gw_max = math.min(ih, round(iw * 0.4))
local art = glyphArt(g, gw_max, ih - px(4))
local art = glyphArt(g, gw_max, ih - px(4), BoardColour.ink(ctx, INK.black))
if not art then return skip("no glyph") end
local tw = iw - gw_max - px(14)
if tw < px(90) then return skip("no room for words") end
@@ -594,7 +608,7 @@ function Decor.glyph(ctx, w, hh)
if ih > px(150) then
table.insert(variants, function()
local foot = lh1(15) + lh1(12) + (g.sample and lh1(14) + px(8) or 0) + px(10)
local art = glyphArt(g, iw - px(20), ih - foot - px(10))
local art = glyphArt(g, iw - px(20), ih - foot - px(10), BoardColour.ink(ctx, INK.black))
if not art then return skip("no glyph") end
return padded(w, hh, {
flex(0), art, flex(0),
@@ -608,7 +622,7 @@ function Decor.glyph(ctx, w, hh)
for _i, two in ipairs({ true, false }) do
table.insert(variants, function()
local foot = lh1(12) + (two and lh1(11) or 0) + px(6)
local art = glyphArt(g, iw, ih - foot)
local art = glyphArt(g, iw, ih - foot, BoardColour.ink(ctx, INK.black))
if not art then return skip("no glyph") end
return padded(w, hh, {
flex(0), art, flex(0),
@@ -618,7 +632,7 @@ function Decor.glyph(ctx, w, hh)
end)
end
table.insert(variants, function()
local art = glyphArt(g, iw, ih) or text(name, { s = 14, b = true, w = iw, align = "center" })
local art = glyphArt(g, iw, ih, BoardColour.ink(ctx, INK.black)) or text(name, { s = 14, b = true, w = iw, align = "center" })
return padded(w, hh, { flex(0), art, flex(0) }, "center")
end)
return pick(variants)
@@ -628,7 +642,16 @@ end
-- One ring a month, the oldest at the pith; a ring is as wide as that
-- month's reading. The pith sits a little off centre and every ring wobbles
-- a little, as in real wood.
local function ringsArt(dia, months, seed)
-- Bark, rings and heartwood: wood colours on a colour screen, grays otherwise.
local function woodColours(ctx)
return {
bark = artHex(ctx, "#444444", "#6b4426"), edge = artHex(ctx, "#000000", "#3b2512"), ring = artHex(ctx, "#ffffff", "#f4e4c6"),
peak = artHex(ctx, "#dddddd", "#e2b56a"), line = artHex(ctx, "#666666", "#a37a52"), pith = artHex(ctx, "#555555", "#7a4f2c"),
crack = artHex(ctx, "#222222", "#3b2512"),
}
end
local function ringsArt(dia, months, seed, wood)
local r = rng(seed)
local bark = math.max(3, px(5))
-- Room for the wobble and the off-centre pith, so the bark never clips.
@@ -666,26 +689,26 @@ local function ringsArt(dia, months, seed)
for i, m in ipairs(months) do
if m.min > months[peak].min then peak = i end
end
local body = { '<path d="' .. loop(R, bark) .. '" fill="#444444" stroke="#000000" stroke-width="' .. n(math.max(1, px(1.25))) .. '"/>' }
local body = { '<path d="' .. loop(R, bark) .. '" fill="' .. wood.bark .. '" stroke="' .. wood.edge .. '" stroke-width="' .. n(math.max(1, px(1.25))) .. '"/>' }
for i = #months, 1, -1 do
table.insert(body, '<path d="' .. loop(bounds[i]) .. '" fill="' .. (i == peak and "#dddddd" or "#ffffff") .. '" stroke="#666666" stroke-width="'
table.insert(body, '<path d="' .. loop(bounds[i]) .. '" fill="' .. (i == peak and wood.peak or wood.ring) .. '" stroke="' .. wood.line .. '" stroke-width="'
.. n(math.max(1, px(i == #months and 1.25 or 1))) .. '"/>')
end
table.insert(body, '<path d="' .. loop(pith_r * 0.9) .. '" fill="#555555"/>')
table.insert(body, '<path d="' .. loop(pith_r * 0.9) .. '" fill="' .. wood.pith .. '"/>')
-- A drying check: a thin crack from the bark towards the heart.
local ca = -0.62
local x1, y1 = cx + (R + bark) * math.cos(ca), cy + (R + bark) * math.sin(ca)
local x2, y2 = cx + R * 0.45 * math.cos(ca + 0.05), cy + R * 0.45 * math.sin(ca + 0.05)
local nx, ny = -math.sin(ca) * px(1.6), math.cos(ca) * px(1.6)
table.insert(body, '<path d="M' .. n(x1 - nx) .. ' ' .. n(y1 - ny) .. ' L' .. n(x2) .. ' ' .. n(y2) .. ' L' .. n(x1 + nx) .. ' ' .. n(y1 + ny) .. ' Z" fill="#222222"/>')
table.insert(body, '<path d="M' .. n(x1 - nx) .. ' ' .. n(y1 - ny) .. ' L' .. n(x2) .. ' ' .. n(y2) .. ' L' .. n(x1 + nx) .. ' ' .. n(y1 + ny) .. ' Z" fill="' .. wood.crack .. '"/>')
return svg(round(dia), round(dia), table.concat(body))
end
local function sapling(dia)
local function sapling(dia, wood)
local h = dia / 2
return svg(dia, dia, '<circle cx="' .. n(h) .. '" cy="' .. n(h) .. '" r="' .. n(h - 1) .. '" fill="#444444" stroke="#000000" stroke-width="' .. n(math.max(1, px(1)))
.. '"/><circle cx="' .. n(h) .. '" cy="' .. n(h) .. '" r="' .. n(h - math.max(3, px(4))) .. '" fill="#ffffff" stroke="#666666" stroke-width="' .. n(math.max(1, px(1)))
.. '"/><circle cx="' .. n(h) .. '" cy="' .. n(h) .. '" r="' .. n(math.max(2, dia * 0.08)) .. '" fill="#555555"/>')
return svg(dia, dia, '<circle cx="' .. n(h) .. '" cy="' .. n(h) .. '" r="' .. n(h - 1) .. '" fill="' .. wood.bark .. '" stroke="' .. wood.edge .. '" stroke-width="' .. n(math.max(1, px(1)))
.. '"/><circle cx="' .. n(h) .. '" cy="' .. n(h) .. '" r="' .. n(h - math.max(3, px(4))) .. '" fill="' .. wood.ring .. '" stroke="' .. wood.line .. '" stroke-width="' .. n(math.max(1, px(1)))
.. '"/><circle cx="' .. n(h) .. '" cy="' .. n(h) .. '" r="' .. n(math.max(2, dia * 0.08)) .. '" fill="' .. wood.pith .. '"/>')
end
function Decor.rings(ctx, w, hh)
@@ -708,7 +731,7 @@ function Decor.rings(ctx, w, hh)
space(nil, px(6)),
text(_("A ring grows for every month you read."), { s = 13, col = INK.g4, lines = 3, w = iw }),
with_art and flex(0) or false,
with_art and hstack({ w = iw }, { flex(0), sapling(dia), flex(0) }) or false,
with_art and hstack({ w = iw }, { flex(0), sapling(dia, woodColours(ctx)), flex(0) }) or false,
with_art and flex(0) or false,
})
end)
@@ -737,7 +760,7 @@ function Decor.rings(ctx, w, hh)
if tw < px(100) then return skip("no room for words") end
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, {
ringsArt(dia, months, 3),
ringsArt(dia, months, 3, woodColours(ctx)),
vstack({ w = tw }, {
text(_("Tree rings"), { s = v[1], b = true, w = tw }),
v[2] and text(_("One ring a month"), { s = 12, col = INK.g4, w = tw }) or false,
@@ -759,7 +782,7 @@ function Decor.rings(ctx, w, hh)
return padded(w, hh, {
head(iw, _("Tree rings"), text(_("12 months"), { s = 12, col = INK.g4 })),
flex(0),
ringsArt(dia, months, 3),
ringsArt(dia, months, 3, woodColours(ctx)),
flex(0),
caption and labelRuns(_("Widest ring"), peak, true, 12, iw, "center") or false,
}, "center")
@@ -800,16 +823,16 @@ local function lineArt(w, cs, o)
local track_y = px(10)
local art_h = px(20)
local body = {
'<line x1="' .. n(train_x) .. '" y1="' .. track_y .. '" x2="' .. n(x1) .. '" y2="' .. track_y .. '" stroke="#bbbbbb" stroke-width="' .. lw .. '" stroke-linecap="round"/>',
'<line x1="' .. n(x0) .. '" y1="' .. track_y .. '" x2="' .. n(train_x) .. '" y2="' .. track_y .. '" stroke="#000000" stroke-width="' .. lw .. '" stroke-linecap="round"/>',
'<line x1="' .. n(train_x) .. '" y1="' .. track_y .. '" x2="' .. n(x1) .. '" y2="' .. track_y .. '" stroke="' .. (o.ahead or "#bbbbbb") .. '" stroke-width="' .. lw .. '" stroke-linecap="round"/>',
'<line x1="' .. n(x0) .. '" y1="' .. track_y .. '" x2="' .. n(train_x) .. '" y2="' .. track_y .. '" stroke="' .. (o.line or "#000000") .. '" stroke-width="' .. lw .. '" stroke-linecap="round"/>',
}
for i, stop in ipairs(cs.stops) do
local past = stop.pct <= cs.pos
local last = i == count
local rr = math.min(last and r * 1.25 or r, (x1 - x0) / (count - 1) * 0.36)
table.insert(body, '<circle cx="' .. n(xi(i)) .. '" cy="' .. track_y .. '" r="' .. n(rr) .. '" fill="' .. (past and "#000000" or "#ffffff")
.. '" stroke="#000000" stroke-width="' .. n(math.max(1, px(1.5))) .. '"/>')
if last then table.insert(body, '<circle cx="' .. n(xi(i)) .. '" cy="' .. track_y .. '" r="' .. n(rr * 0.42) .. '" fill="#000000"/>') end
table.insert(body, '<circle cx="' .. n(xi(i)) .. '" cy="' .. track_y .. '" r="' .. n(rr) .. '" fill="' .. (past and (o.line or "#000000") or "#ffffff")
.. '" stroke="' .. (o.line or "#000000") .. '" stroke-width="' .. n(math.max(1, px(1.5))) .. '"/>')
if last then table.insert(body, '<circle cx="' .. n(xi(i)) .. '" cy="' .. track_y .. '" r="' .. n(rr * 0.42) .. '" fill="' .. (o.line or "#000000") .. '"/>') end
end
table.insert(body, tram(train_x, track_y, 1))
local layers = { { widget = svg(w, art_h, table.concat(body)) } }
@@ -843,7 +866,7 @@ end
-- The next stations as a timetable down a vertical line, with how long
-- until each. The stretch the train is on is taller, so the car stands
-- clear of both its stations. Nil when fewer than three rows fit.
local function timetable(w, avail, cs)
local function timetable(w, avail, cs, line_col, ahead_col)
local row_h = lh1(14) + px(6)
local stretch = 0.7
local rows = math.floor(avail / row_h - stretch)
@@ -864,22 +887,22 @@ local function timetable(w, avail, cs)
local clear = r + t_h / 2 + px(1)
local seg = cy(1) - cy(0)
local ty = clamp(cy(0) + seg * trainFraction(cs), cy(0) + clear, cy(1) - clear)
local body = { '<line x1="' .. lx .. '" y1="' .. n(cy(0)) .. '" x2="' .. lx .. '" y2="' .. n(ty) .. '" stroke="#000000" stroke-width="' .. lw .. '"/>' }
local body = { '<line x1="' .. lx .. '" y1="' .. n(cy(0)) .. '" x2="' .. lx .. '" y2="' .. n(ty) .. '" stroke="' .. line_col .. '" stroke-width="' .. lw .. '"/>' }
local last_y = cy(#list - 1)
if show_end then
local gap_y = cy(#list - 2)
table.insert(body, '<line x1="' .. lx .. '" y1="' .. n(ty) .. '" x2="' .. lx .. '" y2="' .. n(gap_y) .. '" stroke="#bbbbbb" stroke-width="' .. lw .. '"/>')
table.insert(body, '<line x1="' .. lx .. '" y1="' .. n(gap_y) .. '" x2="' .. lx .. '" y2="' .. n(last_y) .. '" stroke="#bbbbbb" stroke-width="' .. lw
table.insert(body, '<line x1="' .. lx .. '" y1="' .. n(ty) .. '" x2="' .. lx .. '" y2="' .. n(gap_y) .. '" stroke="' .. ahead_col .. '" stroke-width="' .. lw .. '"/>')
table.insert(body, '<line x1="' .. lx .. '" y1="' .. n(gap_y) .. '" x2="' .. lx .. '" y2="' .. n(last_y) .. '" stroke="' .. ahead_col .. '" stroke-width="' .. lw
.. '" stroke-dasharray="' .. px(2) .. ' ' .. px(4) .. '"/>')
else
table.insert(body, '<line x1="' .. lx .. '" y1="' .. n(ty) .. '" x2="' .. lx .. '" y2="' .. n(last_y) .. '" stroke="#bbbbbb" stroke-width="' .. lw .. '"/>')
table.insert(body, '<line x1="' .. lx .. '" y1="' .. n(ty) .. '" x2="' .. lx .. '" y2="' .. n(last_y) .. '" stroke="' .. ahead_col .. '" stroke-width="' .. lw .. '"/>')
end
for k, i in ipairs(list) do
local last = i == last_i
local rr = last and r * 1.2 or r
table.insert(body, '<circle cx="' .. lx .. '" cy="' .. n(cy(k - 1)) .. '" r="' .. n(rr) .. '" fill="' .. (k == 1 and "#000000" or "#ffffff")
.. '" stroke="#000000" stroke-width="' .. n(math.max(1, px(1.5))) .. '"/>')
if last then table.insert(body, '<circle cx="' .. lx .. '" cy="' .. n(cy(k - 1)) .. '" r="' .. n(rr * 0.42) .. '" fill="#000000"/>') end
table.insert(body, '<circle cx="' .. lx .. '" cy="' .. n(cy(k - 1)) .. '" r="' .. n(rr) .. '" fill="' .. (k == 1 and line_col or "#ffffff")
.. '" stroke="' .. line_col .. '" stroke-width="' .. n(math.max(1, px(1.5))) .. '"/>')
if last then table.insert(body, '<circle cx="' .. lx .. '" cy="' .. n(cy(k - 1)) .. '" r="' .. n(rr * 0.42) .. '" fill="' .. line_col .. '"/>') end
end
local car_w, win = px(11), px(3.2)
table.insert(body, '<rect x="' .. n(lx - car_w / 2) .. '" y="' .. n(ty - t_h / 2) .. '" width="' .. n(car_w) .. '" height="' .. n(t_h) .. '" rx="' .. n(px(3))
@@ -902,10 +925,11 @@ local function timetable(w, avail, cs)
end
function Decor.chapters(ctx, w, hh)
local line, ahead = BoardColour.artHex(ctx, "#000000", "ink"), BoardColour.artHex(ctx, "#bbbbbb", "tint")
local cs = ctx.d.chapters
local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad
local function train() return icon("train-front", px(17), { sw = 2.25 }) end
local function train() return icon("train-front", px(17), BoardColour.icon(ctx, 2.25)) end
if not cs then
return emptyTile(w, hh, _("Next stop"), train, _("Open a book and its chapters line up here."), _("Open a book to start."))
end
@@ -920,7 +944,7 @@ function Decor.chapters(ctx, w, hh)
if ih > px(150) and iw < ih * 1.9 then
table.insert(variants, function()
local foot = lh1(12) + px(10)
local tt = timetable(iw, ih - lh1(13) - px(12) - foot, cs)
local tt = timetable(iw, ih - lh1(13) - px(12) - foot, cs, line, ahead)
if not tt then return skip("timetable needs three rows") end
return padded(w, hh, {
head(iw, cs.book.title or "", text(BoardData.pctLabel(cs.book) or "", { s = 12, col = INK.g4 })),
@@ -935,7 +959,7 @@ function Decor.chapters(ctx, w, hh)
for _i, v in ipairs({ { 16, true, true }, { 16, false, true }, { 15, false, false }, { 14, false, false } }) do
table.insert(variants, function()
if iw < px(220) then return skip("too narrow for the line") end
local art = lineArt(iw, cs, { labels = v[2], max = iw > px(500) and 6 or 3 })
local art = lineArt(iw, cs, { labels = v[2], max = iw > px(500) and 6 or 3, line = line, ahead = ahead })
local foot = false
if v[3] then
local left = endRuns(12)
@@ -961,7 +985,7 @@ function Decor.chapters(ctx, w, hh)
text(when, { s = 20, b = true, w = iw }),
with_name and text(cs.stops[cs.next].title, { s = 12, b = true, serif = true, w = iw, data = true }) or false,
flex(0),
lineArt(iw, cs, {}),
lineArt(iw, cs, { line = line, ahead = ahead }),
})
end)
end
@@ -981,7 +1005,8 @@ local RARITY = {
-- The medal: rarity shows in the rim (plain, double, beaded, sunburst). A
-- badge in progress wears a progress ring; an earned one hangs on ribbons.
local function medal(D0, b, earned)
-- rf: the rarity's hex shades on a colour screen, nil for grays.
local function medal(D0, b, earned, rf)
local cx = D0 / 2
local ring_w = earned and 0 or math.max(3, px(5))
local gap = earned and 0 or px(4)
@@ -995,7 +1020,7 @@ local function medal(D0, b, earned)
local y0, y1 = cy + R * 0.3, cy + R + ribbon_h
local wr = R * 0.34
return '<path d="M' .. n(x0 - wr / 2) .. ' ' .. n(y0) .. ' L' .. n(x0 + wr / 2) .. ' ' .. n(y0) .. ' L' .. n(x1 + wr / 2) .. ' ' .. n(y1)
.. ' L' .. n(x1) .. ' ' .. n(y1 - wr * 0.45) .. ' L' .. n(x1 - wr / 2) .. ' ' .. n(y1) .. ' Z" fill="#555555" stroke="#000000" stroke-width="'
.. ' L' .. n(x1) .. ' ' .. n(y1 - wr * 0.45) .. ' L' .. n(x1 - wr / 2) .. ' ' .. n(y1) .. ' Z" fill="' .. (rf and rf.ribbon or "#555555") .. '" stroke="#000000" stroke-width="'
.. n(math.max(1, px(1.25))) .. '" stroke-linejoin="round"/>'
end
table.insert(body, tail(-1))
@@ -1003,14 +1028,14 @@ local function medal(D0, b, earned)
else
local pr = R + gap + ring_w / 2
local p = (b.threshold and b.threshold > 0 and b.progress) and clamp(b.progress / b.threshold, 0, 0.9999) or 0
table.insert(body, '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(pr) .. '" fill="none" stroke="#dddddd" stroke-width="' .. ring_w .. '"/>')
table.insert(body, '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(pr) .. '" fill="none" stroke="' .. (rf and rf.tint or "#dddddd") .. '" stroke-width="' .. ring_w .. '"/>')
if p > 0.005 then
local a = p * 2 * math.pi
table.insert(body, '<path d="M' .. n(cx) .. ' ' .. n(cy - pr) .. ' A' .. n(pr) .. ' ' .. n(pr) .. ' 0 ' .. (p > 0.5 and 1 or 0) .. ' 1 '
.. n(cx + pr * math.sin(a)) .. ' ' .. n(cy - pr * math.cos(a)) .. '" fill="none" stroke="#000000" stroke-width="' .. ring_w .. '" stroke-linecap="round"/>')
.. n(cx + pr * math.sin(a)) .. ' ' .. n(cy - pr * math.cos(a)) .. '" fill="none" stroke="' .. (rf and rf.ink or "#000000") .. '" stroke-width="' .. ring_w .. '" stroke-linecap="round"/>')
end
end
local face, ink = earned and "#000000" or "#ffffff", earned and "#ffffff" or "#000000"
local face, ink = earned and (rf and rf.deep or "#000000") or "#ffffff", earned and "#ffffff" or "#000000"
local sw = math.max(1, px(1.5))
if b.rarity == "legendary" then
local pts = {}
@@ -1018,7 +1043,7 @@ local function medal(D0, b, earned)
local a, rr = i * math.pi / 16, (i % 2 == 1) and R * 0.9 or R * 1.06
table.insert(pts, (i == 0 and "M" or "L") .. n(cx + rr * math.sin(a)) .. " " .. n(cy - rr * math.cos(a)))
end
table.insert(body, '<path d="' .. table.concat(pts) .. 'Z" fill="#000000"/>')
table.insert(body, '<path d="' .. table.concat(pts) .. 'Z" fill="' .. (rf and rf.ink or "#000000") .. '"/>')
table.insert(body, '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(R * 0.84) .. '" fill="' .. face .. '" stroke="' .. (earned and "#ffffff" or "#000000")
.. '" stroke-width="' .. n(sw) .. '"/>')
else
@@ -1043,9 +1068,15 @@ end
function Decor.badge(ctx, w, hh)
local data = ctx.d.badges or { missing = true }
-- A badge's rarity colours, the same in every scheme.
local function rarity(r)
if not BoardColour.on(ctx) then return nil end
local f = BoardColour.rarity(r)
return { ink = BoardColour.hex(f.ink), deep = BoardColour.hex(f.deep), tint = BoardColour.hex(f.tint), ribbon = BoardColour.hex(BoardColour.rarity("ribbon").ink) }
end
local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad
local function award() return icon("award", px(17), { sw = 2.25 }) end
local function award() return icon("award", px(17), BoardColour.icon(ctx, 2.25)) end
if data.missing then
return emptyTile(w, hh, _("Next badge"), award, _("Badges show up here with a newer BookOrbit server."), _("Needs a newer server."))
end
@@ -1080,7 +1111,7 @@ function Decor.badge(ctx, w, hh)
head(iw, label, text(T(_("%1 of %2"), data.earned, data.total), { s = 12, col = INK.g4 })),
space(nil, px(12)),
hstack({ w = iw, h = room, gap = px(14) }, {
medal(dia, b, false),
medal(dia, b, false, rarity(b.rarity)),
vstack({ w = tw }, {
v[2] and text((RARITY[b.rarity] or RARITY.common)(), { s = 12, col = INK.g4, w = tw }) or false,
text(b.name, { s = 16, b = true, w = tw, lines = 2, data = true }),
@@ -1097,7 +1128,7 @@ function Decor.badge(ctx, w, hh)
table.insert(items, BoardUI.rect(iw, math.max(1, px(0.75)), INK.gc))
table.insert(items, space(nil, px(10)))
table.insert(items, hstack({ w = iw, h = px(36), gap = px(10) }, {
medal(px(36), latest, true),
medal(px(36), latest, true, rarity(latest.rarity)),
vstack({ w = iw - px(46) }, {
text(latest.name, { s = 13, b = true, w = iw - px(46), data = true }),
text(latest.when or "", { s = 12, col = INK.g4, w = iw - px(46) }),
@@ -1122,7 +1153,7 @@ function Decor.badge(ctx, w, hh)
local name_s = BoardUI.fitSize(b.name, { s = v[1], b = true }, tw, 12)
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, {
medal(dia, b, earned),
medal(dia, b, earned, rarity(b.rarity)),
vstack({ w = tw }, {
v[4] and text(label, { s = 12, col = INK.g4, w = tw }) or false,
text(b.name, { s = name_s, b = true, w = tw, data = true }),
@@ -1145,7 +1176,7 @@ function Decor.badge(ctx, w, hh)
local name_s = BoardUI.fitSize(b.name, { s = 12, b = true }, iw, 10)
return padded(w, hh, {
flex(0),
medal(dia, b, earned),
medal(dia, b, earned, rarity(b.rarity)),
with_name and space(nil, px(6)) or false,
with_name and text(b.name, { s = name_s, b = true, w = iw, align = "center", data = true }) or false,
with_name and hstack({ w = iw }, { flex(0), progressRuns(12), flex(0) }) or false,
@@ -1214,12 +1245,12 @@ local function dustArt(cw, ch, seed)
return svg(round(cw), round(ch), table.concat(body))
end
local function dustyCover(book, cw, ch)
local function dustyCover(book, cw, ch, edge)
local cov = BoardUI.cover(book, cw, ch, {
path = book.cover_path,
loading = book.cover_state == "loading",
border = math.max(1, px(1)),
border_col = INK.gb,
border_col = edge or INK.gb,
-- A film of dust: the cover a shade paler.
lighten = 0.22,
})
@@ -1277,7 +1308,7 @@ function Decor.dust(ctx, w, hh)
head(iw, _("Gathering dust"), text(T(_.ngettext("%1 book", "%1 books", dd.count), dd.count), { s = 12, col = INK.g4 })),
space(nil, px(10)),
hstack({ w = iw, gap = px(14), valign = "top" }, {
dustyCover(book, cw, ch),
dustyCover(book, cw, ch, BoardColour.coverEdge(ctx, INK.gb)),
vstack({ w = tw }, {
text(book.title or "", { s = 16, b = true, serif = true, lines = 3, w = tw, data = true }),
text(book.authors and book.authors[1] or "", { s = 13, col = INK.g4, w = tw, data = true }),
@@ -1304,7 +1335,7 @@ function Decor.dust(ctx, w, hh)
if tw < px(150) then return skip("no room for words") end
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(12) }, {
dustyCover(book, cw, ch),
dustyCover(book, cw, ch, BoardColour.coverEdge(ctx, INK.gb)),
vstack({ w = tw, h = ih }, {
v[4] and text(_("Gathering dust"), { s = 12, col = INK.g4, w = tw }) or false,
text(book.title or "", { s = 14, b = true, serif = true, lines = v[1], w = tw, data = true }),
@@ -1325,7 +1356,7 @@ function Decor.dust(ctx, w, hh)
if ch < px(50) and with_caption then return skip("cover too small") end
ch, cw = math.max(1, ch), math.max(1, cw)
return padded(w, hh, {
dustyCover(book, cw, ch),
dustyCover(book, cw, ch, BoardColour.coverEdge(ctx, INK.gb)),
with_caption and space(nil, px(6)) or false,
with_caption and text(caption, { s = 12, b = true, w = iw, align = "center" }) or false,
}, "center")
@@ -456,6 +456,12 @@ function BoardEdit:boardSetTiles(tiles)
if self:dashboardMode() then self:updateItems(nil, true) end
end
function BoardEdit:boardSetColour(colour)
self:persistSetting(BoardStore.SETTING_KEY, BoardStore.withColour(self.settings, colour))
require("bookorbit_board_ui").clearSnapshots()
if self:dashboardMode() then self:updateItems(nil, true) end
end
function BoardEdit.install(Catalog)
for name, fn in pairs(BoardEdit) do
if name ~= "install" and type(fn) == "function" and name:match("^board") then
@@ -15,18 +15,21 @@ layout; until then the starter pages are computed fresh, so new widgets and
device differences (a frontlight or not) apply without a migration.
Stored under catalog_board, keyed by column count plus "l" in landscape:
{ version = 1, preset = "reader", tiles = "outline", started = true,
{ version = 1, preset = "reader", tiles = "outline", colour = "accents", started = true,
custom = { ["4"] = { pages = { { items = {...} }, ... } }, ["4l"] = ... } }
]]
local _ = require("gettext")
local BoardColour = require("bookorbit_board_colour")
local BoardStore = {}
BoardStore.SETTING_KEY = "catalog_board"
BoardStore.VERSION = 1
BoardStore.PRESETS = { "reader", "stats", "library" }
BoardStore.TILE_STYLES = { "outline", "soft", "ink" }
BoardStore.COLOURS = BoardColour.SCHEMES
BoardStore.PAGE_COUNT = 4
BoardStore.PAGE_IDS = { "home", "insights", "library", "decor" }
@@ -163,12 +166,13 @@ end
local function readState(settings)
local stored = settings and settings[BoardStore.SETTING_KEY]
if type(stored) ~= "table" or stored.version ~= BoardStore.VERSION then
return { version = BoardStore.VERSION, preset = "reader", tiles = "outline", started = false, custom = {} }
return { version = BoardStore.VERSION, preset = "reader", tiles = "outline", colour = BoardColour.DEFAULT, started = false, custom = {} }
end
local state = {
version = BoardStore.VERSION,
preset = stored.preset,
tiles = stored.tiles,
colour = BoardColour.normalize(stored.colour),
started = stored.started == true,
custom = type(stored.custom) == "table" and stored.custom or {},
}
@@ -253,4 +257,10 @@ function BoardStore.withTiles(settings, tiles)
return state
end
function BoardStore.withColour(settings, colour)
local state = readState(settings)
state.colour = BoardColour.normalize(colour)
return state
end
return BoardStore
@@ -42,6 +42,35 @@ 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 = {}
@@ -128,6 +157,41 @@ local function faceKind(o)
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)
@@ -160,15 +224,17 @@ function BoardUI.text(str, o)
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 = o.col or INK.black,
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
@@ -433,10 +499,31 @@ function BoardUI.spread(w, left, right, h)
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,
@@ -683,7 +770,7 @@ function BoardCover:paintTo(bb, x, y)
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 bb:paintBorder(px_, py_, img.w, img.h, self.border, self.border_col or INK.gb) 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
@@ -708,7 +795,7 @@ function BoardCover:paintTo(bb, x, y)
bb:invertRect(dx, dy, dw, dh)
end
if self.border > 0 then
bb:paintBorder(dx, dy, dw, dh, self.border, self.border_col or INK.gb)
paintBorder(bb, dx, dy, dw, dh, self.border, self.border_col or INK.gb)
end
end
@@ -769,7 +856,7 @@ function BoardUI.cover(book, w, h, o)
-- 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 }), BoardUI.flex(0) }
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))
@@ -798,10 +885,12 @@ function CellGrid:paintTo(bb, x, y)
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 then
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
bb:paintRoundedRect(x + cell.x, y + cell.y, s, s, cell.col, r)
paintRoundedRect(bb, x + cell.x, y + cell.y, s, s, cell.col, r)
end
end
end
@@ -12,6 +12,7 @@ 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")
@@ -85,7 +86,7 @@ local function cover(ctx, book, w, h, o)
path = book and book.cover_path,
loading = book and book.cover_state == "loading",
border = o.border or thin(),
border_col = o.border_col or INK.gb,
border_col = o.border_col or BoardColour.coverEdge(ctx, INK.gb),
dim = o.dim,
})
end
@@ -132,7 +133,7 @@ W.hero = function(ctx, w, hh)
return hstack({ w = w, h = hh }, {
space(pad, 1),
vstack({ w = tw }, {
text(_("Start reading"), { s = 11, b = true, col = INK.gb, 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 }),
}),
@@ -159,7 +160,7 @@ W.hero = function(ctx, w, hh)
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 = INK.gb, w = iw }),
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)),
@@ -195,9 +196,9 @@ W.hero = function(ctx, w, hh)
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 = INK.gc })
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 = INK.gc })
table.insert(m, { t = " · " .. T(_("%1 left"), BoardData.formatShortDuration(book.minutes_left)), col = BoardColour.onInk(ctx, INK.gc, "hero") })
end
return m
end
@@ -216,11 +217,11 @@ W.hero = function(ctx, w, hh)
space(px(14), 1),
vstack({ w = tw2, h = ch }, {
flex(0),
lab and text(lab, { s = 11, b = true, col = INK.gb, w = tw2 }) or false,
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 = INK.g5, fill = INK.white, r = px(2.5) }), m }),
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),
@@ -252,13 +253,13 @@ W.hero = function(ctx, w, hh)
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 = INK.gb }), icon("chevron-right", px(15), { col = "gb", sw = 2.5 }) })
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 = INK.gc })
table.insert(m, { t = " · " .. T(_("%1 left"), dur), col = BoardColour.onInk(ctx, INK.gc, "hero") })
end
return m
end
@@ -276,7 +277,7 @@ W.hero = function(ctx, w, hh)
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 = INK.gb })
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,
@@ -288,9 +289,9 @@ W.hero = function(ctx, w, hh)
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 = INK.gd, w = tw, data = true }),
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 = INK.g5, fill = INK.white, r = px(3) }),
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),
@@ -319,17 +320,17 @@ W.hero = function(ctx, w, hh)
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 = INK.gb, w = tw }),
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 = INK.gd, w = tw, data = true }) 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 = INK.g5, fill = INK.white, r = px(3) }),
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 } }),
}),
@@ -345,7 +346,7 @@ W.hero = function(ctx, w, hh)
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 = INK.gb })
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)),
@@ -354,11 +355,11 @@ W.hero = function(ctx, w, hh)
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 = INK.gd, w = tw, data = true }) 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 = INK.g5, fill = INK.white, r = px(3) }),
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)),
@@ -374,7 +375,7 @@ W.hero = function(ctx, w, hh)
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 = INK.gb, w = iw }),
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)),
@@ -382,7 +383,7 @@ W.hero = function(ctx, w, hh)
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 = INK.g5, fill = INK.white, r = px(3) }),
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 } }),
}),
@@ -424,9 +425,9 @@ W.streak = function(ctx, w, hh)
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)
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), { sw = 2.25 })),
head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
count(iw),
flex(0),
@@ -436,14 +437,14 @@ W.streak = function(ctx, w, hh)
-- A short row: the week beside the count.
function()
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Streak"), icon("flame", px(17), { sw = 2.25 })),
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)),
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), { sw = 2.25 })),
head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
count(iw),
})
@@ -464,7 +465,7 @@ W.week = function(ctx, w, hh)
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 INK.black or INK.g8) or INK.gc
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 }, {
@@ -530,7 +531,7 @@ W.today = function(ctx, w, hh)
return pick({
function()
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Today"), icon("clock", px(17), { sw = 2.25 })),
head(iw, _("Today"), icon("clock", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
value(),
flex(0),
@@ -539,7 +540,7 @@ W.today = function(ctx, w, hh)
end,
function()
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Today"), icon("clock", px(17), { sw = 2.25 })),
head(iw, _("Today"), icon("clock", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
value(),
})
@@ -559,7 +560,7 @@ W.goalpace = function(ctx, w, hh)
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), { sw = 2.25 })),
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)),
})
@@ -583,7 +584,7 @@ W.goalpace = function(ctx, w, hh)
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), { sw = 2.25 })
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)
@@ -592,7 +593,7 @@ W.goalpace = function(ctx, w, hh)
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 = INK.gd, r = px(3) }), x = 0, y = px(4) },
{ 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
@@ -669,13 +670,14 @@ W.heatmap = function(ctx, w, hh)
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 INK.ge end
if v < 30 then return INK.gb end
if v < 60 then return INK.g8 end
if v < 120 then return INK.g4 end
return INK.black
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))
@@ -816,7 +818,7 @@ W.calendar = function(ctx, w, hh)
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 INK.black or INK.g6 })
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
@@ -878,11 +880,11 @@ W.also = function(ctx, w, hh)
})
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(), border_col = INK.gb, dim = offlineDim(ctx, b) }),
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 = INK.gc, r = px(2) }), right }),
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
@@ -907,7 +909,7 @@ W.highlight = function(ctx, w, hh)
return pick({
function()
return vstack({ w = w, h = hh, pad = pad }, {
icon("quote", px(20), { fill = true, sw = 1 }),
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),
})
@@ -925,7 +927,7 @@ W.highlight = function(ctx, w, hh)
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 }), text(_("Highlight of the day"), { s = 12, b = true, col = INK.g4 }), flex(0) })
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()
@@ -1005,7 +1007,7 @@ W.shelf = function(ctx, w, hh, props, item)
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 = 2.25, col = d.online and "black" or "ga" }) }),
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 }) })
@@ -1035,7 +1037,7 @@ W.shelf = function(ctx, w, hh, props, item)
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(), border_col = INK.gb, dim = offlineDim(ctx, b) }),
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 } }))
@@ -1080,8 +1082,8 @@ local function fan(ctx, books, cover_h)
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, INK.white), x = x - ring, y = -ring })
table.insert(layers, { widget = cover(ctx, list[i], cw, cover_h, { border = thin(), border_col = INK.g9 }), x = x, y = 0 })
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
@@ -1117,7 +1119,7 @@ W.link = function(ctx, w, hh, props)
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), { sw = 2.25 }) or false,
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,
})
@@ -1141,7 +1143,7 @@ W.link = function(ctx, w, hh, props)
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 = INK.gd, r = px(3) }), label2 }))
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,
@@ -1253,7 +1255,7 @@ W.clock = function(ctx, w, hh)
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 = INK.gc }),
text(d.day_month, { s = 13, w = iw, col = BoardColour.onInk(ctx, INK.gc) }),
})
end,
-- A short row: the time over the date.
@@ -1281,11 +1283,11 @@ W.sync = function(ctx, w, hh)
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), { sw = 2.25 })),
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), { sw = 2.5 }), text(on and _("Sync now") or _("Connect"), { s = 12, b = true }) }),
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
@@ -1293,7 +1295,7 @@ W.sync = function(ctx, w, hh)
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), { sw = 2.25 })),
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 }),
})
@@ -1327,7 +1329,7 @@ W.hours = function(ctx, w, hh)
local iw = w - 2 * pad
local hours = d.hours or {}
local part = dayPart(hours)
local function sunMoon() return icon("sun-moon", px(17), { sw = 2.25 }) end
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()),
@@ -1351,7 +1353,7 @@ W.hours = function(ctx, w, hh)
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 INK.black or INK.g8))
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
@@ -1419,7 +1421,7 @@ W.records = function(ctx, w, hh)
local r = d.records
local pad = px(12)
local iw = w - 2 * pad
local function award() return icon("award", px(17), { sw = 2.25 }) end
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()),
@@ -1503,7 +1505,7 @@ W.toggles = function(ctx, w, hh)
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 INK.black or INK.white, border = on and 0 or math.max(1, px(1.5)) },
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
@@ -1541,7 +1543,7 @@ W.light = function(ctx, w, hh)
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), { sw = 2.25 })),
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 } }),
})
@@ -1549,7 +1551,7 @@ W.light = function(ctx, w, hh)
local function compact(bd, s)
return function()
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Light"), icon("sun", px(16), { sw = 2.25 })),
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 }),
@@ -1565,13 +1567,13 @@ W.light = function(ctx, w, hh)
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)
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), { sw = 2.25 }),
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 = INK.gd, r = px(4) }),
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" }),
})
@@ -1580,9 +1582,9 @@ W.light = function(ctx, w, hh)
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"),
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") or false,
rows_n == 2 and stepper("thermometer-sun", lt.warmth, "warmth", "orange") or false,
})
end
end
@@ -1602,7 +1604,7 @@ W.forecast = function(ctx, w, hh)
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), { sw = 2.25 })),
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 }),
})
@@ -1619,7 +1621,7 @@ W.forecast = function(ctx, w, hh)
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 INK.black or (is_today and INK.g6 or INK.gc), px(4)),
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" }),
}))
@@ -1665,7 +1667,7 @@ W.forecast = function(ctx, w, hh)
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), { sw = 2.25 })),
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),
@@ -1678,7 +1680,7 @@ W.forecast = function(ctx, w, hh)
})
end
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, cfg.label, icon("hourglass", px(16), { sw = 2.25 })),
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,
@@ -1712,7 +1714,7 @@ 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), { sw = 2.25 })
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
@@ -1733,7 +1735,7 @@ W.word = function(ctx, w, hh)
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 }),
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)))
@@ -1768,7 +1770,7 @@ 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), { sw = 2.25 })
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
@@ -1862,7 +1864,8 @@ end
-- the outline never costs the content any room.
function BoardWidgets.tile(ctx, item, w, h)
local fn = W[item.type]
local ch = BoardWidgets.chrome(ctx.tiles, item.chrome)
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
@@ -24,6 +24,7 @@ local logger = require("logger")
local T = require("ffi/util").template
local _ = require("gettext")
local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data")
local BoardEdit = require("bookorbit_board_edit")
local BoardLayout = require("bookorbit_board_layout")
@@ -367,7 +368,7 @@ function CatalogBoard:boardPreview(ctx, entry, box_w, box_h, g)
local item = { type = entry.type, cw = cw, rh = rh, chrome = entry.chrome or "line", props = entry.props }
local key = "preview:" .. entry.type .. ":" .. tostring(entry.props and (entry.props.source or entry.props.link or entry.props.animal) or "") .. ":" .. pw .. "x" .. ph .. ":" .. sw
local shot = BoardUI.snapshot(key, pw, ph, sw, sh, function()
return BoardWidgets.tile({ d = ctx.d, tiles = "outline", menu = self }, item, pw, ph)
return BoardWidgets.tile({ d = ctx.d, tiles = "outline", colour = ctx.colour, menu = self }, item, pw, ph)
end)
return BoardUI.box({ w = box_w, h = box_h, radius = px(12), bg = INK.ge }, hstack({ w = box_w, h = box_h }, { flex(0), shot, flex(0) }))
end
@@ -734,7 +735,7 @@ function CatalogBoard:updateDashboardItems(select_number, no_recalculate_dimen)
local page = self:boardPage()
local d = BoardData.build(self, context, os.time())
local state = BoardStore.read(self.settings)
local ctx = { d = d, tiles = state.tiles, menu = self, tap_resumes = self.settings.catalog_dashboard_tap_resumes ~= false }
local ctx = { d = d, tiles = state.tiles, colour = state.colour, menu = self, tap_resumes = self.settings.catalog_dashboard_tap_resumes ~= false }
self._board_ctx = ctx
self._board_geometry = g
local t_data = PROFILE and os.clock()
@@ -782,11 +783,18 @@ function CatalogBoard:updateDashboardItems(select_number, no_recalculate_dimen)
self:boardRefreshPets()
end
-- The pets are colour pictures. A colour screen shows them in full colour only
-- through a dithered refresh (on Kaleido, its colour waveform), and the
-- board's own "ui" refresh is not one, so each pet tile gets its own.
-- A colour screen shows colour only through a dithered refresh (on Kaleido,
-- its colour waveform), and the board's own "ui" refresh is not one. With the
-- Colour setting on, the whole board gets one; with it off, only the pets,
-- which are colour pictures, get their own.
function CatalogBoard:boardRefreshPets()
if not Screen:isColorEnabled() then return end
if BoardColour.on(self._board_ctx) then
UIManager:setDirty(nil, function()
if self.dimen and self.dimen.x then return "partial", self.dimen, true end
end)
return
end
for _i, entry in ipairs(self._board_tiles or {}) do
if entry.item.type == "buddy" then
local tile = entry.tile
@@ -798,9 +806,16 @@ function CatalogBoard:boardRefreshPets()
end
-- Repaints one tile in place: rebuilds its content and refreshes only its
-- rectangle, so a stepper or a hero page does not flash the whole board.
-- rectangle, so a stepper or a hero page does not flash the whole board. A
-- coloured board's tiles keep their colours through the dithered refresh.
function CatalogBoard:boardRefreshTile(entry, mode, dither)
if not entry or not self._board_ctx then return end
-- The digital clock is black and white unless tiles are tinted, so its
-- minute stays on the fast refresh.
local plain_clock = entry.item.type == "clock" and not BoardColour.tinted(self._board_ctx)
if BoardColour.on(self._board_ctx) and not plain_clock and (mode == nil or mode == "ui" or mode == "fast") then
mode, dither = "partial", true
end
self._board_ctx.d = BoardData.build(self, self.current_context or {}, os.time())
local fresh = self:boardBuildTile(self._board_ctx, entry.item, entry.w, entry.h)
entry.tile[1] = fresh[1]
@@ -564,6 +564,15 @@ function MainMenu:applyBoardTiles(catalog, tiles)
end
end
function MainMenu:applyBoardColour(catalog, colour)
if catalog and catalog.boardSetColour then
catalog:boardSetColour(colour)
else
self.settings[BoardStore.SETTING_KEY] = BoardStore.withColour(self.settings, colour)
G_reader_settings:flush()
end
end
function MainMenu:chooseDownloadFolder(catalog, on_done)
if catalog and catalog.chooseDownloadDir then
catalog:chooseDownloadDir(on_done)
@@ -33,6 +33,7 @@ local STRATEGY = { SILENT = 2, PROMPT = 1, DISABLE = 3 }
local STRATEGY_ORDER = { STRATEGY.SILENT, STRATEGY.PROMPT, STRATEGY.DISABLE }
local PRESETS = { "reader", "stats", "library" }
local TILES = { "outline", "soft", "ink" }
local COLOURS = BoardStore.COLOURS
local function scaleLabel(value)
return string.format("%.2f", value):gsub("0$", "") .. "x"
@@ -297,19 +298,54 @@ local function homeBody(page, w, embedded)
})
local preset_labels = { _("Reader"), _("Stats"), _("Library") }
local tile_labels = { _("Outline"), _("Soft"), _("Ink") }
-- Colour only on a screen that shows it.
local colour_labels = Device.screen:isColorEnabled() and { _("Accents"), _("Tinted"), _("Off") } or nil
local function colourRow(rw, tight)
local row = segRow(rw, _("Colour"), nil, colour_labels, indexOf(COLOURS, state.colour, 1),
function(index) return { kind = "colour", value = COLOURS[index] } end, tight)
row._no_icon = true
return row
end
-- Colour sits beside Tile style when half a row holds both controls'
-- labels, so the page keeps the height it has on a black-and-white screen.
local function pairsWithTiles(half)
local seg = math.floor((half - 2 * math.max(1, px(1.5))) / 3) - px(8)
for _i, list in ipairs({ tile_labels, colour_labels }) do
for _j, label in ipairs(list) do
local t = text(label, { s = 14, b = true })
local wide = t:getSize().w > seg
t:free()
if wide then return false end
end
end
return true
end
local scale_labels = {}
for index, scale in ipairs(BoardMetrics.UI_SCALES) do
scale_labels[index] = scaleLabel(scale)
end
local a = segRow(iw, _("Starting layout"), _("Replaces your pages with a starter set"), preset_labels, indexOf(PRESETS, state.preset, 1),
function(index) return { kind = "preset", value = PRESETS[index] } end)
local b = segRow(iw, _("Tile style"), nil, tile_labels, indexOf(TILES, state.tiles, 1),
local look_gap = px(16)
local look_half = math.floor((iw - look_gap) / 2)
local pair_colour = colour_labels ~= nil and not page.compact and pairsWithTiles(look_half)
local b = segRow(pair_colour and look_half or iw, _("Tile style"), nil, tile_labels, indexOf(TILES, state.tiles, 1),
function(index) return { kind = "tiles", value = TILES[index] } end)
local c = segRow(iw, _("Interface size"), _("Text and controls across BookOrbit"), scale_labels,
indexOf(BoardMetrics.UI_SCALES, BoardMetrics.normalizeUserScale(settings.ui_scale), 1),
function(index) return { kind = "ui-scale", value = BoardMetrics.UI_SCALES[index] } end)
a._no_icon, b._no_icon, c._no_icon = true, true, true
local look = MenuUI.group(w, { a, b, c }, { inset = 0 })
local look
if pair_colour then
local pair = hstack({ w = iw, gap = look_gap, valign = "top" }, { b, colourRow(iw - look_half - look_gap) })
pair._no_icon = true
look = MenuUI.group(w, { a, pair, c }, { inset = 0 })
else
look = MenuUI.group(w, { a, b, c }, { inset = 0 })
end
-- Too narrow to share Tile style's row: Colour is a card of its own after
-- the first, which a two-column page can set beside Look.
local colour_card = colour_labels and not pair_colour and MenuUI.group(w, { colourRow(iw, page.compact ~= nil) }, { inset = 0 }) or nil
-- On a short, wide screen whose columns cannot hold the full card
-- (page.compact, set by build): without the notes, and at level 1 the
-- layout and tile style sharing a row when each half has room.
@@ -341,6 +377,8 @@ local function homeBody(page, w, embedded)
embedded and text(_("Home"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
top,
colour_card and space(nil, px(16)) or false,
colour_card or false,
space(nil, px(16)),
MenuUI.section(w, _("Look"), look),
space(nil, px(16)),
@@ -818,6 +856,9 @@ local function onAction(page, action)
elseif kind == "tiles" then
plugin:applyBoardTiles(catalog, action.value)
page:refresh()
elseif kind == "colour" then
plugin:applyBoardColour(catalog, action.value)
page:refresh()
elseif kind == "ui-scale" then
if settings.ui_scale == action.value then return true end
local scale = BoardMetrics.setUserScale(action.value)