Repository navigation
LuaUI 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.
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.wCentre 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.
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 })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
...
endTo 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.
local input = col:TextInput{ w = 180, h = 20, numeric = true, maxValue = 100000 }Length rules, character filters, IME and encoding: Text input.
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.
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| 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.
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)| 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.
| 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.
MuLua Scripting Plugin | Home
🖼️ Lua UI
- File map
- Creating windows
- Client and server
- Designing a window
- Protocol
- Scrolling and scrollbars
- Controls
- Custom text
- Text input
- Debug console
- Bitmap Slicer
- Encrypting scripts
Maps
Monsters
Shared
Version 1.14 | 2026-09-09