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