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 self.dimen.x, self.dimen.y = x, y
local g = renderGlyph(self.file, self.codepoint, self.size) local g = renderGlyph(self.file, self.codepoint, self.size)
if not g then return end 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 end
-- One character's bitmap at a pixel size: { bb, l, t }, or nil. Drawn by -- 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 _ = require("gettext")
local BoardArt = require("bookorbit_board_art") local BoardArt = require("bookorbit_board_art")
local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data") local BoardData = require("bookorbit_board_data")
local BoardIcons = require("bookorbit_board_icons") local BoardIcons = require("bookorbit_board_icons")
local BoardMetrics = require("bookorbit_board_metrics") local BoardMetrics = require("bookorbit_board_metrics")
@@ -38,6 +39,12 @@ local Decor = {}
-- Set by install: the board's own header row and number formatting. -- Set by install: the board's own header row and number formatting.
local head, formatCount 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) local function clamp(v, lo, hi)
if v < lo then return lo end if v < lo then return lo end
if v > hi then return hi 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 bw = math.max(1, px(1.5))
local pad_x, pad_y = px(10), px(7) local pad_x, pad_y = px(10), px(7)
local tw = w - 2 * pad_x - 2 * bw 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 if lines[2] then
table.insert(items, space(nil, px(2))) 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 end
local inner = vstack({ w = tw }, items) local inner = vstack({ w = tw }, items)
local h = inner:getSize().h + 2 * pad_y + 2 * bw local h = inner:getSize().h + 2 * pad_y + 2 * bw
@@ -270,7 +278,7 @@ function Decor.buddy(ctx, w, hh, props)
end end
-- ================================================================ analog clock -- ================================================================ analog clock
local function clockFace(dia, hour, minute) local function clockFace(dia, hour, minute, accent)
local r = dia / 2 local r = dia / 2
local cx, cy = r, r local cx, cy = r, r
local body = { '<circle cx="' .. n(cx) .. '" cy="' .. n(cy) .. '" r="' .. n(r - 0.5) .. '" fill="#ffffff"/>' } 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 r1 = r - px(major and 4 or 5)
local r2 = r - (major and r * 0.2 or r * 0.12) - px(3) 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)) 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"/>') .. n(major and math.max(2, px(2.5)) or math.max(1, px(1.25))) .. '" stroke-linecap="round"/>')
end end
local ah = ((hour % 12) + minute / 60) * math.pi / 6 local ah = ((hour % 12) + minute / 60) * math.pi / 6
@@ -291,13 +299,14 @@ local function clockFace(dia, hour, minute)
end end
table.insert(body, hand(ah, r * 0.5, math.max(3, px(4.5)), r * 0.08)) 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, 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"/>') 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)) return svg(round(dia), round(dia), table.concat(body))
end end
function Decor.analog(ctx, w, hh) function Decor.analog(ctx, w, hh)
local d = ctx.d local d = ctx.d
local accent = BoardColour.artHex(ctx, "#000000", "ink", "red")
local pad = px(12) local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad local iw, ih = w - 2 * pad, hh - 2 * pad
local now = d.now or os.time() 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 if tw < px(70) then return skip("no room for the date") end
return padded(w, hh, { return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, { hstack({ w = iw, h = ih, gap = px(14) }, {
clockFace(dia, hour, minute), clockFace(dia, hour, minute, accent),
vstack({ w = tw }, { vstack({ w = tw }, {
text(d.weekday, { s = s, b = true, w = tw, col = INK.white }), 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)), 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
end end
table.insert(variants, function() 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) end)
return pick(variants) return pick(variants)
end end
@@ -343,10 +352,10 @@ end
-- Procellarum and Nubium, drawn only where wholly in the light. -- 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 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 R = dia / 2 - math.max(1, px(1))
local cx, cy = dia / 2, dia / 2 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 if m.lit > 0.004 then
local k = math.cos(2 * math.pi * m.p) local k = math.cos(2 * math.pi * m.p)
local waxing = m.p < 0.5 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 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) 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) 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 for _i, mare in ipairs(MARIA) do
local mx, my, ex, ey, rot = mare[1], mare[2], mare[3], mare[4], mare[5] local mx, my, ex, ey, rot = mare[1], mare[2], mare[3], mare[4], mare[5]
local ok = true local ok = true
@@ -368,7 +377,7 @@ local function moonDisc(dia, m)
end end
if ok then 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) 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 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 if tw < px(80) then return skip("no room for words") end
local words = vstack({ w = tw }, { local words = vstack({ w = tw }, {
text(m.name, { s = v[1], b = true, w = tw, col = INK.white }), 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)), 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, { 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 = 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 }, { widget = vstack({ w = tw, h = hh }, { flex(0), words, flex(0) }), x = pad + dia + px(16), y = 0 },
}) })
end) end)
@@ -436,7 +445,7 @@ function Decor.moon(ctx, w, hh)
local top = pad + (ih - cap_h - dia) / 2 local top = pad + (ih - cap_h - dia) / 2
local layers = { 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 = 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 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) }) 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 -- ================================================================ odometer
-- The last wheel, caught mid-turn: the bottom of its digit and the top of -- 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. -- 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() function RollingWheel:getSize()
return self.dimen return self.dimen
@@ -467,7 +476,7 @@ function RollingWheel:paintTo(bb, x, y)
local gy = y + round(center_y - gh / 2) local gy = y + round(center_y - gh / 2)
local top, bottom = math.max(gy, y), math.min(gy + gh, y + h) local top, bottom = math.max(gy, y), math.min(gy + gh, y + h)
if bottom <= top then return end 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 end
draw(self.digit, h / 2 - self.roll) draw(self.digit, h / 2 - self.roll)
if self.roll > 0 then draw((self.digit + 1) % 10, h / 2 - self.roll + self.pitch) end 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 digits = string.format("%06d", total)
local count = #digits local count = #digits
local g = px(3) local g = px(3)
local hot = BoardColour.on(ctx)
local function drums(dw, dh) local function drums(dw, dh)
local fs = math.floor(dh * 0.62 / BoardMetrics.get().dp) local fs = math.floor(dh * 0.62 / BoardMetrics.get().dp)
local items = {} local items = {}
for i = 1, count do for i = 1, count do
local ch = digits:sub(i, i) 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 = { 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.16) },
{ widget = BoardUI.rect(dw, math.max(1, px(1)), shade_col), y = round(dh * 0.84) }, { widget = BoardUI.rect(dw, math.max(1, px(1)), shade_col), y = round(dh * 0.84) },
} }
@@ -499,9 +509,11 @@ function Decor.odometer(ctx, w, hh)
local pitch = dh * 0.8 local pitch = dh * 0.8
-- Nothing read today: the wheel rests on its digit. -- Nothing read today: the wheel rests on its digit.
local roll = today > 0 and round(pitch * 0.3) or 0 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 = 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 }) }) 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
end
table.insert(items, overlay(dw, dh, layers)) table.insert(items, overlay(dw, dh, layers))
end end
return hstack({ gap = g }, items) return hstack({ gap = g }, items)
@@ -531,7 +543,7 @@ function Decor.odometer(ctx, w, hh)
local dw_fit = math.min(dw, round(dh / 1.32)) local dw_fit = math.min(dw, round(dh / 1.32))
local right = nil local right = nil
if mode == "beside" then right = todayRuns(12) 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, { return padded(w, hh, {
head(iw, _("Pages turned"), right), head(iw, _("Pages turned"), right),
flex(0), flex(0),
@@ -545,8 +557,10 @@ function Decor.odometer(ctx, w, hh)
end end
-- ================================================================ glyph -- ================================================================ glyph
local function glyphArt(g, max_w, max_h) local function glyphArt(g, max_w, max_h, col)
return BoardArt.glyph(g.font, g.cp, math.max(1, max_w), math.max(1, max_h)) 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 end
-- A line of the alphabet in the glyph's own font. -- 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 for _i, s in ipairs({ 15, 14 }) do
table.insert(variants, function() table.insert(variants, function()
local gw_max = math.min(ih, round(iw * 0.4)) 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 if not art then return skip("no glyph") end
local tw = iw - gw_max - px(14) local tw = iw - gw_max - px(14)
if tw < px(90) then return skip("no room for words") end 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 if ih > px(150) then
table.insert(variants, function() table.insert(variants, function()
local foot = lh1(15) + lh1(12) + (g.sample and lh1(14) + px(8) or 0) + px(10) 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 if not art then return skip("no glyph") end
return padded(w, hh, { return padded(w, hh, {
flex(0), art, flex(0), flex(0), art, flex(0),
@@ -608,7 +622,7 @@ function Decor.glyph(ctx, w, hh)
for _i, two in ipairs({ true, false }) do for _i, two in ipairs({ true, false }) do
table.insert(variants, function() table.insert(variants, function()
local foot = lh1(12) + (two and lh1(11) or 0) + px(6) 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 if not art then return skip("no glyph") end
return padded(w, hh, { return padded(w, hh, {
flex(0), art, flex(0), flex(0), art, flex(0),
@@ -618,7 +632,7 @@ function Decor.glyph(ctx, w, hh)
end) end)
end end
table.insert(variants, function() 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") return padded(w, hh, { flex(0), art, flex(0) }, "center")
end) end)
return pick(variants) return pick(variants)
@@ -628,7 +642,16 @@ end
-- One ring a month, the oldest at the pith; a ring is as wide as that -- 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 -- month's reading. The pith sits a little off centre and every ring wobbles
-- a little, as in real wood. -- 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 r = rng(seed)
local bark = math.max(3, px(5)) local bark = math.max(3, px(5))
-- Room for the wobble and the off-centre pith, so the bark never clips. -- 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 for i, m in ipairs(months) do
if m.min > months[peak].min then peak = i end if m.min > months[peak].min then peak = i end
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 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))) .. '"/>') .. n(math.max(1, px(i == #months and 1.25 or 1))) .. '"/>')
end 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. -- A drying check: a thin crack from the bark towards the heart.
local ca = -0.62 local ca = -0.62
local x1, y1 = cx + (R + bark) * math.cos(ca), cy + (R + bark) * math.sin(ca) 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 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) 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)) return svg(round(dia), round(dia), table.concat(body))
end end
local function sapling(dia) local function sapling(dia, wood)
local h = dia / 2 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))) 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="#ffffff" stroke="#666666" 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="#555555"/>') .. '"/><circle cx="' .. n(h) .. '" cy="' .. n(h) .. '" r="' .. n(math.max(2, dia * 0.08)) .. '" fill="' .. wood.pith .. '"/>')
end end
function Decor.rings(ctx, w, hh) function Decor.rings(ctx, w, hh)
@@ -708,7 +731,7 @@ function Decor.rings(ctx, w, hh)
space(nil, px(6)), space(nil, px(6)),
text(_("A ring grows for every month you read."), { s = 13, col = INK.g4, lines = 3, w = iw }), 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 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, with_art and flex(0) or false,
}) })
end) end)
@@ -737,7 +760,7 @@ function Decor.rings(ctx, w, hh)
if tw < px(100) then return skip("no room for words") end if tw < px(100) then return skip("no room for words") end
return padded(w, hh, { return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, { hstack({ w = iw, h = ih, gap = px(14) }, {
ringsArt(dia, months, 3), ringsArt(dia, months, 3, woodColours(ctx)),
vstack({ w = tw }, { vstack({ w = tw }, {
text(_("Tree rings"), { s = v[1], b = true, 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, 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, { return padded(w, hh, {
head(iw, _("Tree rings"), text(_("12 months"), { s = 12, col = INK.g4 })), head(iw, _("Tree rings"), text(_("12 months"), { s = 12, col = INK.g4 })),
flex(0), flex(0),
ringsArt(dia, months, 3), ringsArt(dia, months, 3, woodColours(ctx)),
flex(0), flex(0),
caption and labelRuns(_("Widest ring"), peak, true, 12, iw, "center") or false, caption and labelRuns(_("Widest ring"), peak, true, 12, iw, "center") or false,
}, "center") }, "center")
@@ -800,16 +823,16 @@ local function lineArt(w, cs, o)
local track_y = px(10) local track_y = px(10)
local art_h = px(20) local art_h = px(20)
local body = { 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(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="#000000" 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 for i, stop in ipairs(cs.stops) do
local past = stop.pct <= cs.pos local past = stop.pct <= cs.pos
local last = i == count local last = i == count
local rr = math.min(last and r * 1.25 or r, (x1 - x0) / (count - 1) * 0.36) 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") table.insert(body, '<circle cx="' .. n(xi(i)) .. '" cy="' .. track_y .. '" r="' .. n(rr) .. '" fill="' .. (past and (o.line or "#000000") or "#ffffff")
.. '" stroke="#000000" stroke-width="' .. n(math.max(1, px(1.5))) .. '"/>') .. '" 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="#000000"/>') end 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 end
table.insert(body, tram(train_x, track_y, 1)) table.insert(body, tram(train_x, track_y, 1))
local layers = { { widget = svg(w, art_h, table.concat(body)) } } 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 -- 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 -- 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. -- 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 row_h = lh1(14) + px(6)
local stretch = 0.7 local stretch = 0.7
local rows = math.floor(avail / row_h - stretch) 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 clear = r + t_h / 2 + px(1)
local seg = cy(1) - cy(0) local seg = cy(1) - cy(0)
local ty = clamp(cy(0) + seg * trainFraction(cs), cy(0) + clear, cy(1) - clear) 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) local last_y = cy(#list - 1)
if show_end then if show_end then
local gap_y = cy(#list - 2) 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(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="#bbbbbb" 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) .. '"/>') .. '" stroke-dasharray="' .. px(2) .. ' ' .. px(4) .. '"/>')
else 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 end
for k, i in ipairs(list) do for k, i in ipairs(list) do
local last = i == last_i local last = i == last_i
local rr = last and r * 1.2 or r 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") table.insert(body, '<circle cx="' .. lx .. '" cy="' .. n(cy(k - 1)) .. '" r="' .. n(rr) .. '" fill="' .. (k == 1 and line_col or "#ffffff")
.. '" stroke="#000000" stroke-width="' .. n(math.max(1, px(1.5))) .. '"/>') .. '" 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="#000000"/>') end if last then table.insert(body, '<circle cx="' .. lx .. '" cy="' .. n(cy(k - 1)) .. '" r="' .. n(rr * 0.42) .. '" fill="' .. line_col .. '"/>') end
end end
local car_w, win = px(11), px(3.2) 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)) 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 end
function Decor.chapters(ctx, w, hh) 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 cs = ctx.d.chapters
local pad = px(12) local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad 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 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.")) return emptyTile(w, hh, _("Next stop"), train, _("Open a book and its chapters line up here."), _("Open a book to start."))
end end
@@ -920,7 +944,7 @@ function Decor.chapters(ctx, w, hh)
if ih > px(150) and iw < ih * 1.9 then if ih > px(150) and iw < ih * 1.9 then
table.insert(variants, function() table.insert(variants, function()
local foot = lh1(12) + px(10) 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 if not tt then return skip("timetable needs three rows") end
return padded(w, hh, { return padded(w, hh, {
head(iw, cs.book.title or "", text(BoardData.pctLabel(cs.book) or "", { s = 12, col = INK.g4 })), 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 for _i, v in ipairs({ { 16, true, true }, { 16, false, true }, { 15, false, false }, { 14, false, false } }) do
table.insert(variants, function() table.insert(variants, function()
if iw < px(220) then return skip("too narrow for the line") end 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 local foot = false
if v[3] then if v[3] then
local left = endRuns(12) local left = endRuns(12)
@@ -961,7 +985,7 @@ function Decor.chapters(ctx, w, hh)
text(when, { s = 20, b = true, w = iw }), 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, with_name and text(cs.stops[cs.next].title, { s = 12, b = true, serif = true, w = iw, data = true }) or false,
flex(0), flex(0),
lineArt(iw, cs, {}), lineArt(iw, cs, { line = line, ahead = ahead }),
}) })
end) end)
end end
@@ -981,7 +1005,8 @@ local RARITY = {
-- The medal: rarity shows in the rim (plain, double, beaded, sunburst). A -- 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. -- 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 cx = D0 / 2
local ring_w = earned and 0 or math.max(3, px(5)) local ring_w = earned and 0 or math.max(3, px(5))
local gap = earned and 0 or px(4) 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 y0, y1 = cy + R * 0.3, cy + R + ribbon_h
local wr = R * 0.34 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) 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"/>' .. n(math.max(1, px(1.25))) .. '" stroke-linejoin="round"/>'
end end
table.insert(body, tail(-1)) table.insert(body, tail(-1))
@@ -1003,14 +1028,14 @@ local function medal(D0, b, earned)
else else
local pr = R + gap + ring_w / 2 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 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 if p > 0.005 then
local a = p * 2 * math.pi 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 ' 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
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)) local sw = math.max(1, px(1.5))
if b.rarity == "legendary" then if b.rarity == "legendary" then
local pts = {} 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 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))) table.insert(pts, (i == 0 and "M" or "L") .. n(cx + rr * math.sin(a)) .. " " .. n(cy - rr * math.cos(a)))
end 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") 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) .. '"/>') .. '" stroke-width="' .. n(sw) .. '"/>')
else else
@@ -1043,9 +1068,15 @@ end
function Decor.badge(ctx, w, hh) function Decor.badge(ctx, w, hh)
local data = ctx.d.badges or { missing = true } 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 pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad 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 if data.missing then
return emptyTile(w, hh, _("Next badge"), award, _("Badges show up here with a newer BookOrbit server."), _("Needs a newer server.")) return emptyTile(w, hh, _("Next badge"), award, _("Badges show up here with a newer BookOrbit server."), _("Needs a newer server."))
end 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 })), head(iw, label, text(T(_("%1 of %2"), data.earned, data.total), { s = 12, col = INK.g4 })),
space(nil, px(12)), space(nil, px(12)),
hstack({ w = iw, h = room, gap = px(14) }, { hstack({ w = iw, h = room, gap = px(14) }, {
medal(dia, b, false), medal(dia, b, false, rarity(b.rarity)),
vstack({ w = tw }, { vstack({ w = tw }, {
v[2] and text((RARITY[b.rarity] or RARITY.common)(), { s = 12, col = INK.g4, w = tw }) or false, 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 }), 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, BoardUI.rect(iw, math.max(1, px(0.75)), INK.gc))
table.insert(items, space(nil, px(10))) table.insert(items, space(nil, px(10)))
table.insert(items, hstack({ w = iw, h = px(36), gap = 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) }, { vstack({ w = iw - px(46) }, {
text(latest.name, { s = 13, b = true, w = iw - px(46), data = true }), 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) }), 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) local name_s = BoardUI.fitSize(b.name, { s = v[1], b = true }, tw, 12)
return padded(w, hh, { return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, { hstack({ w = iw, h = ih, gap = px(14) }, {
medal(dia, b, earned), medal(dia, b, earned, rarity(b.rarity)),
vstack({ w = tw }, { vstack({ w = tw }, {
v[4] and text(label, { s = 12, col = INK.g4, w = tw }) or false, 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 }), 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) local name_s = BoardUI.fitSize(b.name, { s = 12, b = true }, iw, 10)
return padded(w, hh, { return padded(w, hh, {
flex(0), 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 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 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, 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)) return svg(round(cw), round(ch), table.concat(body))
end end
local function dustyCover(book, cw, ch) local function dustyCover(book, cw, ch, edge)
local cov = BoardUI.cover(book, cw, ch, { local cov = BoardUI.cover(book, cw, ch, {
path = book.cover_path, path = book.cover_path,
loading = book.cover_state == "loading", loading = book.cover_state == "loading",
border = math.max(1, px(1)), 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. -- A film of dust: the cover a shade paler.
lighten = 0.22, 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 })), head(iw, _("Gathering dust"), text(T(_.ngettext("%1 book", "%1 books", dd.count), dd.count), { s = 12, col = INK.g4 })),
space(nil, px(10)), space(nil, px(10)),
hstack({ w = iw, gap = px(14), valign = "top" }, { hstack({ w = iw, gap = px(14), valign = "top" }, {
dustyCover(book, cw, ch), dustyCover(book, cw, ch, BoardColour.coverEdge(ctx, INK.gb)),
vstack({ w = tw }, { vstack({ w = tw }, {
text(book.title or "", { s = 16, b = true, serif = true, lines = 3, w = tw, data = true }), 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 }), 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 if tw < px(150) then return skip("no room for words") end
return padded(w, hh, { return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(12) }, { 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 }, { vstack({ w = tw, h = ih }, {
v[4] and text(_("Gathering dust"), { s = 12, col = INK.g4, w = tw }) or false, 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 }), 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 if ch < px(50) and with_caption then return skip("cover too small") end
ch, cw = math.max(1, ch), math.max(1, cw) ch, cw = math.max(1, ch), math.max(1, cw)
return padded(w, hh, { 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 space(nil, px(6)) or false,
with_caption and text(caption, { s = 12, b = true, w = iw, align = "center" }) or false, with_caption and text(caption, { s = 12, b = true, w = iw, align = "center" }) or false,
}, "center") }, "center")
@@ -456,6 +456,12 @@ function BoardEdit:boardSetTiles(tiles)
if self:dashboardMode() then self:updateItems(nil, true) end if self:dashboardMode() then self:updateItems(nil, true) end
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) function BoardEdit.install(Catalog)
for name, fn in pairs(BoardEdit) do for name, fn in pairs(BoardEdit) do
if name ~= "install" and type(fn) == "function" and name:match("^board") then 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. device differences (a frontlight or not) apply without a migration.
Stored under catalog_board, keyed by column count plus "l" in landscape: 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"] = ... } } custom = { ["4"] = { pages = { { items = {...} }, ... } }, ["4l"] = ... } }
]] ]]
local _ = require("gettext") local _ = require("gettext")
local BoardColour = require("bookorbit_board_colour")
local BoardStore = {} local BoardStore = {}
BoardStore.SETTING_KEY = "catalog_board" BoardStore.SETTING_KEY = "catalog_board"
BoardStore.VERSION = 1 BoardStore.VERSION = 1
BoardStore.PRESETS = { "reader", "stats", "library" } BoardStore.PRESETS = { "reader", "stats", "library" }
BoardStore.TILE_STYLES = { "outline", "soft", "ink" } BoardStore.TILE_STYLES = { "outline", "soft", "ink" }
BoardStore.COLOURS = BoardColour.SCHEMES
BoardStore.PAGE_COUNT = 4 BoardStore.PAGE_COUNT = 4
BoardStore.PAGE_IDS = { "home", "insights", "library", "decor" } BoardStore.PAGE_IDS = { "home", "insights", "library", "decor" }
@@ -163,12 +166,13 @@ end
local function readState(settings) local function readState(settings)
local stored = settings and settings[BoardStore.SETTING_KEY] local stored = settings and settings[BoardStore.SETTING_KEY]
if type(stored) ~= "table" or stored.version ~= BoardStore.VERSION then 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 end
local state = { local state = {
version = BoardStore.VERSION, version = BoardStore.VERSION,
preset = stored.preset, preset = stored.preset,
tiles = stored.tiles, tiles = stored.tiles,
colour = BoardColour.normalize(stored.colour),
started = stored.started == true, started = stored.started == true,
custom = type(stored.custom) == "table" and stored.custom or {}, custom = type(stored.custom) == "table" and stored.custom or {},
} }
@@ -253,4 +257,10 @@ function BoardStore.withTiles(settings, tiles)
return state return state
end end
function BoardStore.withColour(settings, colour)
local state = readState(settings)
state.colour = BoardColour.normalize(colour)
return state
end
return BoardStore return BoardStore
@@ -42,6 +42,35 @@ local BoardUI = {}
BoardUI.INK = INK BoardUI.INK = INK
BoardUI.px = px 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 -- ------------------------------------------------------------- fit checks
local fit_stack = {} local fit_stack = {}
@@ -128,6 +157,41 @@ local function faceKind(o)
return o.i and "sans_italic" or "sans" return o.i and "sans_italic" or "sans"
end 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), -- 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. -- b, i, serif, col, w (box width), align, lines, data.
function BoardUI.text(str, o) 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 if tb.line_with_ellipsis and not o.data then problem("clamped", str) end
return tb return tb
end end
local rgb = isRGB(o.col) and Blitbuffer.TYPE_BB8 ~= nil
local tw = TextWidget:new{ local tw = TextWidget:new{
text = str, text = str,
padding = 0, padding = 0,
face = face, face = face,
bold = o.b == true, 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, 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 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 if not o.w then return tw end
local dimen = Geom:new{ w = o.w, h = tw:getSize().h } local dimen = Geom:new{ w = o.w, h = tw:getSize().h }
if o.align == "center" then return CenterContainer:new{ dimen = dimen, tw } end if o.align == "center" then return CenterContainer:new{ dimen = dimen, tw } end
@@ -433,10 +499,31 @@ function BoardUI.spread(w, left, right, h)
end end
-- ----------------------------------------------------------------- frames -- ----------------------------------------------------------------- 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. -- A fixed box with an optional fill, radius and inner border.
function BoardUI.box(o, child) function BoardUI.box(o, child)
local border = o.border or 0 local border = o.border or 0
local inner_w, inner_h = o.w - 2 * border, o.h - 2 * border 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{ return FrameContainer:new{
margin = 0, margin = 0,
padding = 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) 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 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 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 return
end end
local dx, dy, dw, dh local dx, dy, dw, dh
@@ -708,7 +795,7 @@ function BoardCover:paintTo(bb, x, y)
bb:invertRect(dx, dy, dw, dh) bb:invertRect(dx, dy, dw, dh)
end end
if self.border > 0 then 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
end end
@@ -769,7 +856,7 @@ function BoardUI.cover(book, w, h, o)
-- too small for one line stays an empty frame. -- 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))) 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 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 end
return BoardUI.box({ w = w, h = h, border = frame_b, border_col = INK.black, bg = INK.ge }, 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)) 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 - 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) bb:paintRoundedRect(x + cell.x - gap, y + cell.y - gap, s + 2 * gap, s + 2 * gap, INK.white, r + gap)
end 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) bb:paintBorder(x + cell.x, y + cell.y, s, s, cell.border, cell.col, r, true)
else 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 end
end end
@@ -12,6 +12,7 @@ local datetime = require("datetime")
local T = require("ffi/util").template local T = require("ffi/util").template
local _ = require("gettext") local _ = require("gettext")
local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data") local BoardData = require("bookorbit_board_data")
local BoardStore = require("bookorbit_board_store") local BoardStore = require("bookorbit_board_store")
local BoardMetrics = require("bookorbit_board_metrics") local BoardMetrics = require("bookorbit_board_metrics")
@@ -85,7 +86,7 @@ local function cover(ctx, book, w, h, o)
path = book and book.cover_path, path = book and book.cover_path,
loading = book and book.cover_state == "loading", loading = book and book.cover_state == "loading",
border = o.border or thin(), 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, dim = o.dim,
}) })
end end
@@ -132,7 +133,7 @@ W.hero = function(ctx, w, hh)
return hstack({ w = w, h = hh }, { return hstack({ w = w, h = hh }, {
space(pad, 1), space(pad, 1),
vstack({ w = tw }, { 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)), space(nil, px(3)),
text(label, { s = 16, b = true, col = INK.white, lines = 2, w = tw }), 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 }, 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 }) })) 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 }, { 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)), space(nil, px(3)),
text(_("Pick a book from your library to begin"), { s = 16, b = true, col = INK.white, lines = 2, w = iw }), text(_("Pick a book from your library to begin"), { s = 16, b = true, col = INK.white, lines = 2, w = iw }),
flex(px(10)), flex(px(10)),
@@ -195,9 +196,9 @@ W.hero = function(ctx, w, hh)
local function meta(mv) local function meta(mv)
local m = { { t = BoardData.pctLabel(book) or "0%", b = true, col = INK.white } } local m = { { t = BoardData.pctLabel(book) or "0%", b = true, col = INK.white } }
if mv == 2 and book.minutes_left then 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 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 end
return m return m
end end
@@ -216,11 +217,11 @@ W.hero = function(ctx, w, hh)
space(px(14), 1), space(px(14), 1),
vstack({ w = tw2, h = ch }, { vstack({ w = tw2, h = ch }, {
flex(0), 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, 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 }), text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw2, data = true }),
space(nil, px(7)), 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), flex(0),
}), }),
space(px(14), 1), space(px(14), 1),
@@ -252,13 +253,13 @@ W.hero = function(ctx, w, hh)
end end
local function pager() local function pager()
if total <= 1 then return nil end 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 end
local function meta(full) local function meta(full)
local m = { { t = tostring(math.floor(pct + 0.5)) .. "%", b = true, col = INK.white } } local m = { { t = tostring(math.floor(pct + 0.5)) .. "%", b = true, col = INK.white } }
if book.minutes_left then if book.minutes_left then
local dur = full and BoardData.formatDuration(book.minutes_left) or BoardData.formatShortDuration(book.minutes_left) 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 end
return m return m
end 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 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 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 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" }, { return hstack({ w = w, h = hh, valign = "top" }, {
space(pad, 1), space(pad, 1),
-- A cover narrower than the column's height is centred in it, -- 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), space(nil, label_gap),
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }), text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }),
space(nil, author_gap), 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), 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), space(nil, meta_gap),
runs(meta(opt.full_meta), { s = 13, w = tw }), runs(meta(opt.full_meta), { s = 13, w = tw }),
space(nil, action_gap), 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) }), 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), space(gutter, 1),
vstack({ w = tw, h = hh, pad = { pad, 0, pad, 0 }, strict_padding = true }, { 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)), space(nil, px(6)),
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }), 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 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)), flex(px(8)),
hstack({ w = tw, gap = px(10) }, { hstack({ w = tw, gap = px(10) }, {
vstack({ w = bar_w }, { vstack({ w = bar_w }, {
text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }), text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }),
space(nil, px(5)), 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 } }), 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 cover_w = math.floor(cover_h * 2 / 3 + 0.5)
local tw = iw - cover_w - px(12) 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 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 }, { return vstack({ w = w, h = hh, pad = pad, strict_padding = true }, {
opt.pager and BoardUI.spread(iw, label, pager()) or label, opt.pager and BoardUI.spread(iw, label, pager()) or label,
space(nil, px(8)), space(nil, px(8)),
@@ -354,11 +355,11 @@ W.hero = function(ctx, w, hh)
vstack({ w = tw }, { vstack({ w = tw }, {
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }), 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 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)), 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)), space(nil, px(8)),
runs(meta(opt.full_meta), { s = 13, w = iw }), runs(meta(opt.full_meta), { s = 13, w = iw }),
space(nil, px(12)), space(nil, px(12)),
@@ -374,7 +375,7 @@ W.hero = function(ctx, w, hh)
local bar_w = iw - btn_d - px(10) 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) 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 }, { 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)), space(nil, px(6)),
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = iw, data = true }), text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = iw, data = true }),
flex(px(8)), flex(px(8)),
@@ -382,7 +383,7 @@ W.hero = function(ctx, w, hh)
vstack({ w = bar_w }, { vstack({ w = bar_w }, {
text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }), text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }),
space(nil, px(5)), 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 } }), 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)) local dot_gap = wide and px(7) or math.max(px(3), math.floor((iw - 7 * px(9)) / 6))
return pick({ return pick({
function() 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 }, { 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), flex(0),
count(iw), count(iw),
flex(0), flex(0),
@@ -436,14 +437,14 @@ W.streak = function(ctx, w, hh)
-- A short row: the week beside the count. -- A short row: the week beside the count.
function() function()
return vstack({ w = w, h = hh, pad = pad }, { 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), 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, end,
function() function()
return vstack({ w = w, h = hh, pad = pad }, { 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), flex(0),
count(iw), count(iw),
}) })
@@ -464,7 +465,7 @@ W.week = function(ctx, w, hh)
local bars = {} local bars = {}
for i, v in ipairs(d.week) do 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 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)) table.insert(bars, BoardUI.rect(bar_w, h, col))
end end
return vstack({ w = iw }, { return vstack({ w = iw }, {
@@ -530,7 +531,7 @@ W.today = function(ctx, w, hh)
return pick({ return pick({
function() function()
return vstack({ w = w, h = hh, pad = pad }, { 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), flex(0),
value(), value(),
flex(0), flex(0),
@@ -539,7 +540,7 @@ W.today = function(ctx, w, hh)
end, end,
function() function()
return vstack({ w = w, h = hh, pad = pad }, { 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), flex(0),
value(), value(),
}) })
@@ -559,7 +560,7 @@ W.goalpace = function(ctx, w, hh)
local function empty(str) local function empty(str)
return function() return function()
return vstack({ w = w, h = hh, pad = pad }, { 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)), space(nil, px(6)),
hint(str, iw, hh - 2 * pad - lh1(13) - 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) } statuses = { T(_("%1 behind pace"), behind), T(_("%1 behind"), behind) }
end end
local behind = not met and done > 0 and done < expected 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 function goalBar()
local scale = math.max(goal, projected, done, 1) local scale = math.max(goal, projected, done, 1)
local goal_x = math.floor(iw * goal / scale + 0.5) 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 }) 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) or rect(tick_w, px(14), INK.black)
return BoardUI.overlay(iw, px(14), { 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 }, { 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 end
@@ -669,13 +670,14 @@ W.heatmap = function(ctx, w, hh)
if v > 0 then days_read = days_read + 1 end if v > 0 then days_read = days_read + 1 end
if v > best then best, best_i = v, i end if v > best then best, best_i = v, i end
end end
local ramp = BoardColour.ramp(ctx, { INK.ge, INK.gb, INK.g8, INK.g4, INK.black })
local function level(v) local function level(v)
if v == nil then return nil end if v == nil then return nil end
if v <= 0 then return INK.ge end if v <= 0 then return ramp[1] end
if v < 30 then return INK.gb end if v < 30 then return ramp[2] end
if v < 60 then return INK.g8 end if v < 60 then return ramp[3] end
if v < 120 then return INK.g4 end if v < 120 then return ramp[4] end
return INK.black return ramp[5]
end end
local grid_w = weeks * cell + (weeks - 1) * g local grid_w = weeks * cell + (weeks - 1) * g
local radius = math.max(1, px(1.5)) 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 x, y = (i % per_row) * (cell + gap), math.floor(i / per_row) * (cell + gap)
local is_read = read[day] == true local is_read = read[day] == true
if is_read then 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 else
table.insert(cells, { x = x, y = y, col = day > today and INK.gd or INK.gb, border = outline }) table.insert(cells, { x = x, y = y, col = day > today and INK.gd or INK.gb, border = outline })
end end
@@ -878,11 +880,11 @@ W.also = function(ctx, w, hh)
}) })
table.insert(rows, sep or false) table.insert(rows, sep or false)
table.insert(rows, BoardUI.hit(hstack({ w = iw, h = row_inner_h, gap = px(10) }, { 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 }, { vstack({ w = tw }, {
text(b.title, { s = 14, b = true, w = tw, data = true }), text(b.title, { s = 14, b = true, w = tw, data = true }),
space(nil, px(5)), 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 } })) }), { kind = "book", book = b, resume = ctx.tap_resumes, list = { label = _("Continue reading"), books = d.continue_reading } }))
end end
@@ -907,7 +909,7 @@ W.highlight = function(ctx, w, hh)
return pick({ return pick({
function() function()
return vstack({ w = w, h = hh, pad = pad }, { 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)), space(nil, px(8)),
hint(str, iw, hh - 2 * pad - px(20) - px(8), o), 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 }) 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 end
local label = function() 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 end
return pick({ return pick({
function() 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 title = finished and T(_("Finished in %1"), d.goal.year) or BoardWidgets.shelfTitle(props)
local right local right
if props.shuffle then 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" }) d.online and { kind = "shuffle" } or { kind = "noop" })
elseif finished then 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 }) }) 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 for i = 1, math.min(n, #books) do
local b = books[i] local b = books[i]
table.insert(cards, BoardUI.hit(vstack({ w = cover_w }, { 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)), space(nil, px(6)),
text(b.title, { s = title_size, b = true, w = cover_w, data = true }), text(b.title, { s = title_size, b = true, w = cover_w, data = true }),
}), { kind = "detail", book_id = b.id, list = { label = title, books = books } })) }), { kind = "detail", book_id = b.id, list = { label = title, books = books } }))
@@ -1080,8 +1082,8 @@ local function fan(ctx, books, cover_h)
local layers = {} local layers = {}
for i = #list, 1, -1 do for i = #list, 1, -1 do
local x = (#list - i) * step 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 = 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 = INK.g9 }), x = x, y = 0 }) 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 end
return BoardUI.overlay(width, cover_h, layers) return BoardUI.overlay(width, cover_h, layers)
end end
@@ -1117,7 +1119,7 @@ W.link = function(ctx, w, hh, props)
need = text(label, { s = size, b = true }):getSize().w need = text(label, { s = size, b = true }):getSize().w
end end
return hstack({ w = iw, h = lh1(13), gap = px(5) }, { 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 }), text(label, { s = size, b = true, w = lab_w }),
chevron or false, 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 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) 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, 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 end
return vstack({ w = w, h = hh, pad = pad }, items) return vstack({ w = w, h = hh, pad = pad }, items)
end, end,
@@ -1253,7 +1255,7 @@ W.clock = function(ctx, w, hh)
text(d.clock, { s = val_s, b = true, w = iw, col = INK.white }), text(d.clock, { s = val_s, b = true, w = iw, col = INK.white }),
flex(0), flex(0),
text(d.weekday, { s = 13, b = true, w = iw, col = INK.white }), 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, end,
-- A short row: the time over the date. -- 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 for _i, o in ipairs(opts) do
table.insert(variants, function() table.insert(variants, function()
return vstack({ w = w, h = hh, pad = pad }, { 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), flex(0),
text(o[1], { s = o[2], b = true, w = iw }), text(o[1], { s = o[2], b = true, w = iw }),
space(nil, px(6)), 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)
end end
@@ -1293,7 +1295,7 @@ W.sync = function(ctx, w, hh)
for _i, o in ipairs(opts) do for _i, o in ipairs(opts) do
table.insert(variants, function() table.insert(variants, function()
return vstack({ w = w, h = hh, pad = pad }, { 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), flex(0),
text(o[1], { s = o[2], b = true, w = iw }), 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 iw = w - 2 * pad
local hours = d.hours or {} local hours = d.hours or {}
local part = dayPart(hours) 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 if not part then
return vstack({ w = w, h = hh, pad = pad }, { return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("When you read"), sunMoon()), head(iw, _("When you read"), sunMoon()),
@@ -1351,7 +1353,7 @@ W.hours = function(ctx, w, hh)
local v = hours[i] or 0 local v = hours[i] or 0
if v > 0 then if v > 0 then
local h = math.max(px(2), math.floor((chart_h - px(2)) * v / max + 0.5)) 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 else
table.insert(bars, space(bar_w, 0)) table.insert(bars, space(bar_w, 0))
end end
@@ -1419,7 +1421,7 @@ W.records = function(ctx, w, hh)
local r = d.records local r = d.records
local pad = px(12) local pad = px(12)
local iw = w - 2 * pad 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 if not r then
return vstack({ w = w, h = hh, pad = pad }, { return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Reading records"), award()), head(iw, _("Reading records"), award()),
@@ -1503,7 +1505,7 @@ W.toggles = function(ctx, w, hh)
table.insert(items, { "refresh-cw", _("Sync"), false, "sync" }) table.insert(items, { "refresh-cw", _("Sync"), false, "sync" })
local function circleBtn(size, it) local function circleBtn(size, it)
local on = it[3] 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] }) icon(it[1], math.floor(size * 0.46 + 0.5), { col = on and "white" or "black", sw = 2.25 })), { kind = it[4] })
end end
if small then if small then
@@ -1541,7 +1543,7 @@ W.light = function(ctx, w, hh)
local iw, ih = w - 2 * pad, hh - 2 * pad local iw, ih = w - 2 * pad, hh - 2 * pad
if not lt then if not lt then
return vstack({ w = w, h = hh, pad = pad }, { 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)), space(nil, px(6)),
hint(_("This device has no frontlight."), iw, hh - 2 * pad - lh1(13) - px(6), { sizes = { 12, 11 } }), 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) local function compact(bd, s)
return function() return function()
return vstack({ w = w, h = hh, pad = pad }, { 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), flex(0),
hstack({ w = iw }, { hstack({ w = iw }, {
BoardUI.hit(stepButton(bd, "minus"), { kind = "light", what = "brightness", delta = -5 }), 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 avail = ih - lh1(13) - px(6)
local rh = math.min(px(38), math.floor((avail - (rows_n - 1) * px(5)) / rows_n)) local rh = math.min(px(38), math.floor((avail - (rows_n - 1) * px(5)) / rows_n))
local bd = math.min(px(32), rh) 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 vw = px(36)
local bw = iw - px(18) - 2 * bd - vw - 4 * px(8) local bw = iw - px(18) - 2 * bd - vw - 4 * px(8)
return hstack({ w = iw, h = rh, gap = 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 }), 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 }), BoardUI.hit(stepButton(bd, "plus"), { kind = "light", what = what, delta = 5 }),
text(tostring(val), { s = 13, b = true, w = vw, align = "right" }), 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 }, { return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Light"), with_label and text(label, { s = 12, col = INK.g4 }) or nil), head(iw, _("Light"), with_label and text(label, { s = 12, col = INK.g4 }) or nil),
flex(0), 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 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
end end
@@ -1602,7 +1604,7 @@ W.forecast = function(ctx, w, hh)
for _i, long in ipairs({ true, false }) do for _i, long in ipairs({ true, false }) do
table.insert(variants, function() table.insert(variants, function()
return vstack({ w = w, h = hh, pad = pad }, { 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)), 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 }), 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 is_end, is_today = i == n, i == 1
local t = d.now + (i - 1) * 86400 local t = d.now + (i - 1) * 86400
table.insert(cells, vstack({ w = cw, align = "center" }, { 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)), 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" }), 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 cover_w = math.floor(cover_h * 2 / 3 + 0.5)
local tw = iw - cover_w - px(12) local tw = iw - cover_w - px(12)
return vstack({ w = w, h = hh, pad = pad }, { 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)), space(nil, px(8)),
hstack({ gap = px(12), valign = "bottom" }, { hstack({ gap = px(12), valign = "bottom" }, {
cover(ctx, f.book, cover_w, cover_h), cover(ctx, f.book, cover_w, cover_h),
@@ -1678,7 +1680,7 @@ W.forecast = function(ctx, w, hh)
}) })
end end
return vstack({ w = w, h = hh, pad = pad }, { 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), flex(0),
dayEl(cfg.day, iw), dayEl(cfg.day, iw),
cfg.strip and space(nil, px(8)) or false, cfg.strip and strip(iw) or false, 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 wd = ctx.d.word
local pad = px(12) local pad = px(12)
local iw = w - 2 * pad 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 if not wd then
local variants = {} local variants = {}
for _i, long in ipairs({ true, false }) do for _i, long in ipairs({ true, false }) do
@@ -1733,7 +1735,7 @@ W.word = function(ctx, w, hh)
local items = { local items = {
head(iw, _("Word of the day"), word_icon), head(iw, _("Word of the day"), word_icon),
space(nil, px(cfg.big and 10 or 4)), 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 if cfg.ctx_lines > 0 then
table.insert(items, space(nil, px(4))) table.insert(items, space(nil, px(4)))
@@ -1768,7 +1770,7 @@ W.yearago = function(ctx, w, hh)
local y = ctx.d.year_ago local y = ctx.d.year_ago
local pad = px(12) local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad 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 if not y then
local variants = {} local variants = {}
for _i, long in ipairs({ true, false }) do for _i, long in ipairs({ true, false }) do
@@ -1862,7 +1864,8 @@ end
-- the outline never costs the content any room. -- the outline never costs the content any room.
function BoardWidgets.tile(ctx, item, w, h) function BoardWidgets.tile(ctx, item, w, h)
local fn = W[item.type] 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 local content
if fn then if fn then
BoardUI.debug_widget = item.type .. " " .. w .. "x" .. h BoardUI.debug_widget = item.type .. " " .. w .. "x" .. h
@@ -24,6 +24,7 @@ local logger = require("logger")
local T = require("ffi/util").template local T = require("ffi/util").template
local _ = require("gettext") local _ = require("gettext")
local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data") local BoardData = require("bookorbit_board_data")
local BoardEdit = require("bookorbit_board_edit") local BoardEdit = require("bookorbit_board_edit")
local BoardLayout = require("bookorbit_board_layout") 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 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 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() 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) 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) })) 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 end
@@ -734,7 +735,7 @@ function CatalogBoard:updateDashboardItems(select_number, no_recalculate_dimen)
local page = self:boardPage() local page = self:boardPage()
local d = BoardData.build(self, context, os.time()) local d = BoardData.build(self, context, os.time())
local state = BoardStore.read(self.settings) 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_ctx = ctx
self._board_geometry = g self._board_geometry = g
local t_data = PROFILE and os.clock() local t_data = PROFILE and os.clock()
@@ -782,11 +783,18 @@ function CatalogBoard:updateDashboardItems(select_number, no_recalculate_dimen)
self:boardRefreshPets() self:boardRefreshPets()
end end
-- The pets are colour pictures. A colour screen shows them in full colour only -- A colour screen shows colour only through a dithered refresh (on Kaleido,
-- through a dithered refresh (on Kaleido, its colour waveform), and the -- its colour waveform), and the board's own "ui" refresh is not one. With the
-- board's own "ui" refresh is not one, so each pet tile gets its own. -- Colour setting on, the whole board gets one; with it off, only the pets,
-- which are colour pictures, get their own.
function CatalogBoard:boardRefreshPets() function CatalogBoard:boardRefreshPets()
if not Screen:isColorEnabled() then return end 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 for _i, entry in ipairs(self._board_tiles or {}) do
if entry.item.type == "buddy" then if entry.item.type == "buddy" then
local tile = entry.tile local tile = entry.tile
@@ -798,9 +806,16 @@ function CatalogBoard:boardRefreshPets()
end end
-- Repaints one tile in place: rebuilds its content and refreshes only its -- 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) function CatalogBoard:boardRefreshTile(entry, mode, dither)
if not entry or not self._board_ctx then return end 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()) 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) local fresh = self:boardBuildTile(self._board_ctx, entry.item, entry.w, entry.h)
entry.tile[1] = fresh[1] entry.tile[1] = fresh[1]
@@ -564,6 +564,15 @@ function MainMenu:applyBoardTiles(catalog, tiles)
end end
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) function MainMenu:chooseDownloadFolder(catalog, on_done)
if catalog and catalog.chooseDownloadDir then if catalog and catalog.chooseDownloadDir then
catalog:chooseDownloadDir(on_done) 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 STRATEGY_ORDER = { STRATEGY.SILENT, STRATEGY.PROMPT, STRATEGY.DISABLE }
local PRESETS = { "reader", "stats", "library" } local PRESETS = { "reader", "stats", "library" }
local TILES = { "outline", "soft", "ink" } local TILES = { "outline", "soft", "ink" }
local COLOURS = BoardStore.COLOURS
local function scaleLabel(value) local function scaleLabel(value)
return string.format("%.2f", value):gsub("0$", "") .. "x" 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 preset_labels = { _("Reader"), _("Stats"), _("Library") }
local tile_labels = { _("Outline"), _("Soft"), _("Ink") } 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 = {} local scale_labels = {}
for index, scale in ipairs(BoardMetrics.UI_SCALES) do for index, scale in ipairs(BoardMetrics.UI_SCALES) do
scale_labels[index] = scaleLabel(scale) scale_labels[index] = scaleLabel(scale)
end end
local a = segRow(iw, _("Starting layout"), _("Replaces your pages with a starter set"), preset_labels, indexOf(PRESETS, state.preset, 1), 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) 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) function(index) return { kind = "tiles", value = TILES[index] } end)
local c = segRow(iw, _("Interface size"), _("Text and controls across BookOrbit"), scale_labels, local c = segRow(iw, _("Interface size"), _("Text and controls across BookOrbit"), scale_labels,
indexOf(BoardMetrics.UI_SCALES, BoardMetrics.normalizeUserScale(settings.ui_scale), 1), indexOf(BoardMetrics.UI_SCALES, BoardMetrics.normalizeUserScale(settings.ui_scale), 1),
function(index) return { kind = "ui-scale", value = BoardMetrics.UI_SCALES[index] } end) function(index) return { kind = "ui-scale", value = BoardMetrics.UI_SCALES[index] } end)
a._no_icon, b._no_icon, c._no_icon = true, true, true 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 -- 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 -- (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. -- 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 text(_("Home"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false, embedded and space(nil, px(12)) or false,
top, top,
colour_card and space(nil, px(16)) or false,
colour_card or false,
space(nil, px(16)), space(nil, px(16)),
MenuUI.section(w, _("Look"), look), MenuUI.section(w, _("Look"), look),
space(nil, px(16)), space(nil, px(16)),
@@ -818,6 +856,9 @@ local function onAction(page, action)
elseif kind == "tiles" then elseif kind == "tiles" then
plugin:applyBoardTiles(catalog, action.value) plugin:applyBoardTiles(catalog, action.value)
page:refresh() page:refresh()
elseif kind == "colour" then
plugin:applyBoardColour(catalog, action.value)
page:refresh()
elseif kind == "ui-scale" then elseif kind == "ui-scale" then
if settings.ui_scale == action.value then return true end if settings.ui_scale == action.value then return true end
local scale = BoardMetrics.setUserScale(action.value) local scale = BoardMetrics.setUserScale(action.value)
@@ -0,0 +1,208 @@
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BLACK = { grey = 0 }
package.loaded["ffi/blitbuffer"] = {
COLOR_BLACK = BLACK,
ColorRGB32 = function(r, g, b, a) return { r = r, g = g, b = b, a = a } end,
isColor8 = function(c) return c.grey ~= nil end,
}
package.loaded["gettext"] = function(value) return value end
local pixels_of = {}
package.loaded["ui/renderimage"] = {
renderImageFile = function(_self, path)
local list = pixels_of[path]
if not list then return nil end
return {
getWidth = function() return #list end,
getHeight = function() return 1 end,
getPixel = function(_bb, x)
local p = list[x + 1]
return { getColorRGB32 = function() return { r = p[1], g = p[2], b = p[3] } end }
end,
free = function() end,
}
end,
}
local Colour = require("bookorbit_board_colour")
local Layout = require("bookorbit_board_layout")
local Store = require("bookorbit_board_store")
local function hex(c) return string.format("#%02x%02x%02x", c.r, c.g, c.b) end
local function rgb(s) return tonumber(s:sub(2, 3), 16), tonumber(s:sub(4, 5), 16), tonumber(s:sub(6, 7), 16) end
local function chroma(s)
local r, g, b = rgb(s)
return math.max(r, g, b) - math.min(r, g, b)
end
local function luma(s)
local r, g, b = rgb(s)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
end
-- Colour math.
assert(Colour.mix("#000000", "#ffffff", 0.5) == "#808080", "mix halves")
assert(Colour.boost("#777777", 1.4) == "#777777", "boost leaves a gray alone")
assert(chroma(Colour.boost("#2b61d6", 1.4)) > chroma("#2b61d6"), "boost pushes chroma out")
assert(math.abs(luma(Colour.boost("#2b61d6", 1.4)) - luma("#2b61d6")) < 3, "boost keeps luma")
assert(Colour.forNight("#808080") == "#808080", "night leaves a gray alone")
do
-- Inverting what is painted for night shows the colour's own hue.
local painted = Colour.forNight("#de4127")
local r, g, b = rgb(painted)
local hn = Colour.rgbToHls((255 - r) / 255, (255 - g) / 255, (255 - b) / 255)
local r0, g0, b0 = rgb("#de4127")
local h0, l0 = Colour.rgbToHls(r0 / 255, g0 / 255, b0 / 255)
local _, lp = Colour.rgbToHls(r / 255, g / 255, b / 255)
assert(math.abs(hn - h0) < 0.01, "night shows the same hue")
assert(math.abs(lp - l0) < 0.01, "night paints at the same lightness")
end
-- Cover families.
local function fill(n, p) local t = {} for i = 1, n do t[i] = p end return t end
assert(Colour.familyFromPixels(fill(100, { 128, 128, 128 })) == nil, "a gray jacket has no colour")
assert(Colour.familyFromPixels(fill(100, { 250, 250, 250 })) == nil, "a white jacket has no colour")
do
local mostly_gray = fill(97, { 30, 30, 30 })
for _i = 1, 3 do table.insert(mostly_gray, { 40, 90, 200 }) end
assert(Colour.familyFromPixels(mostly_gray) == nil, "a few coloured pixels are not enough")
local blue = fill(60, { 40, 90, 200 })
for _i = 1, 40 do table.insert(blue, { 20, 20, 20 }) end
local fam = Colour.familyFromPixels(blue)
assert(fam, "a blue jacket has a family")
assert(luma(fam.deep) < luma(fam.ink) and luma(fam.ink) < luma(fam.tint) and luma(fam.tint) < luma(fam.wash), "deep, ink, tint, wash get lighter")
local r, g, b = rgb(fam.ink)
assert(b > r and b > g, "the family keeps the jacket's hue")
-- Deep carries white text.
assert(luma(fam.deep) < 80, "deep is dark enough for white text")
end
pixels_of["/covers/ocean.jpg"] = fill(50, { 30, 120, 160 })
pixels_of["/covers/plain.jpg"] = fill(50, { 200, 200, 200 })
assert(Colour.coverFamily("/covers/ocean.jpg"), "a cover file gives its family")
pixels_of["/covers/ocean.jpg"] = nil
assert(Colour.coverFamily("/covers/ocean.jpg"), "a cover's family is kept")
assert(Colour.coverFamily("/covers/plain.jpg") == nil and Colour.coverFamily("/covers/plain.jpg") == nil, "no family, asked twice")
assert(Colour.coverFamily(nil) == nil, "no path, no family")
-- What reaches the screen.
local GREY = { grey = 136 }
local function ctx(scheme, extra)
local c = { colour = scheme, tiles = "outline", d = { continue_reading = { { cover_path = "/covers/ocean.jpg" } } } }
for k, v in pairs(extra or {}) do c[k] = v end
return c
end
local LCD, EINK = { colour = true, eink = false, night = false }, { colour = true, eink = true, night = false }
local MONO = { colour = false, eink = true, night = false }
Colour._env = MONO
for _, scheme in ipairs(Colour.SCHEMES) do
local c = ctx(scheme, { tile_key = "streak" })
assert(Colour.ink(c, GREY) == GREY and Colour.track(c, GREY) == GREY and Colour.mid(c, GREY) == GREY, scheme .. ": a black-and-white screen keeps its grays")
assert(Colour.inkFill(c, "hero") == BLACK, scheme .. ": Continue reading stays black there")
local base = { bg = GREY, border = 1, border_col = GREY }
assert(Colour.chrome(c, "line", "highlight", base, 1, 2) == base, scheme .. ": tiles unchanged there")
assert(Colour.icon(c, 2.25).col == nil and Colour.icon(c, 2.25).sw == 2.25, scheme .. ": icons unchanged there")
end
Colour._env = LCD
do
local off = ctx("off", { tile_key = "streak" })
assert(Colour.ink(off, GREY) == GREY, "Off keeps the grays on a colour screen")
local acc = ctx("accents", { tile_key = "streak" })
assert(hex(Colour.ink(acc, GREY)) == Colour.FAM.orange.ink, "Streak is orange")
assert(hex(Colour.track(acc, GREY)) == Colour.FAM.orange.tint, "a bar runs on its own tint")
assert(Colour.paint("#123456") == Colour.paint("#123456"), "one object per colour")
assert(Colour.icon(acc, 2.25).sw == 2.25, "a phone keeps icon weights")
-- Accents: tiles as they are, the highlight yellow, Continue reading in its cover.
local base = { bg = GREY, border = 1, border_col = GREY, radius = 4 }
assert(Colour.chrome(acc, "line", "streak", base, 1, 2) == base, "Accents keeps a tile's own chrome")
local hl = Colour.chrome(acc, "fill", "highlight", base, 1, 2)
assert(hex(hl.bg) == Colour.FAM.yellow.wash and hl.border == 0 and hl.radius == 4, "Highlight of the day is a yellow card")
local lead = Colour.coverFamily("/covers/ocean.jpg")
assert(hex(Colour.chrome(acc, "ink", "hero", base, 1, 2).bg) == lead.deep, "Continue reading takes its cover's deep shade")
assert(Colour.chrome(acc, "ink", "clock", base, 1, 2).bg == BLACK, "the clock stays black in Accents")
assert(Colour.chrome(acc, "ink", "moon", base, 1, 2).bg == BLACK, "the moon stays black in Accents")
assert(Colour.onInk(acc, GREY, "clock") == GREY, "quiet text on black stays gray")
assert(Colour.onInk(acc, GREY, "hero") ~= GREY, "quiet text on the cover's shade takes a pale shade")
local plain = ctx("accents", { d = { continue_reading = { { cover_path = "/covers/plain.jpg" } } } })
assert(Colour.inkFill(plain, "hero") == BLACK, "a jacket without colour keeps the black tile")
-- Tinted: the wash, with the tile style's edge in the widget's colour.
local outline = ctx("tinted", { tile_key = "week" })
local ch = Colour.chrome(outline, "line", "week", base, 1, 2)
assert(hex(ch.bg) == Colour.FAM.green.wash and ch.border == 1 and hex(ch.border_col) == Colour.mix(Colour.FAM.green.wash, Colour.FAM.green.ink, 0.4), "Outline: wash and a hairline")
local soft = Colour.chrome(ctx("tinted", { tiles = "soft" }), "line", "week", base, 1, 2)
assert(hex(soft.bg) == Colour.FAM.green.wash and soft.border == 0, "Soft: the wash alone")
local ink = Colour.chrome(ctx("tinted", { tiles = "ink" }), "line", "week", base, 1, 2)
assert(ink.border == 2 and hex(ink.border_col) == Colour.FAM.green.deep, "Ink: a firm deep edge")
assert(hex(Colour.chrome(outline, "ink", "clock", base, 1, 2).bg) == Colour.FAM.blue.deep, "the clock goes deep blue when tinted")
assert(hex(Colour.track(outline, GREY)) == "#ffffff", "bars run on white in a washed tile")
assert(hex(Colour.halo(outline, GREY)) == Colour.FAM.green.wash, "fanned covers sit in the wash")
assert(Colour.halo(acc, GREY) == GREY, "and keep their white halo on white tiles")
assert(Colour.chrome(outline, "open", "week", base, 1, 2) == base, "open tiles stay open")
end
Colour._env = EINK
do
local acc = ctx("accents", { tile_key = "streak" })
local ink = hex(Colour.ink(acc, GREY))
assert(ink == Colour.boost(Colour.FAM.orange.ink, 1.4), "a colour e-reader gets the boosted shade")
assert(Colour.icon(acc, 2.25).sw > 2.25, "and heavier icons")
end
Colour._env = { colour = true, eink = false, night = true }
do
local acc = ctx("accents", { tile_key = "streak" })
assert(hex(Colour.ink(acc, GREY)) == Colour.forNight(Colour.FAM.orange.ink), "night mode paints the turned hue")
assert(Colour.icon(acc, 2.25).col == Colour.forNight(Colour.FAM.orange.ink), "icons too")
end
Colour._env = nil
-- The setting.
assert(Store.read({}).colour == "accents", "Accents by default")
assert(Store.read({ catalog_board = { version = 1, colour = "loud" } }).colour == "accents", "an unknown value reads as Accents")
assert(Store.withColour({}, "tinted").colour == "tinted", "Tinted is kept")
assert(Store.withColour({ catalog_board = { version = 1, tiles = "ink", colour = "tinted" } }, "off").tiles == "ink", "changing colour keeps the tile style")
assert(Colour.normalize(nil) == "accents" and Colour.normalize("off") == "off", "normalize")
-- No two touching tiles on a starter page share a colour or a look-alike
-- wash, on any screen; a dark ink tile may touch a pale one of its family.
local FIXED = { highlight = true, hero = true, chapters = true, clock = true, analog = true, moon = true }
local ALIKE = { gold = "yellow", yellow = "gold", rose = "red", red = "rose", teal = "green", green = "teal" }
local function clash(a, b) return a == b or ALIKE[a] == b end
for _, cols in ipairs({ 2, 4, 6 }) do
for _, landscape in ipairs({ false, true }) do
for _, has_light in ipairs({ true, false }) do
for _, page in ipairs(Store.starterPages(cols, "reader", has_light, landscape)) do
local packed = Layout.pack(page.base, cols)
local at = {}
for _, it in ipairs(packed.placed) do
for y = it.y, it.y + it.rh - 1 do
for x = it.x, it.x + it.cw - 1 do
at[y .. ":" .. x] = it
end
end
end
local function fam(it)
local key = Colour.keyFor(it.type, it.props)
return Colour.HUE[key]
end
for _, it in ipairs(packed.placed) do
for y = it.y, it.y + it.rh - 1 do
for _, other in ipairs({ at[y .. ":" .. (it.x + it.cw)] }) do
if other and other ~= it and not FIXED[it.type] and not FIXED[other.type] then
assert(not clash(fam(it), fam(other)), string.format("%d cols%s %s: %s beside %s", cols, landscape and " landscape" or "", page.id, it.type, other.type))
end
end
end
for x = it.x, it.x + it.cw - 1 do
local other = at[(it.y + it.rh) .. ":" .. x]
if other and other ~= it and not FIXED[it.type] and not FIXED[other.type] then
assert(not clash(fam(it), fam(other)), string.format("%d cols%s %s: %s above %s", cols, landscape and " landscape" or "", page.id, it.type, other.type))
end
end
end
end
end
end
end
print("bookorbit_board_colour_test: ok")