291f0f20b5
The Lua modules were split between tabs and spaces because nothing pinned a style; .editorconfig is what lua-language-server reads on save, so the editor and the tree now agree. clang-format already had a config and left native/ unchanged. Embedded modules regenerate from the reformatted ui.lua.
539 lines
13 KiB
Lua
539 lines
13 KiB
Lua
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 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 = {}, {}, {}, {}
|
|
-- 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
|
|
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),
|
|
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 = 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 = {}, {}, {}, {}
|
|
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 {}
|
|
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
|
|
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 columns = gui.getWidth() >= gui.getHeight() and 3 or 2
|
|
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)
|
|
-- 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
|
|
|
|
---@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)
|
|
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 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
|