ui
Controls for your script on the scripts page and on the pages of the menu: checkboxes, sliders, combos, colours, keybinds, text, values, folds, inputs, lists, groups and their layout, and how the values are kept.
On this page
ui adds controls to the menu. You create a control by calling a function and keep the handle it returns. The user changes the control in the menu, and your script reads it with handle:get(). By default the controls go into the box of your script on the scripts page. With groups you can place them on the rage, legit, visuals and misc pages as well, lay them out in rows, fold them away, and add text, values, a text input and a list. Whole tabs, a style for the menu and drawing panels are on the menu page.
Controls belong to the script. They are not Starlit settings. They never enter the key bind list or the config, and other scripts do not see them. The look is Starlit's own: the menu draws every control, and your script reads and sets plain values.
Rules for all controls
- Where to create them. Usually in the main chunk, once. You can create controls later, in any callback, but they count against the same caps as the others.
- Caps. 64 interactive controls (checkbox, slider, combo, multicombo, colour, keybind, button, input, list, fold), 32 display rows (label, separator, text, info, fold end), and 12 groups with the default one included. Going over raises
too many controls (64),too many labels (32)ortoo many groups (12). - Labels. The label is a string and is required. At most 48 bytes are used. Control characters are removed, and so is every
##. A label that ends up empty becomescontrol. Two controls may have the same label. - Order. Controls appear in the order you create them, inside their group.
- Options. The last argument of every creator may be a table of options: a tooltip, a width, a row layout, a callback. See the option table.
- Saving. The values of all kinds except labels, buttons, text, info rows and inputs are kept for the next load. See saved values. The saved value is applied while the control is created, so
get()in the main chunk already returns it.
Groups
Every group is a box in the menu. The default group is the one the plain ui.checkbox(...) functions use. On the scripts page its title is the file name of the script. A group you create with ui.group("Name") is titled Name - file name.
ui.group with the same name returns the same group. The name "" is the default group. A name is at most 32 bytes and ## is removed from it.
Placing a group. ui.group(name, options) puts the group on a page, in a column, at a position:
| option | values | default |
|---|---|---|
page | "scripts", "rage", "legit", "visuals", "misc" | "scripts" |
column | "auto", "left", "right" | "auto" |
order | a number, rounded and kept within -100 to 100 | 0 |
- Calling
ui.groupagain with options places the group again, and only the options you give change. The default group""can be placed too. "auto"columns alternate left, right, left, among the automatic groups of a page, in the order below. A group on the scripts page is shown in the right half of that page, whatever its column says.- Groups are listed by
order, then by script (file name order), then by creation. Script groups come after Starlit's own rows of a page. - On the rage and legit pages a group is appended at the end of the
aimbotandgeneral(rage) andrecoil controlandother(legit) sections. On the visuals and misc pages it goes at the end of the left or right column. The legit page hides script groups together with its own rows while the weapon group is off. - A group with no visible control shows no header. Script groups are not part of the menu search.
- The header of a group on a page reads
NAME - file. group:place{ page = ..., column = ..., order = ... }takes the same options for an existing group.group:visible(false)hides the whole group, and its controls keep working and keep their values.group:visible(true)shows it again.
The user has one switch on the scripts page, show script groups in pages (on by default), and one for each script, show in pages, remembered in the script's cfg file. When either is off, the groups stay on the scripts page, so every value is still reachable.
Functions
ui.checkbox
ui.checkbox(label [, default]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text next to the box |
default | boolean | the start value, false if left out |
Returns a handle. get() returns a boolean, set(boolean) changes the box.
Errors default has to be a boolean or nil.
local show = ui.checkbox("show text", true)
events.on_paint(function()
if show:get() then draw.text(20, 20, "visible", 255, 255, 255) end
end)
ui.slider
ui.slider(label, min, max [, default]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text of the slider |
min, max | number | the bounds. min has to be smaller than max. Bounds beyond -1e9 and 1e9 are clamped to them. |
default | number | the start value. It is clamped into the bounds. min if left out. |
Returns a handle. get() returns the value and set(number) changes it. The value is always inside the bounds.
The slider is an integer slider when min, max and default are all Lua integers, for example ui.slider("size", 1, 20, 5). Then get() returns an integer and the slider moves in whole steps. If any of them is a float, for example 0.5 or 1.0, it is a float slider and get() returns a float. An integer slider rounds the value you give to set to the nearest whole number.
Errors max must be greater than min. A bound that is not a finite number raises a type error.
local size = ui.slider("size", 1, 20, 5)
local alpha = ui.slider("opacity", 0.0, 1.0, 0.8)
events.on_paint(function()
draw.circle_filled(100, 100, size:get(), 255, 255, 255, math.floor(alpha:get() * 255))
end)
ui.combo
ui.combo(label, items [, default]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text of the control |
items | table | an array of 1 to 16 strings, each at most 32 bytes |
default | number | the 1-based index of the start item. 1 if left out. It has to be a whole number from 1 to the number of items. |
Returns a handle. get() returns two values, the 1-based index and the text of the selected item. set(index) selects by index.
Errors 1 to 16 items expected, items must be strings, item index expected.
local mode = ui.combo("mode", { "off", "names", "boxes" }, 2)
events.on_paint(function()
local index, text = mode:get()
if index > 1 then draw.text(20, 20, "mode " .. text, 255, 255, 255) end
end)
ui.multicombo
ui.multicombo(label, items [, selected]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text of the control |
items | table | an array of 1 to 16 strings, each at most 32 bytes |
selected | table | the start selection: an array of item texts, of 1-based indices, or a mix. An entry that matches nothing is ignored. |
Returns a handle with three methods:
get()returns an array of the selected item texts, in the order of the items.set(list)replaces the selection.listis an array of texts or indices. Unknown entries select nothing.has(x)returnstrueif the item with that text, or at that index, is selected.
local parts = ui.multicombo("show", { "name", "health", "weapon" }, { "name" })
events.on_paint(function()
if parts:has("health") then draw.text(20, 20, "health is on", 255, 255, 255) end
for _, text in ipairs(parts:get()) do log.info(text) end
end)
ui.color
ui.color(label, r, g, b [, a]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text of the control |
r, g, b | number | 0 to 255 |
a | number | 0 to 255, 255 if left out |
Returns a handle. get() returns r, g, b, a as four integers. set(r, g, b [, a]) changes the colour. These functions take numbers, not a colour object.
local tint = ui.color("tint", 190, 170, 255)
events.on_paint(function()
draw.rect_filled(100, 100, 40, 40, color(tint:get()))
end)
color(tint:get()) turns the four returned numbers into a colour object. In the last position of a call, tint:get() also expands to four numbers by itself, so draw.rect_filled(100, 100, 40, 40, tint:get()) works as well.
ui.keybind
ui.keybind(label [, mode]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text of the control |
mode | string | "hold" (default), "toggle" or "always" |
Returns a handle. get() returns two values, active (a boolean) and key (the virtual-key number the user picked, or 0).
The key starts unbound. The user picks it on the scripts page, and the control reports that key. See Keybind rules below.
Errors unknown mode (hold, toggle, always).
local peek = ui.keybind("peek key", "hold")
events.on_paint(function()
local active, key = peek:get()
if key ~= 0 and active then draw.text(20, 20, "key is down", 255, 255, 255) end
end)
ui.label
ui.label(text) -> handle
Adds a line of text to the box. get() returns the text and set(text) replaces it. The text follows the label rules above. An empty text leaves a small gap. A label does not count against the 64 interactive controls.
local status = ui.label("waiting")
events.on_paint(function()
status:set(game.in_game() and "in a match" or "not in a match")
end)
ui.button
ui.button(label, fn) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text on the button |
fn | function | called without arguments after a click |
Returns a handle. get() returns how many presses have been handled so far.
When the user presses the button, we queue the click. The function runs on the next frame, after the keybinds are updated and before the events. It has 100,000 instructions and 1.5 ms. It never runs inside the menu's drawing. At most 8 clicks wait in the queue. Draw functions do not work inside it.
Errors fn has to be a function.
local clicks = 0
local reset = ui.button("reset counter", function()
clicks = 0
log.info("counter reset")
end)
ui.group
ui.group(name [, options]) -> group object
| argument | type | meaning |
|---|---|---|
name | string | the title of the group. At most 32 bytes. "" is the default group. |
options | table | page, column and order, as described under Groups. Optional. |
Returns a group object. Its methods create controls inside that group.
Errors too many groups (12) for a thirteenth group. An unknown page or column raises unknown page (scripts, rage, legit, visuals, misc) or unknown column (auto, left, right). The options are checked before anything is created.
local look = ui.group("Look")
local boxes = look:checkbox("boxes", true)
local tint = look:color("tint", 255, 255, 255)
local extra = ui.group("my esp", { page = "visuals", column = "right", order = 10 })
local enabled = extra:checkbox("enabled", true)
ui.separator
ui.separator([options]) -> handle
A rule between rows. It has no value, and get() returns nothing. It counts as a display row.
ui.text
ui.text(str [, tone] [, options]) -> handle
| argument | type | meaning |
|---|---|---|
str | string | a line of text, at most 64 bytes |
tone | string | "dim" (default), "normal", "bright" or "accent" |
A line of text in the group. get() returns the text and set(text) replaces it. It counts as a display row.
local g = ui.group("notes")
g:text("drawn in the top left corner", "dim")
ui.info
ui.info(label, value [, options]) -> handle
A row with the label on the left and a value on the right. value is a string or a number, at most 64 bytes as text. get() returns the value as text and set(v) shows a new value. It counts as a display row.
local seen = ui.info("rounds seen", 0)
events.on_round_start(function() seen:set(1) end)
ui.fold
ui.fold(label [, open] [, options]) -> handle
ui.fold_end()
ui.fold starts a block that the user can open and close. The controls you create after it, up to the matching ui.fold_end(), belong to the block. A fold starts closed unless open is true. get() returns true while the fold is open, and set(true) opens it.
Folds nest 4 deep. fold_end closes the innermost open fold of that group, and a fold left open at the end of a group simply ends with it. The open or closed state is the user's: it is saved in the script's cfg file and wins over the script's first choice. fold_end raises no open fold in this group when there is none, and a fifth level raises folds nest 4 deep at most.
local g = ui.group("advanced")
g:fold("fade", false, { tooltip = "open to change the fade time" })
local fade = g:slider("fade time (s)", 1, 30, 8)
g:fold_end()
ui.input
ui.input(label [, default] [, options]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text of the control |
default | string | the text at the start. Empty if left out. |
options.max | number | the length limit, 1 to 64, default 32 |
A text field the user types in. get() returns the typed text and set(text) replaces it, cut to the length limit. The text is not saved: it starts at the script's default each time.
local tag
tag = ui.input("tag", "round", { max = 16, on_change = function() log.info("tag is now " .. tag:get()) end })
ui.list
ui.list(label, items [, options]) -> handle
| argument | type | meaning |
|---|---|---|
label | string | the text of the control |
items | table | an array of 1 to 16 strings |
options.rows | number | how many rows are visible, 3 to 10, default 6 |
A list box. get() returns the 1-based index and the text of the chosen item, like a combo, and set(index) chooses by index. The choice is saved like a combo's.
local mode = ui.list("style", { "quiet", "normal", "loud" }, { rows = 3 })
local _, name = mode:get()
Group object methods
A group object has the methods below. They work like the functions of the same name, except that the label is the first argument after the object, and the control goes into that group.
| method | arguments | same as |
|---|---|---|
group:checkbox | label [, default] | ui.checkbox |
group:switch | label [, default] | alias of group:checkbox |
group:slider | label, min, max [, default] | ui.slider |
group:combo | label, items [, default] | ui.combo |
group:multicombo | label, items [, selected] | ui.multicombo |
group:selectable | label, items [, selected] | alias of group:multicombo |
group:color | label, r, g, b [, a] | ui.color |
group:color_picker | label, r, g, b [, a] | alias of group:color |
group:keybind | label [, mode] | ui.keybind |
group:hotkey | label [, mode] | alias of group:keybind |
group:label | text | ui.label |
group:button | label, fn | ui.button |
group:separator | [options] | ui.separator |
group:text | str [, tone] [, options] | ui.text |
group:info | label, value [, options] | ui.info |
group:fold | label [, open] [, options] | ui.fold |
group:fold_end | none | ui.fold_end |
group:input | label [, default] [, options] | ui.input |
group:list | label, items [, options] | ui.list |
group:place | { page =, column =, order = } | places the group again, see Groups |
group:visible | boolean | hides or shows the whole group |
The creators take the option table as their last argument, described in the option table. A group object itself is read-only and has no other methods. Using a group object in place of a control handle, or the other way round, raises invalid handle.
The option table
The last argument of every control creator, in the module form and in the group form, may be a table of options.
local g = ui.group("demo")
g:slider("speed", 0, 100, 50, { tooltip = "ms per step", width = 0.5, on_change = function() log.info("changed") end })
g:checkbox("on", true, { same_line = true })
| key | value | meaning |
|---|---|---|
same_line | boolean | drawn beside the control before it. A row holds up to four controls. |
width | number | 0 means the rest of the row, otherwise 0.2 to 1. The share of the group's width. |
tooltip | string | up to 64 bytes, shown when the pointer rests on the control |
visible | boolean | false hides the control |
disabled | boolean | shown dimmed and cannot be edited |
on_change | function | called after the user changed the control, see on_change |
- The table has to be the last argument. Where an optional argument is left out, the table takes its place:
g:slider("s", 0, 10, { width = 0.5 }). Formulticombo(label, items, defaults, options)the defaults come before the options, and you writenilfor no defaults. - A width on the first control of a row and
same_lineon the next ones gives rows such as 50 percent and 50 percent. Controls without a width split what is left equally. - A value of the wrong type raises an error that names the key.
Handle methods
Every control returns a handle with the methods get and set. has exists for multicombos only. Handles are valid for as long as the script runs. A handle from another script, or a made-up one, raises invalid handle.
Every handle of any kind has these layout methods as well:
| method | what it does |
|---|---|
handle:visible(b) | shows or hides the control |
handle:disabled(b) | dims the control and stops edits, or restores it |
handle:same_line(b) | puts the control beside the one before it, or back on its own row |
handle:tooltip(text) | sets the tooltip, and "" removes it |
handle:name(text) | renames what is shown. Text and separator rows have no name and raise this control has no name. |
handle:width(fraction) | sets the share of the row, 0 for the rest of the row, otherwise 0.2 to 1 |
handle:on_change(fn) | sets the change callback, or removes it with nil |
handle:on_change exists for the kinds a user edits. A button, label, separator, text, info row or fold end raises this control has no change event. A button has its own function.
handle:get
handle:get() -> value(s)
| control | returns |
|---|---|
| checkbox | a boolean |
| slider | a number, an integer for an integer slider |
| combo | the 1-based index and the text |
| multicombo | an array of the selected texts |
| colour | r, g, b, a, four integers |
| keybind | active, key |
| label | the text |
| button | the number of presses handled so far |
| text | the text |
| info | the value as text |
| fold | true while the fold is open |
| input | the typed text |
| list | the 1-based index and the text, like a combo |
| separator | nothing |
local item = ui.combo("shape", { "circle", "square" })
local index, text = item:get()
handle:set
handle:set(value)
| control | argument |
|---|---|
| checkbox | a boolean |
| slider | a number. It is clamped into the bounds, and an integer slider rounds it. |
| combo | the 1-based index. Anything else raises item index expected. |
| multicombo | an array of texts or indices |
| colour | r, g, b [, a] as numbers |
| keybind | a boolean. It sets the active state only, never the key. |
| label | the new text |
| text | the new text |
| info | a string or a number, the new value |
| fold | a boolean, true opens the fold |
| input | a string, cut to the length limit |
| list | the 1-based index |
| button | not allowed. Raises a button has no value. |
| separator | not allowed. Raises this control has no value. |
A set made by the script does not call on_change.
local volume = ui.slider("volume", 0, 100, 50)
volume:set(75)
handle:has
handle:has(x) -> boolean
For multicombos only. x is an item text or a 1-based index. Returns true if that item is selected. On any other control it raises not a multi-select control.
local parts = ui.multicombo("parts", { "a", "b", "c" }, { 2 })
local b_on = parts:has("b")
Keybind rules
- The key is a Windows virtual-key number from 1 to 254. 0 means unbound. The user picks it on the scripts page.
- We read the state of that key once for every frame, and only while the game window has the focus.
get()reports the key the user picked and whether it is active. "hold":activeistruewhile the key is down."toggle": each press of the key flipsactive. It starts asfalse."always":activeis alwaystrue. The key has no effect.set(boolean)changesactiveand nothing else. Inholdandalwaysthe next frame overwrites it. It is useful intogglemode.- The saved key is restored with the other values. The
activestate is not saved.
Because the key starts unbound, a script that wants a key should say what happens before the user has chosen one. A common way is to treat key == 0 as "always on":
local only_with_key = ui.keybind("show only while held", "hold")
local function wanted()
local active, key = only_with_key:get()
return key == 0 or active
end
on_change
on_change lets a script react to the user instead of polling a control every frame.
local mode = ui.combo("mode", { "off", "names", "boxes" }, 2, { on_change = function() log.info("mode changed") end })
- The function is called without arguments, on the frame after the user edited the control: a click, a drag, a typed letter. It runs once per frame at most for each control.
- A
set()made by the script does not call it. - The calls of a script run together with its other callbacks, within 1.5 ms and 16 calls per frame. What does not fit follows in the next frame. Each call has the budget of a button callback, 100,000 instructions and 1.5 ms.
- An error or an overrun counts like in any other callback.
handle:on_change(fn)sets or changes the function later, andhandle:on_change(nil)removes it.
Example: a feature group on the visuals page
Distance labels for the enemies, with its own group in the right column of the visuals page.
local grp = ui.group("distance labels", { page = "visuals", column = "right", order = 10 })
local enabled = grp:checkbox("enabled", true)
local tint = grp:color("color", 120, 200, 255)
local reach = grp:slider("max distance (m)", 5, 200, 80, { tooltip = "enemies farther away are not labelled" })
events.on_paint(function()
if not enabled:get() then return end
local me = entity.local_player()
if not me then return end
local mx, my, mz = me:origin()
local r, g, b, a = tint:get()
local limit = reach:get() / 0.0254 -- metres to game units
for _, p in ipairs(entity.players(true)) do
local x, y, z = p:origin()
if x then
local d = math.sqrt((x - mx) ^ 2 + (y - my) ^ 2 + (z - mz) ^ 2)
local sx, sy = game.to_screen(x, y, z + 72)
if d <= limit and sx then
draw.text(sx - 20, sy, string.format("%s %.0f m", p:name(), d * 0.0254), r, g, b, a, "small")
end
end
end
end)
Example: fold, info, list and input on the misc page
A round counter with a style list, a text input, an info row and a fold that holds the fade time and a reset button.
local rounds = 0
local grp = ui.group("round notes", { page = "misc", column = "left" })
local mode = grp:list("style", { "quiet", "normal", "loud" }, { rows = 3 })
local tag
tag = grp:input("tag", "round", { max = 16, on_change = function() log.info("tag is now " .. tag:get()) end })
local seen = grp:info("rounds seen", 0)
grp:separator()
grp:fold("advanced", { tooltip = "open to change the fade time" })
local fade = grp:slider("fade time (s)", 1, 30, 8)
grp:button("reset counter", function() rounds = 0; seen:set(0) end)
grp:fold_end()
events.on_round_start(function() rounds = rounds + 1; seen:set(rounds) end)
events.on_paint(function()
local _, name = mode:get()
draw.text(20, 20, tag:get() .. " " .. rounds .. " (" .. name .. ", fade " .. fade:get() .. " s)", 255, 255, 255, 220)
end)
Example: two controls on one row
A checkbox and a colour picker side by side on the scripts page, with the group moved to the top.
local grp = ui.group("hud", { order = -10 })
local show = grp:checkbox("show", true, { width = 0.5 })
local tint = grp:color("tint", 255, 255, 255, 255, { same_line = true })
grp:text("drawn in the top left corner", "dim")
events.on_paint(function()
if not show:get() then return end
local r, g, b, a = tint:get()
draw.text(20, 20, "hello", r, g, b, a, "bold")
end)