Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
e2adef9
First refactor to MVC-pattern
Garanas Jun 20, 2026
0d7af2b
DIrectly reference the model
Garanas Jun 20, 2026
0b2c525
Separate the controller from the lobby methods instance
Garanas Jun 21, 2026
ddf02fd
Add a launch script for the custom lobby
Garanas Jun 21, 2026
1823042
Bare-bone custom lobby that is easy to test
Garanas Jun 21, 2026
ba57fb6
Delen van de CPU score
Garanas Jun 21, 2026
fe26307
Add support for new cpu benchmark
Garanas Jun 22, 2026
b8b242a
Add support for players disconnecting (from the host)
Garanas Jun 22, 2026
0ece4d5
Add plan for column-based teams
Garanas Jun 22, 2026
5371d6f
Add support for requesting a slot as a player
Garanas Jun 22, 2026
d0c0e2e
Add support for drag/drop
Garanas Jun 22, 2026
052d9a8
Add support for a (reuseable) menu context
Garanas Jun 22, 2026
66f5707
Add an observers area
Garanas Jun 22, 2026
89983cd
Make all functionality callable from text (for chat commands)
Garanas Jun 22, 2026
c72549e
Add support for a map preview
Garanas Jun 22, 2026
8327c05
Separate the state between what is relevant for the game, the host an…
Garanas Jun 22, 2026
f620e02
Add a basic map selection dialog
Garanas Jun 22, 2026
79ca4df
Polish the map selection dialog
Garanas Jun 22, 2026
fdd5075
Reduce memory allocations by sharing common bitmaps
Garanas Jun 22, 2026
6964698
Tweak and tune the custom lobby map list
Garanas Jun 22, 2026
de37cd0
Fix issues with hot reload
Garanas Jun 22, 2026
f182f67
Refactor the map preview control
Garanas Jun 22, 2026
2bf52a9
Add support for a mod selection dialog
Garanas Jun 22, 2026
55e606d
Add an options dialog
Garanas Jun 22, 2026
450e5bf
Only update preferences upon destroying dialog
Garanas Jun 22, 2026
1882395
Keep track of progress
Garanas Jun 22, 2026
2f6c2ed
Separate the configuration side into a new class
Garanas Jun 22, 2026
43ebef1
Limit resolution to 1024x768
Garanas Jun 22, 2026
34e7ce4
Introduce configuration panel
Garanas Jun 22, 2026
32dce10
Refine configuration panels
Garanas Jun 22, 2026
ba7cf27
Add reclaim values
Garanas Jun 22, 2026
98d5619
Combine the map preview related components
Garanas Jun 22, 2026
888ed9f
Use the label/value format
Garanas Jun 22, 2026
ee2e3da
Implement the layout of Bobka
Garanas Jun 22, 2026
3e6dd39
Provide more space for the map preview
Garanas Jun 22, 2026
2c2bf57
Extend the user stories
Garanas Jun 22, 2026
5e987e0
Fix depth of performance popover
Garanas Jun 23, 2026
a136988
Implement the feature to launch the game
Garanas Jun 23, 2026
2e90dbc
Experiment with using trashbags
Garanas Jun 24, 2026
bdb9741
Document how we want to work with freeing allocations
Garanas Jun 24, 2026
56908fe
Refactor lobby back to what we have now
Garanas Jun 24, 2026
9c69d3b
Add basic interactions with the map (icons need improvements)
Garanas Jun 24, 2026
1dfeab4
Extract the slots area into a separate component
Garanas Jun 24, 2026
6d168a0
Turn off lazyvar evaluation
Garanas Jun 24, 2026
499e42d
Rework how we determine what slot is on what team when autoteams is e…
Garanas Jun 24, 2026
1185acb
Rework existing setup to a one-column setup with shared base function…
Garanas Jun 24, 2026
5baa95f
Implement one-column and two-column layout
Garanas Jun 24, 2026
d4e1ca7
Add separate layout for two columns
Garanas Jun 24, 2026
171c99f
Make cards face each other
Garanas Jun 24, 2026
a9155e0
Add config buttons and badges to tabs
Garanas Jun 25, 2026
5a7bae4
Add a basic logs panel, for debugging
Garanas Jun 25, 2026
9baa57f
Uitbreiden werking van de logs
Garanas Jun 25, 2026
8509126
Refinement of existing elements
Garanas Jun 25, 2026
3f98bca
Fix popover for sim performance
Garanas Jun 25, 2026
aed967c
Add support for unit restrictions
Garanas Jun 25, 2026
d541a8a
Extend support for unit restrictions
Garanas Jun 25, 2026
c3a739c
Always show the badges, even when there's 0
Garanas Jun 25, 2026
97bf770
Add default background for the lobby
Garanas Jun 25, 2026
ecb3fdf
Add support for basic lobby presets
Garanas Jun 25, 2026
8f3d906
Help Claude understand texture size
Garanas Jun 25, 2026
4be5abe
Add support for custom background images
Garanas Jun 25, 2026
dc25fec
Make custom backgrounds take precedence over the built in backgrounds
Garanas Jun 25, 2026
237bab4
Refinement of the lobby presets dialog
Garanas Jun 25, 2026
f458794
Add a derived model for the chosen scenario to enrich it
Garanas Jun 26, 2026
0c675dc
Add derived state for options
Garanas Jun 26, 2026
3cbb75b
Uitbreiden van mods en restricties derived state
Garanas Jun 26, 2026
4c7378b
Add derived state for lobby slots
Garanas Jun 26, 2026
e58c908
Reduce duplicated code
Garanas Jun 26, 2026
871e8e7
Make lobby rules pure
Garanas Jun 27, 2026
2cdcca3
Refactor models (and derivatives) to be in their own folder
Garanas Jun 27, 2026
5973efa
Refactor the derived state to implement Destroyable
Garanas Jun 27, 2026
158caff
Make the derived mods state a simple class that can be destroyed
Garanas Jun 27, 2026
5be3615
Rewrite all derived models into a destroyable class
Garanas Jun 27, 2026
8dbe2cf
Turn authoritative models into classes that can be destroyed
Garanas Jun 27, 2026
86c1d8b
Add dedup to the derived options
Garanas Jun 27, 2026
ed8a6c8
Implement auto balance
Garanas Jun 27, 2026
51435b6
Enhance the lobby balancer dialog
Garanas Jun 27, 2026
1a5d90d
Apply the auto teams setting on launch
Garanas Jun 27, 2026
514a036
Enrich the balance dialog
Garanas Jun 27, 2026
3d596c9
Add drag-and-drop functionality to update the balance
Garanas Jun 27, 2026
8c65764
Improve balance preview dialog
Garanas Jun 27, 2026
3fe306c
Add a basic chat panel
Garanas Jun 27, 2026
34a8b67
Fix bugs of chat panel
Garanas Jun 27, 2026
01ee2c6
Extend chat and balance preview
Garanas Jun 27, 2026
5227173
Improve chat and balance dialog
Garanas Jun 27, 2026
31d5b61
Setup teams when the game starts
Garanas Jun 27, 2026
582c7ea
Add support for wrapping long chat messages
Garanas Jun 27, 2026
646b6c8
Add basic faction selection
Garanas Jun 28, 2026
0fd1f48
Highlight your personal slot
Garanas Jun 28, 2026
92ab1f7
Fix not being initialized with lazy var
Garanas Jun 28, 2026
cc9b132
Documnent highlight feature
Garanas Jun 28, 2026
04623d4
Fix type/edit area of chat panel
Garanas Jun 28, 2026
a9436c4
Remove debug code
Garanas Jun 28, 2026
d8d10fe
Rework the lobby options panel to be standalone and easier to debug
Garanas Jun 28, 2026
633ffc7
Rework the debug feature of the lobby chat panel
Garanas Jun 28, 2026
7300f11
Refinement of the logs panel
Garanas Jun 28, 2026
39ddd61
Add a toggle to make debugging UI elements easier
Garanas Jun 28, 2026
b11b1be
Simplify the observers panel
Garanas Jun 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .claude/skills/add-slot-menu-item/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
name: add-slot-menu-item
description: Add an entry to the CustomLobby slot right-click context menu (e.g. Kick, Move to observers, Close slot). Use when the user asks to add, gate, or scaffold a slot context-menu action in the custom lobby. Edits the declarative list in lua/ui/lobby/customlobby/CustomLobbyMenus.lua and, for a new operation, adds a CustomLobbyController intent.
---

