Initial flame_lua_runtime package

This commit is contained in:
gem
2026-06-07 22:53:58 +08:00
commit 733b2fb798
262 changed files with 28439 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
---@alias RuntimeLayoutAlign 'start'|'center'|'end'
---@class (exact) RuntimeLayoutItem
---@field node RuntimeNode
---@field marginLeft? number
---@field marginRight? number
---@field marginTop? number
---@field marginBottom? number
---@class RuntimeLayoutItemOpts
---@field margin? number
---@field mx? number
---@field my? number
---@field ml? number
---@field mr? number
---@field mt? number
---@field mb? number
---@field marginLeft? number
---@field marginRight? number
---@field marginTop? number
---@field marginBottom? number
---@class RuntimeLinearLayoutOpts
---@field x? number
---@field y? number
---@field width? number
---@field height? number
---@field gap? number
---@field align? RuntimeLayoutAlign
---@field padding? number
---@field paddingX? number
---@field paddingY? number
---@field px? number
---@field py? number
---@field paddingLeft? number
---@field paddingTop? number
---@class RuntimeBoxLayoutOpts: RuntimeLinearLayoutOpts
---@field rows? integer
---@field columns? integer
---@field cols? integer
---@field cellWidth? number
---@field cellHeight? number
---@field cellW? number
---@field cellH? number
---@field gapX? number
---@field gapY? number
---@field valign? RuntimeLayoutAlign
---@class RuntimeLayout
local layout = {}
---@param value any
---@param fallback number
---@return number
local function read_number(value, fallback)
if type(value) == "number" then
return value
end
return fallback
end
---@param opts? table
---@param key string
---@return number
local function spacing(opts, key)
opts = opts or {}
if key == "left" then
return read_number(opts.paddingLeft, read_number(opts.paddingX, read_number(opts.px, read_number(opts.padding, 0))))
end
if key == "top" then
return read_number(opts.paddingTop, read_number(opts.paddingY, read_number(opts.py, read_number(opts.padding, 0))))
end
return 0
end
---@param node RuntimeNode
---@param parent? string
local function apply_parent(node, parent)
if parent ~= nil and parent ~= "" then
node.parent = parent
elseif parent == "" then
node.parent = nil
end
end
---@param axis_size number
---@param item_size number
---@param align RuntimeLayoutAlign
---@return number
local function align_cross(axis_size, item_size, align)
if align == "center" then
return (axis_size - item_size) / 2
end
if align == "end" then
return axis_size - item_size
end
return 0
end
---@param value any
---@return RuntimeNode
local function node_of(value)
return value.node or value
end
---@param value any
---@param key string
---@return number
local function margin_of(value, key)
local all = read_number(value.margin, 0)
if key == "marginLeft" then
return read_number(value.marginLeft, read_number(value.ml, read_number(value.mx, all)))
end
if key == "marginRight" then
return read_number(value.marginRight, read_number(value.mr, read_number(value.mx, all)))
end
if key == "marginTop" then
return read_number(value.marginTop, read_number(value.mt, read_number(value.my, all)))
end
if key == "marginBottom" then
return read_number(value.marginBottom, read_number(value.mb, read_number(value.my, all)))
end
return 0
end
---@param node RuntimeNode
---@param opts? RuntimeLayoutItemOpts
---@return RuntimeLayoutItem
function layout.item(node, opts)
opts = opts or {}
return {
node = node,
marginLeft = margin_of(opts, "marginLeft"),
marginRight = margin_of(opts, "marginRight"),
marginTop = margin_of(opts, "marginTop"),
marginBottom = margin_of(opts, "marginBottom")
}
end
---@param origin RuntimePoint
---@param position RuntimePoint
---@return RuntimePoint
function layout.local_position(origin, position)
return {
x = position.x - origin.x,
y = position.y - origin.y
}
end
---@param parent? string
---@param items (RuntimeNode|RuntimeLayoutItem)[]
---@param opts? RuntimeLinearLayoutOpts
---@return RuntimeNode[]
function layout.row(parent, items, opts)
opts = opts or {}
local cursor = read_number(opts.x, 0) + spacing(opts, "left")
local base_y = read_number(opts.y, 0) + spacing(opts, "top")
local gap = read_number(opts.gap, 0)
local height = read_number(opts.height, 0)
local align = opts.align or "start"
local nodes = {}
for index, item in ipairs(items) do
local node = node_of(item)
cursor = cursor + margin_of(item, "marginLeft")
node.x = cursor
local item_height = read_number(node.height, 0)
node.y = base_y + align_cross(height, item_height, align)
apply_parent(node, parent)
cursor = cursor + read_number(node.width, 0) + margin_of(item, "marginRight") + gap
nodes[index] = node
end
return nodes
end
---@param parent? string
---@param items (RuntimeNode|RuntimeLayoutItem)[]
---@param opts? RuntimeLinearLayoutOpts
---@return RuntimeNode[]
function layout.column(parent, items, opts)
opts = opts or {}
local cursor = read_number(opts.y, 0) + spacing(opts, "top")
local base_x = read_number(opts.x, 0) + spacing(opts, "left")
local gap = read_number(opts.gap, 0)
local width = read_number(opts.width, 0)
local align = opts.align or "start"
local nodes = {}
for index, item in ipairs(items) do
local node = node_of(item)
cursor = cursor + margin_of(item, "marginTop")
node.y = cursor
local item_width = read_number(node.width, 0)
node.x = base_x + align_cross(width, item_width, align)
apply_parent(node, parent)
cursor = cursor + read_number(node.height, 0) + margin_of(item, "marginBottom") + gap
nodes[index] = node
end
return nodes
end
---@param parent? string
---@param items (RuntimeNode|RuntimeLayoutItem)[]
---@param opts? RuntimeLinearLayoutOpts
---@return RuntimeNode[]
function layout.stack(parent, items, opts)
opts = opts or {}
local offset_x = read_number(opts.x, 0) + spacing(opts, "left")
local offset_y = read_number(opts.y, 0) + spacing(opts, "top")
local nodes = {}
for index, item in ipairs(items) do
local node = node_of(item)
node.x = read_number(node.x, 0) + offset_x
node.y = read_number(node.y, 0) + offset_y
apply_parent(node, parent)
nodes[index] = node
end
return nodes
end
---@param parent? string
---@param items (RuntimeNode|RuntimeLayoutItem)[]
---@param opts? RuntimeBoxLayoutOpts
---@return RuntimeNode[]
function layout.box(parent, items, opts)
opts = opts or {}
local count = #items
local rows = math.floor(read_number(opts.rows, 0))
local columns = math.floor(read_number(opts.columns, read_number(opts.cols, 0)))
if columns <= 0 and rows > 0 then
columns = math.ceil(count / rows)
end
if columns <= 0 then
columns = count > 0 and count or 1
end
if rows <= 0 then
rows = math.ceil(count / columns)
end
local gap_x = read_number(opts.gapX, read_number(opts.gap, 0))
local gap_y = read_number(opts.gapY, read_number(opts.gap, 0))
local origin_x = read_number(opts.x, 0) + spacing(opts, "left")
local origin_y = read_number(opts.y, 0) + spacing(opts, "top")
local cell_w = read_number(opts.cellWidth, read_number(opts.cellW, 0))
local cell_h = read_number(opts.cellHeight, read_number(opts.cellH, 0))
local width = read_number(opts.width, 0)
local height = read_number(opts.height, 0)
if cell_w <= 0 and width > 0 then
cell_w = (width - gap_x * (columns - 1)) / columns
end
if cell_h <= 0 and height > 0 then
cell_h = (height - gap_y * (rows - 1)) / rows
end
local align = opts.align or "start"
local valign = opts.valign or "start"
local nodes = {}
for index, item in ipairs(items) do
local node = node_of(item)
local item_width = read_number(node.width, cell_w)
local item_height = read_number(node.height, cell_h)
local effective_cell_w = cell_w > 0 and cell_w or item_width
local effective_cell_h = cell_h > 0 and cell_h or item_height
local col = (index - 1) % columns
local row = math.floor((index - 1) / columns)
local margin_left = margin_of(item, "marginLeft")
local margin_right = margin_of(item, "marginRight")
local margin_top = margin_of(item, "marginTop")
local margin_bottom = margin_of(item, "marginBottom")
local inner_w = effective_cell_w - margin_left - margin_right
local inner_h = effective_cell_h - margin_top - margin_bottom
node.x = origin_x + col * (effective_cell_w + gap_x) + margin_left + align_cross(inner_w, item_width, align)
node.y = origin_y + row * (effective_cell_h + gap_y) + margin_top + align_cross(inner_h, item_height, valign)
apply_parent(node, parent)
nodes[index] = node
end
return nodes
end
return layout

