Skip to content

LuaUI Controls

wezzzyrek1 edited this page Aug 13, 2026 · 4 revisions

Lua UI - Controls

Control Created with What it is for
Label col:Label(text, opts) a line of text - captions, readouts, status
Button col:Button(text, opts) something to click, drawn or skinned
Text input col:TextInput{...} a single-line field the player types into
Image win:handle():addImage(...) decoration that ignores the mouse
Panel win:Panel{...} a sub-area with its own controls, optionally scrolling

The rest of the page is shared ground: Callbacks fire per frame, The raw handle is what the wrapper sits on, The UI table and Mouse and keyboard are global helpers.

Controls are created through a layout group, which positions them - none of them take x/y.

local col = win:Column{ top = 40, padding = 16, spacing = 6, align = "center" }
local row = win:Row{ top = 120, padding = 16, spacing = 8 }
Option Values Default Configures
top number, canvas units padding where the group starts, from the parent's top edge
y number, canvas units - alias for top
x number, canvas units padding left edge of the group
padding number, canvas units 12 default left margin, and the gap kept from the right edge
spacing number, canvas units 6 gap between consecutive controls
align "left", "center", "right" "left" horizontal placement; vertical groups only
w number, canvas units up to the usable width maximum width a control may take

A Column stacks downwards, a Row runs left to right. Both work identically on a window and on a panel. Use several groups when a window has distinct areas - a header column, then a panel, then a footer row.

Size keys are abbreviated, size properties are not

Option tables take w and h; the controls they produce expose .width and .height. Windows are the odd one out and take w with height:

Where Width Height
ui.Window{...} w height (or minHeight / maxHeight)
win:Panel{...} w h
Label / Button / TextInput options w h
Column / Row options w -
Any control or window afterwards .width .height
local btn = col:Button("Convert", { w = 120, h = 26 })
btn.width = 140   -- not btn.w

Centre alignment centres on the box, but a control is always kept clear of the scrollbar, so a wide control in a narrow scrolling panel drifts left rather than sliding under the bar.

Label

local lbl = col:Label("Coins: 0", {
	w = 200, h = 16,
	align = ALIGN_CENTER,
	color = RGBA(220, 220, 220, 255),
	scale = 0.9,
	bold  = true,
	slide = false,
})
Option Values Default Configures
w number, canvas units the group's usable width box width
h number, canvas units 16 box height
align ALIGN_LEFT, ALIGN_RIGHT, ALIGN_CENTER ALIGN_CENTER where the text sits in the box
color RGBA(r, g, b, a), each 0-255 RGBA(255, 255, 255, 255) text colour
scale number, multiplier 1.0 font size; FontS (0.75) FontM (1.0) FontL (1.25) FontLB (1.4) are presets
bold true / false false bold face
slide true / false true slide overflowing text sideways on hover
Property Type Access What it is
.id integer read the id the layout group assigned
.text string read/write the string on screen
.visible boolean read/write whether it is drawn at all
.x .y .width .height number, canvas units read/write its box
.slide .bold boolean read/write the slide and bold options
.color RGBA(r, g, b, a) write text colour; assigning works, reading gives nil
:setColor(c) :setScale(n) :setAlign(a) :setSlide(b) call - setter form of the matching options

slide. Hovering a label whose text overflows its box slides the text sideways so the rest can be read. The overflow test is conservative and fires on text that looks like it fits, so anything that should sit still - a readout, a status line - wants slide = false.

Raising scale means raising h. The font is rasterised at the size you ask for, so a bigger scale really is bigger type - and text is clipped to its box, so a row still sized for the base font loses the tops of the letters. Move both together: { h = 20, scale = 1.15 }.

An empty label draws nothing. That makes it free to keep a status line in the layout permanently and simply write to it when there is something to say. It also means a label you seeded with "" looks broken until the first update, so seed it with real text if it should be visible immediately.

Give a label the column's full width rather than a snug one. A centred label only centres while the text fits; once it overflows, drawing starts at the left edge and the line reads as shifted.

