Skip to content

LuaUI Scrolling

wezzzyrek1 edited this page Aug 13, 2026 · 1 revision

Lua UI - Scrolling and Scrollbars

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.

A scrolling panel

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{...}.

Panel options

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.

Scrolling the window itself

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.

Styling the bar with colours

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.

Styling the bar with a bitmap

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.

Changing the bar later

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 = true

Reach 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 was

The width argument is ignored unless it is greater than zero, so setScrollbar(on, 0) means the same as setScrollbar(on).

What a window cannot do

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.

Reading the scroll state

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.scrollMax

Rebuilding a list

The 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)
end

A 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.

See Also

Clone this wiki locally