4b89b27947
Chrome takes the top of the panel before an app builds anything, and layout has not run yet when it does, so ui.frame() reports what the mount left it.
553 lines
14 KiB
Lua
553 lines
14 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 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)
|
|
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
|