# Adding a slot context-menu item

One edit in most cases: append an entry to the `SlotMenu` list in [CustomLobbyMenus.lua](/lua/ui/lobby/customlobby/CustomLobbyMenus.lua).

```lua
{
label = "Close slot", -- example of a NEW action (RequestCloseSlot doesn't exist yet — add it)
when = function(ctx) return ctx.isHost and ctx.isOpen end,
action = function(ctx) CustomLobbyController.RequestCloseSlot(ctx.slot) end,
-- enabled = function(ctx) ... end, -- optional; omit for always-enabled. false = greyed, not hidden
},
```

(Already wired, for reference: `Move to observers` → `RequestMoveToObserver`, `Eject` → `RequestEject`, ready toggles, `Take this slot` → `RequestTakeSlot`.)

`ctx` (see `SlotContext`): `slot`, `player|false`, `isHost`, `isYou`, `isOpen`.

## Do / Don't

| | Rule |
|---|------|
| ✅ | Gate visibility with `when(ctx)` — that's how host vs player / open vs occupied menus differ. Use `enabled` only when you want the item shown-but-greyed. |
| ✅ | Have `action(ctx)` call a **`CustomLobbyController` intent** (`RequestX`). Keep it one line. |
| ✅ | If the action is a new operation, add the intent to [CustomLobbyController.lua](/lua/ui/lobby/customlobby/CustomLobbyController.lua) first: host applies + `BroadcastPlayers`; a client `SendData`s a request the host validates (add a typed message in [CustomLobbyMessages.lua](/lua/ui/lobby/customlobby/CustomLobbyMessages.lua) + a `ProcessX` handler). The host stays authoritative. |
| 🚫 | Write to the model from `action` — read it, mutate via a controller intent. |
| 🚫 | Touch [CustomLobbyContextMenu.lua](/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua) — it's a generic renderer and knows nothing about the lobby. |
| 🚫 | Build the menu in the slot row — the row only calls `BuildSlotMenu(slot)`; definitions live in `CustomLobbyMenus`. |
| 🚫 | Use the `%` operator (FAF is Lua 5.0 — use `math.mod`). |

