Skip to content

A settings panel with components

Status: Implemented (API 1.22). A settings screen built from React-style components: tabs, toggles, a slider and a list the player adds to and removes from, in the player’s Appearance style. The complete mod is sdk/examples/react_settings_lua.

Reference: Components, view, The UI Kit.

1. Think in components

Split the screen into parts that each own their state:

  • Settings: the panel with a heading, the tabs and a Done button; it owns the selected tab.
  • General: three options; each is state that is saved to the config.
  • Waypoints: the list and the “add” row; it owns the names and the text being typed.
  • WaypointRow: one name and a remove button; it owns nothing (only props).

A component is a function from props to nodes:

local ui = cl.ui

local WaypointRow = ui.component("WaypointRow", function(p)
  return ui.row{ gap = 4, height = 20,
    ui.label{ text = p.name, grow = 1 },
    ui.icon_button{ icon = "trash", tooltip = "Remove " .. p.name, on_press = p.on_remove },
  }
end)

2. State and saving

ui.use_state gives a value and a setter. A setter that gets a new value renders that component again; the same value does nothing. Save with an effect, which runs after the update when its dependencies changed:

local config = cl.config.open("client")
config:define_bool("show_beams", true)
config:define_i32("max_distance", 500, 50, 5000)

local General = ui.component("General", function()
  local beams, set_beams = ui.use_state(config:get_bool("show_beams"))
  local max, set_max = ui.use_state(config:get_i32("max_distance"))
  ui.use_effect(function()
    config:set_bool("show_beams", beams)
    config:set_i32("max_distance", max)
    config:save()
  end, { beams, max })
  return ui.column{ gap = 6,
    ui.toggle{ label = "Show beams", value = beams, on_change = set_beams },
    ui.slider{ label = "Hide farther than", min = 50, max = 5000, step = 50, value = max, on_change = set_max },
  }
end)

The toggle’s handler gets a boolean and the slider’s a number, so the setters can be the handlers.

3. Lists with keys

Give every row a key. When the list changes, rows with the same key keep their widgets: removing “Home” removes one row and moves the others, it doesn’t rebuild the list.

local Waypoints = ui.component("Waypoints", function()
  local list, set_list = ui.use_state({ "Home", "Mine entrance" })
  local draft, set_draft = ui.use_state("")
  local function remove(name)
    local rest = {}
    for _, n in ipairs(list) do if n ~= name then rest[#rest + 1] = n end end
    set_list(rest)          -- a new table: the state changed
  end
  return ui.column{ gap = 4,
    ui.row{ gap = 4, height = 20,
      ui.text_field{ hint = "New waypoint", text = draft, on_change = set_draft },
      ui.button{ label = "Add", width = 60, disabled = draft == "", on_press = function()
        local next = { table.unpack(list) }
        next[#next + 1] = draft
        set_list(next)
        set_draft("")
      end },
    },
    #list == 0 and ui.label{ text = "No waypoints yet." } or false,
    ui.map(list, function(name)
      return WaypointRow{ key = name, name = name, on_remove = function() remove(name) end }
    end),
  }
end)

State is compared with ==: change a table by making a new one (as above), not by editing it in place.

4. Put it together and open it

local view
local Settings = ui.component("Settings", function()
  local tab, set_tab = ui.use_state(0)
  return ui.panel{ padding = 10, gap = 8,
    ui.heading{ text = "Waypoint Settings" },
    ui.tabs{ tabs = { "General", "Waypoints" }, value = tab, on_change = set_tab },
    tab == 0 and General{} or Waypoints{},
    ui.button{ label = "Done", on_press = function() view:close() end },
  }
end)

local function open_settings()
  view = ui.open{ title = "Waypoint Settings", root = Settings, x = 40, y = 30, width = 320 }
end

Switching tabs swaps General for Waypoints at the same place: a different component, so the old one unmounts (its effects clean up) and the new one mounts with fresh state.

5. Check the cost

view:stats() tells what happened: renders, updates, and the widgets created, changed and removed. Clicking a toggle renders one component and changes one widget; a screen nobody touches renders nothing.

local s = view:stats()
print(s.renders, s.created, s.updated, s.removed)

The same in C and C++

C emits the tree in a render callback and keeps state in named slots (cl_view_state_num, cl_view_set_state_num); C++ uses clight_ui.hpp with the same hooks. Both are in Components and the C / C++ course lessons.