Skip to main content

Crate kui_lua

Crate kui_lua 

Source
Expand description

Lua scripts as kui extensions: a script returns its view as a table tree and gets events back as tables.

kui-lua loads a Lua script as an Extension a kui host can place in its frame. The script defines view(env, slot), which returns a plain table tree built with the row / column / text / button prelude, and optionally on_event(ev), which receives the events the script’s own nodes emit and may return replies for the host. Nothing but data crosses the boundary: the host gets nodes, the script gets event tables, and no closure lives on either side. It sits beside kui-ffi (C plugins) and kui-node (the Node.js package) as one of the bindings over kui_core; most hosts run it inside kui-native, the windowed runner.

Two readers meet here. A Rust application wants scriptable panels or plugins: it declares a slot in its own view, loads a script under a namespace, and counts the replies that come back, without ever looking at the script’s UI. The author of such a script wants to know what view is handed, which builders exist and which props a table takes; the reference below is for them, and luals_meta turns it into completion for lua-language-server.

§Quick start

The Rust side: a kui-native app that reserves a position for the script and hears its replies. extension_as names the script’s namespace, so the slot the view declares is todos/panel for a script whose slots global lists "panel".

use kui_lua::LuaExtension;
use kui_native::{App, NodeSpec, Ui, UiEvent, Value};

struct Host {
    toggles: u32,
}

