Skip to content

LuaUI Windows

wezzzyrek1 edited this page Sep 2, 2026 · 2 revisions

Lua UI - Creating Windows

A window script is a plain Lua file in UI\Windows\. It creates one window, fills it with controls, and registers whatever should happen afterwards.

The smallest complete window

local ui = require("UI")
local R  = require("Registry")

local win = ui.Window{
	id = R.win.exchange,
	center = true,
	w = 280, height = 160,
	title = "Coin Exchange",
}

if win then
	local col = win:Column{ top = 40, padding = 16, spacing = 6 }
	col:Label("Nothing here yet.")
end

ui.Window returns nil if the id is already taken, so guard everything with if win then. Without that guard a duplicate id turns into an error on the very first line that touches the window.

Naming the id

Ids are integers, but you never write the integer in a window script. Name it once in Registry.lua and refer to the name everywhere:

R.win.exchange      = 3   -- the floating window
R.win.exchangehud   = 4   -- the HUD ticker beside it
R.win.exchangerules = 5   -- the scrolling rules window

Pin ids explicitly for anything the server also knows about - the server's UIProtocol.WINDOW table has to carry the same number. Names left unpinned get an id automatically, which is fine for a purely local window but drifts the moment another script is added before it.

Registry.lua builds its two pools with ui.newIdSet(startAt, maxCount, label) - one for window ids, one for the texture slots used when skinning. Add a pool of your own the same way if you need a third kind of stable id. ui.idNames(pool) returns every name a pool has handed out as { name = id }, which is how a script can enumerate windows it did not create.

Placement and size

The UI is laid out on a fixed 640x480 canvas that the client maps onto the real screen, so coordinates are already resolution independent. Three ways to place a window:

center = true                          -- middle of the screen
x = 120, y = 80                        -- fixed spot on the canvas
anchor = "right", margin = { 6, 0 }    -- pinned to an edge

Anchors: topleft top topright left center right bottomleft bottom bottomright. margin is { x, y }, measured inward from the anchored edge. Prefer an anchor over fixed coordinates for anything edge-hugging: the rendered size depends on the player's UI scale while x/y do not, and the anchor accounts for that.

A centred axis ignores its margin. There is nothing to measure inward from when a window is centred on that axis, so the component is dropped:

Anchor margin.x margin.y
topleft topright bottomleft bottomright works works
left right works ignored - centred vertically
top bottom ignored - centred horizontally works
center ignored ignored

This is what catches people out when two HUD panels overlap: a panel at right cannot be nudged up or down by its margin, because right means "vertically centred on the right edge". Move it to topright or bottomright and the margin starts working.

Screen space is shared, and the game's own interface has already taken the good spots - the character status and party list at the top left, the minimap at the top right, the skill bar along the bottom. The right edge, vertically centred, is the roomiest area in a default layout, which is exactly why two HUD panels both reaching for it will collide.

The same maths is available on its own, for placing something the option cannot reach - a panel inside a window, or a window you reposition yourself:

local x, y = ui.anchor(w, h, "bottom", 0, 12)

It returns the two coordinates for a box of that size at that anchor, with the margins applied. ui.Window calls it for you when you pass anchor.

Units

Kind of value Unit
x y w h height, margins, padding, spacing canvas units - pixels on the fixed 640x480 canvas
Source rectangles { sx, sy, sw, sh } pixels in the bitmap
fontSize on text screen pixels, not canvas units - boxes scale with the window, letters keep their size
Colour components in RGBA(r, g, b, a) 0-255 each, a 0 = invisible
maxLen minLen characters
maxBytes minBytes bytes in the client's codepage
scrollStep canvas units per wheel notch

Canvas units are not screen pixels. The canvas is a fixed 640x480 space the client maps onto whatever resolution the player runs, so a window at x = 320 sits in the same relative place on every machine. What changes with the player's UI scale is how large it is drawn: the rendered size is w * UI.Scale(), while x/y are not scaled. That asymmetry is the reason anchor exists.

