Skip to content

The UI kit

Status: Implemented (API 1.21). Themed widgets for mod screens that follow the player’s Appearance style (Vanilla or Fancy) and theme by themselves, and themed drawing for your own parts.

Function tables: screen, gui, ui. Related: Theme Tokens, HUD Elements, Appearance Pages, GUI: Immediate, Components (the same widgets, declared as a tree), HTML/CSS Overlays. Example mod: sdk/examples/ui_kit_lua.

The two styles

Celestial draws every screen in one of two styles that the player picks in Appearance:

Style What kit widgets look like
Vanilla The game’s own widgets: the button sprite, the checkbox, the slider, cycle buttons (“Labels: Letters”), tab sprites, the selection list with its scroller, the vanilla font.
Fancy The Celestial design: glass plates with a lighter white hover, toggles, segmented tabs, dropdown fields with a popup, lists in a recessed well, cards, a colour picker, the UI font.

The player can also give Mod screens their own style (Appearance > Menus > Mods), and every theme changes the accents. You never ask which style is on: you build the screen once and the kit draws it right. When the player switches the style while your screen is open, it redraws at once.

A themed screen

Open the screen with themed = true. That is the only difference to a screen of API 1.0:

cl.screen.open{
  title = "Compass Plus",
  pause = false,
  themed = true,
  init = function(s)
    local x, y = s:width() // 2 - 150, 40
    s:panel(x - 14, y - 14, 328, 200)
    s:heading(x, y, "Compass Plus", 1)
    s:toggle(x, y + 30, 300, 20, "Show the compass bar", true, function(t) print(t:value()) end)
    s:slider(x, y + 56, 300, 20, "Bar width", 80, 240, 160, 10, function(w) print(w:value()) end)
    s:dropdown(x, y + 86, 300, 20, "Labels", "Letters\nDegrees", 0, function(d) print(d:value()) end)
    s:button(x + 210, y + 150, 90, 20, "Done", function() s:close() end)
  end,
}

On a themed screen:

  • the background is the theme’s overlay in Fancy (the vanilla menu background in Vanilla);
  • the 1.0 widgets (button, checkbox, slider, label) are drawn by the kit too;
  • text fields stay real text boxes (typing, selection, the clipboard), drawn as a Fancy field in Fancy;
  • tooltips use the screen’s style.

Without themed, a screen keeps the vanilla look of API 1.0, but the new kit widgets still work there (in their Vanilla form).

The widgets

All of them belong to the screen like the 1.0 widgets: they go when the screen closes and are built again by init after a resize. Coordinates are GUI pixels.

C Lua (on the screen s) What it is value()
cl_widget_toggle s:toggle(x, y, w, h, label, on, fn) A label with a switch (Vanilla: “Label: ON”) 1 / 0
cl_widget_icon_button s:icon_button(x, y, size, icon, tooltip, fn) A square button with an icon
cl_widget_dropdown s:dropdown(x, y, w, h, label, choices, selected, fn) A choice; Fancy opens a list, Vanilla cycles like a cycle button the index
cl_widget_tabs s:tabs(x, y, w, h, tabs, selected, fn) A row of tabs (Fancy: a segmented control) the index
cl_widget_list s:list(x, y, w, h, row_height, fn) A scrolling list; w:list_add(title, detail, icon), w:list_clear() the selected row (-1: none)
cl_widget_panel s:panel(x, y, w, h) A container drawn under the widgets after it
cl_widget_card s:card(x, y, w, h, title, body, icon, fn) Icon, title, wrapped text; clickable when fn is given
cl_widget_heading s:heading(x, y, text, level) A heading, level 1 to 3
cl_widget_color_picker s:color_picker(x, y, w, h, label, argb, fn) A swatch with its code; opens the client’s colour picker (Fancy) or a palette (Vanilla) the ARGB colour
cl_widget_button (1.0) s:button(x, y, w, h, label, fn) A button
cl_widget_checkbox (1.0) s:checkbox(x, y, label, checked, fn) A checkbox 1 / 0
cl_widget_slider (1.0) s:slider(x, y, w, h, label, min, max, value, step, fn) A slider (Fancy: the label and value above a track) the value
cl_widget_text_field (1.0) s:text_field(x, y, w, h, hint, max_length, fn) A text box text()
cl_widget_label (1.0) s:label(x, y, text, colour, centred) A line of text

choices and tabs are one entry per line: "Small\nMedium\nLarge". Icons are names of the icon set (Theme Tokens) or "<mod id>:<path>" for your own GUI sprite (assets/<mod id>/textures/gui/sprites/<path>.png).

Every widget also has the 1.0 methods: set_active, set_visible, set_label, set_tooltip, set_bounds, value / set_value, text / set_text, remove. text() of a dropdown, tabs or list is the selected entry’s text. A card’s set_text changes its body.

Callbacks run when the player changes a value (not when your code calls set_value).

Input works with the mouse and the wheel (lists scroll, sliders and Vanilla dropdowns step); Esc closes an open dropdown or colour picker first.

The colour picker

API 1.23. In the Fancy style the colour picker is the same one the client uses for every colour setting.

  • A colour wheel (hue around, saturation outward) with a brightness bar, and an opacity bar when you turn it on with cl_widget_color_picker_alpha(w, true) (Lua w:color_picker_alpha(true)). Without it the value is always opaque (0xFF in the top byte); with it value() carries the alpha.
  • A hex field: click it and type RRGGBB (or RRGGBBAA with opacity); Ctrl+V pastes a code, Ctrl+C copies it, Enter applies, Esc cancels.
  • Theme color (the player’s theme accent), the 16 palette colours as presets, and the player’s recent and saved colours (shared by every picker in the client; Shift-click removes a saved one).
  • Your callback runs while the player drags, so a preview can follow the colour live.

