local ui = {} ---@alias UiHandler fun(id: NodeId, x?: integer, y?: integer) ---@class UiSpec ---@field [integer] NodeId Child nodes. ---@field w? number|"fill"|"auto" ---@field h? number|"fill"|"auto" ---@field pad? number ---@field gap? number ---@field align? "start"|"center"|"end"|"stretch" ---@field justify? "start"|"center"|"end"|"between" ---@field row? boolean ---@field at? table ---@field capture? boolean ---@field label? string ---@field font? GuiFont ---@field style? GuiTextStyle ---@field fit? integer Maximum label width. ---@field background? GuiColor ---@field face? GuiColor ---@field pressedFace? GuiColor ---@field pressedColor? GuiColor ---@field focusColor? GuiColor ---@field focusWidth? integer ---@field textStyle? GuiTextStyle ---@field on_enter? UiHandler ---@field on_exit? UiHandler ---@field on_click? UiHandler ---@field paint? fun(id: NodeId, x: integer, y: integer, w: integer, h: integer) ---@field press_style? boolean False for a widget that paints its own press feedback. ---@class UiConfirmSpec ---@field title string ---@field message? string ---@field ok? string ---@field cancel? string|false ---@field w? number ---@field background? GuiColor ---@field border? GuiColor ---@field on_ok? UiHandler ---@field on_cancel? UiHandler ---@field on_outside? UiHandler local THEMES = { light = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 120, 255 }, radius = 6 }, dark = { background = { 18, 18, 20 }, color = { 235, 235, 235 }, accent = { 166, 118, 255 }, radius = 6 }, mono = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 0, 0 }, radius = 0 }, } local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {} -- A widget that paints its own press feedback opts out, so pressing one key does not -- repaint the whole node the way a pressed style would. local pressStyles = {} local laidOut = false local themeName -- One tree per state, built by the function ui.mount() was given. Rebuilding is -- cheap enough that nothing is retained between screens. local builder, root, captured, insideCaptured, confirming local inset = 0 local applyPalette local function mix(a, b, amount) local out = {} for i = 1, 3 do out[i] = math.floor(a[i] + (b[i] - a[i]) * amount + 0.5) end return out end local function color(rgb) return gui.color(rgb[1], rgb[2], rgb[3]) end local function palette(seed) local background, foreground, accent = seed.background, seed.color, seed.accent return { background = color(background), color = color(foreground), muted = color(mix(foreground, background, 0.45)), accent = color(accent), disabled = color(mix(background, foreground, 0.25)), face = color(mix(background, foreground, 0.08)), pressedFace = color(accent), pressedColor = color(background), focusColor = color(accent), radius = seed.radius, } end local function loadTheme(name) themeName = THEMES[name] and name or "light" ui.theme = palette(THEMES[themeName]) end ---@return string function ui.getTheme() return themeName end ---@return string[] function ui.themeNames() local names = {} for name in pairs(THEMES) do names[#names + 1] = name end table.sort(names) return names end ---@param name string ---@return true? ok ---@return string? error function ui.setTheme(name) if not THEMES[name] then return nil, "Unknown theme" end local ok, err = settings.setTheme(name) if not ok then return nil, err end loadTheme(name) if root then applyPalette(root) gui.clear(ui.theme.background) node.invalidate(root) end return true end loadTheme(settings.getTheme()) local function clearState() enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {} pressStyles = {} end node.setPainter(function(id, x, y, w, h) local painter = painters[id] if painter then painter(id, x, y, w, h) end end) local STYLE_KEYS = { "color", "fill", "border", "face", "pressedFace", "pressedColor", "focusColor", "radius", "font", "textStyle", } local function applyStyle(id, spec) local style, hasStyle = {}, false for _, key in ipairs(STYLE_KEYS) do if spec[key] ~= nil then style[key], hasStyle = spec[key], true end end if spec.background ~= nil then style.background, style.fill, hasStyle = spec.background, spec.background, true end if hasStyle then node.setStyle(id, style) end end local function build(spec, kind) spec = spec or {} -- Nodes outside a build would reset the arena under the screen already on the -- panel, chrome included. Rebuilding is the only way to change one. if laidOut then error("build nodes from the function ui.mount() was given, then ui.rebuild()", 3) end local children = {} for index, child in ipairs(spec) do children[index] = child spec[index] = nil end spec.type = kind spec.interactive = spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil local id = node.create(nil, spec) for _, child in ipairs(children) do node.attach(id, child) end applyStyle(id, spec) enterHandlers[id] = spec.on_enter exitHandlers[id] = spec.on_exit clickHandlers[id] = spec.on_click painters[id] = spec.paint pressStyles[id] = spec.press_style ~= false return id end ---@param spec UiSpec ---@return NodeId function ui.box(spec) return build(spec, "box") end ---Side length for a grid of square cards that fits the frame, and the column count that ---produced it. Landscape gets a wider grid, so the same screen reflows on rotation. ---@param count integer Cards to place. ---@param pad integer Padding outside the grid. ---@param gap integer Gap between cards. ---@param reserve? integer Height to leave free below the grid. ---@return integer side ---@return integer columns function ui.cardSide(count, pad, gap, reserve) local width, height = ui.frame() local columns = width >= height and 3 or 2 local rows = math.ceil(count / columns) local byWidth = (width - 2 * pad - (columns - 1) * gap) // columns local byHeight = (height - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows return math.min(byWidth, byHeight), columns end ---How much of the panel chrome took before the app was built. Set by whatever mounts the ---tree, because layout has not run yet when an app sizes itself. ---@param px integer function ui.setInset(px) inset = px end ---The box the app is built into, which is the panel minus the chrome above it. ---@return integer w ---@return integer h function ui.frame() return gui.getWidth(), gui.getHeight() - inset end ---@param spec UiSpec ---@return NodeId function ui.spacer(spec) spec = spec or {} return build({ w = spec.w, h = spec.h }, "box") end ---@param text string ---@param spec? UiSpec ---@return NodeId function ui.text(text, spec) spec = spec or {} spec.label = text spec.textStyle, spec.style = spec.style, nil return build(spec, "text") end ---@param text string ---@param spec? UiSpec ---@return NodeId function ui.label(text, spec) spec = spec or {} local font, style = spec.font or gui.FONT_UI, spec.style or gui.STYLE_NORMAL if spec.fit and gui.getTextWidth(font, text, style) > spec.fit then while #text > 1 and gui.getTextWidth(font, text .. "~", style) > spec.fit do text = text:sub(1, -2) end text = text .. "~" end spec.w = gui.getTextWidth(font, text, style) spec.h = gui.getFontHeight(font, style) spec.font, spec.fit = font, nil return ui.text(text, spec) end ---@param spec UiSpec ---@return NodeId function ui.button(spec) spec = spec or {} spec.pad = spec.pad or 8 spec.align = spec.align or "center" local label, font = spec.label, spec.font spec.label = nil local id = build(spec, "button") if label then node.create(id, { type = "text", label = label, font = font or gui.FONT_UI }) end return id end ---@param spec UiSpec ---@return NodeId function ui.custom(spec) return build(spec, "custom") end ---@param id NodeId ---@param text string function ui.setText(id, text) if node.getLabel(id) == text then return end node.setLabel(id, text) node.invalidate(id) end ---@param id NodeId function ui.invalidate(id) node.invalidate(id) end ---@param spec UiConfirmSpec ---@return NodeId function ui.confirm(spec) local card = { w = spec.w or 0.85, pad = 16, gap = 12, background = spec.background or ui.theme.background, 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_click = spec.on_cancel } end buttons[#buttons + 1] = ui.button { label = spec.ok or "ok", on_click = spec.on_ok } card[#card + 1] = ui.box(buttons) return ui.box { at = { x = 0, y = 0 }, w = "fill", h = "fill", capture = true, align = "center", justify = "center", on_click = spec.on_outside, ui.box(card), } end function ui.reset() node.reset() clearState() laidOut = false root, captured, insideCaptured, confirming = nil, nil, nil, nil end applyPalette = function(root) -- The root is the panel background, not a card: no border, so it takes the fast fillRect -- path rather than the per-pixel roundRect one. Radius stays so cards inherit it. node.setStyle(root, { color = ui.theme.color, background = ui.theme.background, face = ui.theme.face, pressedFace = ui.theme.pressedFace, pressedColor = ui.theme.pressedColor, focusColor = ui.theme.focusColor, radius = ui.theme.radius, font = gui.FONT_UI, }) end ---Registers the function that builds the whole tree and shows what it returns. ---@param fn fun(): NodeId function ui.mount(fn) builder = fn ui.rebuild() end ---Rebuilds the tree from scratch and repaints. Screens are not retained, so this ---is how a screen changes, a rotation is answered and a dialog opens. function ui.rebuild() ui.reset() root = builder() node.setSize(root, "fill", "fill") applyPalette(root) local ok, err = node.layout(root, 0, 0, gui.getWidth(), gui.getHeight()) if not ok then error(err, 2) end node.dropScratch() laidOut = true gui.clear(ui.theme.background) node.draw(root) -- A build allocates a spec table per node and drops them all here, and the next thing an -- app does may be the one that needs a contiguous WiFi buffer. Collecting now costs a few -- milliseconds on a screen change nobody can see, and leaves the heap in a known state -- instead of one that depends on when the incremental GC last ran. collectgarbage() end function ui.draw() if root then node.draw(root) end end local function inside(id, x, y) local rx, ry, rw, rh = node.getRect(id) return x >= rx and x < rx + rw and y >= ry and y < ry + rh end local function enter(id, x, y) if pressStyles[id] then node.setPressed(id, true) end local handler = enterHandlers[id] if handler then handler(id, x, y) end end local function exit(id, x, y) if pressStyles[id] then node.setPressed(id, false) end local handler = exitHandlers[id] if handler then handler(id, x, y) end end ---@param x integer ---@param y integer ---@return boolean handled function ui.down(x, y) local focused = node.getFocus() if focused then node.setFocus(nil) local handler = exitHandlers[focused] if handler then handler(focused) end end local target = root and node.hit(root, x, y) if not target then return false end captured, insideCaptured = target, true enter(target, x, y) return true end ---@param x integer ---@param y integer ---@return boolean handled function ui.move(x, y) if not captured then return false end local isInside = inside(captured, x, y) if isInside ~= insideCaptured then insideCaptured = isInside if isInside then enter(captured, x, y) else exit(captured, x, y) end end return true end ---@param x integer ---@param y integer ---@return boolean handled function ui.up(x, y) local target = captured if not target then return false end local wasActive = insideCaptured local releasedInside = inside(target, x, y) local handler = releasedInside and clickHandlers[target] or nil captured, insideCaptured = nil, nil if wasActive then exit(target, x, y) end if handler then handler(target, x, y) end return true end local DIRECTIONS = { up = true, down = true, left = true, right = true } local function focusFirst() local focused = node.focusFirst(root) if focused then local handler = enterHandlers[focused] if handler then handler(focused) end end return focused end ---@param name string Button name; directions and confirm are handled. ---@param pressed boolean ---@return boolean handled function ui.buttonPress(name, pressed) if type(pressed) ~= "boolean" then error("button state must be boolean", 2) end if DIRECTIONS[name] then if not pressed then return true end local previous = node.getFocus() if not previous then focusFirst() return true end local focused = node.moveFocus(root, name) if focused ~= previous then local leave = exitHandlers[previous] if leave then leave(previous) end local arrive = enterHandlers[focused] if arrive then arrive(focused) end end return true end if name ~= "confirm" then return false end local focused = node.getFocus() or focusFirst() if not focused then return false end if pressed then node.setPressed(focused, true) confirming = focused else local target = confirming confirming = nil if target then node.setPressed(target, false) local handler = clickHandlers[target] if handler then handler(target) end end end return true end return ui