Height comes in two flavours:

w = 280, height = 380                     -- fixed
w = 280, minHeight = 120, maxHeight = 400 -- grows with its content, clamped

A content-driven window measures what you laid out and resizes itself on the first frame, then re-centres or re-anchors. Use it for a window whose row count varies. Use a fixed height when the window has a scrolling list inside - there the list should scroll, not the window.

The full option list

Option Values Default Configures
id integer, from Registry.lua required which window this is
center true / false false centre on screen; ignores x/y
x y number, canvas units 0, 0 fixed position
anchor topleft top topright left center right bottomleft bottom bottomright none pin to a screen edge
margin { x, y }, canvas units { 0, 0 } inset from the anchored edge
w number, canvas units 260 width
height number, canvas units none fixed height
minHeight maxHeight number, canvas units none grow to fit content, clamped to this range
padBottom number, canvas units 12 gap kept under the last control when fitting
title string none text drawn in the header
titleColor RGBA(r, g, b, a), each 0-255 RGBA(255, 255, 255, 255) title colour
titleY number, canvas units 7, or centred in header title baseline
header number, canvas units none reserved top strip, with a separator drawn under it
contentTop number, canvas units header, else 28 where scrollable content begins
drag { x, y, w, h } or true none grab area; true makes the whole header one; omit and the window cannot be dragged
skin table, see Designing a window none frame bitmap
mode "window", "hud" "window" render tier and open behaviour
clickThrough true / false false let clicks reach the world underneath
autoOpen true / false false show the window when the player enters the world
priority number 0 ordering when several windows open together; higher ends up on top
dim number, 0 to 100 0 blacks out the whole screen behind this window, as a percentage
dimClickThrough true / false false whether clicks that miss the window pass through the dim
scroll true / false false content taller than the window scrolls
scrollbar true / false false give that scroll an interactive bar
scrollbarWidth number, canvas units 12 bar width
scrollbarMargin number, canvas units 6 inset from the frame edge
scrollbarPad { top, bottom } none inset from the viewport ends; also scrollbarPadTop / scrollbarPadBottom
scrollbarAutoHide true / false false show the bar only while hovering
scrollbarAlways true / false false keep track and arrows even without overflow
contentFullWidth true / false false let content run under the bar instead of stopping at it

The scrollbar* group behaves identically on windows and panels; the scrolling page covers what it looks like.

Note the pairing: a window takes w with height, while panels and controls take w with h, and everything exposes .width / .height once created. The controls page has the full table.

Short form, when all you need is a box:

local win = ui.Window(R.win.exchange, ui.CENTER, 260, 160, "Coin Exchange")
local win = ui.Window(R.win.exchange, 120, 80, 260, 160, "Coin Exchange")

Dimming the screen behind a window

dim paints the whole screen black behind one window, as a percentage: 0 is off, 100 is solid. It is drawn immediately before that window, so it covers the world and every window below, and nothing above it.

ui.Window{ id = ..., dim = 45 }
win:setDim(45)

By default the dim also eats clicks that miss the window, which is usually what you want for a dialog. dimClickThrough = true lets them past, so you get the darkening without blocking anything.

Dimming is not the same as being modal. It changes what the player sees and, unless you opt out, swallows stray clicks, but it does not stop another window from being raised over yours. For a real dialog, pair it with setModal.

The two do not stack, they override: a modal window blocks the mouse before the dim is ever consulted, so dimClickThrough has no effect while setModal(true) is in force. Pick one. ui.ConfirmUrl does exactly that, going modal only when the caller has not asked for click-through.

Dropping modality has a price worth stating: a window that is not modal can be buried. Click the window underneath and it is raised over your dialog, because nothing holds the dialog on top any more. That is the deal dimClickThrough makes, so keep it for panels the player may ignore, not for a question you need answered.

A dialog is a window like any other, so it takes drag too. Give it the strip its title sits in and the player can move it out of the way:

