f7c86ded80
A dialog is a component the app includes when its state says so, placed absolutely so it covers the flow instead of joining it, and dismissed by rebuilding without it. No layer stack, no module state, no lifecycle: "on top" already means "later in the child list", which the draw walk gives for free, and nothing needs to survive a rebuild because the tree is derived from state rather than mutated beside it. The alternative was a ui.push/ui.pop stack of roots. It would have been the only imperative thing in an otherwise declarative program, and needed rules to reconcile two ways for something to reach the screen -- the rule that a rebuild must only replace the base being the one that would eventually be forgotten. Three primitives were missing and are now here. capture makes a component swallow the taps its children missed, without which the hit walk falls back to earlier siblings and a dialog can be tapped through. border draws a box as a rounded rect. fill names a size a fraction cannot express, since any number >= 1 is read as pixels, so w = 1.0 asked for one pixel. The root now measures as "fill" rather than auto. It is placed at the full panel rect already, so its own children could not resolve a fraction of the one component whose size is never in doubt. Settings asks before forgetting a network, which is the first caller.
418 lines
14 KiB
Lua
418 lines
14 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), "fill" for all
|
|
-- of it, or "auto" to size to content.
|
|
|
|
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
|
|
-- "fill" exists because a fraction cannot say 1.0: any number >= 1 is a pixel count,
|
|
-- so w = 1.0 asks for a single pixel. Covering a parent needs a word, not a number.
|
|
if value == "fill" then
|
|
if span == nil then
|
|
error("fill " .. axis .. " inside an auto-sized parent", 3)
|
|
end
|
|
return span
|
|
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
|
|
-- A capturing component swallows the taps its children missed, so what it covers
|
|
-- cannot be tapped through. Without it the hit walk falls back to earlier siblings,
|
|
-- which is how a dialog would let you press the button underneath it.
|
|
if self.capture then return self end
|
|
return self.on_press and self or nil
|
|
end
|
|
|
|
function Component:invalidate()
|
|
self.dirty = true
|
|
end
|
|
|
|
local function component(spec)
|
|
-- A bordered box is drawn as a rounded rect over its own background fill. The fill is
|
|
-- square and the border is not, but both are the same color as whatever sits behind a
|
|
-- box on this panel, so the corners have nothing to give away.
|
|
if spec.border then
|
|
spec.paint = function(self)
|
|
local r = self.rect
|
|
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, self.bg,
|
|
self.bg, self.border)
|
|
end
|
|
end
|
|
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
|
|
|
|
-- A dialog is not a layer the toolkit manages: it is a node an app includes when its
|
|
-- state calls for one, placed absolutely so it covers the flow instead of joining it.
|
|
-- Dismissing it means rebuilding without it, the same way every other state change works.
|
|
function ui.confirm(spec)
|
|
local card = {
|
|
w = spec.w or 0.85,
|
|
pad = 16,
|
|
gap = 12,
|
|
bg = ui.theme.bg, -- opaque: this is what hides the content behind
|
|
border = spec.border or ui.theme.muted,
|
|
ui.text(spec.title),
|
|
}
|
|
if spec.message then card[#card + 1] = ui.text(spec.message, {color = ui.theme.muted}) end
|
|
|
|
local buttons = {row = true, gap = 8, justify = "end"}
|
|
if spec.cancel ~= false then
|
|
buttons[#buttons + 1] = ui.button{label = spec.cancel or "cancel", on_press = spec.on_cancel}
|
|
end
|
|
buttons[#buttons + 1] = ui.button{label = spec.ok or "ok", on_press = spec.on_ok}
|
|
card[#card + 1] = ui.box(buttons)
|
|
|
|
-- bg = false leaves the app's own screen visible around the card; capture stops it
|
|
-- being tapped. on_press is deliberately absent, so a stray touch answers nothing.
|
|
return ui.box{
|
|
at = {x = 0, y = 0}, w = "fill", h = "fill",
|
|
bg = false, capture = true,
|
|
align = "center", justify = "center",
|
|
on_press = spec.on_outside,
|
|
ui.box(card),
|
|
}
|
|
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)
|
|
-- The root is placed at the full panel rect, so it must measure that way too. Leaving
|
|
-- it auto made its height unknown to its own children, and a child asking for a
|
|
-- fraction of the screen failed inside the one component whose size is never in doubt.
|
|
if root.w == nil then root.w = "fill" end
|
|
if root.h == nil then root.h = "fill" end
|
|
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
|