impl App for Host {
    fn view(&mut self, ui: &mut Ui<'_>) {
        ui.configure_root(NodeSpec::row().fill().pad(16.0).gap(16.0));
        // The script draws here. The params are its to read from
        // `slot.params`; `on_toggle` is the reply shape the host wants.
        ui.slot_with(
            "todos/panel",
            &Value::map([
                ("title", "todos".into()),
                ("on_toggle", Value::map([("kind", "toggled".into())])),
            ]),
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        if ev.kind() == Some("toggled") {
            self.toggles += 1;
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let script = LuaExtension::from_file("panel.lua")?;
    kui_native::app("todos")
        .extension_as("todos", script)
        .run(Host { toggles: 0 })
}

The Lua side, panel.lua: a todo list that keeps its own state, reads the title and the reply template the host passed, and answers a toggle by returning that template filled in.

slots = { "panel" }

local todos = { "ship the layout solver", "wire up wgpu" }
local done = {}
local on_toggle -- the host's reply template, kept for on_event

function view(env, slot)
  local t = env.theme
  local params = slot.params or {}
  on_toggle = params.on_toggle
  local items = {}
  for i, todo in ipairs(todos) do
    items[#items + 1] = row {
      gap = 8, pad = { t = 4, b = 4 },
      on_click = { kind = "toggle", index = i },
      text((done[i] and "[x] " or "[ ] ") .. todo, { size = 14, color = t.fg }),
    }
  end
  return column {
    width = 300, height = "grow", pad = 16, gap = 10,
    bg = t.surface, radius = 10, border = { w = 1, color = t.border },
    text(params.title or "todos", { size = 12, color = t.muted }),
    edit { key = "filter", label = "filter todos", initial = "", width = "grow" },
    column { height = "grow", scroll = true, table.unpack(items) },
    button { label = "add", on_click = { kind = "add" } },
  }
end

function on_event(ev)
  if ev.kind == "toggle" then
    done[ev.index] = not done[ev.index]
    if on_toggle then
      local reply = { index = ev.index, done = done[ev.index] }
      for k, v in pairs(on_toggle) do reply[k] = v end
      return reply -- a returned table is a reply the host hears
    end
  elseif ev.kind == "add" then
    todos[#todos + 1] = "todo #" .. (#todos + 1)
  end
end

A headless host (tests, tools) uses kui_core::Core directly: load with LuaExtension::from_source, push the extension into a kui_core::Extensions list and build frames with Core::frame_with. The LuaExtension docs show that path.

§What a script gets

§Builders

The prelude injects one global per element; each takes a table of props whose integer keys are the children and returns it with type set.

  • Containers: row { }, column { }, grid { } (a table whose cells line up in columns; named grid because table is Lua’s).
  • Text: text("plain", opts) or text({ "a ", { "b", bold = true } }, opts) for spans; tooltip("hint") as a node, or tooltip = "hint" as a prop.
  • Controls: button { label = }, checkbox, radio, switch, radio_group { radio { }, ... }, slider { value_now =, value_min =, value_max = }, input { label = } (single line with chrome), edit { key =, initial = } (bare editor), dropdown { options =, current = } (named dropdown because select is Lua’s).
  • Media: image { id = }, fragment { id = } (a WGSL-painted box), line { from =, to = }, polygon { points = }, path { d = } (any outline, SVG path data), cells { } (a terminal screen as one node), audio { src = }.
  • Window chrome: titlebar { title = }, window_buttons(), menu_bar { menu = }, latency_graph(), latency_hud { }.
  • Lists: uniform_list(env, opts, row) for rows of one height, list(env, opts, measure, row) for rows of different heights, sliced by a row_heights(rows, estimate) the script keeps between frames; reveal_row(env, key, i, row_h) and rows_in_view(env, key, row_h) go with them. splitter(env, { key =, dir =, on_drag = }) is a draggable divider.
  • Hosting: fill { name = "ns/slot", params = } is the position a C plugin this script loaded draws in; devtools_tab { name =, label =, slot = | view = } adds a tab to the core’s devtools panel.

§The env table

env is built fresh for every view call. Its values are the facts the host wrote before view ran; its functions answer about the frame being built and the last one finished.

Facts:

  • Timing and size: refresh_hz, frame_budget_ms, viewport_w, viewport_h. A fact the host cannot tell is left out rather than nil.
  • Focus: focused (the window has the keyboard), focus (the focused node’s key, nil for none), focus_visible, caret_visible, region.
  • Window: window.id, window.fullscreen, window.maximized, window.always_on_top, window.custom_chrome, window.controls_w / window.controls_h (the keep-out extent of OS-drawn controls).
  • System: system.appearance, system.accent, system.locale, system.motion, system.assistive; audio.live, audio.device.
  • Palette: env.theme has one 0xRRGGBBAA number per theme role (bg, surface, fg, muted, border, accent, …) plus appearance and disabled_opacity; env.metrics the sizes the stock widgets use; env.tokens.colors / env.tokens.lengths the named tokens in force, the script’s own over the host’s. All read-only.

Functions, by topic. key is an integer key an event carried or the string label a node’s key prop declared, resolved among the script’s own nodes:

  • Queries: edit_text(key), set_edit_text(key, text), is_focused(key), is_hovered(key), is_pressed(key), is_drop_target(key), drop_target(), layout_of(key), measure_text(s, opts, max_w) (what layout gives the same text), extension_namespaces().
  • Focus: set_focus(key), blur(), focus_next(), focus_prev(), focus_region(key), announce(text, politeness).
  • Scrolling: reveal(key), scroll_offset(key), scroll_geometry(key), set_scroll(key, x, y), shift_scroll(key, drawn, target).
  • Text and selection: text_hit(key, x, y), caret_rect(key, byte), selection_text(), selection_html(), selection_ends(), cell_selection(), select_all_in(key), clear_selection(), answer_selection_range(text).
  • Clipboard and dialogs: set_clipboard(text, html), set_clipboard_secret(text), request_copy(), request_paste(), awaiting_paste(), request_files(opts), awaiting_files().
  • Menus and windows: open_menu(key, x, y, items), close_menu(), set_window_size(window, w, h), focus_window(window).
  • Declarations: set_tokens(decl) replaces the script’s token table; add_extension(namespace, path) loads a C plugin (see below).

The root table may also carry host state beside its children: window_title, always_on_top, secure_input, option_as_alt, ime_off and a windows list of name | { name, kind, width, height, activates, anchor }.

§The two focus names

env.focused and env.focus are different facts. focused is a boolean, whether this window has the keyboard at all. focus is the focused node’s key, nil for none. Because focus is a value, moving focus is env.set_focus(key); blur, focus_next and focus_prev keep the names the other bindings use. set_focus and blur take effect at once; focus_next / focus_prev resolve when the frame finishes, because the Tab ring is built from a finished tree. env.focus still reads the focus the frame opened with, so env.is_focused(key) is the query that answers about now.

§The slot argument

view’s second argument says which slot the host is filling: slot.name (the slot in the script’s own vocabulary), slot.namespace (what the host loaded the script under), slot.params (the host’s table, nil when it passed none) and slot.key (the integer key events and set_focus use). A slots global lists the names the script fills: no global means "root", { "*" } means every name the host declares under the namespace.

§Events

on_event(ev) receives the payload table the node declared (on_click = { kind = "toggle", index = i }) plus node_key (the emitting node’s integer key), window (the window it came from) and slot (the full name of the slot the node was filled into). Edit widgets emit { kind = "changed" | "submit" }; read the text back with env.edit_text. What on_event returns is the script’s replies to the host: nothing, one table, or a sequence of tables.

§Props

Every row of the shared schema in kui_core::schema is reachable under its snake_case name (min_width, on_click, line_height, …), so Lua and Node accept the same surface. Only the composites have Lua shapes:

  • pad = 8 or pad = { all =, x =, y =, l =, r =, t =, b = }
  • border = { w = 1, color = 0x... }
  • scroll, scroll_x, scroll_y, clip as booleans
  • float = "below" | "above" or float = { anchor =, at =, self =, dx =, dy =, fit = }
  • sizing: width = 300, "grow", { grow = 2 }, { pct = 50 }, or a size expression string
  • tooltip = "hint" on a container (hover-gated)
  • a colour is 0xRRGGBBAA or a "$token" name

A key no table claims is an unknown-prop warning the host can read.

§A script may host a plugin

env.add_extension(namespace, path) loads a C extension, a .so / .dylib / .dll exporting the kui_ext_* entry points kui-ffi’s kui.h describes, and fill { name = "ns/slot", params = } is the position it draws in among the script’s children. The plugin’s replies reach on_event with from set to the namespace. A script loads C libraries only; a host that wants two scripts loads two.

§Where to look

Book: https://kui-book.qxuken.dev. Repository: https://github.com/qxuken/kui (the design records live under docs/adr there).

Structs§

LuaExtension
A Lua script loaded as a kui Extension.

Constants§

MAX_VALUE_DEPTH
How deep a Lua value may nest before lua_to_value refuses it. A table holding itself, or one nested deeper than this, is an error rather than a stack overflow.
MAX_VIEW_DEPTH
How deep a view table may nest. Past it, or on a table that is its own ancestor (t[1] = t), the view is refused with an error rather than recursing off the stack.

Functions§

lua_to_value
Converts a Lua value to a kui_core::Value, the shape event payloads and replies travel in.
luals_meta
The ---@meta file for lua-language-server, as text.
parse_props
Reads a node table’s props into a PropsOut.
parse_tokens
Reads a tokens table as a kui_core::Tokens.
value_to_lua
Converts a kui_core::Value to a Lua value: the inverse of lua_to_value, used to hand event payloads and slot params to a script.

Type Aliases§

Refs
What a $name in a prop resolves through while a table is parsed: the core’s token lookup for the running origin, plus the names that did not resolve, which are raised as unknown-token warnings afterwards. A prop whose name resolves to nothing is left out and keeps its default, rather than becoming an explicit transparent or zero.