Skip to content

LuaUI Controls

wezzzyrek1 edited this page Sep 2, 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.

Font size is in screen pixels

Every option that sets a text size takes it in screen pixels. Leaving it out, or passing 0, uses the client's base size, which the server owner sets in config.ini under [InstallFont] Size and which is 12 unless they changed it.

Sizes are not canvas units. A box scales with the window, letters do not, so a label at fontSize = 16 is sixteen pixels tall at every resolution.

Each size is rasterised from the outline separately, so it is a real font at that size rather than a stretched bitmap, and it costs nothing per frame once drawn. Anything below 6 or above 128 pixels is clamped.

Four presets are defined for you as globals, worked out from the base size so a script built on them keeps its proportions when the server owner changes it:

Preset Size At the default base of 12
FontS 75% of the base 9
FontM the base size 12
FontL 125% of the base 15
FontLB 140% of the base 17

Going below the base size

Letters are drawn on whole pixels, so the rasteriser rounds the font's design onto the pixel grid. That rounding is a fraction of a pixel whatever the size, which means it costs proportionally more the smaller the text is. Above the base size you will not notice it. Below it, a single rounding can swallow a whole row of a lowercase letter while its width stays where it was, and the result reads as flattened rather than simply small.

Which sizes this happens at depends entirely on the font, because it depends on where that font's x-height falls between two pixels. Batang is a clear example: at 12 pixels its lowercase body is 7 by 6 pixels, at 11 it is 7 by 5, the same width and one row shorter. Many CJK families ship hand-drawn bitmaps for exactly these small sizes because their outlines do not survive there. The client does not use those bitmaps: they cannot be scaled or emboldened, so they would sit oddly next to the rest of the interface.

What to do about it:

  • Keep body text at the base size and make things stand out by going up, not down. The game's own interface does not go below its base size either.
  • Treat FontS as the exception rather than a default. It is the one preset that lands below the base size, so it inherits this whole problem.
  • If a smaller size really is the right call, look at it before you ship it. Drawing the same string at a ladder of sizes, one under the other, shows in seconds where a given font stops holding its shape. The answer differs per font, so it is worth checking against the one your server actually ships.

Label

