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
endA 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; namedgridbecausetableis Lua’s). - Text:
text("plain", opts)ortext({ "a ", { "b", bold = true } }, opts)for spans;tooltip("hint")as a node, ortooltip = "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 = }(nameddropdownbecauseselectis 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 arow_heights(rows, estimate)the script keeps between frames;reveal_row(env, key, i, row_h)androws_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.themehas one0xRRGGBBAAnumber per theme role (bg,surface,fg,muted,border,accent, …) plusappearanceanddisabled_opacity;env.metricsthe sizes the stock widgets use;env.tokens.colors/env.tokens.lengthsthe 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 sametext),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 = 8orpad = { all =, x =, y =, l =, r =, t =, b = }border = { w = 1, color = 0x... }scroll,scroll_x,scroll_y,clipas booleansfloat = "below" | "above"orfloat = { 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
0xRRGGBBAAor 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
LuaExtension: the script as an extension;LuaExtension::from_fileandLuaExtension::from_sourceload it.luals_meta: a---@metafile for lua-language-server, generated from the schema.lua_to_value/value_to_lua: the table <->kui_core::Valueconversion events and replies go through.parse_propsandparse_tokens: the table readers, for a host that wants to parse a view table or atokenstable itself.MAX_VIEW_DEPTH/MAX_VALUE_DEPTH: the nesting limits.
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_valuerefuses 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
---@metafile for lua-language-server, as text. - parse_
props - Reads a node table’s props into a
PropsOut. - parse_
tokens - Reads a
tokenstable as akui_core::Tokens. - value_
to_ lua - Converts a
kui_core::Valueto a Lua value: the inverse oflua_to_value, used to hand event payloads and slot params to a script.
Type Aliases§
- Refs
- What a
$namein 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 asunknown-tokenwarnings afterwards. A prop whose name resolves to nothing is left out and keeps its default, rather than becoming an explicit transparent or zero.