-- 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 -- 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() 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", "dimmed"} -- 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", } local function seedFrom(node, palette, style) for key, role in pairs(ROOT_STYLE) do if node[key] == nil then node[key] = (style and style[key]) or palette[role] end end end local function inherit(child, parent) -- Crossing into or out of a dimmed region restyles from the matching palette instead -- of inheriting its neighbour's, which is how a dialog stays lit over dimmed content. if child.dimmed ~= nil and child.dimmed ~= parent.dimmed then seedFrom(child, child.dimmed and ui.theme.dim or ui.theme) end for _, key in ipairs(INHERITED) do if child[key] == nil then child[key] = parent[key] end end -- What this node sits on, which is not the same as what it fills. Anti-aliased edges -- blend into the surface, so a rounded card needs the color behind it and not its own. if child.surface == nil then child.surface = parent.bg or parent.surface 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 and not self.paintsBackground 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. -- A bordered box paints its own background as a rounded rect, so the square fill in -- Component:draw is suppressed. Filling first would leave square corners outside the -- border, which is invisible against a matching surface and obvious against any other. if spec.border then spec.paintsBackground = true 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.surface, 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 -- Text size is panel state, not node state, so every measure and paint sets it: a -- neighbour at another size would otherwise decide this node's metrics. local function measureText(self, available) gui.setTextSize(self.size or 1) self.mw = resolve(self.w, available.w, "width") or gui.getTextWidth(self.label) self.mh = resolve(self.h, available.h, "height") or gui.getFontHeight() return self.mw, self.mh end local function paintText(self) gui.setTextSize(self.size or 1) gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg) end local function setText(self, text) if text == self.label then return end self.label = text self:invalidate() end function ui.text(label, spec) spec = spec or {} spec.label = label local node = component(spec) node.measure = measureText node.paint = paintText node.setText = setText return node 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 3 or 2 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 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 local function paintButton(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 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 -- No background for a gradient face: an opaque glyph fill is one flat colour, which -- only matches the one row of the gradient it was taken from. The face is repainted -- above on every change, so the children have nothing to erase. -- `x and nil or y` can never be nil, so this stays an if. if gradient then child.bg = nil else child.bg = bottom or self.bg end child.color = self.pressed and (self.press_color or self.bg) or self.color child.dirty = true end end local function setButtonText(self, text) local label = self.children[1] if text == label.label then return end label:setText(text) self:invalidate() end function ui.button(spec) spec.pad = spec.pad or 8 spec.align = spec.align or "center" local hasLabel = spec.label ~= nil if hasLabel then spec.children = {ui.text(spec.label)} spec.label = nil end local node = component(spec) node.paint = paintButton if hasLabel then node.setText = setButtonText 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, 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 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 seedFrom(root, root.dimmed and ui.theme.dim or ui.theme, style) screen:relayout() return screen end function Screen:relayout() local rect = {x = 0, y = 0, w = gui.getWidth(), h = gui.getHeight()} 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.getMillis() - 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.getMillis() if target.press_style ~= false then target.pressed = true target:invalidate() end end function Screen:up(x, y) local target = self.captured self.captured = nil if not target then return end if target.press_style ~= false then self.released = target end local inside = contains(target.rect, x, y) if inside and target.on_press then target.on_press(target, x, y) end end return ui