5a3aabf220
Assigning .label repainted nothing until the caller also remembered to invalidate, and forgetting produced a screen that silently stopped updating with no error to follow. setText makes the pair atomic and skips the repaint when the text is unchanged, so an app can push a value on every tick without comparing first.
358 lines
11 KiB
Lua
358 lines
11 KiB
Lua
-- Layout borrowed from CSS block flow: nesting plus box model, no cascade.
|
|
-- Sizes are pixels (>= 1), a fraction of the parent content box (< 1), or "auto".
|
|
|
|
local ui = {}
|
|
|
|
local PRESS_MS = 80 -- a fast tap must stay visible for at least this long
|
|
local SLOP = 4 -- resistive panels land a few pixels off
|
|
|
|
-- Theme --------------------------------------------------------------------
|
|
-- Inheritance carries context (what surface am I on); the theme carries constants
|
|
-- (what is the accent). Roles are derived from three seeds so a new component costs
|
|
-- no theme keys, and so a theme cannot state its own contrast wrongly.
|
|
|
|
local BLACK, WHITE = {0, 0, 0}, {255, 255, 255}
|
|
local FALLBACK = {bg = WHITE, fg = BLACK, accent = {0, 120, 255}}
|
|
local PINNED = {muted = "color", accent_fg = "color", face = "pair", face_pressed = "pair", radius = "number"}
|
|
|
|
local function channel(value)
|
|
return math.max(0, math.min(math.floor(value + 0.5), 255))
|
|
end
|
|
|
|
local function mix(a, b, amount)
|
|
local blended = {}
|
|
for i = 1, 3 do blended[i] = channel(a[i] + (b[i] - a[i]) * amount) end
|
|
return blended
|
|
end
|
|
|
|
-- Perceived brightness, so text on the accent is chosen rather than configured.
|
|
local function luminance(c)
|
|
return (c[1] * 299 + c[2] * 587 + c[3] * 114) / 1000
|
|
end
|
|
|
|
local function rgb(c)
|
|
return gui.color(c[1], c[2], c[3])
|
|
end
|
|
|
|
local function palette(seed)
|
|
local bg, fg, accent = seed.bg, seed.fg, seed.accent
|
|
local theme = {
|
|
bg = rgb(bg),
|
|
fg = rgb(fg),
|
|
muted = rgb(mix(fg, bg, 0.45)),
|
|
accent = rgb(accent),
|
|
accent_fg = rgb(luminance(accent) > 140 and BLACK or WHITE),
|
|
face = {rgb(bg), rgb(mix(bg, fg, 0.08))},
|
|
face_pressed = {rgb(mix(accent, WHITE, 0.12)), rgb(mix(accent, BLACK, 0.12))},
|
|
radius = 6,
|
|
}
|
|
for role, kind in pairs(PINNED) do
|
|
local pin = seed[role]
|
|
if pin ~= nil then
|
|
if kind == "color" then theme[role] = rgb(pin)
|
|
elseif kind == "pair" then theme[role] = {rgb(pin[1]), rgb(pin[2])}
|
|
else theme[role] = pin end
|
|
end
|
|
end
|
|
return theme
|
|
end
|
|
|
|
local function themes()
|
|
local ok, loaded = pcall(require, "theme")
|
|
return ok and type(loaded) == "table" and loaded or {}
|
|
end
|
|
|
|
function ui.themeNames()
|
|
local names = {}
|
|
for name in pairs(themes()) do names[#names + 1] = name end
|
|
table.sort(names)
|
|
return names
|
|
end
|
|
|
|
-- Re-read the saved theme. Called at load, and again by the settings app so a
|
|
-- changed theme takes effect without relaunching.
|
|
function ui.reloadTheme()
|
|
ui.themeName = sys.getTheme and sys.getTheme() or "light"
|
|
local available = themes()
|
|
ui.theme = palette(available[ui.themeName] or available.light or FALLBACK)
|
|
return ui.theme
|
|
end
|
|
|
|
ui.reloadTheme()
|
|
|
|
local function resolve(value, span, axis)
|
|
if value == nil or value == "auto" then return nil end
|
|
if value < 1 then
|
|
if span == nil then
|
|
error("fractional " .. axis .. " inside an auto-sized parent", 3)
|
|
end
|
|
return math.floor(value * span)
|
|
end
|
|
return math.floor(value)
|
|
end
|
|
|
|
local function padding(spec)
|
|
local p = spec.pad or 0
|
|
if type(p) == "number" then return {t = p, r = p, b = p, l = p} end
|
|
return {t = p.t or 0, r = p.r or 0, b = p.b or 0, l = p.l or 0}
|
|
end
|
|
|
|
-- The palette set on the root reaches every descendant, so styling is one place.
|
|
local INHERITED = {"color", "bg", "size", "button_bg", "press_bg", "press_color", "radius",
|
|
"gradient", "press_gradient"}
|
|
|
|
local function inherit(child, parent)
|
|
for _, key in ipairs(INHERITED) do
|
|
if child[key] == nil then child[key] = parent[key] end
|
|
end
|
|
end
|
|
|
|
local function contains(rect, x, y)
|
|
return x >= rect.x - SLOP and x < rect.x + rect.w + SLOP
|
|
and y >= rect.y - SLOP and y < rect.y + rect.h + SLOP
|
|
end
|
|
|
|
-- Components ---------------------------------------------------------------
|
|
|
|
local Component = {}
|
|
Component.__index = Component
|
|
|
|
function Component:measure(available)
|
|
local pad = self.pad_
|
|
local w = resolve(self.w, available.w, "width")
|
|
local h = resolve(self.h, available.h, "height")
|
|
local inner = {
|
|
w = w and w - pad.l - pad.r or (available.w and available.w - pad.l - pad.r),
|
|
h = h and h - pad.t - pad.b or nil,
|
|
}
|
|
|
|
local main, cross = 0, 0
|
|
for index, child in ipairs(self.children) do
|
|
inherit(child, self)
|
|
local cw, ch = child:measure(inner)
|
|
if self.row then
|
|
main = main + cw + (index > 1 and self.gap or 0)
|
|
cross = math.max(cross, ch)
|
|
else
|
|
main = main + ch + (index > 1 and self.gap or 0)
|
|
cross = math.max(cross, cw)
|
|
end
|
|
end
|
|
|
|
if self.row then
|
|
self.mw = w or main + pad.l + pad.r
|
|
self.mh = h or cross + pad.t + pad.b
|
|
else
|
|
self.mw = w or cross + pad.l + pad.r
|
|
self.mh = h or main + pad.t + pad.b
|
|
end
|
|
return self.mw, self.mh
|
|
end
|
|
|
|
function Component:place(rect)
|
|
self.rect = rect
|
|
self.dirty = true
|
|
local pad = self.pad_
|
|
local content = {
|
|
x = rect.x + pad.l,
|
|
y = rect.y + pad.t,
|
|
w = rect.w - pad.l - pad.r,
|
|
h = rect.h - pad.t - pad.b,
|
|
}
|
|
|
|
-- Main-axis distribution, CSS justify-content minus the modes nothing here asks for.
|
|
-- Absolutely placed children take no part in the flow, so they are excluded.
|
|
local flowing, used = 0, 0
|
|
for _, child in ipairs(self.children) do
|
|
if not child.at then
|
|
flowing = flowing + 1
|
|
used = used + (self.row and child.mw or child.mh)
|
|
end
|
|
end
|
|
used = used + math.max(0, flowing - 1) * self.gap
|
|
local free = math.max(0, (self.row and content.w or content.h) - used)
|
|
|
|
local offset, spread = 0, 0
|
|
if self.justify == "end" then offset = free
|
|
elseif self.justify == "center" then offset = math.floor(free / 2)
|
|
elseif self.justify == "between" and flowing > 1 then spread = math.floor(free / (flowing - 1))
|
|
end
|
|
|
|
for _, child in ipairs(self.children) do
|
|
local cw, ch = child.mw, child.mh
|
|
-- Cross axis fills the parent unless the child asked for a size, like CSS blocks.
|
|
if self.row then
|
|
if child.h == nil or child.h == "auto" then ch = content.h end
|
|
else
|
|
if child.w == nil or child.w == "auto" then cw = content.w end
|
|
end
|
|
|
|
local x, y
|
|
if child.at then
|
|
x = content.x + resolve(child.at.x, content.w, "x")
|
|
y = content.y + resolve(child.at.y, content.h, "y")
|
|
elseif self.row then
|
|
x, y = content.x + offset, content.y
|
|
if self.align == "center" then y = y + math.floor((content.h - ch) / 2)
|
|
elseif self.align == "end" then y = y + content.h - ch end
|
|
offset = offset + cw + self.gap + spread
|
|
else
|
|
x, y = content.x, content.y + offset
|
|
if self.align == "center" then x = x + math.floor((content.w - cw) / 2)
|
|
elseif self.align == "end" then x = x + content.w - cw end
|
|
offset = offset + ch + self.gap + spread
|
|
end
|
|
child:place{x = x, y = y, w = cw, h = ch}
|
|
end
|
|
end
|
|
|
|
function Component:draw()
|
|
if self.dirty then
|
|
if self.bg then gui.fillRect(self.rect.x, self.rect.y, self.rect.w, self.rect.h, self.bg) end
|
|
if self.paint then self:paint() end
|
|
self.dirty = false
|
|
for _, child in ipairs(self.children) do child.dirty = true end
|
|
end
|
|
for _, child in ipairs(self.children) do child:draw() end
|
|
end
|
|
|
|
-- Deepest interactive component wins, so a tappable child beats its tappable parent.
|
|
function Component:hit(x, y)
|
|
if not self.rect or not contains(self.rect, x, y) then return nil end
|
|
for index = #self.children, 1, -1 do
|
|
local found = self.children[index]:hit(x, y)
|
|
if found then return found end
|
|
end
|
|
return self.on_press and self or nil
|
|
end
|
|
|
|
function Component:invalidate()
|
|
self.dirty = true
|
|
end
|
|
|
|
local function component(spec)
|
|
spec.children = spec.children or {}
|
|
for index, child in ipairs(spec) do
|
|
spec.children[index] = child
|
|
spec[index] = nil
|
|
end
|
|
spec.gap = spec.gap or 0
|
|
spec.align = spec.align or "start"
|
|
spec.pad_ = padding(spec)
|
|
return setmetatable(spec, Component)
|
|
end
|
|
|
|
ui.box = component
|
|
|
|
function ui.spacer(spec)
|
|
return component{w = spec.w, h = spec.h}
|
|
end
|
|
|
|
function ui.text(label, spec)
|
|
spec = spec or {}
|
|
spec.label = label
|
|
local node = component(spec)
|
|
node.measure = function(self, available)
|
|
self.mw = resolve(self.w, available.w, "width") or gui.textWidth(self.label)
|
|
self.mh = resolve(self.h, available.h, "height") or gui.fontHeight()
|
|
return self.mw, self.mh
|
|
end
|
|
node.paint = function(self)
|
|
gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg)
|
|
end
|
|
-- Setting .label alone repaints nothing, which fails silently. Unchanged text also
|
|
-- costs nothing, so a caller can push a value every tick without thinking about it.
|
|
node.setText = function(self, text)
|
|
if text == self.label then return end
|
|
self.label = text
|
|
self:invalidate()
|
|
end
|
|
return node
|
|
end
|
|
|
|
function ui.button(spec)
|
|
spec.pad = spec.pad or 8
|
|
spec.align = spec.align or "center"
|
|
if spec.label then
|
|
spec.children = {ui.text(spec.label)}
|
|
spec.label = nil
|
|
end
|
|
local node = component(spec)
|
|
node.paint = function(self)
|
|
local r = self.rect
|
|
local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient)
|
|
local top, bottom
|
|
if gradient then
|
|
top, bottom = gradient[1], gradient[2]
|
|
else
|
|
top = self.pressed and (self.press_bg or self.color) or self.button_bg
|
|
bottom = top
|
|
end
|
|
-- self.bg is the surface this button sits on, which the anti-aliased edge blends into.
|
|
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, top, bottom, self.color)
|
|
for _, child in ipairs(self.children) do
|
|
child.bg = bottom or self.bg -- text blends against the bottom stop
|
|
child.color = self.pressed and (self.press_color or self.bg) or self.color
|
|
child.dirty = true
|
|
end
|
|
end
|
|
return node
|
|
end
|
|
|
|
-- Screen -------------------------------------------------------------------
|
|
|
|
local Screen = {}
|
|
Screen.__index = Screen
|
|
|
|
-- The root seeds the tree, so an app that names no colors is themed by inheritance.
|
|
local ROOT_STYLE = {
|
|
color = "fg", bg = "bg", press_bg = "accent", press_color = "accent_fg",
|
|
gradient = "face", press_gradient = "face_pressed", radius = "radius",
|
|
}
|
|
|
|
function ui.screen(root, style)
|
|
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
|
|
for key, role in pairs(ROOT_STYLE) do
|
|
if root[key] == nil then root[key] = (style and style[key]) or ui.theme[role] end
|
|
end
|
|
screen:relayout()
|
|
return screen
|
|
end
|
|
|
|
function Screen:relayout()
|
|
local rect = {x = 0, y = 0, w = gui.width(), h = gui.height()}
|
|
self.root:measure(rect)
|
|
self.root:place(rect)
|
|
gui.clear(self.root.bg)
|
|
end
|
|
|
|
function Screen:draw()
|
|
self.root:draw()
|
|
-- Hold the pressed look briefly so a fast tap is still perceptible.
|
|
if self.released and sys.millis() - self.pressedAt >= PRESS_MS then
|
|
self.released.pressed = false
|
|
self.released:invalidate()
|
|
self.released = nil
|
|
end
|
|
end
|
|
|
|
function Screen:down(x, y)
|
|
local target = self.root:hit(x, y)
|
|
if not target then return end
|
|
self.captured = target
|
|
self.pressedAt = sys.millis()
|
|
target.pressed = true
|
|
target:invalidate()
|
|
end
|
|
|
|
function Screen:up(x, y)
|
|
local target = self.captured
|
|
self.captured = nil
|
|
if not target then return end
|
|
self.released = target
|
|
local inside = contains(target.rect, x, y)
|
|
if inside and target.on_press then target.on_press(target) end
|
|
end
|
|
|
|
return ui
|