Files
flutter_lua_runtime/assets/runtime/lua/runtime_widgets.lua
2026-06-07 22:53:58 +08:00

774 lines
20 KiB
Lua

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