local lbl = col:Label("Coins: 0", {
	w = 200, h = 16,
	align = ALIGN_CENTER,
	color = RGBA(220, 220, 220, 255),
	fontSize = 11,
	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
fontSize number, screen pixels the base size height of the letters, see Font size
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
.fontSize number, screen pixels read/write the fontSize option; 0 means the base size
:setColor(c) :setFontSize(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
fontSize number, screen pixels the base size label font size, see Font size
faceColor RGBA(r, g, b, a) the built-in blue face colour for a button with no artwork; hover and press are derived from it

faceColor fills the whole button, not the text behind it: the caption is centred in the box you gave with w and h, and there is no padding to set. Give the button room, because a caption wider than its box spills over the edges rather than wrapping or scrolling the way a label does. A button with setImage artwork ignores the colour entirely.

| url | string | none | opens a web page after asking the player, see Links out of the game | | urlPrompt | string | the wording from CustomText | the sentence shown above the address | | urlParent | window | none | centre the confirmation on that window instead of on the screen | | 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
.faceColor RGBA(r, g, b, a) write face colour, after creation
:setText(text, color, fontSize) 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 }

.fontSize sets the size of both the text and the placeholder, in screen pixels; the caret follows it. See Font size.

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

Links out of the game

A button with a url asks before it sends the player anywhere. Clicking it opens a confirmation in the middle of the screen showing the address in full, with Continue and Cancel.

col:Button("Our website", {
	url = "https://www.igcn.mu/",
	urlParent = win,          -- centre it on this window instead of the screen
})

onClick still runs if you set both; the confirmation comes up afterwards.

Only http:// and https:// are ever opened. Anything else is refused, because the shell would happily start a program for other schemes. The check happens when the page is opened, so a button with a rejected address shows the confirmation and then does nothing.

The same box is available on its own, which is what the button uses:

ui.ConfirmUrl("https://www.igcn.mu/", {
	title = "Leaving the game",
	prompt = "This link opens in your web browser:",
	yes = "Continue", no = "Cancel",
	parent = win,             -- omit to centre on the screen
	dim = 45,                 -- how dark the rest of the screen goes
	dimClickThrough = false,
})

Leave the wording out and it comes from CustomText.xml, section UrlConfirm, ids 0 to 3 - title, prompt, confirm button, cancel button. The English text is built in as a fallback, so a client without that section still shows a sensible box. See Custom text.

The window is modal while it is up, so nothing underneath reacts until the player answers, and it can be dragged by its title bar. Passing dimClickThrough = true drops the modality, which also means the box can be buried by clicking the window underneath - see Windows.

A long address does not overflow the box: the line it sits on scrolls sideways while the cursor is over it, the same behaviour any label has by default.

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 [, fontSize]) width of a string in canvas units, so it compares directly with a box; pass the same size you will draw it at
UI.OpenUrl(url) opens a page in the player's browser; refuses anything that is not http or https, and returns whether it went
ui.ConfirmUrl(url, opts) the confirmation box above
ui.LangText(section, id, fallback) a string from CustomText.xml by section name, or the fallback when it is not there
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.GetTickCount() milliseconds from a monotonic clock, for animation, see Animating a drawing
UI.WindowIds() the ids of every live window, whether or not a script registered them
UI.ShowPanel(panel [, show]) / UI.TogglePanel(panel) the game's own panels, see Opening the game's panels
Log.Add(text) / Log.AddC(color, text) prints a line to the dev debug console (AddC colour from Enums.Color)
Lang.GetText(type, id) / Lang.TYPE.* localised text from CustomText.bmd, see Localised text

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.

Opening the game's panels

The command panel and the windows it opens - the ranking board, add stats, the item bank, daily rewards - can be opened from a script, so a menu of your own can sit beside the built-in buttons or replace them.

Every panel on this page belongs to the paid plugin. They are not part of the base client, and a server running without the plugin has none of them, so none of these calls will open anything there.

UI.ShowPanel(UI.PANEL.RANKING)          -- open
UI.ShowPanel(UI.PANEL.RANKING, false)   -- close
UI.TogglePanel(UI.PANEL.RANKING)        -- open if shut, shut if open

ShowPanel sets the state you name, the same way win:show does for a window of your own. TogglePanel flips it, which is what the command panel's own buttons do: wire a menu button to that one and it behaves like the built-in, second press included.

Reach for ShowPanel when the panel has to end up in a known state whatever it was before - opening the item bank at the end of an NPC dialog, say. TogglePanel there would close it for a player who already had it open.

Panel Opens Needs
UI.PANEL.COMMAND the command panel itself the plugin, command panel enabled
UI.PANEL.RANKING ranking board the above, plus the ranking panel enabled
UI.PANEL.ADD_STATS add stats the above, plus add stats enabled
UI.PANEL.ITEM_BANK item bank the above, plus the item bank enabled
UI.PANEL.DAILY_REWARD daily rewards the above, plus daily rewards enabled

Opening a panel this way is the same as clicking its button in the command panel: the panel is shown and raised, and it holds whatever the server has already sent it. No extra request goes out.

A panel that is already in the state you ask for is left untouched. Showing an open panel does not raise it, so a button wired to UI.ShowPanel can be pressed twice without the window jumping.

Panels this server does not have

Two things have to be true before a panel can open: the server runs the plugin, and its interface configuration switches that particular panel on. The command panel gates the rest, so turning it off takes all four with it.

Where either is missing, the call does nothing and reports nothing. There is no window to open, and no way for a script to tell that apart from a panel that simply stayed shut - which is deliberate, since the answer would say what this server is licensed for.

Write your menu so that costs you nothing. A button that opens a panel this server does not have is a button that does nothing when pressed, which is a layout decision rather than an error to handle:

col:Button("Item Bank", {
	onClick = function() UI.ShowPanel(UI.PANEL.ITEM_BANK) end,
})

Localised text

Lang.GetText reads a string out of the client's CustomText.bmd, so a window can show text in whatever language the player runs:

lbl.text = Lang.GetText(Lang.TYPE.ItemBank, 16)

Lang.TYPE.<Section> names a section of the file - by its XML node name - and the second argument is the entry id. A missing entry comes back as "Missing Text: <id>", never nil.

Adding your own sections, the fallback helper and encoding the .bmd are on their own page: Custom text.

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