2255fe214e
A custom node that paints its own key highlight does not want the whole node marked dirty on every press.
460 lines
12 KiB
Lua
460 lines
12 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),
|
|
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 = {}, {}, {}, {}
|
|
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", "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
|
|
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 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)
|
|
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
|