Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_board_colour.lua
T
neon 267f53fccd 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.
2026-10-27 15:27:39 +00:00

438 lines
17 KiB
Lua

--[[--
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