Skip to content

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 one widget_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).