ALIGN_RIGHT earns its place on figures. A caption on the left and its value on the right, both in the same box, keeps the digits in one column as they grow - where a centred number shifts sideways every time it gains a digit:

local row = win:Row{ top = 60, padding = 12 }

row:Label("today", { w = 80, align = ALIGN_LEFT,  color = DIM })
row:Label(amount,  { w = 80, align = ALIGN_RIGHT, color = BRIGHT })

Button

local btn = col:Button("Convert", {
	w = 120, h = 26,
	color = RGBA(255, 255, 255, 255),
	scale = 1.0,
	onClick = function()
		...
	end,
})
Option Values Default Configures
w number, canvas units 120 button width
h number, canvas units 26 button height
color RGBA(r, g, b, a), each 0-255 RGBA(255, 255, 255, 255) label colour
scale number, multiplier 1.0 label font size
onClick function none called when the button is pressed
Property Type Access What it is
.id integer read the id the layout group assigned
.click function, or nil read/write the click handler, replaceable at any time
.render function, or nil read/write draw callback, run after the button draws itself
.hover boolean read cursor is over the button
.state integer, one of ui.STATE read see below
.visible boolean read/write whether it is drawn at all
.x .y .width .height number, canvas units read/write its box
:setText(text, color, scale) call - the label and its colour and size
:setImage(state, slot, sx, sy, sw, sh) call - artwork for one state, see Designing a window

States are named in ui.STATE - use those rather than the bare numbers, both when reading .state and when calling setImage:

Constant Value When
ui.STATE.NORMAL 0 at rest
ui.STATE.INACTIVE 1 reserved - see below
ui.STATE.HOVER 2 cursor over it
ui.STATE.PRESSED 3 held down

A state with no image of its own falls back to NORMAL, so supplying only the resting artwork is a valid way to skin a button.

INACTIVE completes the numbering but is not reachable from a script: the button never enters it on its own, and .state is read-only. An image assigned to it would never be drawn.

Disable a button by replacing its handler rather than hiding it:

btn.click = function()
	if busy then return end
	...
end

To make that visible, swap the NORMAL artwork for the greyed cut while it is disabled - .state is read-only, so a disabled look is something you paint, not a state you set.

Text input

local input = col:TextInput{ w = 180, h = 20, numeric = true, maxValue = 100000 }

Length rules, character filters, IME and encoding: Text input.

Image

Decoration that does not react to the mouse. Created on the raw handle, since a layout group has nothing to lay out for it:

local icon = win:handle():addImage(win:nextId(), 12, 40, 32, 32)
icon:setImage(R.slot.reward, 0, 0, 32, 32)

setImage(slot, sx, sy, sw, sh) picks the region in bitmap pixels; the control's own size, in canvas units, decides how it is stretched.

Properties: .id (read-only), .slot (read-only), .x, .y, .width, .height, .visible.

Panel

A box that clips and optionally scrolls its content.

local panel = win:Panel{ x = 14, y = 200, w = 252, h = 150, back = RGBA(0,0,0,90) }

Options, scrollbars and styling: Scrolling and scrollbars.

Panels are also the cheapest way to draw a solid block - a bar, a rule, a highlight - without a render callback:

local track = win:Panel{ x = 20, y = 90, w = 200, h = 3, back = RGBA(0, 0, 0, 150) }
local fill  = win:Panel{ x = 20, y = 90, w = 1,   h = 3, back = RGBA(198, 157, 0, 230) }

fill:handle().width = 200 * ratio

Callbacks

Callback On Signature
.click button function() end
.onEnter text input function() end, fires on Enter
.render window, panel, button function(handle) end, every frame after the control draws
.update window function(handle) end, every frame regardless of drawing

win:background(fn) is the wrapper's own hook and receives (wrapper, handle); it draws behind the controls, whereas .render draws over them.

Assign nil to remove any of them.

An onEnter passed as a field option is wrapped for you and stays silent until the field satisfies minLen/minBytes. Assigning .onEnter directly on the control skips that wrapper, so check ui.textOk(input) yourself.

