starlit

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.

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) or too 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 becomes control. 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:

optionvaluesdefault
page"scripts", "rage", "legit", "visuals", "misc""scripts"
column"auto", "left", "right""auto"
ordera number, rounded and kept within -100 to 1000
  • Calling ui.group again 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 aimbot and general (rage) and recoil control and other (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
argumenttypemeaning
labelstringthe text next to the box
defaultbooleanthe 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
argumenttypemeaning
labelstringthe text of the slider
min, maxnumberthe bounds. min has to be smaller than max. Bounds beyond -1e9 and 1e9 are clamped to them.
defaultnumberthe 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
argumenttypemeaning
labelstringthe text of the control
itemstablean array of 1 to 16 strings, each at most 32 bytes
defaultnumberthe 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
argumenttypemeaning
labelstringthe text of the control
itemstablean array of 1 to 16 strings, each at most 32 bytes
selectedtablethe 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. list is an array of texts or indices. Unknown entries select nothing.
  • has(x) returns true if 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
argumenttypemeaning
labelstringthe text of the control
r, g, bnumber0 to 255
anumber0 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
argumenttypemeaning
labelstringthe text of the control
modestring"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
argumenttypemeaning
labelstringthe text on the button
fnfunctioncalled 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
argumenttypemeaning
namestringthe title of the group. At most 32 bytes. "" is the default group.
optionstablepage, 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
argumenttypemeaning
strstringa line of text, at most 64 bytes
tonestring"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
argumenttypemeaning
labelstringthe text of the control
defaultstringthe text at the start. Empty if left out.
options.maxnumberthe 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
argumenttypemeaning
labelstringthe text of the control
itemstablean array of 1 to 16 strings
options.rowsnumberhow 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.

methodargumentssame as
group:checkboxlabel [, default]ui.checkbox
group:switchlabel [, default]alias of group:checkbox
group:sliderlabel, min, max [, default]ui.slider
group:combolabel, items [, default]ui.combo
group:multicombolabel, items [, selected]ui.multicombo
group:selectablelabel, items [, selected]alias of group:multicombo
group:colorlabel, r, g, b [, a]ui.color
group:color_pickerlabel, r, g, b [, a]alias of group:color
group:keybindlabel [, mode]ui.keybind
group:hotkeylabel [, mode]alias of group:keybind
group:labeltextui.label
group:buttonlabel, fnui.button
group:separator[options]ui.separator
group:textstr [, tone] [, options]ui.text
group:infolabel, value [, options]ui.info
group:foldlabel [, open] [, options]ui.fold
group:fold_endnoneui.fold_end
group:inputlabel [, default] [, options]ui.input
group:listlabel, items [, options]ui.list
group:place{ page =, column =, order = }places the group again, see Groups
group:visiblebooleanhides 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 })
keyvaluemeaning
same_linebooleandrawn beside the control before it. A row holds up to four controls.
widthnumber0 means the rest of the row, otherwise 0.2 to 1. The share of the group's width.
tooltipstringup to 64 bytes, shown when the pointer rests on the control
visiblebooleanfalse hides the control
disabledbooleanshown dimmed and cannot be edited
on_changefunctioncalled 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 }). For multicombo(label, items, defaults, options) the defaults come before the options, and you write nil for no defaults.
  • A width on the first control of a row and same_line on 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:

methodwhat 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)
controlreturns
checkboxa boolean
slidera number, an integer for an integer slider
combothe 1-based index and the text
multicomboan array of the selected texts
colourr, g, b, a, four integers
keybindactive, key
labelthe text
buttonthe number of presses handled so far
textthe text
infothe value as text
foldtrue while the fold is open
inputthe typed text
listthe 1-based index and the text, like a combo
separatornothing
local item = ui.combo("shape", { "circle", "square" })
local index, text = item:get()

handle:set

handle:set(value)
controlargument
checkboxa boolean
slidera number. It is clamped into the bounds, and an integer slider rounds it.
combothe 1-based index. Anything else raises item index expected.
multicomboan array of texts or indices
colourr, g, b [, a] as numbers
keybinda boolean. It sets the active state only, never the key.
labelthe new text
textthe new text
infoa string or a number, the new value
folda boolean, true opens the fold
inputa string, cut to the length limit
listthe 1-based index
buttonnot allowed. Raises a button has no value.
separatornot 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": active is true while the key is down.
  • "toggle": each press of the key flips active. It starts as false.
  • "always": active is always true. The key has no effect.
  • set(boolean) changes active and nothing else. In hold and always the next frame overwrites it. It is useful in toggle mode.
  • The saved key is restored with the other values. The active state 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, and handle: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)

previous: draw · next: menu