-- Widgets and palette. The tree itself lives in the firmware: every constructor here -- returns an integer handle, and the C++ arena holds sixteen bytes where a Lua table -- held several hundred. -- -- Layout is 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 -- Dimming is a black scrim at this strength. The panel has no alpha and there is no -- framebuffer to blend against, so instead of compositing pixels the whole palette is -- re-derived from darkened seeds: content behind a dialog is repainted in those colors, -- which is what a scrim would have produced anyway. local SCRIM = 0.45 local function dimSeed(seed) local out = {} for role, value in pairs(seed) do if type(value) ~= "table" then out[role] = value elseif type(value[1]) == "table" then out[role] = {mix(value[1], BLACK, SCRIM), mix(value[2], BLACK, SCRIM)} else out[role] = mix(value, BLACK, SCRIM) end end return out 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 -- Saves the theme and reloads the palette. The firmware stores only the name and cannot -- call back into Lua, so this is the seam that keeps the two halves from drifting. function ui.setTheme(name) local ok = settings.setTheme(name) ui.reloadTheme() return ok end -- Re-read the saved theme. Called at load, and by ui.setTheme(), so a changed theme takes -- effect without relaunching. function ui.reloadTheme() ui.themeName = settings.getTheme and settings.getTheme() or "light" local available = themes() local seed = available[ui.themeName] or available.light or FALLBACK ui.theme = palette(seed) ui.theme.dim = palette(dimSeed(seed)) return ui.theme end ui.reloadTheme() -- Handlers ----------------------------------------------------------------- -- Callbacks stay in Lua, keyed by node id. Ids are handed out densely from zero, so -- these are array parts rather than hashes, which is cheaper than a registry reference -- and leaves nothing to release. local press, down, unpress, painters, flat = {}, {}, {}, {}, {} -- Which nodes were given a whole palette, and which one. A root that already chose the -- dim palette must not be reseeded with the lit one, and the panel is cleared to whatever -- the root resolved to rather than to the theme's own background. local paletted = {} -- Set once a tree has been laid out, so the next node built starts a new screen. Apps -- rebuild rather than mutate, and making that automatic is what keeps a forgotten reset -- from quietly growing the arena one screen at a time. local laidOut = false local function forget() press, down, unpress, painters, flat = {}, {}, {}, {}, {} paletted = {} end -- Every node's paint routine is looked up here, so one dispatcher serves the whole tree. node.setPainter(function(id, x, y, w, h) local paint = painters[id] if paint then paint(id, x, y, w, h) end end) -- Components --------------------------------------------------------------- local STYLE_KEYS = {"color", "border", "face", "face_pressed", "press_color", "radius", "size", "text_align"} -- Applies a whole palette to one node. Descendants inherit it by walking up, so a -- dimmed region and the lit dialog above it are each one call, not a tree walk. local function applyPalette(id, theme) paletted[id] = theme node.setStyle(id, { color = theme.fg, bg = theme.bg, face = theme.face, face_pressed = theme.face_pressed, press_color = theme.accent_fg, radius = theme.radius, }) end local function applyStyle(id, spec) if spec.dimmed ~= nil then applyPalette(id, spec.dimmed and ui.theme.dim or ui.theme) end local style, any = {}, false for _, key in ipairs(STYLE_KEYS) do if spec[key] ~= nil then style[key], any = spec[key], true end end -- A box that names a background both paints it and offers it to its children; `false` -- says to paint nothing, which is how a dialog layer covers without covering up. if type(spec.bg) == "number" then style.bg, style.fill, any = spec.bg, spec.bg, true end if any then node.setStyle(id, style) end end local function build(spec, kind) if laidOut then ui.reset() end local children = {} for index, child in ipairs(spec) do children[index] = child spec[index] = nil end spec.type = kind spec.interactive = spec.on_press ~= nil or spec.on_down ~= nil local id = node.create(nil, spec) for _, child in ipairs(children) do node.attach(id, child) end applyStyle(id, spec) if spec.on_press then press[id] = spec.on_press end if spec.on_down then down[id] = spec.on_down end if spec.on_unpress then unpress[id] = spec.on_unpress end if spec.paint then painters[id] = spec.paint end if spec.press_style == false then flat[id] = true end return id end function ui.box(spec) return build(spec, "box") end function ui.spacer(spec) return build({w = spec.w, h = spec.h}, "box") end function ui.text(label, spec) spec = spec or {} spec.label = label return build(spec, "text") end -- A text node sized to its own glyphs, so a centering parent has something to center: a -- plain ui.text takes the parent's full width and paints from its left edge. `fit` clips -- the label to a pixel budget, because the panel has one font and no wrapping. function ui.label(text, spec) spec = spec or {} gui.setTextSize(spec.size or 1) if spec.fit and gui.getTextWidth(text) > spec.fit then while #text > 1 and gui.getTextWidth(text .. "~") > spec.fit do text = text:sub(1, -2) end text = text .. "~" end spec.w, spec.fit = gui.getTextWidth(text), nil return ui.text(text, spec) end function ui.button(spec) spec.pad = spec.pad or 8 spec.align = spec.align or "center" local label, size = spec.label, spec.size spec.label = nil local id = build(spec, "button") -- The label is created after its button, so it needs no adoption. if label then node.create(id, {type = "text", label = label, size = size}) end return id end -- A node that paints itself through the gui bindings. One node instead of a table per -- part is what keeps a full keyboard off the heap while wifi is up. function ui.custom(spec) return build(spec, "custom") end -- Replaces a node's text. The node keeps the box it was placed with, so this is for -- values that fit the space already reserved for them. function ui.setText(id, text) if node.getLabel(id) == text then return end node.setLabel(id, text) node.invalidate(id) end function ui.invalidate(id) node.invalidate(id) end -- Geometry for a grid of square cards: a wide frame takes another column, and the side -- shrinks until every row fits, because there is no scrolling and a card below the fold -- cannot be tapped. `reserve` is height the caller needs for anything under the grid. -- ponytail: enough cards make them unusably small; that is the point to add scrolling. function ui.cardSide(count, pad, gap, reserve) local cols = gui.getWidth() >= gui.getHeight() and 4 or 3 local rows = math.ceil(count / cols) local byWidth = (gui.getWidth() - 2 * pad - (cols - 1) * gap) // cols local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows return math.min(byWidth, byHeight), cols 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 is left unset so the app's own screen stays visible around the card; capture stops -- it being tapped. on_press is optional, so a stray touch answers nothing by default. return ui.box{ at = {x = 0, y = 0}, w = "fill", h = "fill", capture = true, dimmed = false, -- the card is lit, whatever is behind it align = "center", justify = "center", on_press = spec.on_outside, ui.box(card), } end -- Screen ------------------------------------------------------------------- local Screen = {} Screen.__index = Screen -- Starts a new tree. Every screen is built from scratch, which is why the heap returns to -- the same shape after each one instead of fragmenting. function ui.reset() node.reset() forget() laidOut = false end function ui.screen(root, style) -- 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. node.setSize(root, "fill", "fill") if not paletted[root] then applyPalette(root, ui.theme) end if style then node.setStyle(root, style) end local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen) screen:relayout() return screen end function Screen:relayout() local ok, err = node.layout(self.root, 0, 0, gui.getWidth(), gui.getHeight()) if not ok then error(err, 2) end node.dropScratch() laidOut = true gui.clear((paletted[self.root] or ui.theme).bg) end local function finishRelease(screen) local released = screen.released if not released then return end if unpress[released.target] then unpress[released.target](released.target) else node.setPressed(released.target, false) end screen.released = nil end function Screen:draw() -- Same-Frame Release - Queue the normal state before drawing so it does not wait for -- another frame. if self.released and sys.getMillis() - self.released.pressedAt >= PRESS_MS then finishRelease(self) end node.draw(self.root) end function Screen:down(x, y) -- Overlapping Presses - A second tap can begin during the first tap's 80 ms hold. -- Finish the old visual before a custom component changes what it is highlighting. finishRelease(self) local target = node.hit(self.root, x, y) if not target then return end self.captured = target self.pressedAt = sys.getMillis() if down[target] then down[target](target, x, y) end if not flat[target] then node.setPressed(target, true) end end function Screen:up(x, y) local target = self.captured self.captured = nil if not target then return end if not flat[target] or unpress[target] then self.released = {target = target, pressedAt = self.pressedAt} end local rx, ry, rw, rh = node.getRect(target) local inside = x >= rx - SLOP and x < rx + rw + SLOP and y >= ry - SLOP and y < ry + rh + SLOP if inside and press[target] then press[target](target, x, y) end end return ui