ui.Window{ id = ..., w = 360, height = 148, dim = 45, drag = { 0, 0, 360, 26 } }

drag = true would work as well, but it assumes a 28 unit header, so pass the rectangle when your own title bar is a different height. ui.ConfirmUrl does this: its box is draggable by the red strip, modal or not.

Modal windows

win:setModal(true) makes a window the only one the mouse reaches: every other window, including the game's own, stops reacting, and nothing can be raised above it. setModal(false) releases it.

win:show(true)
win:setModal(true)

A modal that is hidden or destroyed releases itself, so a script that forgets to call setModal(false) cannot lock the interface. Release it anyway - relying on that is a way to leave the game unclickable for as long as the window lingers.

Mouse movement still reaches the game while a modal is up, so the cursor keeps working normally; only presses are swallowed.

Two kinds of window

mode decides which tier the window is drawn in, and that changes more than the draw order.

"window" (default) "hud"
Opening asks the server first shows immediately
ESC closes the topmost open one ignores it
Z-order lifts above other windows when clicked stays under the game's windows
Typical use something the player opens a permanent readout

A HUD panel usually wants clickThrough = true so the world underneath stays clickable. Drop that only if the panel has buttons of its own, or if you gave it a drag area: clicks that pass through never reach the grab area, so a click-through panel cannot be moved.

Showing and hiding

win:show(true)          -- request open
win:show(false)         -- close, immediate
win:isVisible()         -- current state
win:bringToFront()

For a floating window show(true) is a request, not a command: the client asks the server, and the window appears only if the server agrees. Hiding is always immediate. A HUD window skips the round trip and simply appears.

That is why a window can stay invisible even though your script clearly showed it - the server refused. See Client and server together.

If you need the same window to open in different modes, set openParam before showing; the value travels with the open request and reaches the server handler.

win:handle().openParam = 2
win:show(true)

Opening on login, and stacking order

autoOpen = true shows the window once the player is in the world, so a HUD panel does not need an Event.on("ingame") of its own:

local hud = ui.Window{ id = R.win.ticker, mode = "hud", autoOpen = true,
                       priority = 10, clickThrough = true, ... }

When several windows come up together, priority decides what ends up on top: they are raised lowest first, so the highest priority finishes in front. Windows without a priority keep the old behaviour, whatever was shown or clicked last is on top.

Priority orders windows within their tier, it does not lift a "hud" panel above a "window". And because a floating window only appears once the server agrees, a refused window is simply skipped. If you open several by hand and want the same ordering applied afterwards:

ui.RaiseByPriority()

Reacting to the game

Event.on("update", function() ... end)   -- every frame
Event.on("ingame", function() ... end)   -- the player entered the world
Event.on("load", function() ... end)     -- scripts finished loading

An event can carry any number of handlers, so several scripts registering for update all run. Event.off(name) exists but removes every handler for that event, including other scripts' - so treat it as a debugging tool, not a way to unsubscribe. To stop reacting, return early from your own handler instead.

A common opening pattern - show the window when the player enters the world, and also right now if the script was reloaded while already in game:

Event.on("ingame", function() win:show(true) end)

if Input.GameState() == Enums.GameState.IN_GAME then
	win:show(true)
end

Enums.GameState names the values Input.GameState() returns - the same numbers the scene event is given. It comes from UI\Enums.lua, so require it like any other library:

local Enums = require("Enums")

The file follows the server's Enums.Group.VALUE shape, but the two are separate tables in separate runtimes - a group on one side is not automatically on the other:

Name Value Where the player is
Enums.GameState.SELECT_SERVER 2 server list
Enums.GameState.SWITCH_CHARACTER 5 character select
Enums.GameState.IN_GAME 6 in the world

Toggling on a key:

Event.on("update", function()
	if Input.KeyPressed(VK.F10) then
		win:show(not win:isVisible())
	end
end)

Input.KeyPressed fires once per press, Input.KeyDown every frame the key is held. Both stay quiet while a text field has focus, so typing "f10" into a field does not toggle windows behind it.