## A whole new menu surface (not just a slot item)

The renderer [CustomLobbyContextMenu.lua](/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua) is generic — slots are just its first caller. To add another surface (lobby background, observer list, options…): add a new entry list + `BuildXMenu(...)` in `CustomLobbyMenus.lua` (its own `ctx` shape), then have that control's right-click call `CustomLobbyContextMenu.Show(CustomLobbyMenus.BuildXMenu(...), event.MouseX, event.MouseY)`. Don't edit the renderer.

## Verify

An empty result simply doesn't show. Right-click a slot in each relevant state (your own / another's / open; as host and as client) and confirm the item appears only `when` it should and its intent round-trips. UI-only check: `import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`.
59 changes: 59 additions & 0 deletions .claude/skills/customlobby-model-choice/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
name: customlobby-model-choice
description: Decide which of the three CustomLobby models new state belongs in — Launch (shared + launched), Session (shared, lobby-room only), or Local (per-peer, never synced) — and keep hot-reload working. Use when adding a field/state to the custom lobby, or unsure where lobby state should live.
---

# Which CustomLobby model does this state go in?

Three reactive singletons, chosen by two questions: **is it shared (host-dictated → broadcast to everyone)?** and **does it get launched (becomes part of the game)?**

| Put it in… | Shared? | Launched? | Examples |
|---|---|---|---|
| **[CustomLobbyLaunchModel.lua](/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua)** | ✅ | ✅ | players (per-slot), observers, scenario, game options, mods, auto-teams, spawn-mex. |
| **[CustomLobbySessionModel.lua](/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua)** | ✅ | ❌ | slot count, closed slots (later: lobby title, password, kick log). |
| **[CustomLobbyLocalModel.lua](/lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua)** | ❌ | — | identity (`LocalPeerId` / `HostID` / `IsHost`), CPU benchmarks (later: ping). |