View File

@@ -0,0 +1,261 @@
---@class (exact) RuntimeCommandOpts
---@field id? string
---@field group? string
---@field commandGroup? string
---@field scope? string
---@field onComplete? string
---@field duration? number
---@class (exact) RuntimeAudioCommandOpts: RuntimeCommandOpts
---@field volume? number
---@class (exact) RuntimeBgmCommandOpts: RuntimeAudioCommandOpts
---@field channel? string
---@field loop? boolean
---@class (exact) RuntimeSpineCommandOpts: RuntimeCommandOpts
---@field track? integer
---@field loop? boolean
---@field queue? boolean
---@field delay? number
---@class (exact) RuntimeResourceCommandOpts: RuntimeCommandOpts
---@field failOnError? boolean
---@class RuntimeCommands
local commands = {}
---@param opts? table
---@return table
local function copy_opts(opts)
local result = {}
if opts ~= nil then
for key, value in pairs(opts) do
result[key] = value
end
end
return result
end
---@param command_type RuntimeCommandType
---@param opts? table
---@return RuntimeCommand
local function with_type(command_type, opts)
local command = copy_opts(opts)
command.type = command_type
return command
end
---@param text string
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.toast(text, opts)
local command = with_type("toast", opts)
command.text = text
return command
end
---@param text string
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.copy_text(text, opts)
local command = with_type("copy_text", opts)
command.text = text
return command
end
---@param duration number
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.delay(duration, opts)
local command = with_type("delay", opts)
command.duration = duration
return command
end
---@param items RuntimeCommand[]
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.sequence(items, opts)
local command = with_type("sequence", opts)
command.commands = items or {}
return command
end
---@param items RuntimeCommand[]
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.parallel(items, opts)
local command = with_type("parallel", opts)
command.commands = items or {}
return command
end
---@param target string
---@param path RuntimePoint[]
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.move_path(target, path, opts)
local command = with_type("move_path", opts)
command.target = target
command.path = path or {}
return command
end
---@param target string
---@param x number
---@param y number
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.move_to(target, x, y, opts)
local command = with_type("move_to", opts)
command.target = target
command.x = x
command.y = y
return command
end
---@param target string
---@param alpha number
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.fade_to(target, alpha, opts)
local command = with_type("fade_to", opts)
command.target = target
command.alpha = alpha
return command
end
---@param target string
---@param scale number
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.scale_to(target, scale, opts)
local command = with_type("scale_to", opts)
command.target = target
command.scale = scale
return command
end
---@param target string
---@param angle number
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.rotate_to(target, angle, opts)
local command = with_type("rotate_to", opts)
command.target = target
command.angle = angle
return command
end
---@param target string
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.remove_node(target, opts)
local command = with_type("remove_node", opts)
command.target = target
return command
end
---@param target string
---@param animation string
---@param opts? RuntimeSpineCommandOpts
---@return RuntimeCommand
function commands.play_spine_animation(target, animation, opts)
local command = with_type("play_spine_animation", opts)
command.target = target
command.animation = animation
if command.loop == nil then
command.loop = true
end
return command
end
---@param asset string
---@param opts? RuntimeAudioCommandOpts
---@return RuntimeCommand
function commands.play_sound(asset, opts)
local command = with_type("play_sound", opts)
command.asset = asset
return command
end
---@param asset string
---@param opts? RuntimeBgmCommandOpts
---@return RuntimeCommand
function commands.play_bgm(asset, opts)
local command = with_type("play_bgm", opts)
command.asset = asset
if command.channel == nil then
command.channel = "bgm"
end
if command.loop == nil then
command.loop = true
end
return command
end
---@param channel? string
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.pause_bgm(channel, opts)
local command = with_type("pause_bgm", opts)
command.channel = channel or "bgm"
return command
end
---@param channel? string
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.resume_bgm(channel, opts)
local command = with_type("resume_bgm", opts)
command.channel = channel or "bgm"
return command
end
---@param channel? string
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.stop_bgm(channel, opts)
local command = with_type("stop_bgm", opts)
command.channel = channel or "bgm"
return command
end
---@param group string
---@param opts? RuntimeResourceCommandOpts
---@return RuntimeCommand
function commands.preload_group(group, opts)
local command = with_type("preload_resources", opts)
command.group = group
return command
end
---@param group string
---@param opts? RuntimeCommandOpts
---@return RuntimeCommand
function commands.evict_group(group, opts)
local command = with_type("evict_resources", opts)
command.group = group
return command
end
---@param id string
---@return RuntimeCommand
function commands.cancel_id(id)
return { type = "cancel_commands", id = id }
end
---@param group string
---@return RuntimeCommand
function commands.cancel_group(group)
return { type = "cancel_commands", group = group }
end
---@param scope string
---@return RuntimeCommand
function commands.cancel_scope(scope)
return { type = "cancel_commands", scope = scope }
end
return commands

