Components (react-style GUIs)
Status: Implemented (API 1.22). Declarative GUIs for mod screens: components with props and state, hooks (state, refs, memos, effects), keyed lists, composition, and a re-render that diffs the new tree against the last one so only the widgets that changed are touched. Built on the UI Kit, so every component follows the player’s Appearance style (Vanilla or Fancy) and theme.
Function table: view. Related: The UI Kit, HTML/CSS Overlays, guide A Settings Panel with Components. Example mod: sdk/examples/react_settings_lua. Client only.
The idea
You describe what the screen should show now, as a tree. When something changes you don’t move widgets around yourself: you change state, the component renders a new tree, and the engine compares it with the last one:
- a node with the same key (or the same place among its unkeyed siblings) and the same kind is the same widget: only its changed props are applied (a new label is one
widget_set_label, a moved node onewidget_set_bounds); - a node that is new becomes a widget, a node that is gone loses its widget;
- a child component whose props didn’t change is not rendered again at all (it is memoised).
Nothing renders unless state changed. A still screen costs one flag test per frame, no allocation and no call into your mod.
Lua
local ui = cl.ui
-- a component: props in, a tree out
local Row = ui.component("Row", function(p)
return ui.row{ gap = 4, height = 20,
ui.label{ text = p.name, grow = 1 },
ui.icon_button{ icon = "trash", tooltip = "Remove", on_press = p.on_remove },
}
end)
local Settings = ui.component("Settings", function(props)
local enabled, set_enabled = ui.use_state(true)
local names, set_names = ui.use_state({ "Home", "Mine" })
ui.use_effect(function() print("enabled is now", enabled) end, { enabled })
return ui.panel{ padding = 10, gap = 6,
ui.heading{ text = props.title },
ui.toggle{ label = "Enabled", value = enabled, on_change = set_enabled },
ui.map(names, function(n)
return Row{ key = n, name = n, on_remove = function()
local rest = {}
for _, x in ipairs(names) do if x ~= n then rest[#rest + 1] = x end end
set_names(rest)
end }
end),
}
end)
local view = ui.open{ title = "My Mod", root = Settings, props = { title = "My Mod" }, width = 300, x = 40, y = 30 }
-- later: view:close(), view:set_props{ title = "..." }, view:rerender()
| Lua | What it does |
|---|---|
cl.ui.component(name, fn) |
A component type; fn(props) returns a node (or a list of nodes). Call it with props to use it: Row{ key = "a", name = "A" }. |
cl.ui.open{ root, props, title, pause, x, y, width, height } |
Opens a themed screen showing the component. Returns a view: close(), is_open(), set_props(p), rerender(), flush(), stats(). |
cl.ui.mount(screen, { root, props, x, y, width, height }) |
Puts the component into one of your own screens (cl.screen.open), over its widgets, in that area. |
cl.ui.use_state(initial) |
Returns value, set. set(v) (or set(function(old) return new end)) renders the component again only when the value changed. |
cl.ui.use_effect(fn, deps) |
After the update: runs fn when a value in deps changed (no deps: after every render; {}: once). fn may return a cleanup, run before the next run and when the component goes. |
cl.ui.use_ref(initial) |
A table { current = ... } kept across renders that renders nothing when changed. |
cl.ui.use_memo(fn, deps) / cl.ui.use_callback(fn, deps) |
A value / function kept until deps change. |
cl.ui.use_instance() |
The component’s instance number and view handle (for cl.view.invalidate). |
cl.ui.map(list, fn) |
A list of nodes from a list (false / nil results are skipped). |
Hooks follow React’s rule: call them in the same order on every render (not inside an if). Children in a props table are the array part: ui.column{ gap = 4, a, b, c }. false and nil children are skipped, so cond and ui.label{...} or false shows a node only sometimes. A child component gets its children as props.children.
Handlers get Lua values: toggles and checkboxes a boolean, sliders and colour pickers a number, dropdowns, tabs and lists the index (from 0) and the entry’s text, text fields the text.
Nodes and their props
Every node takes key (stable among its siblings), width, height, grow (a share of the free space), visible (false keeps the widget hidden and takes no space), disabled and tooltip.
| Lua / C++ | C kind | Props | Event |
|---|---|---|---|
column |
CL_VIEW_COLUMN |
gap (4), padding (0), align ("start", "center", "end" for children with a width) |
|
row |
CL_VIEW_ROW |
gap, padding, align (vertical); children without width share the free width |
|
panel |
CL_VIEW_PANEL |
a column on the theme’s panel; padding (8) |
|
spacer |
CL_VIEW_SPACER |
size (8) |
|
label |
CL_VIEW_LABEL |
text, color, centered |
|
heading |
CL_VIEW_HEADING |
text, level (1 to 3) |
|
button |
CL_VIEW_BUTTON |
label |
on_press |
toggle / checkbox |
CL_VIEW_TOGGLE / CL_VIEW_CHECKBOX |
label, value |
on_change |
slider |
CL_VIEW_SLIDER |
label, min, max, value, step |
on_change |
dropdown |
CL_VIEW_DROPDOWN |
label, choices (a list, or one per line), value |
on_change |
tabs |
CL_VIEW_TABS |
tabs, value |
on_change |
list + item |
CL_VIEW_LIST + CL_VIEW_LIST_ITEM |
list: row_height, value; item: title, detail, icon |
on_select |
card |
CL_VIEW_CARD |
title, body, icon |
on_press (clickable when set) |
icon_button |
CL_VIEW_ICON_BUTTON |
icon, tooltip, size |
on_press |
color_picker |
CL_VIEW_COLOR_PICKER |
label, value (ARGB) |
on_change |
text_field |
CL_VIEW_TEXT_FIELD |
hint, text, max_length |
on_change |
Heights default to the kit’s: 20 GUI px for controls, 10 for labels, 11 to 16 for headings, 52 for cards. A column’s children fill its width; the root of a view fills the view’s area.
Props are applied when they change. A widget that the player changes keeps its value until your props change, so keep the value in state and set it from the handler (as above).
C
The engine keeps the tree; your mod emits it in a render callback and keeps state in named slots:
static void render(cl_view v, uint64_t comp, cl_str type, void* user) {
double count = cl_view_state_num(v, comp, CL_STR("count"), 0);
char label[32];
snprintf(label, sizeof label, "Clicked %d", (int)count);
cl_view_begin(v, CL_VIEW_PANEL, CL_STR(""));
cl_view_node(v, CL_VIEW_HEADING, CL_STR(""));
cl_view_prop_str(v, CL_STR("text"), CL_STR("My Mod"));
cl_view_node(v, CL_VIEW_BUTTON, CL_STR(""));
cl_view_prop_str(v, CL_STR("label"), (cl_str){ label, strlen(label) });
cl_view_prop_str(v, CL_STR("on_press"), CL_STR("inc")); /* the handler's name */
cl_view_end(v);
}
static void on_event(cl_view v, uint64_t comp, cl_str handler, double value, cl_str text, void* user) {
if (handler.len == 3 && memcmp(handler.ptr, "inc", 3) == 0)
cl_view_set_state_num(v, comp, CL_STR("count"), cl_view_state_num(v, comp, CL_STR("count"), 0) + 1);
}
void open_settings(void) {
cl_view_desc d = CL_VIEW_DESC_INIT;
d.title = CL_STR("My Mod");
d.render = render;
d.on_event = on_event;
cl_view v;
cl_view_open(&d, &v);
}
| C | What it does |
|---|---|
cl_view_open(&desc, &v) / cl_view_mount(screen, &desc, &v) |
A view on its own themed screen / inside one of your screens |
cl_view_close(v), cl_view_is_open(v), cl_view_root(v) |
|
cl_view_begin(v, kind, key) … cl_view_end(v) |
A container and its children (inside render only) |
cl_view_node(v, kind, key) |
A widget |
cl_view_component(v, type, key) |
A child component; returns its instance. render is called for it with that type when it is new, dirty or got new props |
cl_view_prop_num / cl_view_prop_str |
Props of the node or component just added. Handlers are string props (on_press = "save") that come back in on_event |
cl_view_get_num / cl_view_get_str |
The props a component got |
cl_view_state_num / cl_view_state_str, cl_view_set_state_num / cl_view_set_state_str |
Named state of a component; a set renders it again only when the value changed |
cl_view_invalidate(v, comp) |
Render a component again (state you keep yourself; 0 = the root) |
cl_view_effect(v, name, deps) |
Inside render: on_effect(v, comp, name, false) runs after the update when the effect is new or deps changed; on_effect(..., true) cleans it up first, and when the component goes |
cl_view_get_stats(v, &stats) |
Renders, updates, widgets created / changed / removed, nodes |
cl_view_flush(v) |
Run pending renders now (tests); screens do it every frame by themselves |
on_unmount(v, comp) tells you a component left the tree (after its effect cleanups), so you can free what you kept for it.
C++
clight_ui.hpp (in the SDK next to clight.hpp) has components as functions and the hooks:
#include <clight_ui.hpp>
namespace ui = clight::ui;
ui::Element settings(const ui::Props& p) {
auto [count, set_count] = ui::use_state(0);
ui::use_effect([c = count] { last_count = c; return std::function<void()>(); }, count); // after each change
return ui::panel().padding(10)
.child(ui::heading(p.str("title")))
.child(ui::button("Clicked " + std::to_string(count)).on_press([=] { set_count(count + 1); }))
.child(ui::component("row", row, ui::Props().set("name", "Home")));
}
ui::View view = ui::open("My Mod", settings, ui::Props().set("title", "My Mod"));
| C++ | |
|---|---|
ui::open(title, fn, props), ui::mount(screen, fn, props, type, options) |
Returns a ui::View (close(), open(), rerender(), stats(), flush()) |
ui::column(), ui::row(), ui::panel(), ui::label(text), ui::heading(text, level), ui::button(label), ui::toggle(label, on), ui::checkbox, ui::slider(label, min, max, value, step), ui::dropdown(label, choices, selected), ui::tabs(tabs, selected), ui::list() + ui::item(title, detail, icon), ui::card, ui::icon_button, ui::color_picker, ui::text_field, ui::spacer |
Nodes; .key(), .gap(), .padding(), .width(), .height(), .grow(), .tooltip(), .disabled(), .visible(), .child(), .children(), .prop(name, v) |
.on_press(fn), .on_change(fn(const ui::Event&)), .on_select(fn) |
Handlers; Event has on(), index(), value, text |
ui::component(type, fn, props) |
A child component (memoised while its props are equal and have no handlers or children) |
ui::use_state(v), ui::use_ref(v), ui::use_effect(fn, deps...), ui::use_effect_once(fn) |
Hooks |
It works the same in WebAssembly mods (no exceptions or RTTI needed).
Cost
| Situation | Work |
|---|---|
| No view open | Nothing (one relaxed load per mod screen frame) |
| A view open, nothing changed | One flag test per frame; no call into the mod, no allocation |
| A state change | That component renders; the engine compares its nodes with the last ones and calls the kit only for differences |
| A keyed reorder | One bounds change per moved widget; no widget made again |
Widgets made in the middle of the tree (a new node before existing ones) make the widgets after them again, so the drawing order stays the tree’s order (a panel stays under what it holds).
Versions
The view module came with API 1.22 ("api_version": ">=1.22" in the manifest, or check cl.has("view_open") / cl_core_has("cl_view_open")). Node kinds and prop names are stable names: new kinds and props are added, existing ones keep their meaning (ABI Stability).