**Decide with two litmus questions:**
1. *Does the launched game consume this?* → **Launch**. (A closed slot is just empty at launch and slot count is map-derived presentation — neither reaches the scenario, so they're **Session**, not Launch.)
2. *Is it this client's own state?* → **Local**. Identity is per-peer (the host's `IsHost` is true, a client's is false); broadcasting it would corrupt the receiver — it never goes on the wire.

Everything else host-dictated that isn't launched is **Session**.

**Not all state is a model.** *Reference data* — identical on every peer and derived from disk
or computed from synced inputs — belongs in **neither**. It's a cached module, not a LazyVar
singleton on the wire. Examples: the **map catalog** ([CustomLobbyMapCatalog.lua](/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua),
enumerated scenarios), and the upcoming **option schema** (derived from `ScenarioFile` + `GameMods`).
Only the host's *choice* (the `ScenarioFile` / `GameMods` / `GameOptions` values in Launch) syncs;
each peer recomputes the catalog/schema locally. Filter/search state in a picker is per-peer UI
preference (component-local + Prefs), not a model either.

## Sync mapping (host → clients)

| Model | Message(s) | Broadcast by |
|---|---|---|
| Launch | `SetPlayers` (players + observers) and `SentLaunchInfo` (scenario / options / mods / teams / spawn) | `BroadcastPlayers` / `BroadcastLaunchInfo` |
| Session | `SetSessionState` | `BroadcastSessionState` |
| Local | — (never synced; set on the connection handshake) | — |

The host pushes **whole snapshots** on join and on change. Adding a synced field means adding it to the relevant broadcast *and* its `Process*` handler in the controller.

## Adding a field — also touch OnReload

Each model hand-copies its LazyVars in `__moduleinfo.OnReload` so a hot-reload doesn't reset live state. When you add a field:

1. add it to that model's `SetupSingleton` (a `Create(...)` with its default), and
2. **add a matching copy line in that model's `OnReload`** — miss this and the field silently resets on every save-reload (exactly when you're iterating).

## Gotchas — do / don't