The raw handle

win:handle() (and panel:handle()) gives the underlying object, which is what you need when a control has to be placed at an exact spot instead of by a layout group, or looked up later. Windows and panels expose the same set.

Every add* takes explicit x, y, w, h in canvas units, relative to its parent's top-left corner, and hands back the control it created:

Method Returns
addLabel(id, x, y, w, h) a label - set .text on it
addButton(id, x, y, w, h) a button - set .click and its state artwork
addTextInput(id, x, y, w, h, maxLen) a field; maxLen is in characters and is required here
addImage(id, x, y, w, h) an image - call setImage to give it a region
addScrollPanel(id, x, y, w, h) a raw panel, not the win:Panel{...} wrapper

The rest:

Method Does
getLabel(id) getButton(id) getTextInput(id) getScrollPanel(id) fetch a control back by id
delControl(id) remove one control
checkHover(x, y, w, h) whether the cursor is over that box, in the parent's own coordinates
renderColor renderText renderImage draw, see Designing a window
setAttach(v) / getAttach() park any Lua value on the window or panel

Ids are per parent, and creating a control with an id already in use replaces the old one - it is destroyed, not returned. That is a silent way to lose a control, so let the layout group hand out ids (win:nextId() / panel:nextId(), which is what the group itself uses) and reserve fixed ids for controls another script has to find.

A panel from addScrollPanel is the bare control: it has setScrollbar, setContentHeight and the draw calls, but not :Column, :Row or :fit. Use win:Panel{...} unless you specifically want to place and drive it yourself.

setAttach is a slot for your own data - a row's record, a state table - so that a script which found a panel by id can recover the context it belongs to without a lookup table of its own.

Keep the handles the constructors returned. Reach for getLabel/getButton only when the control was created in a different script:

local w = UI.GetWindow(R.win.exchange)
local lbl = w:getLabel(3)

The UI table

Call Returns / does
UI.NewWindow(id, x, y, w, h) a bare window handle; ui.Window builds on it
UI.GetWindow(id) an existing window, from any script
UI.DelWindow(id) destroys a window
UI.LoadImage(slot, path) loads an atlas; false on failure
UI.Center(w, h) two values - the x, y that centre a box of that size
UI.Scale() the player's UI scale factor
UI.ScreenWidth() / UI.ScreenHeight() the canvas size coordinates are expressed in
UI.GetTextWidth(text) measured width of a string
UI.ToCodePage(s) / UI.FromCodePage(s) / UI.CodePage() encoding, see Text input
UI.RenderColor / UI.RenderText / UI.RenderImage screen-space draws, inside a render callback only
UI.Print(text) writes a line to the client console

ui.Window covers everything UI.NewWindow does and adds layout, skinning and placement, so reach for the raw call only when you have a reason to.

Mouse and keyboard

Call Returns
Input.MouseX() / Input.MouseY() cursor position, in canvas units
Input.WheelDelta() wheel notches this frame; 0 if it did not move
Input.KeyPressed(vk) true once, on the frame the key goes down
Input.KeyDown(vk) true every frame the key is held
Input.CheckMousePos(x, y, w, h) true when the cursor is inside that box; applies the UI scale to w/h, so it agrees with what is drawn
Input.KeyboardCaptured() true while a Lua text field has focus
Input.GameState() the client's state; compare against Enums.GameState

The two sets of constants these calls take:

Set How to reach it Covers
VK global, always there A-Z, 0-9, F1-F12, N0-N9, and named keys: RETURN ESCAPE SPACE TAB BACK DELETE INSERT HOME END PRIOR NEXT LEFT RIGHT UP DOWN SHIFT CONTROL MENU CAPITAL PAUSE LBUTTON RBUTTON MBUTTON
Enums.GameState local Enums = require("Enums") SELECT_SERVER, SWITCH_CHARACTER, IN_GAME - see Creating windows

Key polling already stops while a text field has focus, so a window with an input does not need to guard its hotkeys.

See Also

Clone this wiki locally