diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_board_art.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_board_art.lua
index fa67d317e..85812bf81 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_board_art.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_board_art.lua
@@ -263,7 +263,13 @@ function GlyphWidget:paintTo(bb, x, y)
self.dimen.x, self.dimen.y = x, y
local g = renderGlyph(self.file, self.codepoint, self.size)
if not g then return end
- bb:colorblitFrom(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), self.color or Blitbuffer.COLOR_BLACK)
+ local color = self.color or Blitbuffer.COLOR_BLACK
+ -- The plain colour blit keeps only gray; an RGB colour takes the RGB one.
+ if Blitbuffer.isColor8 and not Blitbuffer.isColor8(color) and bb.colorblitFromRGB32 then
+ bb:colorblitFromRGB32(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), color)
+ else
+ bb:colorblitFrom(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), color)
+ end
end
-- One character's bitmap at a pixel size: { bb, l, t }, or nil. Drawn by
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_board_colour.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_board_colour.lua
new file mode 100644
index 000000000..a780d557b
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_board_colour.lua
@@ -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
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_board_decor.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_board_decor.lua
index 7e0138357..4ea20ba31 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_board_decor.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_board_decor.lua
@@ -23,6 +23,7 @@ local T = require("ffi/util").template
local _ = require("gettext")
local BoardArt = require("bookorbit_board_art")
+local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data")
local BoardIcons = require("bookorbit_board_icons")
local BoardMetrics = require("bookorbit_board_metrics")
@@ -38,6 +39,12 @@ local Decor = {}
-- Set by install: the board's own header row and number formatting.
local head, formatCount
+-- A hex for decor art: `colour` on a screen showing the Colour setting, the
+-- grey otherwise.
+local function artHex(ctx, grey, colour)
+ return BoardColour.on(ctx) and BoardColour.hex(colour) or grey
+end
+
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
@@ -135,10 +142,11 @@ local function bubble(w, lines, s, tail, second_is_data)
local bw = math.max(1, px(1.5))
local pad_x, pad_y = px(10), px(7)
local tw = w - 2 * pad_x - 2 * bw
- local items = { text(lines[1], { s = s, b = true, w = tw, lines = 2 }) }
+ -- The bubble is white whatever the tile under it is, and so is its text's ground.
+ local items = { text(lines[1], { s = s, b = true, w = tw, lines = 2, bg = INK.white }) }
if lines[2] then
table.insert(items, space(nil, px(2)))
- table.insert(items, text(lines[2], { s = math.max(11, s - 2), col = INK.g4, w = tw, lines = 2, data = second_is_data }))
+ table.insert(items, text(lines[2], { s = math.max(11, s - 2), col = INK.g4, w = tw, lines = 2, data = second_is_data, bg = INK.white }))
end
local inner = vstack({ w = tw }, items)
local h = inner:getSize().h + 2 * pad_y + 2 * bw
@@ -270,7 +278,7 @@ function Decor.buddy(ctx, w, hh, props)
end
-- ================================================================ analog clock
-local function clockFace(dia, hour, minute)
+local function clockFace(dia, hour, minute, accent)
local r = dia / 2
local cx, cy = r, r
local body = { '' }
@@ -280,7 +288,7 @@ local function clockFace(dia, hour, minute)
local r1 = r - px(major and 4 or 5)
local r2 = r - (major and r * 0.2 or r * 0.12) - px(3)
table.insert(body, '')
end
local ah = ((hour % 12) + minute / 60) * math.pi / 6
@@ -291,13 +299,14 @@ local function clockFace(dia, hour, minute)
end
table.insert(body, hand(ah, r * 0.5, math.max(3, px(4.5)), r * 0.08))
table.insert(body, hand(am, r * 0.76, math.max(2, px(2.75)), r * 0.1))
- table.insert(body, '')
+ table.insert(body, '')
table.insert(body, '')
return svg(round(dia), round(dia), table.concat(body))
end
function Decor.analog(ctx, w, hh)
local d = ctx.d
+ local accent = BoardColour.artHex(ctx, "#000000", "ink", "red")
local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad
local now = d.now or os.time()
@@ -311,12 +320,12 @@ function Decor.analog(ctx, w, hh)
if tw < px(70) then return skip("no room for the date") end
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, {
- clockFace(dia, hour, minute),
+ clockFace(dia, hour, minute, accent),
vstack({ w = tw }, {
text(d.weekday, { s = s, b = true, w = tw, col = INK.white }),
- text(d.day_month, { s = 13, w = tw, col = INK.gc }),
+ text(d.day_month, { s = 13, w = tw, col = BoardColour.onInk(ctx, INK.gc) }),
space(nil, px(4)),
- text(d.clock, { s = 13, b = true, w = tw, col = INK.gc }),
+ text(d.clock, { s = 13, b = true, w = tw, col = BoardColour.onInk(ctx, INK.gc) }),
}),
}),
})
@@ -324,7 +333,7 @@ function Decor.analog(ctx, w, hh)
end
end
table.insert(variants, function()
- return padded(w, hh, { flex(0), clockFace(math.max(1, math.min(iw, ih)), hour, minute), flex(0) }, "center")
+ return padded(w, hh, { flex(0), clockFace(math.max(1, math.min(iw, ih)), hour, minute, accent), flex(0) }, "center")
end)
return pick(variants)
end
@@ -343,10 +352,10 @@ end
-- Procellarum and Nubium, drawn only where wholly in the light.
local MARIA = { { -0.26, -0.36, 0.24, 0.17, -0.3 }, { 0.2, -0.3, 0.13, 0.12, 0 }, { 0.32, -0.02, 0.15, 0.12, 0.4 }, { -0.5, 0.06, 0.15, 0.3, 0.1 }, { -0.08, 0.42, 0.15, 0.09, 0.2 } }
-local function moonDisc(dia, m)
+local function moonDisc(dia, m, ctx)
local R = dia / 2 - math.max(1, px(1))
local cx, cy = dia / 2, dia / 2
- local body = { '' }
+ local body = { '' }
if m.lit > 0.004 then
local k = math.cos(2 * math.pi * m.p)
local waxing = m.p < 0.5
@@ -356,7 +365,7 @@ local function moonDisc(dia, m)
if waxing then term_sweep = k > 0 and 0 or 1 else term_sweep = k > 0 and 1 or 0 end
local trx = math.max(0.01, math.abs(k) * R)
table.insert(body, '')
+ .. ' 0 0 ' .. term_sweep .. ' ' .. top .. ' Z" fill="' .. artHex(ctx, "#ffffff", "#f7efcf") .. '"/>')
for _i, mare in ipairs(MARIA) do
local mx, my, ex, ey, rot = mare[1], mare[2], mare[3], mare[4], mare[5]
local ok = true
@@ -368,7 +377,7 @@ local function moonDisc(dia, m)
end
if ok then
table.insert(body, '')
+ .. '" transform="rotate(' .. n(rot * 57.3) .. ' ' .. n(cx + mx * R) .. ' ' .. n(cy + my * R) .. ')" fill="' .. artHex(ctx, "#dddddd", "#e2d3a2") .. '"/>')
end
end
end
@@ -415,13 +424,13 @@ function Decor.moon(ctx, w, hh)
if tw < px(80) then return skip("no room for words") end
local words = vstack({ w = tw }, {
text(m.name, { s = v[1], b = true, w = tw, col = INK.white }),
- text(T(_("%1% lit"), lit), { s = 13, w = tw, col = INK.gc }),
+ text(T(_("%1% lit"), lit), { s = 13, w = tw, col = BoardColour.onInk(ctx, INK.gc, "moon") }),
space(nil, px(4)),
- text(m.next, { s = 12, w = tw, col = INK.gc }),
+ text(m.next, { s = 12, w = tw, col = BoardColour.onInk(ctx, INK.gc, "moon") }),
})
return overlay(w, hh, {
{ widget = sky(w, hh, { x = pad + dia / 2, y = hh / 2, r = dia / 2 }, { x = pad + dia, y = 0, w = w, h = hh }) },
- { widget = moonDisc(dia, m), x = pad, y = round((hh - dia) / 2) },
+ { widget = moonDisc(dia, m, ctx), x = pad, y = round((hh - dia) / 2) },
{ widget = vstack({ w = tw, h = hh }, { flex(0), words, flex(0) }), x = pad + dia + px(16), y = 0 },
})
end)
@@ -436,7 +445,7 @@ function Decor.moon(ctx, w, hh)
local top = pad + (ih - cap_h - dia) / 2
local layers = {
{ widget = sky(w, hh, { x = w / 2, y = top + dia / 2, r = dia / 2 }, caption and { x = 0, y = hh - pad - lh1(12), w = w, h = lh1(12) } or nil) },
- { widget = moonDisc(dia, m), x = round((w - dia) / 2), y = round(top) },
+ { widget = moonDisc(dia, m, ctx), x = round((w - dia) / 2), y = round(top) },
}
if caption then
table.insert(layers, { widget = text(m.name, { s = 12, b = true, w = iw, col = INK.white, align = "center" }), x = pad, y = hh - pad - lh1(12) })
@@ -450,7 +459,7 @@ end
-- ================================================================ odometer
-- The last wheel, caught mid-turn: the bottom of its digit and the top of
-- the next, both drawn into the wheel's own box so neither spills out.
-local RollingWheel = Widget:extend{ digit = 0, size = 10, roll = 0, pitch = 0 }
+local RollingWheel = Widget:extend{ digit = 0, size = 10, roll = 0, pitch = 0, color = nil }
function RollingWheel:getSize()
return self.dimen
@@ -467,7 +476,7 @@ function RollingWheel:paintTo(bb, x, y)
local gy = y + round(center_y - gh / 2)
local top, bottom = math.max(gy, y), math.min(gy + gh, y + h)
if bottom <= top then return end
- bb:colorblitFrom(g.bb, gx, top, 0, top - gy, gw, bottom - top, INK.black)
+ bb:colorblitFrom(g.bb, gx, top, 0, top - gy, gw, bottom - top, self.color or INK.black)
end
draw(self.digit, h / 2 - self.roll)
if self.roll > 0 then draw((self.digit + 1) % 10, h / 2 - self.roll + self.pitch) end
@@ -482,14 +491,15 @@ function Decor.odometer(ctx, w, hh)
local digits = string.format("%06d", total)
local count = #digits
local g = px(3)
+ local hot = BoardColour.on(ctx)
local function drums(dw, dh)
local fs = math.floor(dh * 0.62 / BoardMetrics.get().dp)
local items = {}
for i = 1, count do
local ch = digits:sub(i, i)
- local shade_col = i < count and INK.g3 or INK.gd
+ local shade_col = i < count and INK.g3 or (hot and BoardColour.paint(BoardColour.mix(BoardColour.fam(ctx).ink, "#ffffff", 0.3)) or INK.gd)
local layers = {
- { widget = BoardUI.box({ w = dw, h = dh, radius = px(3), bg = i < count and INK.black or INK.white }) },
+ { widget = BoardUI.box({ w = dw, h = dh, radius = px(3), bg = i < count and INK.black or BoardColour.ink(ctx, INK.white) }) },
{ widget = BoardUI.rect(dw, math.max(1, px(1)), shade_col), y = round(dh * 0.16) },
{ widget = BoardUI.rect(dw, math.max(1, px(1)), shade_col), y = round(dh * 0.84) },
}
@@ -499,8 +509,10 @@ function Decor.odometer(ctx, w, hh)
local pitch = dh * 0.8
-- Nothing read today: the wheel rests on its digit.
local roll = today > 0 and round(pitch * 0.3) or 0
- table.insert(layers, { widget = RollingWheel:new{ digit = tonumber(ch), size = BoardMetrics.fs(fs), roll = roll, pitch = pitch, dimen = Geom:new{ w = dw, h = dh } } })
- table.insert(layers, { widget = BoardUI.box({ w = dw, h = dh, radius = px(3), border = math.max(1, px(1.5)), border_col = INK.black }) })
+ table.insert(layers, { widget = RollingWheel:new{ digit = tonumber(ch), size = BoardMetrics.fs(fs), roll = roll, pitch = pitch, color = hot and INK.white or INK.black, dimen = Geom:new{ w = dw, h = dh } } })
+ if not hot then
+ table.insert(layers, { widget = BoardUI.box({ w = dw, h = dh, radius = px(3), border = math.max(1, px(1.5)), border_col = INK.black }) })
+ end
end
table.insert(items, overlay(dw, dh, layers))
end
@@ -531,7 +543,7 @@ function Decor.odometer(ctx, w, hh)
local dw_fit = math.min(dw, round(dh / 1.32))
local right = nil
if mode == "beside" then right = todayRuns(12)
- elseif mode ~= "bare" then right = icon("book-open", px(17), { sw = 2.25 }) end
+ elseif mode ~= "bare" then right = icon("book-open", px(17), BoardColour.icon(ctx, 2.25)) end
return padded(w, hh, {
head(iw, _("Pages turned"), right),
flex(0),
@@ -545,8 +557,10 @@ function Decor.odometer(ctx, w, hh)
end
-- ================================================================ glyph
-local function glyphArt(g, max_w, max_h)
- return BoardArt.glyph(g.font, g.cp, math.max(1, max_w), math.max(1, max_h))
+local function glyphArt(g, max_w, max_h, col)
+ local art = BoardArt.glyph(g.font, g.cp, math.max(1, max_w), math.max(1, max_h))
+ if art then art.color = col end
+ return art
end
-- A line of the alphabet in the glyph's own font.
@@ -573,7 +587,7 @@ function Decor.glyph(ctx, w, hh)
for _i, s in ipairs({ 15, 14 }) do
table.insert(variants, function()
local gw_max = math.min(ih, round(iw * 0.4))
- local art = glyphArt(g, gw_max, ih - px(4))
+ local art = glyphArt(g, gw_max, ih - px(4), BoardColour.ink(ctx, INK.black))
if not art then return skip("no glyph") end
local tw = iw - gw_max - px(14)
if tw < px(90) then return skip("no room for words") end
@@ -594,7 +608,7 @@ function Decor.glyph(ctx, w, hh)
if ih > px(150) then
table.insert(variants, function()
local foot = lh1(15) + lh1(12) + (g.sample and lh1(14) + px(8) or 0) + px(10)
- local art = glyphArt(g, iw - px(20), ih - foot - px(10))
+ local art = glyphArt(g, iw - px(20), ih - foot - px(10), BoardColour.ink(ctx, INK.black))
if not art then return skip("no glyph") end
return padded(w, hh, {
flex(0), art, flex(0),
@@ -608,7 +622,7 @@ function Decor.glyph(ctx, w, hh)
for _i, two in ipairs({ true, false }) do
table.insert(variants, function()
local foot = lh1(12) + (two and lh1(11) or 0) + px(6)
- local art = glyphArt(g, iw, ih - foot)
+ local art = glyphArt(g, iw, ih - foot, BoardColour.ink(ctx, INK.black))
if not art then return skip("no glyph") end
return padded(w, hh, {
flex(0), art, flex(0),
@@ -618,7 +632,7 @@ function Decor.glyph(ctx, w, hh)
end)
end
table.insert(variants, function()
- local art = glyphArt(g, iw, ih) or text(name, { s = 14, b = true, w = iw, align = "center" })
+ local art = glyphArt(g, iw, ih, BoardColour.ink(ctx, INK.black)) or text(name, { s = 14, b = true, w = iw, align = "center" })
return padded(w, hh, { flex(0), art, flex(0) }, "center")
end)
return pick(variants)
@@ -628,7 +642,16 @@ end
-- One ring a month, the oldest at the pith; a ring is as wide as that
-- month's reading. The pith sits a little off centre and every ring wobbles
-- a little, as in real wood.
-local function ringsArt(dia, months, seed)
+-- Bark, rings and heartwood: wood colours on a colour screen, grays otherwise.
+local function woodColours(ctx)
+ return {
+ bark = artHex(ctx, "#444444", "#6b4426"), edge = artHex(ctx, "#000000", "#3b2512"), ring = artHex(ctx, "#ffffff", "#f4e4c6"),
+ peak = artHex(ctx, "#dddddd", "#e2b56a"), line = artHex(ctx, "#666666", "#a37a52"), pith = artHex(ctx, "#555555", "#7a4f2c"),
+ crack = artHex(ctx, "#222222", "#3b2512"),
+ }
+end
+
+local function ringsArt(dia, months, seed, wood)
local r = rng(seed)
local bark = math.max(3, px(5))
-- Room for the wobble and the off-centre pith, so the bark never clips.
@@ -666,26 +689,26 @@ local function ringsArt(dia, months, seed)
for i, m in ipairs(months) do
if m.min > months[peak].min then peak = i end
end
- local body = { '' }
+ local body = { '' }
for i = #months, 1, -1 do
- table.insert(body, '')
end
- table.insert(body, '')
+ table.insert(body, '')
-- A drying check: a thin crack from the bark towards the heart.
local ca = -0.62
local x1, y1 = cx + (R + bark) * math.cos(ca), cy + (R + bark) * math.sin(ca)
local x2, y2 = cx + R * 0.45 * math.cos(ca + 0.05), cy + R * 0.45 * math.sin(ca + 0.05)
local nx, ny = -math.sin(ca) * px(1.6), math.cos(ca) * px(1.6)
- table.insert(body, '')
+ table.insert(body, '')
return svg(round(dia), round(dia), table.concat(body))
end
-local function sapling(dia)
+local function sapling(dia, wood)
local h = dia / 2
- return svg(dia, dia, '')
+ return svg(dia, dia, '')
end
function Decor.rings(ctx, w, hh)
@@ -708,7 +731,7 @@ function Decor.rings(ctx, w, hh)
space(nil, px(6)),
text(_("A ring grows for every month you read."), { s = 13, col = INK.g4, lines = 3, w = iw }),
with_art and flex(0) or false,
- with_art and hstack({ w = iw }, { flex(0), sapling(dia), flex(0) }) or false,
+ with_art and hstack({ w = iw }, { flex(0), sapling(dia, woodColours(ctx)), flex(0) }) or false,
with_art and flex(0) or false,
})
end)
@@ -737,7 +760,7 @@ function Decor.rings(ctx, w, hh)
if tw < px(100) then return skip("no room for words") end
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, {
- ringsArt(dia, months, 3),
+ ringsArt(dia, months, 3, woodColours(ctx)),
vstack({ w = tw }, {
text(_("Tree rings"), { s = v[1], b = true, w = tw }),
v[2] and text(_("One ring a month"), { s = 12, col = INK.g4, w = tw }) or false,
@@ -759,7 +782,7 @@ function Decor.rings(ctx, w, hh)
return padded(w, hh, {
head(iw, _("Tree rings"), text(_("12 months"), { s = 12, col = INK.g4 })),
flex(0),
- ringsArt(dia, months, 3),
+ ringsArt(dia, months, 3, woodColours(ctx)),
flex(0),
caption and labelRuns(_("Widest ring"), peak, true, 12, iw, "center") or false,
}, "center")
@@ -800,16 +823,16 @@ local function lineArt(w, cs, o)
local track_y = px(10)
local art_h = px(20)
local body = {
- '',
- '',
+ '',
+ '',
}
for i, stop in ipairs(cs.stops) do
local past = stop.pct <= cs.pos
local last = i == count
local rr = math.min(last and r * 1.25 or r, (x1 - x0) / (count - 1) * 0.36)
- table.insert(body, '')
- if last then table.insert(body, '') end
+ table.insert(body, '')
+ if last then table.insert(body, '') end
end
table.insert(body, tram(train_x, track_y, 1))
local layers = { { widget = svg(w, art_h, table.concat(body)) } }
@@ -843,7 +866,7 @@ end
-- The next stations as a timetable down a vertical line, with how long
-- until each. The stretch the train is on is taller, so the car stands
-- clear of both its stations. Nil when fewer than three rows fit.
-local function timetable(w, avail, cs)
+local function timetable(w, avail, cs, line_col, ahead_col)
local row_h = lh1(14) + px(6)
local stretch = 0.7
local rows = math.floor(avail / row_h - stretch)
@@ -864,22 +887,22 @@ local function timetable(w, avail, cs)
local clear = r + t_h / 2 + px(1)
local seg = cy(1) - cy(0)
local ty = clamp(cy(0) + seg * trainFraction(cs), cy(0) + clear, cy(1) - clear)
- local body = { '' }
+ local body = { '' }
local last_y = cy(#list - 1)
if show_end then
local gap_y = cy(#list - 2)
- table.insert(body, '')
- table.insert(body, '')
+ table.insert(body, '')
else
- table.insert(body, '')
+ table.insert(body, '')
end
for k, i in ipairs(list) do
local last = i == last_i
local rr = last and r * 1.2 or r
- table.insert(body, '')
- if last then table.insert(body, '') end
+ table.insert(body, '')
+ if last then table.insert(body, '') end
end
local car_w, win = px(11), px(3.2)
table.insert(body, ' px(150) and iw < ih * 1.9 then
table.insert(variants, function()
local foot = lh1(12) + px(10)
- local tt = timetable(iw, ih - lh1(13) - px(12) - foot, cs)
+ local tt = timetable(iw, ih - lh1(13) - px(12) - foot, cs, line, ahead)
if not tt then return skip("timetable needs three rows") end
return padded(w, hh, {
head(iw, cs.book.title or "", text(BoardData.pctLabel(cs.book) or "", { s = 12, col = INK.g4 })),
@@ -935,7 +959,7 @@ function Decor.chapters(ctx, w, hh)
for _i, v in ipairs({ { 16, true, true }, { 16, false, true }, { 15, false, false }, { 14, false, false } }) do
table.insert(variants, function()
if iw < px(220) then return skip("too narrow for the line") end
- local art = lineArt(iw, cs, { labels = v[2], max = iw > px(500) and 6 or 3 })
+ local art = lineArt(iw, cs, { labels = v[2], max = iw > px(500) and 6 or 3, line = line, ahead = ahead })
local foot = false
if v[3] then
local left = endRuns(12)
@@ -961,7 +985,7 @@ function Decor.chapters(ctx, w, hh)
text(when, { s = 20, b = true, w = iw }),
with_name and text(cs.stops[cs.next].title, { s = 12, b = true, serif = true, w = iw, data = true }) or false,
flex(0),
- lineArt(iw, cs, {}),
+ lineArt(iw, cs, { line = line, ahead = ahead }),
})
end)
end
@@ -981,7 +1005,8 @@ local RARITY = {
-- The medal: rarity shows in the rim (plain, double, beaded, sunburst). A
-- badge in progress wears a progress ring; an earned one hangs on ribbons.
-local function medal(D0, b, earned)
+-- rf: the rarity's hex shades on a colour screen, nil for grays.
+local function medal(D0, b, earned, rf)
local cx = D0 / 2
local ring_w = earned and 0 or math.max(3, px(5))
local gap = earned and 0 or px(4)
@@ -995,7 +1020,7 @@ local function medal(D0, b, earned)
local y0, y1 = cy + R * 0.3, cy + R + ribbon_h
local wr = R * 0.34
return ''
end
table.insert(body, tail(-1))
@@ -1003,14 +1028,14 @@ local function medal(D0, b, earned)
else
local pr = R + gap + ring_w / 2
local p = (b.threshold and b.threshold > 0 and b.progress) and clamp(b.progress / b.threshold, 0, 0.9999) or 0
- table.insert(body, '')
+ table.insert(body, '')
if p > 0.005 then
local a = p * 2 * math.pi
table.insert(body, '')
+ .. n(cx + pr * math.sin(a)) .. ' ' .. n(cy - pr * math.cos(a)) .. '" fill="none" stroke="' .. (rf and rf.ink or "#000000") .. '" stroke-width="' .. ring_w .. '" stroke-linecap="round"/>')
end
end
- local face, ink = earned and "#000000" or "#ffffff", earned and "#ffffff" or "#000000"
+ local face, ink = earned and (rf and rf.deep or "#000000") or "#ffffff", earned and "#ffffff" or "#000000"
local sw = math.max(1, px(1.5))
if b.rarity == "legendary" then
local pts = {}
@@ -1018,7 +1043,7 @@ local function medal(D0, b, earned)
local a, rr = i * math.pi / 16, (i % 2 == 1) and R * 0.9 or R * 1.06
table.insert(pts, (i == 0 and "M" or "L") .. n(cx + rr * math.sin(a)) .. " " .. n(cy - rr * math.cos(a)))
end
- table.insert(body, '')
+ table.insert(body, '')
table.insert(body, '')
else
@@ -1043,9 +1068,15 @@ end
function Decor.badge(ctx, w, hh)
local data = ctx.d.badges or { missing = true }
+ -- A badge's rarity colours, the same in every scheme.
+ local function rarity(r)
+ if not BoardColour.on(ctx) then return nil end
+ local f = BoardColour.rarity(r)
+ return { ink = BoardColour.hex(f.ink), deep = BoardColour.hex(f.deep), tint = BoardColour.hex(f.tint), ribbon = BoardColour.hex(BoardColour.rarity("ribbon").ink) }
+ end
local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad
- local function award() return icon("award", px(17), { sw = 2.25 }) end
+ local function award() return icon("award", px(17), BoardColour.icon(ctx, 2.25)) end
if data.missing then
return emptyTile(w, hh, _("Next badge"), award, _("Badges show up here with a newer BookOrbit server."), _("Needs a newer server."))
end
@@ -1080,7 +1111,7 @@ function Decor.badge(ctx, w, hh)
head(iw, label, text(T(_("%1 of %2"), data.earned, data.total), { s = 12, col = INK.g4 })),
space(nil, px(12)),
hstack({ w = iw, h = room, gap = px(14) }, {
- medal(dia, b, false),
+ medal(dia, b, false, rarity(b.rarity)),
vstack({ w = tw }, {
v[2] and text((RARITY[b.rarity] or RARITY.common)(), { s = 12, col = INK.g4, w = tw }) or false,
text(b.name, { s = 16, b = true, w = tw, lines = 2, data = true }),
@@ -1097,7 +1128,7 @@ function Decor.badge(ctx, w, hh)
table.insert(items, BoardUI.rect(iw, math.max(1, px(0.75)), INK.gc))
table.insert(items, space(nil, px(10)))
table.insert(items, hstack({ w = iw, h = px(36), gap = px(10) }, {
- medal(px(36), latest, true),
+ medal(px(36), latest, true, rarity(latest.rarity)),
vstack({ w = iw - px(46) }, {
text(latest.name, { s = 13, b = true, w = iw - px(46), data = true }),
text(latest.when or "", { s = 12, col = INK.g4, w = iw - px(46) }),
@@ -1122,7 +1153,7 @@ function Decor.badge(ctx, w, hh)
local name_s = BoardUI.fitSize(b.name, { s = v[1], b = true }, tw, 12)
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(14) }, {
- medal(dia, b, earned),
+ medal(dia, b, earned, rarity(b.rarity)),
vstack({ w = tw }, {
v[4] and text(label, { s = 12, col = INK.g4, w = tw }) or false,
text(b.name, { s = name_s, b = true, w = tw, data = true }),
@@ -1145,7 +1176,7 @@ function Decor.badge(ctx, w, hh)
local name_s = BoardUI.fitSize(b.name, { s = 12, b = true }, iw, 10)
return padded(w, hh, {
flex(0),
- medal(dia, b, earned),
+ medal(dia, b, earned, rarity(b.rarity)),
with_name and space(nil, px(6)) or false,
with_name and text(b.name, { s = name_s, b = true, w = iw, align = "center", data = true }) or false,
with_name and hstack({ w = iw }, { flex(0), progressRuns(12), flex(0) }) or false,
@@ -1214,12 +1245,12 @@ local function dustArt(cw, ch, seed)
return svg(round(cw), round(ch), table.concat(body))
end
-local function dustyCover(book, cw, ch)
+local function dustyCover(book, cw, ch, edge)
local cov = BoardUI.cover(book, cw, ch, {
path = book.cover_path,
loading = book.cover_state == "loading",
border = math.max(1, px(1)),
- border_col = INK.gb,
+ border_col = edge or INK.gb,
-- A film of dust: the cover a shade paler.
lighten = 0.22,
})
@@ -1277,7 +1308,7 @@ function Decor.dust(ctx, w, hh)
head(iw, _("Gathering dust"), text(T(_.ngettext("%1 book", "%1 books", dd.count), dd.count), { s = 12, col = INK.g4 })),
space(nil, px(10)),
hstack({ w = iw, gap = px(14), valign = "top" }, {
- dustyCover(book, cw, ch),
+ dustyCover(book, cw, ch, BoardColour.coverEdge(ctx, INK.gb)),
vstack({ w = tw }, {
text(book.title or "", { s = 16, b = true, serif = true, lines = 3, w = tw, data = true }),
text(book.authors and book.authors[1] or "", { s = 13, col = INK.g4, w = tw, data = true }),
@@ -1304,7 +1335,7 @@ function Decor.dust(ctx, w, hh)
if tw < px(150) then return skip("no room for words") end
return padded(w, hh, {
hstack({ w = iw, h = ih, gap = px(12) }, {
- dustyCover(book, cw, ch),
+ dustyCover(book, cw, ch, BoardColour.coverEdge(ctx, INK.gb)),
vstack({ w = tw, h = ih }, {
v[4] and text(_("Gathering dust"), { s = 12, col = INK.g4, w = tw }) or false,
text(book.title or "", { s = 14, b = true, serif = true, lines = v[1], w = tw, data = true }),
@@ -1325,7 +1356,7 @@ function Decor.dust(ctx, w, hh)
if ch < px(50) and with_caption then return skip("cover too small") end
ch, cw = math.max(1, ch), math.max(1, cw)
return padded(w, hh, {
- dustyCover(book, cw, ch),
+ dustyCover(book, cw, ch, BoardColour.coverEdge(ctx, INK.gb)),
with_caption and space(nil, px(6)) or false,
with_caption and text(caption, { s = 12, b = true, w = iw, align = "center" }) or false,
}, "center")
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_board_edit.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_board_edit.lua
index 7ed74fa81..f58211fc4 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_board_edit.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_board_edit.lua
@@ -456,6 +456,12 @@ function BoardEdit:boardSetTiles(tiles)
if self:dashboardMode() then self:updateItems(nil, true) end
end
+function BoardEdit:boardSetColour(colour)
+ self:persistSetting(BoardStore.SETTING_KEY, BoardStore.withColour(self.settings, colour))
+ require("bookorbit_board_ui").clearSnapshots()
+ if self:dashboardMode() then self:updateItems(nil, true) end
+end
+
function BoardEdit.install(Catalog)
for name, fn in pairs(BoardEdit) do
if name ~= "install" and type(fn) == "function" and name:match("^board") then
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_board_store.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_board_store.lua
index 7129bbed4..6a40a58a8 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_board_store.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_board_store.lua
@@ -15,18 +15,21 @@ layout; until then the starter pages are computed fresh, so new widgets and
device differences (a frontlight or not) apply without a migration.
Stored under catalog_board, keyed by column count plus "l" in landscape:
- { version = 1, preset = "reader", tiles = "outline", started = true,
+ { version = 1, preset = "reader", tiles = "outline", colour = "accents", started = true,
custom = { ["4"] = { pages = { { items = {...} }, ... } }, ["4l"] = ... } }
]]
local _ = require("gettext")
+local BoardColour = require("bookorbit_board_colour")
+
local BoardStore = {}
BoardStore.SETTING_KEY = "catalog_board"
BoardStore.VERSION = 1
BoardStore.PRESETS = { "reader", "stats", "library" }
BoardStore.TILE_STYLES = { "outline", "soft", "ink" }
+BoardStore.COLOURS = BoardColour.SCHEMES
BoardStore.PAGE_COUNT = 4
BoardStore.PAGE_IDS = { "home", "insights", "library", "decor" }
@@ -163,12 +166,13 @@ end
local function readState(settings)
local stored = settings and settings[BoardStore.SETTING_KEY]
if type(stored) ~= "table" or stored.version ~= BoardStore.VERSION then
- return { version = BoardStore.VERSION, preset = "reader", tiles = "outline", started = false, custom = {} }
+ return { version = BoardStore.VERSION, preset = "reader", tiles = "outline", colour = BoardColour.DEFAULT, started = false, custom = {} }
end
local state = {
version = BoardStore.VERSION,
preset = stored.preset,
tiles = stored.tiles,
+ colour = BoardColour.normalize(stored.colour),
started = stored.started == true,
custom = type(stored.custom) == "table" and stored.custom or {},
}
@@ -253,4 +257,10 @@ function BoardStore.withTiles(settings, tiles)
return state
end
+function BoardStore.withColour(settings, colour)
+ local state = readState(settings)
+ state.colour = BoardColour.normalize(colour)
+ return state
+end
+
return BoardStore
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_board_ui.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_board_ui.lua
index e473101dc..e25dc10ab 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_board_ui.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_board_ui.lua
@@ -42,6 +42,35 @@ local BoardUI = {}
BoardUI.INK = INK
BoardUI.px = px
+-- KOReader's plain painters turn an RGB colour into its gray; RGB colours
+-- (the board's colour schemes) go through the RGB32 painters instead.
+local Blitbuffer = package.loaded["ffi/blitbuffer"] or select(2, pcall(require, "ffi/blitbuffer"))
+if type(Blitbuffer) ~= "table" then Blitbuffer = {} end
+
+-- A truthiness test, never == nil: LuaJIT hands a colour's comparison with
+-- nil to its __eq, which indexes the other side.
+local function isRGB(c)
+ if not c or not Blitbuffer.isColor8 then return false end
+ return not Blitbuffer.isColor8(c)
+end
+BoardUI.isRGB = isRGB
+
+local function paintBorder(bb, x, y, w, h, bw, c, r)
+ if isRGB(c) and bb.paintBorderRGB32 then
+ bb:paintBorderRGB32(x, y, w, h, bw, c, r)
+ else
+ bb:paintBorder(x, y, w, h, bw, c, r)
+ end
+end
+
+local function paintRoundedRect(bb, x, y, w, h, c, r)
+ if isRGB(c) and bb.paintRoundedRectRGB32 then
+ bb:paintRoundedRectRGB32(x, y, w, h, c, r)
+ else
+ bb:paintRoundedRect(x, y, w, h, c, r)
+ end
+end
+
-- ------------------------------------------------------------- fit checks
local fit_stack = {}
@@ -128,6 +157,41 @@ local function faceKind(o)
return o.i and "sans_italic" or "sans"
end
+-- One line of text in an RGB colour. TextWidget paints glyphs in gray only,
+-- so the line is set white on black once, which is its coverage mask, and
+-- blitted in the colour. (Blitbuffer:invert only flips a flag that the C
+-- blitter ignores when both buffers carry it, as a night-mode screen does.)
+local ColourLine = Widget:extend{ line = nil, color = nil }
+
+function ColourLine:getSize()
+ return self.line:getSize()
+end
+
+function ColourLine:isTruncated()
+ return self.line:isTruncated()
+end
+
+function ColourLine:paintTo(bb, x, y)
+ local size = self.line:getSize()
+ self.dimen = Geom:new{ x = x, y = y, w = size.w, h = size.h }
+ if not self.mask then
+ local canvas = Blitbuffer.new(size.w, size.h, Blitbuffer.TYPE_BB8)
+ canvas:fill(Blitbuffer.COLOR_BLACK)
+ self.line:paintTo(canvas, 0, 0)
+ self.mask = canvas
+ end
+ if bb.colorblitFromRGB32 then
+ bb:colorblitFromRGB32(self.mask, x, y, 0, 0, size.w, size.h, self.color)
+ else
+ bb:colorblitFrom(self.mask, x, y, 0, 0, size.w, size.h, self.color)
+ end
+end
+
+function ColourLine:free()
+ if self.mask then self.mask:free(); self.mask = nil end
+ if self.line and self.line.free then self.line:free() end
+end
+
-- A single line, or o.lines lines when o.lines > 1. o: s (nominal size),
-- b, i, serif, col, w (box width), align, lines, data.
function BoardUI.text(str, o)
@@ -160,15 +224,17 @@ function BoardUI.text(str, o)
if tb.line_with_ellipsis and not o.data then problem("clamped", str) end
return tb
end
+ local rgb = isRGB(o.col) and Blitbuffer.TYPE_BB8 ~= nil
local tw = TextWidget:new{
text = str,
padding = 0,
face = face,
bold = o.b == true,
- fgcolor = o.col or INK.black,
+ fgcolor = rgb and INK.white or (o.col or INK.black),
max_width = o.w,
}
if o.w and tw:isTruncated() and not o.data then problem("ellipsis", str .. " w=" .. o.w .. " need=" .. tw:getSize().w) end
+ if rgb then tw = ColourLine:new{ line = tw, color = o.col } end
if not o.w then return tw end
local dimen = Geom:new{ w = o.w, h = tw:getSize().h }
if o.align == "center" then return CenterContainer:new{ dimen = dimen, tw } end
@@ -433,10 +499,31 @@ function BoardUI.spread(w, left, right, h)
end
-- ----------------------------------------------------------------- frames
+-- An RGB border painted over a frame of the same size.
+local ColourRing = Widget:extend{ bw = 1, color = nil, radius = 0 }
+
+function ColourRing:getSize()
+ return self.dimen
+end
+
+function ColourRing:paintTo(bb, x, y)
+ self.dimen.x, self.dimen.y = x, y
+ paintBorder(bb, x, y, self.dimen.w, self.dimen.h, self.bw, self.color, self.radius)
+end
+
-- A fixed box with an optional fill, radius and inner border.
function BoardUI.box(o, child)
local border = o.border or 0
local inner_w, inner_h = o.w - 2 * border, o.h - 2 * border
+ if border > 0 and isRGB(o.border_col) then
+ local plain = {}
+ for k, v in pairs(o) do plain[k] = v end
+ plain.border_col = INK.white
+ return BoardUI.overlay(o.w, o.h, {
+ { widget = BoardUI.box(plain, child) },
+ { widget = ColourRing:new{ dimen = Geom:new{ w = o.w, h = o.h }, bw = border, color = o.border_col, radius = o.radius or 0 } },
+ })
+ end
return FrameContainer:new{
margin = 0,
padding = 0,
@@ -683,7 +770,7 @@ function BoardCover:paintTo(bb, x, y)
bb:blitFrom(img.bb, px_, py_, 0, 0, img.w, img.h)
if self.dim then bb:lightenRect(x, y, w, h, 0.55) end
if Screen.night_mode then bb:invertRect(px_, py_, img.w, img.h) end
- if self.border > 0 then bb:paintBorder(px_, py_, img.w, img.h, self.border, self.border_col or INK.gb) end
+ if self.border > 0 then paintBorder(bb, px_, py_, img.w, img.h, self.border, self.border_col or INK.gb) end
return
end
local dx, dy, dw, dh
@@ -708,7 +795,7 @@ function BoardCover:paintTo(bb, x, y)
bb:invertRect(dx, dy, dw, dh)
end
if self.border > 0 then
- bb:paintBorder(dx, dy, dw, dh, self.border, self.border_col or INK.gb)
+ paintBorder(bb, dx, dy, dw, dh, self.border, self.border_col or INK.gb)
end
end
@@ -769,7 +856,7 @@ function BoardUI.cover(book, w, h, o)
-- too small for one line stays an empty frame.
local lines = math.min(4, math.floor((h - 2 * frame_b - 2 * pad) / BoardMetrics.lh(s)))
if o.loading ~= true and w > px(48) and lines >= 1 then
- inner = { BoardUI.flex(0), BoardUI.text(title, { s = s, b = true, serif = true, lines = lines, w = tw, align = "center", data = true }), BoardUI.flex(0) }
+ inner = { BoardUI.flex(0), BoardUI.text(title, { s = s, b = true, serif = true, lines = lines, w = tw, align = "center", data = true, bg = INK.ge }), BoardUI.flex(0) }
end
return BoardUI.box({ w = w, h = h, border = frame_b, border_col = INK.black, bg = INK.ge },
BoardUI.vstack({ w = w - 2 * frame_b, h = h - 2 * frame_b, pad = pad, align = "center" }, inner))
@@ -798,10 +885,12 @@ function CellGrid:paintTo(bb, x, y)
bb:paintRoundedRect(x + cell.x - ring, y + cell.y - ring, s + 2 * ring, s + 2 * ring, INK.black, r + ring)
bb:paintRoundedRect(x + cell.x - gap, y + cell.y - gap, s + 2 * gap, s + 2 * gap, INK.white, r + gap)
end
- if cell.border then
+ if cell.border and isRGB(cell.col) and bb.paintBorderRGB32 then
+ bb:paintBorderRGB32(x + cell.x, y + cell.y, s, s, cell.border, cell.col, r, true)
+ elseif cell.border then
bb:paintBorder(x + cell.x, y + cell.y, s, s, cell.border, cell.col, r, true)
else
- bb:paintRoundedRect(x + cell.x, y + cell.y, s, s, cell.col, r)
+ paintRoundedRect(bb, x + cell.x, y + cell.y, s, s, cell.col, r)
end
end
end
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_board_widgets.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_board_widgets.lua
index 6e4f79d16..88e73aede 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_board_widgets.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_board_widgets.lua
@@ -12,6 +12,7 @@ local datetime = require("datetime")
local T = require("ffi/util").template
local _ = require("gettext")
+local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data")
local BoardStore = require("bookorbit_board_store")
local BoardMetrics = require("bookorbit_board_metrics")
@@ -85,7 +86,7 @@ local function cover(ctx, book, w, h, o)
path = book and book.cover_path,
loading = book and book.cover_state == "loading",
border = o.border or thin(),
- border_col = o.border_col or INK.gb,
+ border_col = o.border_col or BoardColour.coverEdge(ctx, INK.gb),
dim = o.dim,
})
end
@@ -132,7 +133,7 @@ W.hero = function(ctx, w, hh)
return hstack({ w = w, h = hh }, {
space(pad, 1),
vstack({ w = tw }, {
- text(_("Start reading"), { s = 11, b = true, col = INK.gb, w = tw }),
+ text(_("Start reading"), { s = 11, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = tw }),
space(nil, px(3)),
text(label, { s = 16, b = true, col = INK.white, lines = 2, w = tw }),
}),
@@ -159,7 +160,7 @@ W.hero = function(ctx, w, hh)
local shuffle = BoardUI.box({ w = BoardUI.runs({ _("Shuffle") }, { s = 14 }):getSize().w + px(16) + px(13) + px(16) + px(8), h = btn_h, radius = math.floor(btn_h / 2), bg = INK.white },
hstack({ h = btn_h, gap = px(8) }, { space(px(13), 1), icon("shuffle", px(16), { sw = 2.25 }), text(_("Shuffle"), { s = 14, b = true }) }))
return vstack({ w = w, h = hh, pad = pad }, {
- text(_("Start reading"), { s = 11, b = true, col = INK.gb, w = iw }),
+ text(_("Start reading"), { s = 11, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = iw }),
space(nil, px(3)),
text(_("Pick a book from your library to begin"), { s = 16, b = true, col = INK.white, lines = 2, w = iw }),
flex(px(10)),
@@ -195,9 +196,9 @@ W.hero = function(ctx, w, hh)
local function meta(mv)
local m = { { t = BoardData.pctLabel(book) or "0%", b = true, col = INK.white } }
if mv == 2 and book.minutes_left then
- table.insert(m, { t = " · " .. T(_("%1 left"), BoardData.formatDuration(book.minutes_left)), col = INK.gc })
+ table.insert(m, { t = " · " .. T(_("%1 left"), BoardData.formatDuration(book.minutes_left)), col = BoardColour.onInk(ctx, INK.gc, "hero") })
elseif mv == 1 and book.minutes_left then
- table.insert(m, { t = " · " .. T(_("%1 left"), BoardData.formatShortDuration(book.minutes_left)), col = INK.gc })
+ table.insert(m, { t = " · " .. T(_("%1 left"), BoardData.formatShortDuration(book.minutes_left)), col = BoardColour.onInk(ctx, INK.gc, "hero") })
end
return m
end
@@ -216,11 +217,11 @@ W.hero = function(ctx, w, hh)
space(px(14), 1),
vstack({ w = tw2, h = ch }, {
flex(0),
- lab and text(lab, { s = 11, b = true, col = INK.gb, w = tw2 }) or false,
+ lab and text(lab, { s = 11, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = tw2 }) or false,
lab and space(nil, px(3)) or false,
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw2, data = true }),
space(nil, px(7)),
- hstack({ w = tw2, gap = px(10) }, { bar(math.max(px(20), tw2 - mw - px(10)), px(5), pct, { track = INK.g5, fill = INK.white, r = px(2.5) }), m }),
+ hstack({ w = tw2, gap = px(10) }, { bar(math.max(px(20), tw2 - mw - px(10)), px(5), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(2.5) }), m }),
flex(0),
}),
space(px(14), 1),
@@ -252,13 +253,13 @@ W.hero = function(ctx, w, hh)
end
local function pager()
if total <= 1 then return nil end
- return hstack({ gap = px(2) }, { text(T(_("%1 / %2"), 1, total), { s = 12, col = INK.gb }), icon("chevron-right", px(15), { col = "gb", sw = 2.5 }) })
+ return hstack({ gap = px(2) }, { text(T(_("%1 / %2"), 1, total), { s = 12, col = BoardColour.onInk(ctx, INK.gb, "hero") }), icon("chevron-right", px(15), { col = BoardColour.onInkHex(ctx, "gb", "hero"), sw = 2.5 }) })
end
local function meta(full)
local m = { { t = tostring(math.floor(pct + 0.5)) .. "%", b = true, col = INK.white } }
if book.minutes_left then
local dur = full and BoardData.formatDuration(book.minutes_left) or BoardData.formatShortDuration(book.minutes_left)
- table.insert(m, { t = " · " .. T(_("%1 left"), dur), col = INK.gc })
+ table.insert(m, { t = " · " .. T(_("%1 left"), dur), col = BoardColour.onInk(ctx, INK.gc, "hero") })
end
return m
end
@@ -276,7 +277,7 @@ W.hero = function(ctx, w, hh)
local fixed = lh1(12) + label_gap + author_gap + lh1(15) + progress_gap + px(6) + meta_gap + lh1(13) + action_gap + px(40)
local tf = BoardUI.fitTitle(book.title, { b = true }, tw, { 26, 23, 20, 18, 16 }, 3, cover_h - fixed)
local actions = opt.ghost and hstack({ gap = px(8) }, { btn(opt.short), ghost() }) or btn(opt.short)
- local label = text(_("Continue reading"), { s = 12, b = true, col = INK.gb })
+ local label = text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero") })
return hstack({ w = w, h = hh, valign = "top" }, {
space(pad, 1),
-- A cover narrower than the column's height is centred in it,
@@ -288,9 +289,9 @@ W.hero = function(ctx, w, hh)
space(nil, label_gap),
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }),
space(nil, author_gap),
- text(firstAuthor(book) or "", { s = 15, col = INK.gd, w = tw, data = true }),
+ text(firstAuthor(book) or "", { s = 15, col = BoardColour.onInk(ctx, INK.gd, "hero"), w = tw, data = true }),
flex(progress_gap),
- bar(tw, px(6), pct, { track = INK.g5, fill = INK.white, r = px(3) }),
+ bar(tw, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
space(nil, meta_gap),
runs(meta(opt.full_meta), { s = 13, w = tw }),
space(nil, action_gap),
@@ -319,17 +320,17 @@ W.hero = function(ctx, w, hh)
vstack({ w = cover_w, h = hh, pad = { pad, 0, pad, 0 } }, { flex(0), cover(ctx, book, cover_w, math.min(cover_h, math.floor(cover_w * 1.5 + 0.5)), { border = math.max(1, px(1)), border_col = INK.g4 }), flex(0) }),
space(gutter, 1),
vstack({ w = tw, h = hh, pad = { pad, 0, pad, 0 }, strict_padding = true }, {
- text(_("Continue reading"), { s = 12, b = true, col = INK.gb, w = tw }),
+ text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = tw }),
space(nil, px(6)),
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }),
opt.author and space(nil, px(4)) or false,
- opt.author and text(firstAuthor(book) or "", { s = 13, col = INK.gd, w = tw, data = true }) or false,
+ opt.author and text(firstAuthor(book) or "", { s = 13, col = BoardColour.onInk(ctx, INK.gd, "hero"), w = tw, data = true }) or false,
flex(px(8)),
hstack({ w = tw, gap = px(10) }, {
vstack({ w = bar_w }, {
text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }),
space(nil, px(5)),
- bar(bar_w, px(6), pct, { track = INK.g5, fill = INK.white, r = px(3) }),
+ bar(bar_w, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
}),
BoardUI.hit(roundButton(ctx, btn_d, play_icon, book.on_device, true, not book.on_device and not d.online), { kind = "book", book = book, resume = true, list = { label = _("Continue reading"), books = d.continue_reading } }),
}),
@@ -345,7 +346,7 @@ W.hero = function(ctx, w, hh)
local cover_w = math.floor(cover_h * 2 / 3 + 0.5)
local tw = iw - cover_w - px(12)
local tf = BoardUI.fitTitle(book.title, { b = true }, tw, { 20, 18, 16, 15 }, 3, cover_h - (opt.author and (lh1(13) + px(4)) or 0))
- local label = text(_("Continue reading"), { s = 12, b = true, col = INK.gb })
+ local label = text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero") })
return vstack({ w = w, h = hh, pad = pad, strict_padding = true }, {
opt.pager and BoardUI.spread(iw, label, pager()) or label,
space(nil, px(8)),
@@ -354,11 +355,11 @@ W.hero = function(ctx, w, hh)
vstack({ w = tw }, {
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = tw, data = true }),
opt.author and space(nil, px(4)) or false,
- opt.author and text(firstAuthor(book) or "", { s = 13, col = INK.gd, w = tw, data = true }) or false,
+ opt.author and text(firstAuthor(book) or "", { s = 13, col = BoardColour.onInk(ctx, INK.gd, "hero"), w = tw, data = true }) or false,
}),
}),
flex(px(8)),
- bar(iw, px(6), pct, { track = INK.g5, fill = INK.white, r = px(3) }),
+ bar(iw, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
space(nil, px(8)),
runs(meta(opt.full_meta), { s = 13, w = iw }),
space(nil, px(12)),
@@ -374,7 +375,7 @@ W.hero = function(ctx, w, hh)
local bar_w = iw - btn_d - px(10)
local tf = BoardUI.fitTitle(book.title, { b = true }, iw, { 18, 16, 15 }, 2, ih - lh1(12) - px(6) - px(8) - btn_d)
return vstack({ w = w, h = hh, pad = pad, strict_padding = true }, {
- text(_("Continue reading"), { s = 12, b = true, col = INK.gb, w = iw }),
+ text(_("Continue reading"), { s = 12, b = true, col = BoardColour.onInk(ctx, INK.gb, "hero"), w = iw }),
space(nil, px(6)),
text(book.title, { s = tf.s, b = true, col = INK.white, lines = tf.lines, w = iw, data = true }),
flex(px(8)),
@@ -382,7 +383,7 @@ W.hero = function(ctx, w, hh)
vstack({ w = bar_w }, {
text(tostring(math.floor(pct + 0.5)) .. "%", { s = 13, b = true, col = INK.white, w = bar_w }),
space(nil, px(5)),
- bar(bar_w, px(6), pct, { track = INK.g5, fill = INK.white, r = px(3) }),
+ bar(bar_w, px(6), pct, { track = BoardColour.inkTrack(ctx, INK.g5, "hero"), fill = INK.white, r = px(3) }),
}),
BoardUI.hit(roundButton(ctx, btn_d, play_icon, book.on_device, true, not book.on_device and not d.online), { kind = "book", book = book, resume = true, list = { label = _("Continue reading"), books = d.continue_reading } }),
}),
@@ -424,9 +425,9 @@ W.streak = function(ctx, w, hh)
local dot_gap = wide and px(7) or math.max(px(3), math.floor((iw - 7 * px(9)) / 6))
return pick({
function()
- local dot_row = dots(d.streak_week, dot_size, dot_gap)
+ local dot_row = dots(d.streak_week, dot_size, dot_gap, { col = BoardColour.ink(ctx, INK.black) })
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, _("Streak"), icon("flame", px(17), { sw = 2.25 })),
+ head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
count(iw),
flex(0),
@@ -436,14 +437,14 @@ W.streak = function(ctx, w, hh)
-- A short row: the week beside the count.
function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, _("Streak"), icon("flame", px(17), { sw = 2.25 })),
+ head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
- BoardUI.spread(iw, count(nil), dots(d.streak_week, dot_size, dot_gap)),
+ BoardUI.spread(iw, count(nil), dots(d.streak_week, dot_size, dot_gap, { col = BoardColour.ink(ctx, INK.black) })),
})
end,
function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, _("Streak"), icon("flame", px(17), { sw = 2.25 })),
+ head(iw, _("Streak"), icon("flame", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
count(iw),
})
@@ -464,7 +465,7 @@ W.week = function(ctx, w, hh)
local bars = {}
for i, v in ipairs(d.week) do
local h = v > 0 and math.max(px(3), math.floor((chart_h - px(2)) * v / max + 0.5)) or px(2)
- local col = v > 0 and (i == 7 and INK.black or INK.g8) or INK.gc
+ local col = v > 0 and (i == 7 and BoardColour.ink(ctx, INK.black) or BoardColour.mid(ctx, INK.g8)) or BoardColour.track(ctx, INK.gc)
table.insert(bars, BoardUI.rect(bar_w, h, col))
end
return vstack({ w = iw }, {
@@ -530,7 +531,7 @@ W.today = function(ctx, w, hh)
return pick({
function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, _("Today"), icon("clock", px(17), { sw = 2.25 })),
+ head(iw, _("Today"), icon("clock", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
value(),
flex(0),
@@ -539,7 +540,7 @@ W.today = function(ctx, w, hh)
end,
function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, _("Today"), icon("clock", px(17), { sw = 2.25 })),
+ head(iw, _("Today"), icon("clock", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
value(),
})
@@ -559,7 +560,7 @@ W.goalpace = function(ctx, w, hh)
local function empty(str)
return function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, T(_("%1 goal"), g.year), icon("target", px(16), { sw = 2.25 })),
+ head(iw, T(_("%1 goal"), g.year), icon("target", px(16), BoardColour.icon(ctx, 2.25))),
space(nil, px(6)),
hint(str, iw, hh - 2 * pad - lh1(13) - px(6)),
})
@@ -583,7 +584,7 @@ W.goalpace = function(ctx, w, hh)
statuses = { T(_("%1 behind pace"), behind), T(_("%1 behind"), behind) }
end
local behind = not met and done > 0 and done < expected
- local trophy = icon(met and "trophy" or "target", px(16), { sw = 2.25 })
+ local trophy = icon(met and "trophy" or "target", px(16), BoardColour.icon(ctx, 2.25))
local function goalBar()
local scale = math.max(goal, projected, done, 1)
local goal_x = math.floor(iw * goal / scale + 0.5)
@@ -592,7 +593,7 @@ W.goalpace = function(ctx, w, hh)
and BoardUI.box({ w = tick_w + 2 * math.max(1, px(1)), h = px(14) + 2 * math.max(1, px(1)), border = math.max(1, px(1)), bg = INK.white })
or rect(tick_w, px(14), INK.black)
return BoardUI.overlay(iw, px(14), {
- { widget = bar(iw, px(6), math.floor(100 * done / scale + 0.5), { track = INK.gd, r = px(3) }), x = 0, y = px(4) },
+ { widget = bar(iw, px(6), math.floor(100 * done / scale + 0.5), { track = BoardColour.track(ctx, INK.gd), fill = BoardColour.ink(ctx, INK.black), r = px(3) }), x = 0, y = px(4) },
{ widget = tick, x = math.min(iw - px(2), goal_x) - (met and math.max(1, px(1)) or 0), y = met and -math.max(1, px(1)) or 0 },
})
end
@@ -669,13 +670,14 @@ W.heatmap = function(ctx, w, hh)
if v > 0 then days_read = days_read + 1 end
if v > best then best, best_i = v, i end
end
+ local ramp = BoardColour.ramp(ctx, { INK.ge, INK.gb, INK.g8, INK.g4, INK.black })
local function level(v)
if v == nil then return nil end
- if v <= 0 then return INK.ge end
- if v < 30 then return INK.gb end
- if v < 60 then return INK.g8 end
- if v < 120 then return INK.g4 end
- return INK.black
+ if v <= 0 then return ramp[1] end
+ if v < 30 then return ramp[2] end
+ if v < 60 then return ramp[3] end
+ if v < 120 then return ramp[4] end
+ return ramp[5]
end
local grid_w = weeks * cell + (weeks - 1) * g
local radius = math.max(1, px(1.5))
@@ -816,7 +818,7 @@ W.calendar = function(ctx, w, hh)
local x, y = (i % per_row) * (cell + gap), math.floor(i / per_row) * (cell + gap)
local is_read = read[day] == true
if is_read then
- table.insert(cells, { x = x, y = y, col = day == today and INK.black or INK.g6 })
+ table.insert(cells, { x = x, y = y, col = day == today and BoardColour.deep(ctx, INK.black) or BoardColour.ink(ctx, INK.g6) })
else
table.insert(cells, { x = x, y = y, col = day > today and INK.gd or INK.gb, border = outline })
end
@@ -878,11 +880,11 @@ W.also = function(ctx, w, hh)
})
table.insert(rows, sep or false)
table.insert(rows, BoardUI.hit(hstack({ w = iw, h = row_inner_h, gap = px(10) }, {
- cover(ctx, b, cover_w, cover_h, { border = thin(), border_col = INK.gb, dim = offlineDim(ctx, b) }),
+ cover(ctx, b, cover_w, cover_h, { border = thin(), dim = offlineDim(ctx, b) }),
vstack({ w = tw }, {
text(b.title, { s = 14, b = true, w = tw, data = true }),
space(nil, px(5)),
- hstack({ w = tw, gap = px(8) }, { bar(tw - right_w - px(8), px(4), pct, { track = INK.gc, r = px(2) }), right }),
+ hstack({ w = tw, gap = px(8) }, { bar(tw - right_w - px(8), px(4), pct, { track = BoardColour.track(ctx, INK.gc), fill = BoardColour.ink(ctx, INK.black), r = px(2) }), right }),
}),
}), { kind = "book", book = b, resume = ctx.tap_resumes, list = { label = _("Continue reading"), books = d.continue_reading } }))
end
@@ -907,7 +909,7 @@ W.highlight = function(ctx, w, hh)
return pick({
function()
return vstack({ w = w, h = hh, pad = pad }, {
- icon("quote", px(20), { fill = true, sw = 1 }),
+ icon("quote", px(20), { fill = true, sw = 1, col = BoardColour.artHex(ctx, "#000000", "deep") }),
space(nil, px(8)),
hint(str, iw, hh - 2 * pad - px(20) - px(8), o),
})
@@ -925,7 +927,7 @@ W.highlight = function(ctx, w, hh)
return text(hl.text, { s = qs, i = true, serif = true, lines = lines > 1 and lines or nil, w = iw3, col = INK.g1, data = true })
end
local label = function()
- return hstack({ w = iw3, h = lh1(12), gap = px(6) }, { icon("quote", px(14), { fill = true, sw = 1 }), text(_("Highlight of the day"), { s = 12, b = true, col = INK.g4 }), flex(0) })
+ return hstack({ w = iw3, h = lh1(12), gap = px(6) }, { icon("quote", px(14), { fill = true, sw = 1, col = BoardColour.artHex(ctx, "#000000", "deep") }), text(_("Highlight of the day"), { s = 12, b = true, col = INK.g4 }), flex(0) })
end
return pick({
function()
@@ -1005,7 +1007,7 @@ W.shelf = function(ctx, w, hh, props, item)
local title = finished and T(_("Finished in %1"), d.goal.year) or BoardWidgets.shelfTitle(props)
local right
if props.shuffle then
- right = BoardUI.hit(hstack({ w = px(36), h = lh1(13) }, { flex(0), icon("shuffle", px(17), { sw = 2.25, col = d.online and "black" or "ga" }) }),
+ right = BoardUI.hit(hstack({ w = px(36), h = lh1(13) }, { flex(0), icon("shuffle", px(17), { sw = BoardColour.icon(ctx, 2.25).sw, col = d.online and BoardColour.artHex(ctx, "#000000") or "ga" }) }),
d.online and { kind = "shuffle" } or { kind = "noop" })
elseif finished then
right = hstack({ gap = px(2) }, { text(tostring(d.goal.done or 0), { s = 13, b = true }), icon("chevron-right", px(15), { sw = 2.5 }) })
@@ -1035,7 +1037,7 @@ W.shelf = function(ctx, w, hh, props, item)
for i = 1, math.min(n, #books) do
local b = books[i]
table.insert(cards, BoardUI.hit(vstack({ w = cover_w }, {
- cover(ctx, b, cover_w, cover_h, { border = thin(), border_col = INK.gb, dim = offlineDim(ctx, b) }),
+ cover(ctx, b, cover_w, cover_h, { border = thin(), dim = offlineDim(ctx, b) }),
space(nil, px(6)),
text(b.title, { s = title_size, b = true, w = cover_w, data = true }),
}), { kind = "detail", book_id = b.id, list = { label = title, books = books } }))
@@ -1080,8 +1082,8 @@ local function fan(ctx, books, cover_h)
local layers = {}
for i = #list, 1, -1 do
local x = (#list - i) * step
- table.insert(layers, { widget = rect(cw + 2 * ring, cover_h + 2 * ring, INK.white), x = x - ring, y = -ring })
- table.insert(layers, { widget = cover(ctx, list[i], cw, cover_h, { border = thin(), border_col = INK.g9 }), x = x, y = 0 })
+ table.insert(layers, { widget = rect(cw + 2 * ring, cover_h + 2 * ring, BoardColour.halo(ctx, INK.white)), x = x - ring, y = -ring })
+ table.insert(layers, { widget = cover(ctx, list[i], cw, cover_h, { border = thin(), border_col = BoardColour.coverEdge(ctx, INK.g9) }), x = x, y = 0 })
end
return BoardUI.overlay(width, cover_h, layers)
end
@@ -1117,7 +1119,7 @@ W.link = function(ctx, w, hh, props)
need = text(label, { s = size, b = true }):getSize().w
end
return hstack({ w = iw, h = lh1(13), gap = px(5) }, {
- with_icon and icon(spec[1], px(15), { sw = 2.25 }) or false,
+ with_icon and icon(spec[1], px(15), BoardColour.icon(ctx, 2.25)) or false,
text(label, { s = size, b = true, w = lab_w }),
chevron or false,
})
@@ -1141,7 +1143,7 @@ W.link = function(ctx, w, hh, props)
local label2 = text(T(_("%1 GB free"), string.format("%.1f", storage.free_gb):gsub("%.0$", "")), { s = 11, col = INK.g4 })
local used = math.floor(100 * (storage.total_gb - storage.free_gb) / storage.total_gb + 0.5)
table.insert(items, space(nil, px(5)))
- table.insert(items, hstack({ w = iw, gap = px(8) }, { bar(iw - label2:getSize().w - px(8), px(6), used, { track = INK.gd, r = px(3) }), label2 }))
+ table.insert(items, hstack({ w = iw, gap = px(8) }, { bar(iw - label2:getSize().w - px(8), px(6), used, { track = BoardColour.track(ctx, INK.gd), fill = BoardColour.ink(ctx, INK.black), r = px(3) }), label2 }))
end
return vstack({ w = w, h = hh, pad = pad }, items)
end,
@@ -1253,7 +1255,7 @@ W.clock = function(ctx, w, hh)
text(d.clock, { s = val_s, b = true, w = iw, col = INK.white }),
flex(0),
text(d.weekday, { s = 13, b = true, w = iw, col = INK.white }),
- text(d.day_month, { s = 13, w = iw, col = INK.gc }),
+ text(d.day_month, { s = 13, w = iw, col = BoardColour.onInk(ctx, INK.gc) }),
})
end,
-- A short row: the time over the date.
@@ -1281,11 +1283,11 @@ W.sync = function(ctx, w, hh)
for _i, o in ipairs(opts) do
table.insert(variants, function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, title, icon(on and "cloud-check" or "cloud-off", px(17), { sw = 2.25 })),
+ head(iw, title, icon(on and "cloud-check" or "cloud-off", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
text(o[1], { s = o[2], b = true, w = iw }),
space(nil, px(6)),
- hstack({ gap = px(5) }, { icon(on and "refresh-cw" or "wifi", px(14), { sw = 2.5 }), text(on and _("Sync now") or _("Connect"), { s = 12, b = true }) }),
+ hstack({ gap = px(5) }, { icon(on and "refresh-cw" or "wifi", px(14), BoardColour.icon(ctx, 2.5)), text(on and _("Sync now") or _("Connect"), { s = 12, b = true }) }),
})
end)
end
@@ -1293,7 +1295,7 @@ W.sync = function(ctx, w, hh)
for _i, o in ipairs(opts) do
table.insert(variants, function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, title, icon(on and "cloud-check" or "cloud-off", px(17), { sw = 2.25 })),
+ head(iw, title, icon(on and "cloud-check" or "cloud-off", px(17), BoardColour.icon(ctx, 2.25))),
flex(0),
text(o[1], { s = o[2], b = true, w = iw }),
})
@@ -1327,7 +1329,7 @@ W.hours = function(ctx, w, hh)
local iw = w - 2 * pad
local hours = d.hours or {}
local part = dayPart(hours)
- local function sunMoon() return icon("sun-moon", px(17), { sw = 2.25 }) end
+ local function sunMoon() return icon("sun-moon", px(17), BoardColour.icon(ctx, 2.25)) end
if not part then
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("When you read"), sunMoon()),
@@ -1351,7 +1353,7 @@ W.hours = function(ctx, w, hh)
local v = hours[i] or 0
if v > 0 then
local h = math.max(px(2), math.floor((chart_h - px(2)) * v / max + 0.5))
- table.insert(bars, BoardUI.rect(bar_w, h, i - 1 == peak and INK.black or INK.g8))
+ table.insert(bars, BoardUI.rect(bar_w, h, i - 1 == peak and BoardColour.ink(ctx, INK.black) or BoardColour.mid(ctx, INK.g8)))
else
table.insert(bars, space(bar_w, 0))
end
@@ -1419,7 +1421,7 @@ W.records = function(ctx, w, hh)
local r = d.records
local pad = px(12)
local iw = w - 2 * pad
- local function award() return icon("award", px(17), { sw = 2.25 }) end
+ local function award() return icon("award", px(17), BoardColour.icon(ctx, 2.25)) end
if not r then
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Reading records"), award()),
@@ -1503,7 +1505,7 @@ W.toggles = function(ctx, w, hh)
table.insert(items, { "refresh-cw", _("Sync"), false, "sync" })
local function circleBtn(size, it)
local on = it[3]
- return BoardUI.hit(BoardUI.circle(size, { bg = on and INK.black or INK.white, border = on and 0 or math.max(1, px(1.5)) },
+ return BoardUI.hit(BoardUI.circle(size, { bg = on and BoardColour.ink(ctx, INK.black) or INK.white, border = on and 0 or math.max(1, px(1.5)) },
icon(it[1], math.floor(size * 0.46 + 0.5), { col = on and "white" or "black", sw = 2.25 })), { kind = it[4] })
end
if small then
@@ -1541,7 +1543,7 @@ W.light = function(ctx, w, hh)
local iw, ih = w - 2 * pad, hh - 2 * pad
if not lt then
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, _("Light"), icon("sun", px(16), { sw = 2.25 })),
+ head(iw, _("Light"), icon("sun", px(16), BoardColour.icon(ctx, 2.25))),
space(nil, px(6)),
hint(_("This device has no frontlight."), iw, hh - 2 * pad - lh1(13) - px(6), { sizes = { 12, 11 } }),
})
@@ -1549,7 +1551,7 @@ W.light = function(ctx, w, hh)
local function compact(bd, s)
return function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, _("Light"), icon("sun", px(16), { sw = 2.25 })),
+ head(iw, _("Light"), icon("sun", px(16), BoardColour.icon(ctx, 2.25))),
flex(0),
hstack({ w = iw }, {
BoardUI.hit(stepButton(bd, "minus"), { kind = "light", what = "brightness", delta = -5 }),
@@ -1565,13 +1567,13 @@ W.light = function(ctx, w, hh)
local avail = ih - lh1(13) - px(6)
local rh = math.min(px(38), math.floor((avail - (rows_n - 1) * px(5)) / rows_n))
local bd = math.min(px(32), rh)
- local function stepper(name, val, what)
+ local function stepper(name, val, what, fam)
local vw = px(36)
local bw = iw - px(18) - 2 * bd - vw - 4 * px(8)
return hstack({ w = iw, h = rh, gap = px(8) }, {
- icon(name, px(18), { sw = 2.25 }),
+ icon(name, px(18), BoardColour.icon(ctx, 2.25, fam)),
BoardUI.hit(stepButton(bd, "minus"), { kind = "light", what = what, delta = -5 }),
- bar(math.max(px(16), bw), px(8), val, { track = INK.gd, r = px(4) }),
+ bar(math.max(px(16), bw), px(8), val, { track = BoardColour.track(ctx, INK.gd, fam), fill = BoardColour.ink(ctx, INK.black, fam), r = px(4) }),
BoardUI.hit(stepButton(bd, "plus"), { kind = "light", what = what, delta = 5 }),
text(tostring(val), { s = 13, b = true, w = vw, align = "right" }),
})
@@ -1580,9 +1582,9 @@ W.light = function(ctx, w, hh)
return vstack({ w = w, h = hh, pad = pad }, {
head(iw, _("Light"), with_label and text(label, { s = 12, col = INK.g4 }) or nil),
flex(0),
- stepper("sun", lt.brightness, "brightness"),
+ stepper("sun", lt.brightness, "brightness", "gold"),
rows_n == 2 and space(nil, px(5)) or false,
- rows_n == 2 and stepper("thermometer-sun", lt.warmth, "warmth") or false,
+ rows_n == 2 and stepper("thermometer-sun", lt.warmth, "warmth", "orange") or false,
})
end
end
@@ -1602,7 +1604,7 @@ W.forecast = function(ctx, w, hh)
for _i, long in ipairs({ true, false }) do
table.insert(variants, function()
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, long and _("Finish forecast") or _("Finish"), icon("hourglass", px(16), { sw = 2.25 })),
+ head(iw, long and _("Finish forecast") or _("Finish"), icon("hourglass", px(16), BoardColour.icon(ctx, 2.25))),
space(nil, px(6)),
text(long and _("Read a little on this device and a finish date shows up here.") or _("Read a little"), { s = 13, col = INK.g4, lines = 3, w = iw }),
})
@@ -1619,7 +1621,7 @@ W.forecast = function(ctx, w, hh)
local is_end, is_today = i == n, i == 1
local t = d.now + (i - 1) * 86400
table.insert(cells, vstack({ w = cw, align = "center" }, {
- rect(cw, px(8), is_end and INK.black or (is_today and INK.g6 or INK.gc), px(4)),
+ rect(cw, px(8), is_end and BoardColour.ink(ctx, INK.black) or (is_today and BoardColour.mid(ctx, INK.g6) or BoardColour.track(ctx, INK.gc)), px(4)),
space(nil, px(3)),
text(BoardData.shortWeekday(t):sub(1, 1), { s = 11, b = is_end or is_today, col = (is_end or is_today) and INK.black or INK.g4, w = cw, align = "center" }),
}))
@@ -1665,7 +1667,7 @@ W.forecast = function(ctx, w, hh)
local cover_w = math.floor(cover_h * 2 / 3 + 0.5)
local tw = iw - cover_w - px(12)
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, cfg.label, icon("hourglass", px(16), { sw = 2.25 })),
+ head(iw, cfg.label, icon("hourglass", px(16), BoardColour.icon(ctx, 2.25))),
space(nil, px(8)),
hstack({ gap = px(12), valign = "bottom" }, {
cover(ctx, f.book, cover_w, cover_h),
@@ -1678,7 +1680,7 @@ W.forecast = function(ctx, w, hh)
})
end
return vstack({ w = w, h = hh, pad = pad }, {
- head(iw, cfg.label, icon("hourglass", px(16), { sw = 2.25 })),
+ head(iw, cfg.label, icon("hourglass", px(16), BoardColour.icon(ctx, 2.25))),
flex(0),
dayEl(cfg.day, iw),
cfg.strip and space(nil, px(8)) or false, cfg.strip and strip(iw) or false,
@@ -1712,7 +1714,7 @@ W.word = function(ctx, w, hh)
local wd = ctx.d.word
local pad = px(12)
local iw = w - 2 * pad
- local word_icon = icon("whole-word", px(17), { sw = 2.25 })
+ local word_icon = icon("whole-word", px(17), BoardColour.icon(ctx, 2.25))
if not wd then
local variants = {}
for _i, long in ipairs({ true, false }) do
@@ -1733,7 +1735,7 @@ W.word = function(ctx, w, hh)
local items = {
head(iw, _("Word of the day"), word_icon),
space(nil, px(cfg.big and 10 or 4)),
- text(wd.word, { s = cfg.ws, b = true, serif = true, w = iw, data = true }),
+ text(wd.word, { s = cfg.ws, b = true, serif = true, w = iw, data = true, col = BoardColour.ink(ctx, INK.black) }),
}
if cfg.ctx_lines > 0 then
table.insert(items, space(nil, px(4)))
@@ -1768,7 +1770,7 @@ W.yearago = function(ctx, w, hh)
local y = ctx.d.year_ago
local pad = px(12)
local iw, ih = w - 2 * pad, hh - 2 * pad
- local cal = icon("calendar-clock", px(17), { sw = 2.25 })
+ local cal = icon("calendar-clock", px(17), BoardColour.icon(ctx, 2.25))
if not y then
local variants = {}
for _i, long in ipairs({ true, false }) do
@@ -1862,7 +1864,8 @@ end
-- the outline never costs the content any room.
function BoardWidgets.tile(ctx, item, w, h)
local fn = W[item.type]
- local ch = BoardWidgets.chrome(ctx.tiles, item.chrome)
+ ctx.tile_key = BoardColour.keyFor(item.type, item.props)
+ local ch = BoardColour.chrome(ctx, item.chrome, ctx.tile_key, BoardWidgets.chrome(ctx.tiles, item.chrome), math.max(1, px(1)), math.max(1, px(1.5)))
local content
if fn then
BoardUI.debug_widget = item.type .. " " .. w .. "x" .. h
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_board.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_board.lua
index 68bfaec66..e8578b33e 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_board.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_board.lua
@@ -24,6 +24,7 @@ local logger = require("logger")
local T = require("ffi/util").template
local _ = require("gettext")
+local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data")
local BoardEdit = require("bookorbit_board_edit")
local BoardLayout = require("bookorbit_board_layout")
@@ -367,7 +368,7 @@ function CatalogBoard:boardPreview(ctx, entry, box_w, box_h, g)
local item = { type = entry.type, cw = cw, rh = rh, chrome = entry.chrome or "line", props = entry.props }
local key = "preview:" .. entry.type .. ":" .. tostring(entry.props and (entry.props.source or entry.props.link or entry.props.animal) or "") .. ":" .. pw .. "x" .. ph .. ":" .. sw
local shot = BoardUI.snapshot(key, pw, ph, sw, sh, function()
- return BoardWidgets.tile({ d = ctx.d, tiles = "outline", menu = self }, item, pw, ph)
+ return BoardWidgets.tile({ d = ctx.d, tiles = "outline", colour = ctx.colour, menu = self }, item, pw, ph)
end)
return BoardUI.box({ w = box_w, h = box_h, radius = px(12), bg = INK.ge }, hstack({ w = box_w, h = box_h }, { flex(0), shot, flex(0) }))
end
@@ -734,7 +735,7 @@ function CatalogBoard:updateDashboardItems(select_number, no_recalculate_dimen)
local page = self:boardPage()
local d = BoardData.build(self, context, os.time())
local state = BoardStore.read(self.settings)
- local ctx = { d = d, tiles = state.tiles, menu = self, tap_resumes = self.settings.catalog_dashboard_tap_resumes ~= false }
+ local ctx = { d = d, tiles = state.tiles, colour = state.colour, menu = self, tap_resumes = self.settings.catalog_dashboard_tap_resumes ~= false }
self._board_ctx = ctx
self._board_geometry = g
local t_data = PROFILE and os.clock()
@@ -782,11 +783,18 @@ function CatalogBoard:updateDashboardItems(select_number, no_recalculate_dimen)
self:boardRefreshPets()
end
--- The pets are colour pictures. A colour screen shows them in full colour only
--- through a dithered refresh (on Kaleido, its colour waveform), and the
--- board's own "ui" refresh is not one, so each pet tile gets its own.
+-- A colour screen shows colour only through a dithered refresh (on Kaleido,
+-- its colour waveform), and the board's own "ui" refresh is not one. With the
+-- Colour setting on, the whole board gets one; with it off, only the pets,
+-- which are colour pictures, get their own.
function CatalogBoard:boardRefreshPets()
if not Screen:isColorEnabled() then return end
+ if BoardColour.on(self._board_ctx) then
+ UIManager:setDirty(nil, function()
+ if self.dimen and self.dimen.x then return "partial", self.dimen, true end
+ end)
+ return
+ end
for _i, entry in ipairs(self._board_tiles or {}) do
if entry.item.type == "buddy" then
local tile = entry.tile
@@ -798,9 +806,16 @@ function CatalogBoard:boardRefreshPets()
end
-- Repaints one tile in place: rebuilds its content and refreshes only its
--- rectangle, so a stepper or a hero page does not flash the whole board.
+-- rectangle, so a stepper or a hero page does not flash the whole board. A
+-- coloured board's tiles keep their colours through the dithered refresh.
function CatalogBoard:boardRefreshTile(entry, mode, dither)
if not entry or not self._board_ctx then return end
+ -- The digital clock is black and white unless tiles are tinted, so its
+ -- minute stays on the fast refresh.
+ local plain_clock = entry.item.type == "clock" and not BoardColour.tinted(self._board_ctx)
+ if BoardColour.on(self._board_ctx) and not plain_clock and (mode == nil or mode == "ui" or mode == "fast") then
+ mode, dither = "partial", true
+ end
self._board_ctx.d = BoardData.build(self, self.current_context or {}, os.time())
local fresh = self:boardBuildTile(self._board_ctx, entry.item, entry.w, entry.h)
entry.tile[1] = fresh[1]
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua
index 2edf285dc..4c88e3bd4 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua
@@ -564,6 +564,15 @@ function MainMenu:applyBoardTiles(catalog, tiles)
end
end
+function MainMenu:applyBoardColour(catalog, colour)
+ if catalog and catalog.boardSetColour then
+ catalog:boardSetColour(colour)
+ else
+ self.settings[BoardStore.SETTING_KEY] = BoardStore.withColour(self.settings, colour)
+ G_reader_settings:flush()
+ end
+end
+
function MainMenu:chooseDownloadFolder(catalog, on_done)
if catalog and catalog.chooseDownloadDir then
catalog:chooseDownloadDir(on_done)
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_settings_page.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_settings_page.lua
index 3ecc4c187..10141c8fd 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_settings_page.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_settings_page.lua
@@ -33,6 +33,7 @@ local STRATEGY = { SILENT = 2, PROMPT = 1, DISABLE = 3 }
local STRATEGY_ORDER = { STRATEGY.SILENT, STRATEGY.PROMPT, STRATEGY.DISABLE }
local PRESETS = { "reader", "stats", "library" }
local TILES = { "outline", "soft", "ink" }
+local COLOURS = BoardStore.COLOURS
local function scaleLabel(value)
return string.format("%.2f", value):gsub("0$", "") .. "x"
@@ -297,19 +298,54 @@ local function homeBody(page, w, embedded)
})
local preset_labels = { _("Reader"), _("Stats"), _("Library") }
local tile_labels = { _("Outline"), _("Soft"), _("Ink") }
+ -- Colour only on a screen that shows it.
+ local colour_labels = Device.screen:isColorEnabled() and { _("Accents"), _("Tinted"), _("Off") } or nil
+ local function colourRow(rw, tight)
+ local row = segRow(rw, _("Colour"), nil, colour_labels, indexOf(COLOURS, state.colour, 1),
+ function(index) return { kind = "colour", value = COLOURS[index] } end, tight)
+ row._no_icon = true
+ return row
+ end
+ -- Colour sits beside Tile style when half a row holds both controls'
+ -- labels, so the page keeps the height it has on a black-and-white screen.
+ local function pairsWithTiles(half)
+ local seg = math.floor((half - 2 * math.max(1, px(1.5))) / 3) - px(8)
+ for _i, list in ipairs({ tile_labels, colour_labels }) do
+ for _j, label in ipairs(list) do
+ local t = text(label, { s = 14, b = true })
+ local wide = t:getSize().w > seg
+ t:free()
+ if wide then return false end
+ end
+ end
+ return true
+ end
local scale_labels = {}
for index, scale in ipairs(BoardMetrics.UI_SCALES) do
scale_labels[index] = scaleLabel(scale)
end
local a = segRow(iw, _("Starting layout"), _("Replaces your pages with a starter set"), preset_labels, indexOf(PRESETS, state.preset, 1),
function(index) return { kind = "preset", value = PRESETS[index] } end)
- local b = segRow(iw, _("Tile style"), nil, tile_labels, indexOf(TILES, state.tiles, 1),
+ local look_gap = px(16)
+ local look_half = math.floor((iw - look_gap) / 2)
+ local pair_colour = colour_labels ~= nil and not page.compact and pairsWithTiles(look_half)
+ local b = segRow(pair_colour and look_half or iw, _("Tile style"), nil, tile_labels, indexOf(TILES, state.tiles, 1),
function(index) return { kind = "tiles", value = TILES[index] } end)
local c = segRow(iw, _("Interface size"), _("Text and controls across BookOrbit"), scale_labels,
indexOf(BoardMetrics.UI_SCALES, BoardMetrics.normalizeUserScale(settings.ui_scale), 1),
function(index) return { kind = "ui-scale", value = BoardMetrics.UI_SCALES[index] } end)
a._no_icon, b._no_icon, c._no_icon = true, true, true
- local look = MenuUI.group(w, { a, b, c }, { inset = 0 })
+ local look
+ if pair_colour then
+ local pair = hstack({ w = iw, gap = look_gap, valign = "top" }, { b, colourRow(iw - look_half - look_gap) })
+ pair._no_icon = true
+ look = MenuUI.group(w, { a, pair, c }, { inset = 0 })
+ else
+ look = MenuUI.group(w, { a, b, c }, { inset = 0 })
+ end
+ -- Too narrow to share Tile style's row: Colour is a card of its own after
+ -- the first, which a two-column page can set beside Look.
+ local colour_card = colour_labels and not pair_colour and MenuUI.group(w, { colourRow(iw, page.compact ~= nil) }, { inset = 0 }) or nil
-- On a short, wide screen whose columns cannot hold the full card
-- (page.compact, set by build): without the notes, and at level 1 the
-- layout and tile style sharing a row when each half has room.
@@ -341,6 +377,8 @@ local function homeBody(page, w, embedded)
embedded and text(_("Home"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
top,
+ colour_card and space(nil, px(16)) or false,
+ colour_card or false,
space(nil, px(16)),
MenuUI.section(w, _("Look"), look),
space(nil, px(16)),
@@ -818,6 +856,9 @@ local function onAction(page, action)
elseif kind == "tiles" then
plugin:applyBoardTiles(catalog, action.value)
page:refresh()
+ elseif kind == "colour" then
+ plugin:applyBoardColour(catalog, action.value)
+ page:refresh()
elseif kind == "ui-scale" then
if settings.ui_scale == action.value then return true end
local scale = BoardMetrics.setUserScale(action.value)
diff --git a/koreader-plugin/spec/bookorbit_board_colour_test.lua b/koreader-plugin/spec/bookorbit_board_colour_test.lua
new file mode 100644
index 000000000..689c3fa6d
--- /dev/null
+++ b/koreader-plugin/spec/bookorbit_board_colour_test.lua
@@ -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")