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) ---@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 THEME_PATH = "/.lua/theme" 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 = {}, {}, {}, {} local laidOut = false local themeName local activeScreen 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), face = color(mix(background, foreground, 0.08)), pressedFace = color(accent), pressedColor = color(background), focusColor = color(accent), focusWidth = 2, 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 = fs.writeFile(THEME_PATH, name) if not ok then return nil, err end loadTheme(name) if activeScreen then applyPalette(activeScreen.root) gui.clear(ui.theme.background) node.invalidate(activeScreen.root) end return true end local savedTheme = fs.readFile(THEME_PATH, 32) loadTheme(savedTheme and savedTheme:match("^%s*(.-)%s*$") or "light") local function clearState() enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {} 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", "focusWidth", "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 {} 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_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 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 columns = gui.getWidth() >= gui.getHeight() and 4 or 3 local rows = math.ceil(count / columns) local byWidth = (gui.getWidth() - 2 * pad - (columns - 1) * gap) // columns local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows return math.min(byWidth, byHeight), columns 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 activeScreen = nil end ---@class UiScreen local Screen = {} Screen.__index = Screen applyPalette = function(root) node.setStyle(root, { color = ui.theme.color, background = ui.theme.background, border = ui.theme.muted, face = ui.theme.face, pressedFace = ui.theme.pressedFace, pressedColor = ui.theme.pressedColor, focusColor = ui.theme.focusColor, focusWidth = ui.theme.focusWidth, radius = ui.theme.radius, font = gui.FONT_UI, }) end ---@param root NodeId ---@param style? NodeStyle ---@return UiScreen function ui.screen(root, style) node.setSize(root, "fill", "fill") applyPalette(root) if style then node.setStyle(root, style) end local screen = setmetatable({root = root}, Screen) activeScreen = 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(ui.theme.background) end function Screen:draw() node.draw(self.root) 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) node.setPressed(id, true) local handler = enterHandlers[id] if handler then handler(id, x, y) end end local function exit(id, x, y) node.setPressed(id, false) local handler = exitHandlers[id] if handler then handler(id, x, y) end end ---@param x integer ---@param y integer ---@return boolean handled function Screen: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 = node.hit(self.root, x, y) if not target then return false end self.captured, self.inside = target, true enter(target, x, y) return true end ---@param x integer ---@param y integer ---@return boolean handled function Screen:move(x, y) local target = self.captured if not target then return false end local isInside = inside(target, x, y) if isInside ~= self.inside then self.inside = isInside if isInside then enter(target, x, y) else exit(target, x, y) end end return true end ---@param x integer ---@param y integer ---@return boolean handled function Screen:up(x, y) local target = self.captured if not target then return false end local wasActive = self.inside local releasedInside = inside(target, x, y) local handler = releasedInside and clickHandlers[target] or nil self.captured, self.inside = 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(screen) local focused = node.focusFirst(screen.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 Screen:button(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(self) return true end local focused = node.moveFocus(self.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(self) if not focused then return false end if pressed then node.setPressed(focused, true) self.confirming = focused else local target = self.confirming self.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