Files
slate32/sdcard/lib/ui.lua
T
evan 2cdb7b3702 feat: timezone setting and main-axis justify in the ui toolkit
The launcher clock read UTC and sat next to the title because the toolkit could only
stack children from the start of an axis. justify adds the CSS main-axis modes that
had a caller -- start, end, center, between -- so a header keeps its title left and
its clock right without any app doing arithmetic.

Timezones are stored as POSIX TZ rules rather than offsets, so newlib applies DST
changeovers and os.date() in Lua reports local time with no binding of its own.
/lib/timezones.lua is only the picker list: a zone missing from it still works if its
rule is written into settings, the same split themes already use.

settings_calibration.lua now finds rows by label. Adding the timezone row shifted
every hardcoded y coordinate in it, which is the failure that had been predicted and
would have silently retargeted taps at the wrong control.
2026-08-01 17:03:55 -04:00

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