2cdb7b3702
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.
351 lines
11 KiB
Lua
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
|