In the Vanilla style the picker keeps the palette popup with hue and brightness bars. Appearance page colour rows (cl_ui_page_color) use the same picker; an option holding "#RRGGBBAA" (8 digits) gets the opacity bar.

Tabs with pages

Make every widget once and show the ones of the selected tab:

init = function(s)
  local pages = {}
  local function page(i, w) pages[#pages + 1] = { i = i, w = w }; return w end
  local function show(tab) for _, p in ipairs(pages) do p.w:set_visible(p.i == tab) end end
  s:tabs(20, 40, 300, 20, "General\nLook", 0, function(t) show(t:value()) end)
  page(0, s:toggle(20, 70, 300, 20, "Enabled", true, function() end))
  page(1, s:color_picker(20, 70, 300, 20, "Colour", 0xFFAA00, function() end))
  show(0)
end,

Drawing in the player’s style

Inside a themed screen’s render callback (and in HUD elements) the drawing context g knows the style:

C Lua Draws
cl_gui_panel g:panel(x, y, w, h) A container (Fancy: the theme’s panel; Vanilla: the dark popup frame)
cl_gui_hud_panel g:hud_panel(x, y, w, h) The HUD panel (Fancy: the rounded HUD material; Vanilla: translucent black)
cl_gui_round_rect g:round_rect(x, y, w, h, radius, argb, stroke) A smooth rounded rectangle in any style; stroke > 0 draws only the edge
cl_gui_ui_text g:ui_text(s, x, y, style, argb) Text in a kit style ("body", "small", "h1", "h2", "h3", "button", "chip"); argb = 0 uses the style’s colour; returns the end x
cl_gui_icon g:icon(name, x, y, size, argb) An icon of the set; argb = 0 uses the theme’s icon colour
cl_gui_control g:control(kind, x, y, w, h, label, state, value) The look of a control for your own widgets: "button", "primary", "toggle", "checkbox", "slider", "field", "icon_button"; states "normal", "hover", "pressed", "disabled", "selected"

Fractions of GUI pixels are fine: Fancy shapes are anti-aliased and snap to whole screen pixels.

Animations

The client animates its own HUD and menus only when the player turns Animations on (Features > Animations; off by default, and always off with Reduce Motion). Animate your HUD elements and screens the same way, with the same curves, and only when the player asked for it (API 1.24):

C Lua Does
cl_ui_anim_enabled cl.ui.anim_enabled() True when the player turned Animations on
cl_ui_anim_duration cl.ui.anim_duration(menus) The player’s duration in seconds: the HUD’s (false) or the menus’ (true)
cl_ui_anim_step cl.ui.anim_step(p, show, dt, in_s, out_s) Moves a progress p (0 hidden … 1 shown) by dt seconds towards shown or hidden; frame-rate independent, turns round mid-way without a jump
cl_ui_ease cl.ui.ease(kind, t) t through a curve: CL_EASE_SMOOTH, GENTLE, SNAPPY, BOUNCY (a small overshoot) or LINEAR (Lua: "smooth" …)
local shown, p, last = false, 0, cl.core.time_now_ns()
cl.hud.element_register{
  id = "alert", label = "Alert", width = 90, height = 20, anchor = "top", x = 0, y = 6,
  render = function(g, w, h)
    local now = cl.core.time_now_ns()
    local dt = (now - last) / 1e9
    last = now
    if cl.ui.anim_enabled() then
      local d = cl.ui.anim_duration(false)
      p = cl.ui.anim_step(p, shown, dt, d, d * 0.8)
    else
      p = shown and 1 or 0                  -- off: appear and go at once
    end
    if p <= 0 then return end
    local k = cl.ui.ease("smooth", p)
    local a = math.floor(255 * k)           -- the opacity
    local y = (1 - k) * -10                 -- and a short slide from the top
    g:round_rect(0, y, w, h, 4, (a << 24) | 0x101418, 0)
  end,
}

Everything here is pure arithmetic (no allocation, no client call besides two relaxed loads), so it is safe every frame. Keep inputs working while something animates: a screen takes clicks from its first frame.

C and C++

static void on_toggle(cl_widget w, void* user) { /* cl_widget_value(w) is 1 or 0 */ }

static void init(cl_screen s, void* user) {
    cl_widget w;
    cl_widget_heading(s, 20, 20, CL_STR("My Mod"), 1, &w);
    cl_widget_toggle(s, 20, 50, 300, 20, CL_STR("Enabled"), true, on_toggle, NULL, &w);
}

void open_settings(void) {
    cl_screen_desc d = CL_SCREEN_DESC_INIT;
    d.title = CL_STR("My Mod");
    d.themed = true;
    d.init = init;
    cl_screen s;
    cl_screen_open(&d, &s);
}

In C++ the screen handle has the same methods: screen.widget_toggle(20, 50, 300, 20, "Enabled", true, on_toggle, nullptr) returns Expected<clight::Widget>.

Cost

Nothing runs unless a screen is open. Kit widgets are plain data drawn by the engine (no RmlUi elements, no allocation per frame on a still screen); a 20-widget screen costs a few microseconds of CPU per frame.