initial commit

This commit is contained in:
2026-08-03 16:09:07 -04:00
commit 95fa512047
109 changed files with 35023 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
-- Button hints as a shared module rather than a firmware paint routine: what the strip looks
-- like is composition, and only the device knows which roles exist to label.
local hints = {}
-- Reading order across the strip, so a device showing three of them still reads left to right.
local ORDER = {"back", "left", "up", "down", "right", "confirm"}
local function available()
local roles = {}
if input and input.getButtons then
for _, role in ipairs(input.getButtons()) do roles[role] = true end
end
return roles
end
---Paints the labels this device can actually act on, and returns the height it used.
---@param actions table<string, string> Label per Button role; roles the device lacks are skipped.
---@param options table|nil `y`, `font`, `color`, and `background` overrides.
function hints.draw(actions, options)
options = options or {}
local font = options.font or gui.FONT_SMALL
local color = options.color or gui.color(0, 0, 0)
local background = options.background or gui.color(255, 255, 255)
local height = gui.getFontHeight(font) + 6
local y = options.y or (gui.getHeight() - height)
local roles = available()
local labels = {}
for _, role in ipairs(ORDER) do
if roles[role] and actions[role] then labels[#labels + 1] = actions[role] end
end
gui.fillRect(0, y, gui.getWidth(), height, background)
if #labels == 0 then return height end
local slot = gui.getWidth() // #labels
for index, label in ipairs(labels) do
local left = slot * (index - 1) + (slot - gui.getTextWidth(font, label)) // 2
gui.drawText(font, left, y + 3, label, color, gui.STYLE_NORMAL, background)
end
return height
end
return hints
+437
View File
@@ -0,0 +1,437 @@
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
---@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