From 267f53fccd909cc05dbb8edca88b698a439b8a9e Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:27:39 +0000 Subject: [PATCH] feat(koreader): colour the widget board on colour screens Add a Colour setting to Settings > Home: Accents, the default, colours each widget's marks and draws Continue reading in its cover's deep shade; Tinted also washes every tile in its widget's colour; Off keeps the greys. Black-and- white screens draw as before, colour e-readers get stronger shades, and night mode keeps each colour's hue. --- .../bookorbit_board_art.lua | 8 +- .../bookorbit_board_colour.lua | 437 ++++++++++++++++++ .../bookorbit_board_decor.lua | 181 +++++--- .../bookorbit_board_edit.lua | 6 + .../bookorbit_board_store.lua | 14 +- .../bookorbit.koplugin/bookorbit_board_ui.lua | 101 +++- .../bookorbit_board_widgets.lua | 143 +++--- .../bookorbit_catalog_board.lua | 27 +- .../bookorbit_main_menu.lua | 9 + .../bookorbit_settings_page.lua | 45 +- .../spec/bookorbit_board_colour_test.lua | 208 +++++++++ 11 files changed, 1017 insertions(+), 162 deletions(-) create mode 100644 koreader-plugin/bookorbit.koplugin/bookorbit_board_colour.lua create mode 100644 koreader-plugin/spec/bookorbit_board_colour_test.lua 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")