| | Rule |
|---|------|
| ✅ | Route every write through the owning model's helpers (`SetPlayer`, `SetGameOption`, `AddObserver`, `SetClosed`, `SetCpuBenchmark`, …) — they keep the copy-then-`Set` discipline. |
| ✅ | Add the matching `OnReload` copy line whenever you add a field (see above). |
| ✅ | Write the shared (Launch / Session) models **only from the controller**, after the host validates; clients send a request message. |
| 🚫 | Put ping / CPU / connection churn in **Launch** or **Session** — its dirtying would ride along with a synced snapshot. It's **Local**. |
| 🚫 | Put launch-affecting state in **Session** (it won't reach the game) or **Local** (it won't be shared). If the game needs it and all peers must agree → **Launch**. |
| 🚫 | Broadcast anything from **Local** — identity/connectivity is per-peer; sending it corrupts the receiver. |
| 🚫 | Mutate a LazyVar's held table in place — always `table.copy` → mutate → `:Set` (a new table), or dependents never go dirty. |
| 🚫 | Write any model from a view/component — views read via `Derive` only; the controller is the sole writer. |
| 🚫 | Use the `%` operator (FAF is Lua 5.0 — use `math.mod`). |
5 changes: 4 additions & 1 deletion init_faf.lua
Original file line number Diff line number Diff line change
Expand Up @@ -656,4 +656,7 @@ MountDirectory(SHGetFolderPath('LOCAL_APPDATA') .. 'Gas Powered Games/Supreme Co
MountDirectory(fa_path .. "/movies", '/movies')
MountDirectory(fa_path .. "/sounds", '/sounds')
MountDirectory(fa_path .. "/maps", '/maps')
MountDirectory(fa_path .. "/fonts", '/fonts')
MountDirectory(fa_path .. "/fonts", '/fonts')

-- support for custom backgrounds in the lobby
MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds')
3 changes: 3 additions & 0 deletions init_fafbeta.lua
Original file line number Diff line number Diff line change
Expand Up @@ -642,5 +642,8 @@ MountDirectory(fa_path .. "/sounds", '/sounds')
MountDirectory(fa_path .. "/maps", '/maps')
MountDirectory(fa_path .. "/fonts", '/fonts')

-- support for custom backgrounds in the lobby
MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds')

-- Allows developers to embed code to debug a replay
table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' })
3 changes: 3 additions & 0 deletions init_fafdevelop.lua
Original file line number Diff line number Diff line change
Expand Up @@ -642,5 +642,8 @@ MountDirectory(fa_path .. "/sounds", '/sounds')
MountDirectory(fa_path .. "/maps", '/maps')
MountDirectory(fa_path .. "/fonts", '/fonts')

-- support for custom backgrounds in the lobby
MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds')

-- Allows developers to embed code to debug a replay
table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' })
2 changes: 1 addition & 1 deletion lua/lazyvar.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ local setmetatable = setmetatable

-- Set this true to get tracebacks in error messages. It slows down lazyvars a lot,
-- so don't use except when debugging.
local ExtendedErrorMessages = false
local ExtendedErrorMessages = true
local EvalContext = nil
local WeakKeyMeta = { __mode = 'k' }

Expand Down
66 changes: 66 additions & 0 deletions lua/ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,32 @@ end,

`TrashBag` is a weak table for values, so an item already destroyed elsewhere drops out automatically — `Destroy()` should always be idempotent. See [trashbag.lua:30-50](../system/trashbag.lua#L1-L50) for the contract.

### ⚠️ A `Trash:Add(Derive(...))` with no strong reference gets garbage-collected

This is the load-bearing reason the canonical pattern assigns to `self.Observer` **and** passes through `Trash:Add` — the two references do different jobs:

- `self.Observer = …` is the **strong** reference that keeps the derived LazyVar alive.
- `self.Trash:Add(…)` registers it for **teardown**, nothing more.

`Trash:Add` does **not** keep its contents alive: `TrashBag` is `__mode = 'v'` (weak *values*, [trashbag.lua:40](../system/trashbag.lua#L40)), and a LazyVar's `used_by` (the subscriber list a source walks on `:Set`) is `__mode = 'k'` (weak *keys*, [lazyvar.lua:306](../lazyvar.lua#L306)). So a derived observer reachable **only** through those two tables has no strong referrer and is eligible for collection.

```lua
-- ❌ BUG: nothing holds the derived LazyVar strongly. It fires once on creation,
-- then a GC pass collects it and it silently stops reacting to source changes.
self.Trash:Add(LazyVarDerive(model.IsHost, function(isHostLazy)
self:OnHostChanged(isHostLazy())
end))

-- ✅ CORRECT: the self field is the strong ref; Trash:Add still handles teardown.
self.IsHostObserver = self.Trash:Add(LazyVarDerive(model.IsHost, function(isHostLazy)
self:OnHostChanged(isHostLazy())
end))
```

The failure mode is nasty because it's **timing-dependent and silent**: the observer's first synchronous fire works (before any GC), so initial render looks correct; only a *later* `source:Set` — after a GC cycle — fails to propagate, with no error. (Real example: a tab's host-only gear that stayed hidden because its visibility observer was collected before `IsHost` flipped to `true` at hosting time. The source's own `OnDirty` still fired — the source was strongly held by its model singleton — but the orphaned derived observer was already gone.)

If you build observers in a loop (per row / per tab) and have no natural `self.X` name for each, keep them in a strong list field — `self.Observers = self.Observers or {}; table.insert(self.Observers, self.Trash:Add(Derive(...)))` — so the list holds them and the `Trash` still tears them down.

---

## 4. Layout — Fluent Layouter
Expand Down Expand Up @@ -224,6 +250,46 @@ self.SomeLine.Top:Set(scaled(20))

If you find yourself reaching for `ScaleNumber` a lot, that's usually a sign you should be using `Layouter` instead.

### Standard asset sizes — look them up, don't guess

A `Button` (and most textured controls) sizes to its art by default, so the texture's width *is* the
control width unless you override it. Don't eyeball these. Most textures' native (unscaled) sizes are
dumped in [`/textures/texture-dimensions.csv`](/textures/texture-dimensions.csv)
(`RelativePath, Width, Height, Format`) — grep it for the resolved path. **The `/BUTTON/` tree is not
in that CSV**; read those from the DDS header instead (`od -An -t u4 -j 12 -N 8 <file>` → `height width`).
`SkinnableFile` resolves `/BUTTON/`, `/scx_menu/`, `/dialogs/` to the active **faction** skin (`common`
is only the fallback, and loads differently); `CreateButtonStd`/`CreateButtonWithDropshadow` append
`_btn_{up,…}.dds`. Verified standard buttons (unscaled W × H): `/BUTTON/large/` **296 × 80**,
`/BUTTON/medium/` **132 × 44** (these two are the lobby's Launch/Leave buttons — **there is no
`/BUTTON/small/`**, referencing it makes the button invisible), `/scx_menu/small-btn/small` **200 × 72**
(the map/mod dialogs' button), `/dialogs/close_btn/close` and gear menu-btns **24 × 24**. Don't confuse
`/BUTTON/<size>/` with the separate `widgets/<size>_btn` family in the CSV (392/276/152 wide). Pre-scale —
multiply by the UI scale.

### Scrollbars — reserve the gutter on the content, not the bar

`UIUtil.CreateVertScrollbarFor(content, offset_right)` sets `bar.Left = content.Right + offset_right` (`AnchorToRight`). The convention here: **inset the content's right edge by the gutter and attach the bar at offset 0** — the bar then sits in that reserved strip.

```lua
Layouter(self.Content):AtLeftIn(self, 6):AtRightIn(self, 32):End() -- 32px gutter on the right
self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Content) -- offset 0 → bar sits in it
```

The standard gutter is **32px** (`/lua/ui/lobby/customlobby` is aligned to this). For a fixed-width grid the same reservation goes into the width math (`width = panelW - leftInset - 32`); right-aligned cell content (a value dropdown) then clears the bar automatically.

- **Don't double-reserve.** Use *either* a content inset *or* a negative `offset_right` (e.g. `-32` on a full-width control), never both — combined they push the bar over the content.
- **Pooled/virtualised lists** that attach the bar to themselves (`CreateVertScrollbarFor(self)`, rows full-width) put the bar just *outside* the list's right edge instead — a deliberate different idiom; a negative `offset_right` is the only way to inset those.
- **TextArea**: bind `Width` in `Initialize` (post-mount), not `__post_init` — see the TextArea gotcha in the lobby CLAUDE.md.

**The wheel only scrolls over the bar by default.** The engine routes `WheelRotation` to the scrollbar itself, so spinning the wheel over the *content* does nothing unless the content forwards it. Use `UIUtil.ForwardWheelToScroll(content, scrollable)` (chains any existing `HandleEvent`) right after creating the bar:

```lua
self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid)
UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) -- wheel over the rows now scrolls too
```

It forwards with the explicit `"Vert"` axis: a native `Grid` indexes its scroll state by axis (a `nil` axis errors in [grid.lua](../maui/grid.lua) `ScrollLines`); single-axis scrollables (`ItemList`/`TextArea`, custom lists) ignore the argument. Wheel events bubble from rows/cells to the parent `Grid`/`ItemList`, so attaching to the scrollable itself is enough — and a child that consumes the wheel (e.g. a `Combo` adjusting its value) still wins, which is what you want.

### Reusing layout files

`LayoutHelpers.*RelativeTo` reads positions from a layout `.lua` table — used by older skinned screens. New code generally prefers fluent anchors against siblings; reach for layout files only when matching an existing skinned design.
Expand Down
Loading