Hotkeys are global to the client. Nothing stops two scripts claiming the same key, and when they do, one press toggles both windows. The shipped scripts already hold several - F9 reloads Lua in dev mode, F10 is Demo.lua, F11 is UIPlacement.lua, B is the Item Bank - so pick from what is left and write the key down where the next person will look.

Centring text in a strip

Text is centred inside the box you give it, so a box that does not match the strip centres against the wrong middle:

h:renderText("TITLE", 0, 6, s.w, 18, ALIGN_CENTER, GOLD, FontM)   -- centres at 15
h:renderText("TITLE", 0, 0, s.w, HEADER_H, ALIGN_CENTER, GOLD, FontM)  -- correct

The first looks fine until you notice the caption sitting low in its bar. Give the box the strip's own y and height and it lands where the strip's middle is.

Two objects, not one

ui.Window does not hand you the window. It hands you a wrapper - a small Lua object that knows how to lay controls out, skin the frame and fit the height. The window itself lives inside it, and win:handle() is how you reach it.

The distinction matters because the two carry different things:

the wrapper - win the handle - win:handle()
What it is convenience layer written in Lua the window the client actually draws
Carries layout and skinning helpers: Column Row Panel background frameSkin fit show isVisible textureReady titleColor nextId the properties (x, visible, hover, ...) and the low-level calls (addLabel, renderColor, setScroll, ...)
Does not have any property - win.x is nil :Column{...} and the other helpers

bringToFront and setDragArea are the two calls that work on either.

So win:show(true) and win:handle().visible = true do the same thing, and win.width is nothing while win:handle().width is the width. When an example reaches for win:handle(), it is because the thing it needs lives on that side.

One consequence worth remembering: UI.GetWindow(id) returns a handle, not a wrapper. A script fetching another script's window can read .visible and call addLabel, but not :Column{...} - those helpers belong to the wrapper the other script is holding.

Panels work the same way: win:Panel{...} gives a wrapper, panel:handle() the control underneath.

Window methods

Call Does
win:Column{...} / win:Row{...} layout group that positions controls for you
win:Panel{...} a scrollable sub-area, see the scrolling page
win:background(fn) draw behind the controls, every frame
win:frameSkin(skin) dress the window in a bitmap frame
win:titleColor(c) change the title colour after creation
win:show(v) / win:isVisible() visibility
win:bringToFront() raise above other floating windows
win:setModal(v) while set, this window is the only one the mouse reaches and nothing can be raised over it
win:centerOn(parent) centre on another window, or on the screen when parent is omitted; never lands off screen
win:setDim(percent) / win:setDimClickThrough(v) the two dim options, after creation
win:setDragArea(x,y,w,h) change the grab area later
win:fit(pad) re-measure a content-driven window now instead of next frame
win:nextId() the next free control id, for addLabel and friends
win:textureReady() has the skin bitmap finished loading
win:handle() the window underneath, see above

background, titleColor, setDragArea, show, bringToFront, frameSkin and fit return the window, so they chain: win:show(true):bringToFront(). The rest return what you asked for - a group, a panel, the handle, a boolean, an id.

The handle carries the properties:

Property Type Access What it is
id integer read the id it was created with
x y width height number, canvas units read/write its box
scale number, multiplier read the factor the window is rendered at
depth integer read/write draw order within its tier; higher draws later
mode "window" / "hud" read/write render tier; any other string is an error
clickThrough boolean read/write whether clicks pass through to the world
visible boolean read/write writing true is the open request
openParam integer read/write value sent with the open request
hover dragging boolean read current mouse state
wheel integer, notches read wheel movement this frame; 0 if it did not move
scrollOffset scrollMax number, canvas units read scroll position and range
render update function, or nil read/write per-frame callbacks, see Controls

Reading another script's window works the same way:

local other = UI.GetWindow(R.win.exchange)

if other and other.visible then ... end

See Also

Clone this wiki locally