Repository navigation
LuaUI Scrolling
Two things can scroll: a window, when its content is taller than the window itself, and a panel, a box inside a window with its own viewport and its own bar. Reach for a panel when part of the window should stay put while a list moves.
In the running example the controls stay at the top and a history list sits in a panel underneath:
local list = win:Panel{
x = 14, y = 202, w = 252, h = 152,
back = RGBA(0, 0, 0, 90),
scrollbar = true,
}
local col = list:Column{ top = 8, padding = 10, spacing = 4, align = "left" }
for _, entry in ipairs(Exchange.history) do
col:Label(("%s %d -> %d"):format(entry.date, entry.spent, entry.gained),
{ scale = 0.9, slide = false })
end
list:fit()list:fit() is what turns scrolling on. It measures the laid-out content and
publishes its height; without it the panel knows nothing overflows and the bar
never appears. Call it once, after the last control is added.
A panel takes explicit x/y - it does not follow the column above it. Put it
below where that column ends, or the two draw on top of each other.
Panels nest: panel:Panel{...} works exactly like win:Panel{...}.
| Option | Values | Default | Configures |
|---|---|---|---|
x y
|
number, canvas units |
0, 0
|
position inside the parent |
w |
number, canvas units | parent width less x and 12 |
width |
h |
number, canvas units | 100 |
viewport height - content taller than this scrolls |
id |
integer | assigned automatically | only needed to look the panel up later |
back |
RGBA(r, g, b, a), each 0-255 |
none - transparent | fill drawn behind the content, e.g. RGBA(0, 0, 0, 90)
|
padBottom |
number, canvas units | 8 |
gap fit() keeps under the last control |
scrollbar |
true / false
|
false |
give the panel an interactive bar |
scrollbarWidth |
number, canvas units | 12 |
bar width |
scrollbarMargin |
number, canvas units | 6 |
inset from the panel's right 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 |
scrollStep |
number, canvas units | 22 |
distance scrolled per wheel notch |
contentFullWidth |
true / false
|
false |
let content run under the bar |
colors |
table, see below | the built-in scheme listed below | style the bar with colours |
skin |
table, see below | none | style the bar with bitmap regions |
Windows accept the same scrollbar* group, with the same meanings.
contentFullWidth decides who owns the strip the bar occupies. Left false,
content stops at the bar's left edge - safe, and what you want for a list of
buttons. Set it true and content may run under the bar, which suits a full-width
table whose bar floats over the last column.
A rules window is such a case - a wall of text with a fixed title above it:
local rules = ui.Window{
id = R.win.exchangerules,
center = true,
w = 300, minHeight = 160, maxHeight = 420,
title = "Exchange Rules",
scroll = true,
scrollbar = true,
scrollbarMargin = 16,
contentTop = 34,
}contentTop marks where the scrolling area begins, keeping a title or header row
fixed above it. The window measures itself on the first frame, so a window that
scrolls needs minHeight/maxHeight rather than a fixed height.
Every piece is separately addressable. Omit a key and it keeps the default below.
| Key | Piece | Default |
|---|---|---|
track |
the groove the thumb runs in | RGBA(0, 0, 0, 153) |
arrow |
the button box at each end | RGBA(74, 42, 26, 255) |
arrowPressed |
that box while held | RGBA(122, 74, 42, 255) |
glyph |
the triangle drawn on the box | RGBA(224, 160, 96, 255) |
glyphPressed |
that triangle while held | RGBA(255, 216, 160, 255) |
thumb |
the draggable block | RGBA(200, 90, 42, 224) |
thumbDrag |
that block while dragging | RGBA(224, 112, 56, 255) |
colors = {
track = RGBA(0, 0, 0, 120),
arrow = RGBA(60, 40, 25, 255),
arrowPressed = RGBA(150, 90, 45, 255),
thumb = RGBA(120, 90, 60, 220),
thumbDrag = RGBA(210, 140, 70, 255),
glyph = RGBA(230, 170, 100, 255),
glyphPressed = RGBA(255, 215, 150, 255),
}Because the style is per panel, two lists in one window can look different - a muted bar on a log, a bright one on the list the player actually uses.
Same structure, cut from an atlas instead. Each piece is { sx, sy, sw, sh } in
bitmap pixels, stretched to the bar's own size:
list:handle():setScrollbarSkin{
slot = R.slot.frame, -- reuse the window's already-loaded atlas
up = { 700, 10, 12, 12 },
down = { 700, 24, 12, 12 },
upPressed = { 714, 10, 12, 12 },
downPressed = { 714, 24, 12, 12 },
track = { 728, 10, 12, 26 },
thumb = { 742, 10, 12, 26 },
}Reusing the window's own atlas slot means no extra bitmap to ship and no second load - the frame, the buttons and the bar all come from one file.
For a thumb that should not smear when the list is long, supply a three-slice instead of a single cut:
thumbTop = { 742, 10, 12, 6 },
thumb = { 742, 18, 12, 4 }, -- the middle, repeated
thumbBot = { 742, 24, 12, 6 },Any piece may carry its own slot as { slot, sx, sy, sw, sh } if you need to mix
two atlases.
Skinning obeys the same timing rule as everything else bitmap-backed: the texture
exists from the first render pass onward, so apply the skin after
win:textureReady() rather than at script time. A skin set through the skin =
option on the panel is handled for you.
The options above are read once, when the panel is created. Everything they set can also be changed afterwards, by calling a method on the panel's raw handle:
local list = win:Panel{ x = 14, y = 202, w = 252, h = 152, scrollbar = true }
local h = list:handle()
h:setScrollbarAutoHide(true) -- same effect as scrollbarAutoHide = trueReach for these when the answer is not known at script time - a list that turns read-only, a bar the player chose the colour of, a step size that follows the row height you end up with.
| Method | Arguments | What it does |
|---|---|---|
setScrollbar(on [, width]) |
boolean; number, canvas units | turns the bar on or off, and sets how wide it is |
setScrollbarMargin([margin] [, padTop] [, padBottom]) |
numbers, canvas units | insets the bar from the panel edge, and from the top and bottom of the viewport |
setScrollbarAutoHide(b) |
boolean |
true shows the bar only while the cursor is over the panel |
setScrollbarAlwaysVisible(b) |
boolean |
true keeps track and arrows on screen even with nothing to scroll |
setContentClipRight(b) |
boolean |
true stops the content at the bar's left edge; false lets it run the full width, under the bar |
setContentHeight(units) |
number, canvas units | declares how tall the content is, which is what gives the bar its range |
setScrollStep(units) |
number, canvas units per notch | how far one wheel notch moves the content |
setScrollbarColors(t) |
table, the keys above | recolours the bar |
setScrollbarSkin(t) |
table, the pieces above | draws the bar from bitmap cuts instead of colours |
Each one is the runtime twin of the option of almost the same name - setScrollStep
for scrollStep, setScrollbarAutoHide for scrollbarAutoHide. Two do not follow
that pattern: setContentClipRight(b) is the inverse of contentFullWidth, and
setContentHeight is what fit() publishes for you, also reachable as the panel's
contentHeight property.
None of them return the handle, so they do not chain - call them one per line.
Omitted arguments keep their current value. That is what makes these usable for
a single adjustment: setScrollbar(true) turns the bar on without deciding its
width, and a margin field you leave out is not reset to zero.
h:setScrollbarMargin(nil, nil, 10) -- only the bottom pad moves
h:setScrollbar(true) -- width stays whatever it wasThe width argument is ignored unless it is greater than zero, so setScrollbar(on, 0)
means the same as setScrollbar(on).
The last three methods exist on panels only. A window handle has no
setScrollStep, setScrollbarColors or setScrollbarSkin, and the window option
table has no colors, skin or scrollStep either - calling one is an
attempt to call a nil value.
So a scrolling window gets the default bar, in the default colours, at the default step. When you need a styled bar, put the content in a panel and let the panel own the scrolling - which is the better layout anyway, because it keeps the window's title and buttons still while the list moves.
A window handle has one method a panel does not: setScroll(viewTop, viewBottom),
in canvas units, marking the band of the window that scrolls. The scroll = true
option calls it for you, from contentTop down to the bottom padding.
A panel's own properties: .id (read-only), .x, .y, .width, .height,
.visible, .contentHeight (write), .render, and the two below.
Windows and panels both expose scrollOffset and scrollMax on their raw handle -
enough to tell whether the player is looking at the end of a list, or to request
more rows when they reach it. (A window additionally reports the raw wheel value;
panels do not.)
local h = list:handle()
-- only auto-follow a new history entry if the player was already at the bottom;
-- yanking the view while they are reading older rows is worse than not following
local atBottom = h.scrollOffset >= h.scrollMaxThe history has to refresh after every conversion, so building it once is not enough. Keep the row handles and rewrite their text instead of recreating them:
local ROW_H, ROW_GAP = 16, 4
local rows = {}
local function setHistory(entries)
for _, row in ipairs(rows) do
row.visible = false
end
for i, entry in ipairs(entries) do
local row = rows[i]
if row == nil then
row = col:Label("", { scale = 0.9, slide = false })
rows[i] = row
end
row.text = ("%s %d -> %d"):format(entry.date, entry.spent, entry.gained)
row.visible = true
end
-- the laid-out height only ever grows, so publish the height of what is
-- actually shown or the bar keeps the range of the longest list so far
list:handle():setContentHeight(#entries * (ROW_H + ROW_GAP) + 16)
end
-- the server sends a count byte, then that many entries
net.on(S.id, S.HISTORY, function(result, reader)
local entries = {}
for i = 1, reader:byte() do
entries[i] = { date = reader:text(), spent = reader:dword(), gained = reader:dword() }
end
setHistory(entries)
end)Hiding and reusing rows beats deleting and recreating them: the layout stays
stable, and there is no id churn. list:handle():delControl(id) exists for the
cases where a control really has to go.
list:fit() is the right call for a list built once. For one that shrinks, set the
height yourself as above - fit() reports the tallest layout the panel has ever
held, not the current one.
The server end of that packet, so the two halves can be read together:
local MAX_HISTORY = 20
local function sendHistory(oPlayer)
local list = Exchange.history[oPlayer.userData.CharacterId] or {}
local w = UIPacket.Writer()
w:byte(#list)
for _, entry in ipairs(list) do
w:text(entry.date)
w:dword(entry.spent)
w:dword(entry.gained)
end
UIWindow.Send(oPlayer, S.id, S.HISTORY, S.RESULT.OK, w)
endA count byte caps the list at 255 entries, which is the reason MAX_HISTORY exists
- trim server-side, and the packet can never overflow the field that describes it. Send it from the open handler and again after every change.
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