View File

@@ -0,0 +1,427 @@
---@class RuntimeUi
local runtime_ui = {}
---@generic T: table
---@param base? T
---@param opts? table
---@return T
local function merge(base, opts)
local result = {}
for key, value in pairs(base or {}) do
result[key] = value
end
if opts ~= nil then
for key, value in pairs(opts) do
result[key] = value
end
end
return result
end
---@param value any
---@return boolean
local function is_table(value)
return type(value) == "table"
end
---@param opts? table
---@return table
local function normalize_opts(opts)
local source = opts or {}
local result = {}
for key, value in pairs(source) do
if key ~= "w" and key ~= "h" and key ~= "size" and key ~= "handler" and key ~= "onClick" then
result[key] = value
end
end
if result.width == nil then
result.width = source.w or source.size
end
if result.height == nil then
result.height = source.h or source.size
end
if result.onTap == nil then
result.onTap = source.handler or source.onClick
end
return result
end
---@param base table
---@param opts? table
---@return table
local function node_opts(base, opts)
return merge(base, normalize_opts(opts))
end
---@param base? RuntimeNodeProps
---@param opts? RuntimeNodeProps
---@return RuntimeNodeProps
function runtime_ui.style(base, opts)
return merge(base or {}, opts)
end
---@param parent string
---@param opts? RuntimeNodeProps
---@return RuntimeNodeProps
function runtime_ui.with_parent(parent, opts)
return merge(opts or {}, { parent = parent })
end
---@param node_type RuntimeNodeType
---@param id string
---@param opts? RuntimeNodeProps
---@return RuntimeNode
function runtime_ui.node(node_type, id, opts)
return node_opts({ id = id, type = node_type }, opts)
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.panel(id, x, y, width, height, opts)
if is_table(x) then
return runtime_ui.node("panel", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x))
end
return runtime_ui.node("panel", id, node_opts({
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.rect(id, x, y, width, height, opts)
if is_table(x) then
return runtime_ui.node("rect", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x))
end
return runtime_ui.node("rect", id, node_opts({
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param size? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.circle(id, x, y, size, opts)
if is_table(x) then
return runtime_ui.node("circle", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x))
end
return runtime_ui.node("circle", id, node_opts({
x = x,
y = y,
width = size,
height = size
}, opts))
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.line(id, x, y, width, height, opts)
if is_table(x) then
return runtime_ui.node("line", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x))
end
return runtime_ui.node("line", id, node_opts({
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param value? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.progress(id, x, y, width, height, value, opts)
if is_table(x) then
return runtime_ui.node("progress", id, node_opts({ x = 0, y = 0, width = 0, height = 0, value = 0 }, x))
end
return runtime_ui.node("progress", id, node_opts({
x = x,
y = y,
width = width,
height = height,
value = value
}, opts))
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.particle(id, x, y, width, height, opts)
if is_table(x) then
return runtime_ui.node("particle", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x))
end
return runtime_ui.node("particle", id, node_opts({
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param text string|RuntimeNodeInit
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.text(id, text, x, y, width, height, opts)
if is_table(text) then
return runtime_ui.node("text", id, node_opts({ text = "", x = 0, y = 0, width = 0, height = 0 }, text))
end
return runtime_ui.node("text", id, node_opts({
text = text,
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param text string|RuntimeNodeInit
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param handler? string
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.button(id, text, x, y, width, height, handler, opts)
if is_table(text) then
return runtime_ui.node("button", id, node_opts({
text = "",
x = 0,
y = 0,
width = 0,
height = 0,
interactive = true
}, text))
end
return runtime_ui.node("button", id, node_opts({
text = text,
x = x,
y = y,
width = width,
height = height,
interactive = true,
onTap = handler
}, opts))
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.list_view(id, x, y, width, height, opts)
if is_table(x) then
return runtime_ui.node("listView", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x))
end
return runtime_ui.node("listView", id, node_opts({
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param asset string|RuntimeNodeInit
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.image(id, asset, x, y, width, height, opts)
if is_table(asset) then
return runtime_ui.node("image", id, node_opts({ asset = "", x = 0, y = 0, width = 0, height = 0 }, asset))
end
return runtime_ui.node("image", id, node_opts({
asset = asset,
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param asset string|RuntimeNodeInit
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.sprite(id, asset, x, y, width, height, opts)
if is_table(asset) then
return runtime_ui.node("sprite", id, node_opts({ asset = "", x = 0, y = 0, width = 0, height = 0 }, asset))
end
return runtime_ui.node("sprite", id, node_opts({
asset = asset,
x = x,
y = y,
width = width,
height = height
}, opts))
end
---@param id string
---@param asset string|RuntimeNodeInit
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param animation? string
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function runtime_ui.spine(id, asset, x, y, width, height, animation, opts)
if is_table(asset) then
return runtime_ui.node("spine", id, node_opts({ asset = "", x = 0, y = 0, width = 0, height = 0, loop = true }, asset))
end
return runtime_ui.node("spine", id, node_opts({
asset = asset,
x = x,
y = y,
width = width,
height = height,
animation = animation,
loop = true
}, opts))
end
---@param id string
---@param props RuntimeNodeInit
---@return RuntimeNodeUpdate
function runtime_ui.update(id, props)
return { id = id, props = normalize_opts(props or {}) }
end
---@param id string
---@param text string
---@return RuntimeNodeUpdate
function runtime_ui.text_update(id, text)
return runtime_ui.update(id, { text = text })
end
---@param id string
---@param visible boolean
---@return RuntimeNodeUpdate
function runtime_ui.visible_update(id, visible)
return runtime_ui.update(id, { visible = visible })
end
---@param id string
---@param alpha number
---@return RuntimeNodeUpdate
function runtime_ui.alpha_update(id, alpha)
return runtime_ui.update(id, { alpha = alpha })
end
---@param id string
---@param scale number
---@return RuntimeNodeUpdate
function runtime_ui.scale_update(id, scale)
return runtime_ui.update(id, { scale = scale })
end
---@param id string
---@param x number
---@param y number
---@return RuntimeNodeUpdate
function runtime_ui.position_update(id, x, y)
return runtime_ui.update(id, { x = x, y = y })
end
---@param id string
---@param width number
---@param height number
---@return RuntimeNodeUpdate
function runtime_ui.size_update(id, width, height)
return runtime_ui.update(id, { width = width, height = height })
end
---@param id string
---@param x number
---@param y number
---@param scale number
---@param rotation number
---@return RuntimeNodeUpdate
function runtime_ui.transform_update(id, x, y, scale, rotation)
return runtime_ui.update(id, {
x = x,
y = y,
scale = scale,
rotation = rotation
})
end
---@param ids string[]
---@param props RuntimeNodeInit
---@return RuntimeNodeUpdate[]
function runtime_ui.batch_update(ids, props)
local updates = {}
for _, id in ipairs(ids) do
table.insert(updates, runtime_ui.update(id, props))
end
return updates
end
---@param nodes RuntimeNode[]
---@param node RuntimeNode
---@return RuntimeNode[]
function runtime_ui.append(nodes, node)
table.insert(nodes, node)
return nodes
end
---@param nodes RuntimeNode[]
---@param extra_nodes RuntimeNode[]
---@return RuntimeNode[]
function runtime_ui.append_all(nodes, extra_nodes)
for _, node in ipairs(extra_nodes) do
table.insert(nodes, node)
end
return nodes
end
return runtime_ui

View File

@@ -0,0 +1,773 @@
local runtime_ui = runtime.import("runtime_ui")
---@class (exact) RuntimeDialogButton
---@field id? string
---@field text string
---@field handler string
---@field color? string
---@class (exact) RuntimeDialogOpts
---@field screenWidth? number
---@field screenHeight? number
---@field overlay? boolean
---@field overlayColor? string
---@field blockInput? boolean
---@field layer? integer
---@field color? string
---@field radius? number
---@field panelStyle? RuntimeNodeProps
---@field titleColor? string
---@field titleSize? number
---@field titleStyle? RuntimeNodeProps
---@field messageColor? string
---@field messageSize? number
---@field messageStyle? RuntimeNodeProps
---@field buttons? RuntimeDialogButton[]
---@field buttonGap? number
---@field buttonStyle? RuntimeNodeProps
---@class (exact) RuntimeLabeledProgressOpts: RuntimeNodeProps
---@field labelHeight? number
---@field labelStyle? RuntimeNodeProps
---@class RuntimePillOpts: RuntimeNodeInit
---@field panelStyle? RuntimeNodeProps
---@field textStyle? RuntimeNodeProps
---@class RuntimeTextButtonOpts: RuntimeNodeInit
---@field variant? 'primary'|'secondary'|'ghost'
---@class RuntimeListItemOpts: RuntimeTextButtonOpts
---@field selected? boolean
---@field activeColor? string
---@field inactiveColor? string
---@class RuntimeTabItem
---@field id? string
---@field key? string
---@field text string
---@field handler? string
---@field selected? boolean
---@class RuntimeTabsOpts: RuntimeNodeInit
---@field tabs RuntimeTabItem[]
---@field selected? string
---@field gap? number
---@field itemWidth? number
---@field itemHeight? number
---@field activeColor? string
---@field inactiveColor? string
---@field buttonStyle? RuntimeNodeProps
---@class RuntimeActionItem
---@field id? string
---@field text string
---@field handler? string
---@field visible? boolean
---@field color? string
---@field style? RuntimeNodeProps
---@class RuntimeActionRowOpts: RuntimeNodeInit
---@field actions RuntimeActionItem[]
---@field gap? number
---@field itemWidth? number
---@field itemHeight? number
---@field buttonStyle? RuntimeNodeProps
---@class RuntimePanelHeaderOpts: RuntimeNodeInit
---@field eyebrow? string
---@field title string
---@field summary? string
---@field gap? number
---@field eyebrowId? string
---@field titleId? string
---@field summaryId? string
---@field eyebrowHeight? number
---@field titleHeight? number
---@field summaryHeight? number
---@field eyebrowStyle? RuntimeNodeProps
---@field titleStyle? RuntimeNodeProps
---@field summaryStyle? RuntimeNodeProps
---@class RuntimeWidgetTheme
---@field primary? string
---@field secondary? string
---@field success? string
---@field overlay? string
---@field surface? string
---@field surfaceAlt? string
---@field card? string
---@field text? string
---@field muted? string
---@field progress? string
---@field transparent? string
---@class RuntimeWidgets
local widgets = {}
-- -----------------------------------------------------------------------------
-- Theme/config
-- -----------------------------------------------------------------------------
---@type RuntimeWidgetTheme
local widget_theme = {
primary = "#ff2563eb",
secondary = "#ff475569",
success = "#ff22c55e",
overlay = "#99000000",
surface = "#ff1e293b",
surfaceAlt = "#ff334155",
card = "#ee111827",
text = "#ffffffff",
muted = "#ffe5e7eb",
progress = "#ff22c55e",
transparent = "#00000000"
}
-- -----------------------------------------------------------------------------
-- Internal helpers
-- -----------------------------------------------------------------------------
---@generic T: table
---@param base? T
---@param opts? table
---@return T
local function merge(base, opts)
local result = {}
for key, value in pairs(base or {}) do
result[key] = value
end
for key, value in pairs(opts or {}) do
result[key] = value
end
return result
end
---@param value any
---@return boolean
local function is_table(value)
return type(value) == "table"
end
---@param source? table
---@param keys string[]
---@return table
local function without_keys(source, keys)
local result = merge(source or {}, nil)
for _, key in ipairs(keys) do
result[key] = nil
end
return result
end
---@param opts? RuntimeNodeInit
---@return RuntimeNodeInit
local function normalize_box(opts)
local result = merge(opts or {}, nil)
if result.width == nil then
result.width = result.w or result.size
end
if result.height == nil then
result.height = result.h or result.size
end
result.w = nil
result.h = nil
result.size = nil
return result
end
---@param opts? RuntimeNodeInit
---@param fallback? table
---@return RuntimeNodeInit
local function with_box(opts, fallback)
return merge(fallback or {}, normalize_box(opts))
end
---@param target RuntimeNode[]
---@param source? RuntimeNode[]
---@return RuntimeNode[]
local function append_all(target, source)
for _, node in ipairs(source or {}) do
table.insert(target, node)
end
return target
end
---@param parent string
---@param opts? RuntimeNodeProps
---@return RuntimeNodeProps
local function child_opts(parent, opts)
return merge(opts or {}, { parent = parent })
end
---@param value table
---@param opts? table
---@param key string
---@return table, table
local function collection_args(value, opts, key)
local options = opts or {}
local items = value
if is_table(value) and value[key] ~= nil then
options = value
items = value[key]
end
return items or {}, options
end
---@param tokens? RuntimeWidgetTheme
---@return RuntimeWidgets
function widgets.configure(tokens)
widget_theme = merge(widget_theme, tokens or {})
return widgets
end
---@param name string
---@return string
local function theme_color(name)
return widget_theme[name] or "#ffffffff"
end
-- -----------------------------------------------------------------------------
-- Text helpers
-- -----------------------------------------------------------------------------
---@param id string
---@param text string|RuntimeNodeInit
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function widgets.label(id, text, x, y, width, height, opts)
local fallback = { text = "", x = 0, y = 0, width = 0, height = 0 }
local options
if is_table(text) then
options = with_box(text, fallback)
else
options = with_box(merge({ text = text, x = x, y = y, width = width, height = height }, opts), fallback)
end
return runtime_ui.text(id, merge({
color = theme_color("muted"),
fontSize = 14,
textAlign = "left"
}, options))
end
---@param id string
---@param text string|RuntimeNodeInit
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function widgets.section_title(id, text, x, y, width, height, opts)
local options
if is_table(text) then
options = text
else
options = merge({ text = text, x = x, y = y, width = width, height = height }, opts)
end
return widgets.label(id, merge({
color = theme_color("text"),
fontSize = 18,
textAlign = "left"
}, options))
end
-- -----------------------------------------------------------------------------
-- Badges and buttons
-- -----------------------------------------------------------------------------
---@param id string
---@param text string|RuntimePillOpts
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimePillOpts
---@return RuntimeNode[]
function widgets.pill(id, text, x, y, width, height, opts)
local options
if is_table(text) then
options = with_box(text, { text = "", x = 0, y = 0, width = 0, height = 0 })
else
options = with_box(merge({ text = text, x = x, y = y, width = width, height = height }, opts), {
text = "",
x = 0,
y = 0,
width = 0,
height = 0
})
end
local panel_opts = merge({
color = theme_color("surfaceAlt"),
radius = options.height / 2
}, options.panelStyle)
panel_opts = merge(panel_opts, without_keys(options, { "text", "panelStyle", "textStyle" }))
local text_opts = child_opts(id, merge({
color = theme_color("text"),
fontSize = 12,
textAlign = "center"
}, options.textStyle))
return {
runtime_ui.panel(id, panel_opts),
runtime_ui.text(id .. "_text", merge({
text = options.text or "",
x = 0,
y = 0,
width = options.width,
height = options.height
}, text_opts))
}
end
---@param id string
---@param text string|RuntimeTextButtonOpts
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param handler? string
---@param opts? RuntimeTextButtonOpts
---@return RuntimeNode
function widgets.text_button(id, text, x, y, width, height, handler, opts)
local options
if is_table(text) then
options = with_box(text, { text = "", x = 0, y = 0, width = 0, height = 0 })
else
options = with_box(merge({ text = text, x = x, y = y, width = width, height = height, handler = handler }, opts), {
text = "",
x = 0,
y = 0,
width = 0,
height = 0
})
end
local variant = options.variant or "primary"
local color = theme_color("primary")
if variant == "secondary" then
color = theme_color("secondary")
elseif variant == "ghost" then
color = theme_color("transparent")
end
return runtime_ui.button(id, merge({
color = color,
radius = 10,
fontSize = 13,
textAlign = "center"
}, without_keys(options, { "variant" })))
end
---@param id string
---@param text string|RuntimeListItemOpts
---@param x? number
---@param y? number
---@param width? number
---@param height? number
---@param handler? string
---@param opts? RuntimeListItemOpts
---@return RuntimeNode
function widgets.list_item(id, text, x, y, width, height, handler, opts)
local options
if is_table(text) then
options = with_box(text, { text = "", x = 0, y = 0, width = 0, height = 0 })
else
options = with_box(merge({ text = text, x = x, y = y, width = width, height = height, handler = handler }, opts), {
text = "",
x = 0,
y = 0,
width = 0,
height = 0
})
end
local selected = options.selected == true
local color = selected and (options.activeColor or theme_color("primary")) or (options.inactiveColor or theme_color("surface"))
return widgets.text_button(id, merge({
color = color,
radius = 10,
fontSize = 12
}, without_keys(options, { "selected", "activeColor", "inactiveColor" })))
end
---@param id string
---@param tabs RuntimeTabItem[]|RuntimeTabsOpts
---@param opts? RuntimeTabsOpts
---@return RuntimeNode[]
function widgets.tabs(id, tabs, opts)
local items, options = collection_args(tabs, opts, "tabs")
local origin_x = options.x or 0
local origin_y = options.y or 0
local gap = options.gap or 6
local item_w = options.itemWidth or options.width or options.w or 72
local item_h = options.itemHeight or options.height or options.h or 24
local nodes = {}
for index, tab in ipairs(items) do
local key = tab.key or tostring(index)
local selected = tab.selected == true or key == options.selected
local node_id = tab.id or (id .. "_" .. key)
local style = merge({
x = origin_x + (index - 1) * (item_w + gap),
y = origin_y,
w = item_w,
h = item_h,
text = tab.text or key,
handler = tab.handler or key,
selected = selected,
activeColor = options.activeColor,
inactiveColor = options.inactiveColor
}, options.buttonStyle)
style = merge(style, without_keys(options, {
"tabs",
"selected",
"gap",
"itemWidth",
"itemHeight",
"activeColor",
"inactiveColor",
"buttonStyle",
"x",
"y",
"width",
"height",
"w",
"h"
}))
nodes[index] = widgets.list_item(node_id, style)
end
return nodes
end
---@param id string
---@param actions RuntimeActionItem[]|RuntimeActionRowOpts
---@param opts? RuntimeActionRowOpts
---@return RuntimeNode[]
function widgets.action_row(id, actions, opts)
local items, options = collection_args(actions, opts, "actions")
local count = #items
local gap = options.gap or 8
local origin_x = options.x or 0
local origin_y = options.y or 0
local item_h = options.itemHeight or options.height or options.h or 32
local item_w = options.itemWidth
if item_w == nil and (options.width ~= nil or options.w ~= nil) and count > 0 then
item_w = ((options.width or options.w) - gap * (count - 1)) / count
end
item_w = item_w or 88
local nodes = {}
for index, action in ipairs(items) do
local visible = action.visible ~= false
local style = merge({
x = origin_x + (index - 1) * (item_w + gap),
y = origin_y,
w = item_w,
h = item_h,
text = visible and (action.text or "") or "",
handler = visible and (action.handler or "noop") or "noop",
color = action.color or (index == 1 and theme_color("primary") or theme_color("surfaceAlt")),
visible = visible
}, options.buttonStyle)
style = merge(style, action.style)
style = merge(style, without_keys(options, {
"actions",
"gap",
"itemWidth",
"itemHeight",
"buttonStyle",
"x",
"y",
"width",
"height",
"w",
"h"
}))
nodes[index] = widgets.text_button(action.id or (id .. "_" .. tostring(index)), style)
end
return nodes
end
-- -----------------------------------------------------------------------------
-- Panels and headers
-- -----------------------------------------------------------------------------
---@param id string
---@param opts RuntimePanelHeaderOpts
---@return RuntimeNode[]
function widgets.panel_header(id, opts)
local options = with_box(opts or {}, { x = 0, y = 0, width = 0, height = 0, title = "" })
local nodes = {}
local cursor = options.y
local gap = options.gap or 4
local layer = options.layer
local parent = options.parent
if options.eyebrow ~= nil and options.eyebrow ~= "" then
local h = options.eyebrowHeight or 20
table.insert(nodes, widgets.label(options.eyebrowId or (id .. "_eyebrow"), merge({
text = options.eyebrow,
x = options.x,
y = cursor,
width = options.width,
height = h,
parent = parent,
layer = layer,
color = theme_color("primary"),
fontSize = 13
}, options.eyebrowStyle)))
cursor = cursor + h + gap
end
local title_h = options.titleHeight or 28
table.insert(nodes, widgets.section_title(options.titleId or (id .. "_title"), merge({
text = options.title or "",
x = options.x,
y = cursor,
width = options.width,
height = title_h,
parent = parent,
layer = layer,
fontSize = 20
}, options.titleStyle)))
cursor = cursor + title_h + gap
if options.summary ~= nil and options.summary ~= "" then
local h = options.summaryHeight or 22
table.insert(nodes, widgets.label(options.summaryId or (id .. "_summary"), merge({
text = options.summary,
x = options.x,
y = cursor,
width = options.width,
height = h,
parent = parent,
layer = layer,
color = theme_color("muted"),
fontSize = 12
}, options.summaryStyle)))
end
return nodes
end
---@param id string
---@param width number|RuntimeNodeInit
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function widgets.overlay(id, width, height, opts)
if is_table(width) then
return runtime_ui.rect(id, merge({
x = 0,
y = 0,
color = theme_color("overlay"),
layer = 900
}, width))
end
return runtime_ui.rect(id, 0, 0, width, height, merge({
color = theme_color("overlay"),
layer = 900
}, opts))
end
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function widgets.card(id, x, y, width, height, opts)
if is_table(x) then
return runtime_ui.panel(id, merge({
x = 0,
y = 0,
width = 0,
height = 0,
color = theme_color("card"),
radius = 14,
layer = 910
}, x))
end
return runtime_ui.panel(id, x, y, width, height, merge({
color = theme_color("card"),
radius = 14,
layer = 910
}, opts))
end
-- -----------------------------------------------------------------------------
-- Progress
-- -----------------------------------------------------------------------------
---@param id string
---@param x number|RuntimeNodeInit
---@param y? number
---@param width? number
---@param height? number
---@param value? number
---@param opts? RuntimeNodeInit
---@return RuntimeNode
function widgets.progress_bar(id, x, y, width, height, value, opts)
if is_table(x) then
return runtime_ui.progress(id, merge({
x = 0,
y = 0,
width = 0,
height = 0,
value = 0,
color = theme_color("progress"),
radius = 6
}, x))
end
return runtime_ui.progress(id, x, y, width, height, value, merge({
color = theme_color("progress"),
radius = 6
}, opts))
end
---@param id string
---@param label string
---@param x number
---@param y number
---@param width number
---@param height number
---@param value number
---@param opts? RuntimeLabeledProgressOpts
---@return RuntimeNode[]
function widgets.labeled_progress(id, label, x, y, width, height, value, opts)
local label_height = opts ~= nil and opts.labelHeight or 24
local progress_opts = merge(opts or {}, nil)
progress_opts.labelHeight = nil
progress_opts.labelStyle = nil
return {
runtime_ui.text(id .. "_label", label, x, y, width, label_height, merge({
color = theme_color("text"),
fontSize = 16
}, opts ~= nil and opts.labelStyle or nil)),
widgets.progress_bar(id, x, y + label_height + 6, width, height, value, progress_opts)
}
end
-- -----------------------------------------------------------------------------
-- Dialogs
-- -----------------------------------------------------------------------------
---@param parent string
---@param id string
---@param buttons RuntimeDialogButton[]
---@param x number
---@param y number
---@param width number
---@param height number
---@param gap? number
---@param opts? RuntimeNodeProps
---@return RuntimeNode[]
function widgets.button_row(parent, id, buttons, x, y, width, height, gap, opts)
local nodes = {}
local count = #buttons
if count == 0 then
return nodes
end
local actual_gap = gap or 8
local button_width = (width - actual_gap * (count - 1)) / count
for index, button in ipairs(buttons) do
local button_id = button.id or (id .. "_button_" .. index)
table.insert(nodes, runtime_ui.button(
button_id,
button.text or "OK",
x + (index - 1) * (button_width + actual_gap),
y,
button_width,
height,
button.handler,
child_opts(parent, merge({
layer = 920,
color = button.color or theme_color("primary"),
radius = 10
}, opts))
))
end
return nodes
end
---@param id string
---@param title string
---@param message string
---@param x number
---@param y number
---@param width number
---@param height number
---@param opts? RuntimeDialogOpts
---@return RuntimeNode[]
function widgets.dialog(id, title, message, x, y, width, height, opts)
local options = opts or {}
local nodes = {}
local layer = options.layer or 900
local screen_width = options.screenWidth or 720
local screen_height = options.screenHeight or 720
if options.overlay ~= false then
table.insert(nodes, widgets.overlay(id .. "_overlay", screen_width, screen_height, {
layer = layer,
color = options.overlayColor or theme_color("overlay"),
interactive = options.blockInput == true
}))
end
table.insert(nodes, widgets.card(id, x, y, width, height, merge({
layer = layer + 1,
color = options.color or theme_color("card"),
radius = options.radius or 16
}, options.panelStyle)))
table.insert(nodes, runtime_ui.text(
id .. "_title",
title or "",
20,
18,
width - 40,
32,
child_opts(id, merge({
layer = layer + 2,
color = options.titleColor or theme_color("text"),
fontSize = options.titleSize or 22
}, options.titleStyle))
))
table.insert(nodes, runtime_ui.text(
id .. "_message",
message or "",
20,
60,
width - 40,
height - 116,
child_opts(id, merge({
layer = layer + 2,
color = options.messageColor or theme_color("muted"),
fontSize = options.messageSize or 16
}, options.messageStyle))
))
append_all(nodes, widgets.button_row(
id,
id,
options.buttons or {},
20,
height - 48,
width - 40,
34,
options.buttonGap or 8,
options.buttonStyle
))
return nodes
end
return widgets