Files
bookorbit/koreader-plugin/spec/bookorbit_board_colour_test.lua
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

209 lines
11 KiB
Lua

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")