diff --git a/.claude/skills/add-chat-command/SKILL.md b/.claude/skills/add-chat-command/SKILL.md index 7fc5b1673e..b7fb03a9dc 100644 --- a/.claude/skills/add-chat-command/SKILL.md +++ b/.claude/skills/add-chat-command/SKILL.md @@ -74,7 +74,7 @@ A fully-specified ask ("add `/ping` that prints 'pong' locally") needs no Q&A | βœ… | Route side effects through `ctx.Controller` to preserve MVC discipline (calls below). | | βœ… | Call LazyVars at the moment of use: `ctx.Model.Recipient()`. | | 🚫 | Write to `ChatModel` from a command β€” read the model, mutate via the controller. | -| 🚫 | Cache a LazyVar in a local (see `CLAUDE.md` Β§ Reactive State). | +| 🚫 | Cache a LazyVar in a local (see `AGENTS.md` Β§ Reactive State). | | Goal | Call | |------|------| diff --git a/changelog/snippets/other.7166.md b/changelog/snippets/other.7166.md new file mode 100644 index 0000000000..3f8b4b317e --- /dev/null +++ b/changelog/snippets/other.7166.md @@ -0,0 +1 @@ +- (#7166) Rename `CLAUDE.md` to `AGENTS.md` for cross-agent compatibility. diff --git a/lua/ui/AGENTS.md b/lua/ui/AGENTS.md new file mode 100644 index 0000000000..b26e246e66 --- /dev/null +++ b/lua/ui/AGENTS.md @@ -0,0 +1,395 @@ +# UI β€” General Patterns + +This doc covers conventions that apply to **any** UI work in `/lua/ui` β€” control authoring, layout, reactivity, and lifecycle. Folder-specific docs (e.g. [`game/chat/AGENTS.md`](game/chat/AGENTS.md)) extend or specialize these rules; if you're working on chat, options dialogs, lobby UI, etc., read the local AGENTS.md too. + +The patterns described here came out of refactoring the in-game chat in 2026. Some older modules (legacy lobby, notify, etc.) predate them and won't match β€” when extending those modules, follow the rules here for **new** code rather than mirroring the existing style. + +--- + +## 1. Class Construction β€” `__init` vs `__post_init` + +Every UI class built with `ClassUI(...)` has two construction hooks. The factory in [class.lua:582-589](../system/class.lua#L582-L589) calls them in order, with the same arguments, on a freshly metatabled instance. **Both run before the constructor returns**, so callers see a fully initialized control either way β€” the split exists for ordering inside the class. + +| Hook | Purpose | +|------|---------| +| `__init` | **Build state and children.** Allocate `self.X` fields, instantiate child controls, register hooks, derive observers, initialize plain-Lua bookkeeping (counters, tables, the `TrashBag`). | +| `__post_init` | **Lay out the tree.** Call the fluent layouter on the children built in `__init`. Do work that depends on the parent rect being bound. | + +### Why the split exists + +MAUI controls don't have concrete pixel positions β€” `Left`, `Right`, `Top`, `Bottom`, `Width`, `Height` are LazyVars whose compute functions reference each other (see `Control.ResetLayout` in [control.lua:35-42](../maui/control.lua#L35-L42)). Reading any one of them runs the chain. If a child has no anchor against a parent yet, that chain is **circular**, so calling `child.Width()` returns 0 (or worse, errors). + +In `__init`, the parent hasn't been anchored against *its* parent yet either. So: + +- Anything that just **stores** a layout binding (`self.Foo:Set(function() return ... end)`) is fine in either hook. +- Anything that **evaluates** layout to a concrete number (`Pool.Height()`, `Width()`, sizing a fixed-count pool) must wait until `__post_init` β€” or even later, see "Three-phase init" below. + +### Canonical shape + +```lua +---@class UIMyPanel : Group +---@field Trash TrashBag +---@field Header Text +---@field Body Group +---@field Observer LazyVar +local MyPanel = ClassUI(Group) { + + ---@param self UIMyPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "MyPanel") + + self.Trash = TrashBag() + self.Header = UIUtil.CreateText(self, "Title", 14, UIUtil.bodyFont) + self.Body = Group(self, "MyPanelBody") + + -- Reactive subscription: safe in __init because Derive only registers + -- the dependency edge β€” it doesn't read any layout values. + self.Observer = self.Trash:Add(LazyVarDerive(SomeModel.Foo, function(fooLazy) + self:OnFooChanged(fooLazy()) + end)) + end, + + ---@param self UIMyPanel + __post_init = function(self, parent) + Layouter(self.Header):AtLeftTopIn(self, 4):End() + Layouter(self.Body) + :AnchorToBottom(self.Header, 4) + :AtLeftRightIn(self) + :AtBottomIn(self) + :End() + end, + + OnDestroy = function(self) + self.Trash:Destroy() + end, +} +``` + +### Three-phase init: when even `__post_init` is too early + +If a control's layout depends on the **parent** sizing it (e.g. building a fixed-count pool from a `Height()` evaluation), `__post_init` still fires before the parent has laid the child out. The fix is a public `Initialize()` (or similarly named) method the parent calls *after* anchoring. Real example in [game/chat/ChatLinesInterface.lua:180-191](game/chat/ChatLinesInterface.lua#L180-L191): the pool's `OptionsObserver` is wired in `Initialize`, not `__post_init`, because its first fire reads `Pool.Height()`. + +Reach for this only when needed β€” most controls are happy with the two-phase split. + +### Rules + +- **Always call the parent class's `__init` first** in your own `__init`. It creates the C-side control; without it nothing else works. +- **Never read concrete layout values (`child.Width()`, `Height()`, …) in `__init`.** They return zero or trip the circular-evaluation guard. Defer to `__post_init` or a later method. +- **Never apply layout in `__init`.** The exception is when something downstream forces an early read β€” e.g. `SetupEditStd` reads bounds before `__post_init` runs. In that case, set placeholder values in `__init` and replace them in `__post_init`. See [game/chat/ChatEditInterface.lua:108-113](game/chat/ChatEditInterface.lua#L108-L113). +- **Always declare every `self.X` field on the class.** Every field assigned in `__init` / `__post_init` gets a matching `---@field` immediately above `ClassUI(...)`. See [annotation.md](../../annotation.md) for the project-wide annotation conventions. + +--- + +## 2. Reactivity β€” LazyVars and `Derive` + +LazyVars (defined in [`/lua/lazyvar.lua`](../lazyvar.lua)) are the reactivity primitive across the engine. The MAUI layout system is built on them; you can build feature reactivity on top of them too. + +### Mental model + +A LazyVar is a value that **knows who depends on it**. Reading it (calling it like a function) registers the caller as a dependent. Writing it (`:Set(x)` or `:Set(function() ... end)`) walks the dependency graph and fires `OnDirty` on everything that ever read it. + +This means: if your code "needs to update when X changes," wire it up as a LazyVar dependency once. The engine handles the propagation. You never poll. + +### Naming convention + +A LazyVar is **not** the value it holds β€” it's a handle to a value that may change. Reflect that in the name: suffix LazyVar locals and parameters with `Lazy`, and read into a plainly-named local at the moment of use. + +```lua +local recipientLazy = Create('all') -- the LazyVar (a handle) +local recipient = recipientLazy() -- the value (right now) +``` + +This applies to handler parameters too. Don't use `lv` β€” name the parameter after what it represents (`recipientLazy`, `optionsLazy`, `historyLazy`), then read it into a local at the top of the handler. + +### Three calls you actually use + +```lua +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive + +-- Static value +local recipientLazy = Create('all') -- holds 'all' +recipientLazy:Set('allies') -- fires OnDirty on dependents +print(recipientLazy()) -- 'allies' β€” call to read + +-- Computed value (re-evaluates whenever its inputs change) +local labelLazy = Create() +labelLazy:Set(function() return "To: " .. recipientLazy() end) + +-- Subscribe to a LazyVar you don't own (typical inside __init) +self.RecipientObserver = self.Trash:Add(Derive(model.Recipient, function(recipientLazy) + local recipient = recipientLazy() + self.Label:SetText("To: " .. recipient) +end)) +-- The observer is itself a LazyVar; routing it through Trash:Add ensures it +-- gets destroyed (and its OnDirty unhooked) when the owning control is. +``` + +Reading the value into a local at the top of the handler is the convention even for single uses β€” it keeps the code grep-friendly and avoids walking the dependency graph twice if you read the value more than once. + +### The `Derive` rule + +**Never assign `OnDirty` directly on a LazyVar you don't own.** Direct assignment overwrites whatever handler was there before, silently breaking unrelated code. Always `Derive`. The Derive function bundles the safe three-step dance (create new LazyVar, hang OnDirty on it, Set a reader) into one call. See the rationale and the `Trash:Add` integration in [game/chat/AGENTS.md Β§ Reactive State](game/chat/AGENTS.md). + +### Don't use `OnFrame` for reactivity, unless strictly necessary + +`OnFrame` exists for genuine **per-frame work** β€” animation, smooth interpolation, time-based polling against the wall clock. It is the wrong tool for "X changed β†’ update Y." + +| Need | Use | +|------|-----| +| "Re-render the recipient label when `model.Recipient` changes" | `Derive(model.Recipient, ...)` | +| "Animate this bitmap's alpha over 0.3 s" | `OnFrame` | +| "Recompute total when any item changes" | LazyVar with `Set(function() return sum(...) end)` | +| "Auto-hide the chat 15 s after the last message" | `OnFrame` polling `GetSystemTimeSeconds() - model.LastActivity()` | +| "When the user clicks a row, scroll to bottom" | Direct call from the click handler β€” neither | + +Why this matters: an `OnFrame` poll runs every frame even when nothing changed; a LazyVar dependency only re-runs when an input is `Set`. With dozens of UI controls, the difference is real frame budget. + +When you do need `OnFrame`, remember it is gated by `SetNeedsFrameUpdate(true)` β€” controls don't tick by default. Toggle it with the visibility/enabled state of the work it drives so you don't pay for an idle timer (chat does this in [game/chat/ChatInterface.lua:140-145, 369-377](game/chat/ChatInterface.lua#L369-L377)). + +### Reactivity rules at a glance + +1. **Don't cache a LazyVar's value in a local outside of OnDirty.** Always call it at the moment you need it so the dependency edge stays correct. *Inside* an OnDirty handler, storing the value once for readability is fine β€” the dependency was already registered when the handler was wired up. +2. **`OnDirty` is a pull notification.** It tells you the value *may* have changed; call into the LazyVar inside the handler to actually read. +3. **Use `Derive`, never raw `OnDirty =`,** on any LazyVar you didn't create. +4. **Don't mutate a LazyVar's held table in place.** Build a new table and `Set` it β€” otherwise dependents never go dirty (the value identity didn't change). +5. **Destroy your derived observers** via TrashBag in `OnDestroy`. Dangling `OnDirty` callbacks keep the dependent's frame alive in `used_by` and run forever. + +### Models and controllers + +Models and controllers are module singletons. Import them at the top of any file that needs them β€” never thread them through constructors or callback tables. Direct imports keep dependencies visible at the top of the file and avoid the autolobby's "prop drilling" pattern. See [game/chat/AGENTS.md Β§ Imports vs callbacks](game/chat/AGENTS.md) for the chat refactor's specific framing. + +--- + +## 3. Lifecycle and Cleanup β€” `TrashBag` + +Anything you allocate that has a `Destroy()` (or anything that needs explicit teardown β€” coroutines, timers, derived LazyVars) goes in a `TrashBag` ([trashbag.lua](../system/trashbag.lua)). One bag per control: + +```lua +__init = function(self, parent) + Group.__init(self, parent, "Foo") + self.Trash = TrashBag() + + -- Trash:Add returns what you pass it, so the assignment stays a one-liner: + self.Observer = self.Trash:Add(LazyVarDerive(model.X, function(xLazy) + self:OnXChanged(xLazy()) + end)) +end, + +OnDestroy = function(self) + self.Trash:Destroy() +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. + +--- + +## 4. Layout β€” Fluent Layouter + +Layout in `__post_init` is written through the fluent builder returned by `LayoutHelpers.ReusedLayoutFor` (aliased as `Layouter` by convention): + +```lua +local Layouter = LayoutHelpers.ReusedLayoutFor + +Layouter(self.Body) + :AnchorToBottom(self.Header, 4) + :AtLeftRightIn(self, 8) + :AtBottomIn(self, 4) + :End() +``` + +Conventions: + +- **Always call `:End()`** β€” the builder is reusable and `End` releases it back to the pool. +- **Anchor against parents and siblings, not absolute pixels.** Width/height of children should derive from the parent's rect or a sibling's edge. +- **Use `LayoutHelpers.AnchorTo*` for sibling adjacency**, `:AtLeftIn`/`:AtRightIn`/etc. for pinning into a parent. Padding goes as the trailing argument. +- **Don't store layouter references on `self`.** They are pooled and reused by other controls after `End`. + +Full operator catalog lives in [`/lua/maui/layouthelpers.lua`](../maui/layouthelpers.lua) β€” search for `function ` to see the available `AnchorToX` / `AtXIn` / `Fill` / `Over` / `From*In` / `Percent*` calls. + +### UI scaling + +The engine scales the entire UI by the user's `ui_scale` setting. The fluent `Layouter` runs every numeric padding through `LayoutHelpers.ScaleNumber` automatically, so the values in the examples above are in *unscaled* pixels β€” the output adapts to any scale. + +When you pass numbers to anything **other** than the layouter (manual `:Set(...)`, fixed-size bitmaps, custom layout maths, font sizes through `SetFont`), wrap them in `LayoutHelpers.ScaleNumber` so they scale alongside the rest of the UI: + +```lua +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local scaled = LayoutHelpers.ScaleNumber + +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. + +### 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. + +--- + +## 5. Skinning β€” `SkinnableFile` vs `UIFile` + +Texture paths come from one of two helpers in [`/lua/ui/uiutil.lua`](uiutil.lua): + +| Helper | Returns | Use for | +|--------|---------|---------| +| `UIUtil.SkinnableFile(path)` | a callable that resolves against the current skin **on every read** | anything that should follow the user's skin choice β€” chrome, icons, decorations | +| `UIUtil.UIFile(path)` | a string, frozen at module-load time | assets that aren't skin-themed (debug overlays, fixed brand graphics) | + +`SkinnableFile` shines because MAUI bitmap setters accept LazyVar/callable inputs β€” bind a skinnable path through the layouter and the texture hot-swaps when the skin changes: + +```lua +local WindowTextures = { + tl = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ul.dds'), + tm = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_horz_um.dds'), + -- ... +} +``` + +Real example: [game/chat/ChatInterface.lua:31-42](game/chat/ChatInterface.lua#L31-L42). + +**Do not** use string paths or `UIFile` for skinnable assets. They freeze at module-load time, so the texture is whatever the skin was when the file was first imported β€” even if the user changes skin afterwards. Switching to `SkinnableFile` is usually a one-line fix. + +--- + +## 6. Base Components Reference + +When building UI, prefer existing components over rolling your own. Two layers: + +### 6.1 MAUI primitives (`/lua/maui/`) + +Thin Lua wrappers over the C-side `moho.*_methods` controls. These are the leaves of the control tree. + +| File | Class | Use for | +|------|-------|---------| +| [bitmap.lua](../maui/bitmap.lua) | `Bitmap` | Solid colour, single texture, or skinnable image | +| [border.lua](../maui/border.lua) | `Border` | Nine-slice border using a single texture set | +| [button.lua](../maui/button.lua) | `Button` | Up/over/down/disabled state textures | +| [checkbox.lua](../maui/checkbox.lua) | `Checkbox` | Two-state toggle with hover textures | +| [control.lua](../maui/control.lua) | `Control` | Base class β€” all other controls inherit | +| [cursor.lua](../maui/cursor.lua) | `Cursor` | Custom mouse cursor with hotspot | +| [dragger.lua](../maui/dragger.lua) | `Dragger` | Mouse-drag interaction handler | +| [edit.lua](../maui/edit.lua) | `Edit` | Single-line text input | +| [frame.lua](../maui/frame.lua) | `Frame` | Top-level engine frame (rare; use `GetFrame(0)`) | +| [grid.lua](../maui/grid.lua) | `Grid` | Fixed-cell grid layout | +| [group.lua](../maui/group.lua) | `Group` | Invisible container; the workhorse parent for laying out children | +| [histogram.lua](../maui/histogram.lua) | `Histogram` | Bar-chart visualization | +| [itemlist.lua](../maui/itemlist.lua) | `ItemList` | Scrollable list of strings (legacy; consider a custom Group + pool) | +| [mesh.lua](../maui/mesh.lua) | `Mesh` | Embedded 3D mesh viewport | +| [movie.lua](../maui/movie.lua) | `Movie` | Video playback control | +| [multilinetext.lua](../maui/multilinetext.lua) | `MultiLineText` | Word-wrapped text block | +| [radiobuttons.lua](../maui/radiobuttons.lua) | `RadioButtons` | Mutually exclusive button group | +| [scrollbar.lua](../maui/scrollbar.lua) | `Scrollbar` | Pair with a scrollable control via `SetScrollable` | +| [slider.lua](../maui/slider.lua) | `Slider` / `IntegerSlider` | Continuous or stepped value picker | +| [statusbar.lua](../maui/statusbar.lua) | `StatusBar` | Progress bar with min/max | +| [text.lua](../maui/text.lua) | `Text` | Single-line text run | +| [window.lua](../maui/window.lua) | `Window` | Draggable, optionally resizable framed dialog with title bar + client area | + +### 6.2 UI controls (`/lua/ui/controls/`) + +Higher-level compositions built on the primitives. Use these when one fits β€” they bake in standard skinning and behaviour. + +| File | Class | Use for | +|------|-------|---------| +| [acubutton.lua](controls/acubutton.lua) | `ACUButton` | Faction-coloured ACU portrait button (lobby) | +| [border.lua](controls/border.lua) | `Border` | Themed nine-patch border | +| [checkbox.lua](controls/checkbox.lua) | `Checkbox` | Skinned checkbox with label support | +| [columnlayout.lua](controls/columnlayout.lua) | `ColumnLayout` | Auto-aligned column container | +| [combo.lua](controls/combo.lua) | `Combo` / `BitmapCombo` | Dropdown picker (text or bitmap entries) | +| [filepicker.lua](controls/filepicker.lua) | `FilePicker` | File-browser dialog | +| [mappreview.lua](controls/mappreview.lua) | `MapPreview` | Map thumbnail with markers | +| [ninepatch.lua](controls/ninepatch.lua) | `NinePatch` | Nine-slice scalable image | +| [radiobutton.lua](controls/radiobutton.lua) | `RadioButton` | Skinned single radio button | +| [resmappreview.lua](controls/resmappreview.lua) | `ResMapPreview` | Resource-mode map preview | +| [reticle.lua](controls/reticle.lua) | `Reticle` | World-space selection reticle | +| [specialgrid.lua](controls/specialgrid.lua) | `SpecialGrid` | Specialized grid for unit panels | +| [textarea.lua](controls/textarea.lua) | `TextArea` | Scrollable multi-line text display | +| [togglebutton.lua](controls/togglebutton.lua) | `ToggleButton` | Two-state pressed/unpressed button | +| [worldmesh.lua](controls/worldmesh.lua) | `WorldMesh` | World-anchored 3D mesh | +| [worldview.lua](controls/worldview.lua) | `WorldView` | Embedded world camera viewport | +| [popups/popup.lua](controls/popups/popup.lua) | `Popup` | Modal dialog wrapper | +| [popups/inputdialog.lua](controls/popups/inputdialog.lua) | `InputDialog` | Modal text-prompt dialog | + +Anything not in this table β€” pull it from `/lua/maui/` if it's a primitive, or build it inline in your feature folder if it's a one-off composition. Don't add new files to `/lua/ui/controls/` unless the new control is genuinely reusable across features. + +--- + +## 7. Debugging + +Two patterns are worth standardizing across UI work. + +### 7.1 Layout-bounds overlay + +Each interface file in `/lua/ui/game/chat` declares a module-level `local Debug = false` flag. When flipped to `true`, `__post_init` adds a semi-transparent coloured `Bitmap` (`DebugBG`) covering the control's bounds β€” invaluable for reasoning about which control owns which rect. + +```lua +local Debug = false -- flip to true to visualise this control's bounds + +-- inside __post_init: +if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('40ff4040') -- distinct ARGB per file + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() +end +``` + +Each file uses a distinct ARGB so overlapping controls can be told apart at a glance. `DisableHitTest` keeps the overlay from intercepting clicks; `:Over(self, 100)` lifts it above the control's own children. + +`DebugBG` is annotated as an optional field on the class (`---@field DebugBG? Bitmap`) so the language server stays accurate. When `Debug` is false the field is never assigned β€” that's the entire point. + +### 7.2 Hot reload + +Top-level UI modules (the ones invoked from a hotkey) can hook into the engine's module manager so saving the file rebuilds the open window without restarting the game. Add this block at the bottom of the module: + +```lua + +------------------------------------------------------------------------------- +--#region Debugging + +--- Called by the module manager when this module is reloaded. +---@param newModule any +function __moduleinfo.OnReload(newModule) + newModule.Open() +end + +--- Called by the module manager when this module becomes dirty. +function __moduleinfo.OnDirty() + if Instance then + -- `OnDestroy` empties the trash bag, which in turn destroys every + -- derived observer β€” no more `OnDirty` fires into a dead `self`. + Instance:Destroy() + Instance = nil + end + + ForkThread(function() + WaitFrames(2) + import(__moduleinfo.name) + end) +end + +--#endregion +``` + +`OnDirty` fires when the file is saved on disk: tear down the existing instance (which destroys the `TrashBag` and unhooks every observer) and re-import the module after a couple of frames. `OnReload` runs on the freshly-loaded module and reopens the window, restoring the prior visible state. + +This only works if your module follows the standalone-invocation convention (a module-level `Open()` / `Close()` / `Toggle()` and an `Instance` local). Without that, re-importing has nothing to call. + +--- + +## What else this doc could cover + +The seven sections above are the load-bearing patterns. Candidates for follow-up additions, in rough order of value: + +1. **Standalone invocation convention** β€” every top-level UI module should export a module-level `Toggle()` / `Open()` / `Close()` that's safe to call from the keybind table or console. Currently documented only in [game/chat/AGENTS.md Β§ Standalone Invocation](game/chat/AGENTS.md). Lifting this here would let every new feature inherit the convention without re-explaining it (and the hot-reload block in Β§ 7.2 already assumes it). +2. **Tooltips** β€” `Tooltip.AddButtonTooltip` / `AddCheckboxTooltip` / `AddControlTooltip` and how their string keys resolve through the localization tables. +3. **Localization** β€” `fallback` strings and `LOC` / `LOCF` helpers; when text is user-visible, it must go through the LOC system. +4. **Hit testing** β€” `DisableHitTest()` on visual-only overlays is easy to forget and produces baffling click-through bugs. One paragraph would save a future debugging session. +5. **Render order** β€” `:Over(other, depth)` and `Depth` LazyVars; when stacking decorations or popups, the rules for keeping them above their owners. + +Suggestion: do **(1) Standalone invocation** next β€” the hot-reload pattern in Β§ 7.2 already references it implicitly, so codifying it removes a forward-reference. **(2)–(5)** are useful but lower priority β€” write them when the next bug or feature surfaces them. + +Class field annotations (every `self.X` in `__init` / `__post_init` gets a matching `---@field`) live in the project-wide [`annotation.md Β§ Class fields`](../../annotation.md) β€” see Β§ 1 Rules for the inline reminder. diff --git a/lua/ui/CLAUDE.md b/lua/ui/CLAUDE.md index 4abd43add8..eef4bd20cf 100644 --- a/lua/ui/CLAUDE.md +++ b/lua/ui/CLAUDE.md @@ -1,395 +1 @@ -# UI β€” General Patterns - -This doc covers conventions that apply to **any** UI work in `/lua/ui` β€” control authoring, layout, reactivity, and lifecycle. Folder-specific docs (e.g. [`game/chat/CLAUDE.md`](game/chat/CLAUDE.md)) extend or specialize these rules; if you're working on chat, options dialogs, lobby UI, etc., read the local CLAUDE.md too. - -The patterns described here came out of refactoring the in-game chat in 2026. Some older modules (legacy lobby, notify, etc.) predate them and won't match β€” when extending those modules, follow the rules here for **new** code rather than mirroring the existing style. - ---- - -## 1. Class Construction β€” `__init` vs `__post_init` - -Every UI class built with `ClassUI(...)` has two construction hooks. The factory in [class.lua:582-589](../system/class.lua#L582-L589) calls them in order, with the same arguments, on a freshly metatabled instance. **Both run before the constructor returns**, so callers see a fully initialized control either way β€” the split exists for ordering inside the class. - -| Hook | Purpose | -|------|---------| -| `__init` | **Build state and children.** Allocate `self.X` fields, instantiate child controls, register hooks, derive observers, initialize plain-Lua bookkeeping (counters, tables, the `TrashBag`). | -| `__post_init` | **Lay out the tree.** Call the fluent layouter on the children built in `__init`. Do work that depends on the parent rect being bound. | - -### Why the split exists - -MAUI controls don't have concrete pixel positions β€” `Left`, `Right`, `Top`, `Bottom`, `Width`, `Height` are LazyVars whose compute functions reference each other (see `Control.ResetLayout` in [control.lua:35-42](../maui/control.lua#L35-L42)). Reading any one of them runs the chain. If a child has no anchor against a parent yet, that chain is **circular**, so calling `child.Width()` returns 0 (or worse, errors). - -In `__init`, the parent hasn't been anchored against *its* parent yet either. So: - -- Anything that just **stores** a layout binding (`self.Foo:Set(function() return ... end)`) is fine in either hook. -- Anything that **evaluates** layout to a concrete number (`Pool.Height()`, `Width()`, sizing a fixed-count pool) must wait until `__post_init` β€” or even later, see "Three-phase init" below. - -### Canonical shape - -```lua ----@class UIMyPanel : Group ----@field Trash TrashBag ----@field Header Text ----@field Body Group ----@field Observer LazyVar -local MyPanel = ClassUI(Group) { - - ---@param self UIMyPanel - ---@param parent Control - __init = function(self, parent) - Group.__init(self, parent, "MyPanel") - - self.Trash = TrashBag() - self.Header = UIUtil.CreateText(self, "Title", 14, UIUtil.bodyFont) - self.Body = Group(self, "MyPanelBody") - - -- Reactive subscription: safe in __init because Derive only registers - -- the dependency edge β€” it doesn't read any layout values. - self.Observer = self.Trash:Add(LazyVarDerive(SomeModel.Foo, function(fooLazy) - self:OnFooChanged(fooLazy()) - end)) - end, - - ---@param self UIMyPanel - __post_init = function(self, parent) - Layouter(self.Header):AtLeftTopIn(self, 4):End() - Layouter(self.Body) - :AnchorToBottom(self.Header, 4) - :AtLeftRightIn(self) - :AtBottomIn(self) - :End() - end, - - OnDestroy = function(self) - self.Trash:Destroy() - end, -} -``` - -### Three-phase init: when even `__post_init` is too early - -If a control's layout depends on the **parent** sizing it (e.g. building a fixed-count pool from a `Height()` evaluation), `__post_init` still fires before the parent has laid the child out. The fix is a public `Initialize()` (or similarly named) method the parent calls *after* anchoring. Real example in [game/chat/ChatLinesInterface.lua:180-191](game/chat/ChatLinesInterface.lua#L180-L191): the pool's `OptionsObserver` is wired in `Initialize`, not `__post_init`, because its first fire reads `Pool.Height()`. - -Reach for this only when needed β€” most controls are happy with the two-phase split. - -### Rules - -- **Always call the parent class's `__init` first** in your own `__init`. It creates the C-side control; without it nothing else works. -- **Never read concrete layout values (`child.Width()`, `Height()`, …) in `__init`.** They return zero or trip the circular-evaluation guard. Defer to `__post_init` or a later method. -- **Never apply layout in `__init`.** The exception is when something downstream forces an early read β€” e.g. `SetupEditStd` reads bounds before `__post_init` runs. In that case, set placeholder values in `__init` and replace them in `__post_init`. See [game/chat/ChatEditInterface.lua:108-113](game/chat/ChatEditInterface.lua#L108-L113). -- **Always declare every `self.X` field on the class.** Every field assigned in `__init` / `__post_init` gets a matching `---@field` immediately above `ClassUI(...)`. See [annotation.md](../../annotation.md) for the project-wide annotation conventions. - ---- - -## 2. Reactivity β€” LazyVars and `Derive` - -LazyVars (defined in [`/lua/lazyvar.lua`](../lazyvar.lua)) are the reactivity primitive across the engine. The MAUI layout system is built on them; you can build feature reactivity on top of them too. - -### Mental model - -A LazyVar is a value that **knows who depends on it**. Reading it (calling it like a function) registers the caller as a dependent. Writing it (`:Set(x)` or `:Set(function() ... end)`) walks the dependency graph and fires `OnDirty` on everything that ever read it. - -This means: if your code "needs to update when X changes," wire it up as a LazyVar dependency once. The engine handles the propagation. You never poll. - -### Naming convention - -A LazyVar is **not** the value it holds β€” it's a handle to a value that may change. Reflect that in the name: suffix LazyVar locals and parameters with `Lazy`, and read into a plainly-named local at the moment of use. - -```lua -local recipientLazy = Create('all') -- the LazyVar (a handle) -local recipient = recipientLazy() -- the value (right now) -``` - -This applies to handler parameters too. Don't use `lv` β€” name the parameter after what it represents (`recipientLazy`, `optionsLazy`, `historyLazy`), then read it into a local at the top of the handler. - -### Three calls you actually use - -```lua -local Create = import("/lua/lazyvar.lua").Create -local Derive = import("/lua/lazyvar.lua").Derive - --- Static value -local recipientLazy = Create('all') -- holds 'all' -recipientLazy:Set('allies') -- fires OnDirty on dependents -print(recipientLazy()) -- 'allies' β€” call to read - --- Computed value (re-evaluates whenever its inputs change) -local labelLazy = Create() -labelLazy:Set(function() return "To: " .. recipientLazy() end) - --- Subscribe to a LazyVar you don't own (typical inside __init) -self.RecipientObserver = self.Trash:Add(Derive(model.Recipient, function(recipientLazy) - local recipient = recipientLazy() - self.Label:SetText("To: " .. recipient) -end)) --- The observer is itself a LazyVar; routing it through Trash:Add ensures it --- gets destroyed (and its OnDirty unhooked) when the owning control is. -``` - -Reading the value into a local at the top of the handler is the convention even for single uses β€” it keeps the code grep-friendly and avoids walking the dependency graph twice if you read the value more than once. - -### The `Derive` rule - -**Never assign `OnDirty` directly on a LazyVar you don't own.** Direct assignment overwrites whatever handler was there before, silently breaking unrelated code. Always `Derive`. The Derive function bundles the safe three-step dance (create new LazyVar, hang OnDirty on it, Set a reader) into one call. See the rationale and the `Trash:Add` integration in [game/chat/CLAUDE.md Β§ Reactive State](game/chat/CLAUDE.md). - -### Don't use `OnFrame` for reactivity, unless strictly necessary - -`OnFrame` exists for genuine **per-frame work** β€” animation, smooth interpolation, time-based polling against the wall clock. It is the wrong tool for "X changed β†’ update Y." - -| Need | Use | -|------|-----| -| "Re-render the recipient label when `model.Recipient` changes" | `Derive(model.Recipient, ...)` | -| "Animate this bitmap's alpha over 0.3 s" | `OnFrame` | -| "Recompute total when any item changes" | LazyVar with `Set(function() return sum(...) end)` | -| "Auto-hide the chat 15 s after the last message" | `OnFrame` polling `GetSystemTimeSeconds() - model.LastActivity()` | -| "When the user clicks a row, scroll to bottom" | Direct call from the click handler β€” neither | - -Why this matters: an `OnFrame` poll runs every frame even when nothing changed; a LazyVar dependency only re-runs when an input is `Set`. With dozens of UI controls, the difference is real frame budget. - -When you do need `OnFrame`, remember it is gated by `SetNeedsFrameUpdate(true)` β€” controls don't tick by default. Toggle it with the visibility/enabled state of the work it drives so you don't pay for an idle timer (chat does this in [game/chat/ChatInterface.lua:140-145, 369-377](game/chat/ChatInterface.lua#L369-L377)). - -### Reactivity rules at a glance - -1. **Don't cache a LazyVar's value in a local outside of OnDirty.** Always call it at the moment you need it so the dependency edge stays correct. *Inside* an OnDirty handler, storing the value once for readability is fine β€” the dependency was already registered when the handler was wired up. -2. **`OnDirty` is a pull notification.** It tells you the value *may* have changed; call into the LazyVar inside the handler to actually read. -3. **Use `Derive`, never raw `OnDirty =`,** on any LazyVar you didn't create. -4. **Don't mutate a LazyVar's held table in place.** Build a new table and `Set` it β€” otherwise dependents never go dirty (the value identity didn't change). -5. **Destroy your derived observers** via TrashBag in `OnDestroy`. Dangling `OnDirty` callbacks keep the dependent's frame alive in `used_by` and run forever. - -### Models and controllers - -Models and controllers are module singletons. Import them at the top of any file that needs them β€” never thread them through constructors or callback tables. Direct imports keep dependencies visible at the top of the file and avoid the autolobby's "prop drilling" pattern. See [game/chat/CLAUDE.md Β§ Imports vs callbacks](game/chat/CLAUDE.md) for the chat refactor's specific framing. - ---- - -## 3. Lifecycle and Cleanup β€” `TrashBag` - -Anything you allocate that has a `Destroy()` (or anything that needs explicit teardown β€” coroutines, timers, derived LazyVars) goes in a `TrashBag` ([trashbag.lua](../system/trashbag.lua)). One bag per control: - -```lua -__init = function(self, parent) - Group.__init(self, parent, "Foo") - self.Trash = TrashBag() - - -- Trash:Add returns what you pass it, so the assignment stays a one-liner: - self.Observer = self.Trash:Add(LazyVarDerive(model.X, function(xLazy) - self:OnXChanged(xLazy()) - end)) -end, - -OnDestroy = function(self) - self.Trash:Destroy() -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. - ---- - -## 4. Layout β€” Fluent Layouter - -Layout in `__post_init` is written through the fluent builder returned by `LayoutHelpers.ReusedLayoutFor` (aliased as `Layouter` by convention): - -```lua -local Layouter = LayoutHelpers.ReusedLayoutFor - -Layouter(self.Body) - :AnchorToBottom(self.Header, 4) - :AtLeftRightIn(self, 8) - :AtBottomIn(self, 4) - :End() -``` - -Conventions: - -- **Always call `:End()`** β€” the builder is reusable and `End` releases it back to the pool. -- **Anchor against parents and siblings, not absolute pixels.** Width/height of children should derive from the parent's rect or a sibling's edge. -- **Use `LayoutHelpers.AnchorTo*` for sibling adjacency**, `:AtLeftIn`/`:AtRightIn`/etc. for pinning into a parent. Padding goes as the trailing argument. -- **Don't store layouter references on `self`.** They are pooled and reused by other controls after `End`. - -Full operator catalog lives in [`/lua/maui/layouthelpers.lua`](../maui/layouthelpers.lua) β€” search for `function ` to see the available `AnchorToX` / `AtXIn` / `Fill` / `Over` / `From*In` / `Percent*` calls. - -### UI scaling - -The engine scales the entire UI by the user's `ui_scale` setting. The fluent `Layouter` runs every numeric padding through `LayoutHelpers.ScaleNumber` automatically, so the values in the examples above are in *unscaled* pixels β€” the output adapts to any scale. - -When you pass numbers to anything **other** than the layouter (manual `:Set(...)`, fixed-size bitmaps, custom layout maths, font sizes through `SetFont`), wrap them in `LayoutHelpers.ScaleNumber` so they scale alongside the rest of the UI: - -```lua -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local scaled = LayoutHelpers.ScaleNumber - -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. - -### 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. - ---- - -## 5. Skinning β€” `SkinnableFile` vs `UIFile` - -Texture paths come from one of two helpers in [`/lua/ui/uiutil.lua`](uiutil.lua): - -| Helper | Returns | Use for | -|--------|---------|---------| -| `UIUtil.SkinnableFile(path)` | a callable that resolves against the current skin **on every read** | anything that should follow the user's skin choice β€” chrome, icons, decorations | -| `UIUtil.UIFile(path)` | a string, frozen at module-load time | assets that aren't skin-themed (debug overlays, fixed brand graphics) | - -`SkinnableFile` shines because MAUI bitmap setters accept LazyVar/callable inputs β€” bind a skinnable path through the layouter and the texture hot-swaps when the skin changes: - -```lua -local WindowTextures = { - tl = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ul.dds'), - tm = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_horz_um.dds'), - -- ... -} -``` - -Real example: [game/chat/ChatInterface.lua:31-42](game/chat/ChatInterface.lua#L31-L42). - -**Do not** use string paths or `UIFile` for skinnable assets. They freeze at module-load time, so the texture is whatever the skin was when the file was first imported β€” even if the user changes skin afterwards. Switching to `SkinnableFile` is usually a one-line fix. - ---- - -## 6. Base Components Reference - -When building UI, prefer existing components over rolling your own. Two layers: - -### 6.1 MAUI primitives (`/lua/maui/`) - -Thin Lua wrappers over the C-side `moho.*_methods` controls. These are the leaves of the control tree. - -| File | Class | Use for | -|------|-------|---------| -| [bitmap.lua](../maui/bitmap.lua) | `Bitmap` | Solid colour, single texture, or skinnable image | -| [border.lua](../maui/border.lua) | `Border` | Nine-slice border using a single texture set | -| [button.lua](../maui/button.lua) | `Button` | Up/over/down/disabled state textures | -| [checkbox.lua](../maui/checkbox.lua) | `Checkbox` | Two-state toggle with hover textures | -| [control.lua](../maui/control.lua) | `Control` | Base class β€” all other controls inherit | -| [cursor.lua](../maui/cursor.lua) | `Cursor` | Custom mouse cursor with hotspot | -| [dragger.lua](../maui/dragger.lua) | `Dragger` | Mouse-drag interaction handler | -| [edit.lua](../maui/edit.lua) | `Edit` | Single-line text input | -| [frame.lua](../maui/frame.lua) | `Frame` | Top-level engine frame (rare; use `GetFrame(0)`) | -| [grid.lua](../maui/grid.lua) | `Grid` | Fixed-cell grid layout | -| [group.lua](../maui/group.lua) | `Group` | Invisible container; the workhorse parent for laying out children | -| [histogram.lua](../maui/histogram.lua) | `Histogram` | Bar-chart visualization | -| [itemlist.lua](../maui/itemlist.lua) | `ItemList` | Scrollable list of strings (legacy; consider a custom Group + pool) | -| [mesh.lua](../maui/mesh.lua) | `Mesh` | Embedded 3D mesh viewport | -| [movie.lua](../maui/movie.lua) | `Movie` | Video playback control | -| [multilinetext.lua](../maui/multilinetext.lua) | `MultiLineText` | Word-wrapped text block | -| [radiobuttons.lua](../maui/radiobuttons.lua) | `RadioButtons` | Mutually exclusive button group | -| [scrollbar.lua](../maui/scrollbar.lua) | `Scrollbar` | Pair with a scrollable control via `SetScrollable` | -| [slider.lua](../maui/slider.lua) | `Slider` / `IntegerSlider` | Continuous or stepped value picker | -| [statusbar.lua](../maui/statusbar.lua) | `StatusBar` | Progress bar with min/max | -| [text.lua](../maui/text.lua) | `Text` | Single-line text run | -| [window.lua](../maui/window.lua) | `Window` | Draggable, optionally resizable framed dialog with title bar + client area | - -### 6.2 UI controls (`/lua/ui/controls/`) - -Higher-level compositions built on the primitives. Use these when one fits β€” they bake in standard skinning and behaviour. - -| File | Class | Use for | -|------|-------|---------| -| [acubutton.lua](controls/acubutton.lua) | `ACUButton` | Faction-coloured ACU portrait button (lobby) | -| [border.lua](controls/border.lua) | `Border` | Themed nine-patch border | -| [checkbox.lua](controls/checkbox.lua) | `Checkbox` | Skinned checkbox with label support | -| [columnlayout.lua](controls/columnlayout.lua) | `ColumnLayout` | Auto-aligned column container | -| [combo.lua](controls/combo.lua) | `Combo` / `BitmapCombo` | Dropdown picker (text or bitmap entries) | -| [filepicker.lua](controls/filepicker.lua) | `FilePicker` | File-browser dialog | -| [mappreview.lua](controls/mappreview.lua) | `MapPreview` | Map thumbnail with markers | -| [ninepatch.lua](controls/ninepatch.lua) | `NinePatch` | Nine-slice scalable image | -| [radiobutton.lua](controls/radiobutton.lua) | `RadioButton` | Skinned single radio button | -| [resmappreview.lua](controls/resmappreview.lua) | `ResMapPreview` | Resource-mode map preview | -| [reticle.lua](controls/reticle.lua) | `Reticle` | World-space selection reticle | -| [specialgrid.lua](controls/specialgrid.lua) | `SpecialGrid` | Specialized grid for unit panels | -| [textarea.lua](controls/textarea.lua) | `TextArea` | Scrollable multi-line text display | -| [togglebutton.lua](controls/togglebutton.lua) | `ToggleButton` | Two-state pressed/unpressed button | -| [worldmesh.lua](controls/worldmesh.lua) | `WorldMesh` | World-anchored 3D mesh | -| [worldview.lua](controls/worldview.lua) | `WorldView` | Embedded world camera viewport | -| [popups/popup.lua](controls/popups/popup.lua) | `Popup` | Modal dialog wrapper | -| [popups/inputdialog.lua](controls/popups/inputdialog.lua) | `InputDialog` | Modal text-prompt dialog | - -Anything not in this table β€” pull it from `/lua/maui/` if it's a primitive, or build it inline in your feature folder if it's a one-off composition. Don't add new files to `/lua/ui/controls/` unless the new control is genuinely reusable across features. - ---- - -## 7. Debugging - -Two patterns are worth standardizing across UI work. - -### 7.1 Layout-bounds overlay - -Each interface file in `/lua/ui/game/chat` declares a module-level `local Debug = false` flag. When flipped to `true`, `__post_init` adds a semi-transparent coloured `Bitmap` (`DebugBG`) covering the control's bounds β€” invaluable for reasoning about which control owns which rect. - -```lua -local Debug = false -- flip to true to visualise this control's bounds - --- inside __post_init: -if Debug then - self.DebugBG = Bitmap(self) - self.DebugBG:SetSolidColor('40ff4040') -- distinct ARGB per file - self.DebugBG:DisableHitTest() - Layouter(self.DebugBG):Fill(self):Over(self, 100):End() -end -``` - -Each file uses a distinct ARGB so overlapping controls can be told apart at a glance. `DisableHitTest` keeps the overlay from intercepting clicks; `:Over(self, 100)` lifts it above the control's own children. - -`DebugBG` is annotated as an optional field on the class (`---@field DebugBG? Bitmap`) so the language server stays accurate. When `Debug` is false the field is never assigned β€” that's the entire point. - -### 7.2 Hot reload - -Top-level UI modules (the ones invoked from a hotkey) can hook into the engine's module manager so saving the file rebuilds the open window without restarting the game. Add this block at the bottom of the module: - -```lua - -------------------------------------------------------------------------------- ---#region Debugging - ---- Called by the module manager when this module is reloaded. ----@param newModule any -function __moduleinfo.OnReload(newModule) - newModule.Open() -end - ---- Called by the module manager when this module becomes dirty. -function __moduleinfo.OnDirty() - if Instance then - -- `OnDestroy` empties the trash bag, which in turn destroys every - -- derived observer β€” no more `OnDirty` fires into a dead `self`. - Instance:Destroy() - Instance = nil - end - - ForkThread(function() - WaitFrames(2) - import(__moduleinfo.name) - end) -end - ---#endregion -``` - -`OnDirty` fires when the file is saved on disk: tear down the existing instance (which destroys the `TrashBag` and unhooks every observer) and re-import the module after a couple of frames. `OnReload` runs on the freshly-loaded module and reopens the window, restoring the prior visible state. - -This only works if your module follows the standalone-invocation convention (a module-level `Open()` / `Close()` / `Toggle()` and an `Instance` local). Without that, re-importing has nothing to call. - ---- - -## What else this doc could cover - -The seven sections above are the load-bearing patterns. Candidates for follow-up additions, in rough order of value: - -1. **Standalone invocation convention** β€” every top-level UI module should export a module-level `Toggle()` / `Open()` / `Close()` that's safe to call from the keybind table or console. Currently documented only in [game/chat/CLAUDE.md Β§ Standalone Invocation](game/chat/CLAUDE.md). Lifting this here would let every new feature inherit the convention without re-explaining it (and the hot-reload block in Β§ 7.2 already assumes it). -2. **Tooltips** β€” `Tooltip.AddButtonTooltip` / `AddCheckboxTooltip` / `AddControlTooltip` and how their string keys resolve through the localization tables. -3. **Localization** β€” `fallback` strings and `LOC` / `LOCF` helpers; when text is user-visible, it must go through the LOC system. -4. **Hit testing** β€” `DisableHitTest()` on visual-only overlays is easy to forget and produces baffling click-through bugs. One paragraph would save a future debugging session. -5. **Render order** β€” `:Over(other, depth)` and `Depth` LazyVars; when stacking decorations or popups, the rules for keeping them above their owners. - -Suggestion: do **(1) Standalone invocation** next β€” the hot-reload pattern in Β§ 7.2 already references it implicitly, so codifying it removes a forward-reference. **(2)–(5)** are useful but lower priority β€” write them when the next bug or feature surfaces them. - -Class field annotations (every `self.X` in `__init` / `__post_init` gets a matching `---@field`) live in the project-wide [`annotation.md Β§ Class fields`](../../annotation.md) β€” see Β§ 1 Rules for the inline reminder. +@AGENTS.md \ No newline at end of file diff --git a/lua/ui/game/chat/AGENTS.md b/lua/ui/game/chat/AGENTS.md new file mode 100644 index 0000000000..77abb533de --- /dev/null +++ b/lua/ui/game/chat/AGENTS.md @@ -0,0 +1,244 @@ +# Chat β€” Refactoring Guide + +This directory contains the refactored in-game chat. The goal is to replace the monolithic legacy `/lua/ui/game/chat.lua` with a clean MVC structure where the **model** is reactive (LazyVar-based), the **view** is dumb (reads from the model, never writes), and the **controller** is the only place that sends or receives messages. + +> **Read first:** [`/lua/ui/AGENTS.md`](/lua/ui/AGENTS.md) covers project-wide UI patterns β€” `__init` vs `__post_init`, LazyVars and `Derive`, `TrashBag`, layout, skinning, debug overlays, hot-reload. This doc is chat-specific and assumes those rules. Class field annotation conventions live in [`annotation.md`](annotation.md). + +--- + +## Architecture + +``` +Controller ──writes──► Model (LazyVars) ──OnDirty──► View + β–² β”‚ + └──────────────────── user input β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- **Model** β€” flat sets of `LazyVar` instances in `ChatModel.lua` (chat state) and `config/ChatConfigModel.lua` (options). No UI, no networking. The single source of truth. +- **View** β€” a tree of `*Interface` Groups and Windows that subscribe to model LazyVars via `Derive`. Views never touch each other or write to the model. +- **Controller** β€” `ChatController.lua` (chat) and `config/ChatConfigController.lua` (options). The only files allowed to send network messages, register receive handlers, or write to the model. + +--- + +## File Map + +The chat tree splits one feature into many small files so each `*Interface` is responsible for a single concern. When adding a feature, this table tells you which file to open. + +### Top-level + +| File | Responsibility | +|------|----------------| +| [ChatModel.lua](ChatModel.lua) | `UIChatModel` singleton + `UIChatEntry` / `UIChatEntryLocation` shapes; recipient + history + window-visible + last-activity + pin LazyVars | +| [ChatController.lua](ChatController.lua) | Send / receive pipelines, slash-command dispatch, activity heartbeat, recipient routing β€” the only file allowed to call `SessionSendChatMessage` or write to the model | +| [ChatInterface.lua](ChatInterface.lua) | `UIChatInterface : Window` β€” main draggable, resizable chat window; owns drag handles, idle/fade `OnFrame` timer, `win_alpha` cascade, standalone-invocation entry points | +| [ChatLinesInterface.lua](ChatLinesInterface.lua) | `UIChatLinesInterface : Group` β€” line pool, scrollbar, wrap/rebuild on resize, observes `model.History` + `ChatConfigModel.Committed` | +| [ChatLineInterface.lua](ChatLineInterface.lua) | `UIChatLineInterface : Group` β€” single message row: faction badge, sender name (clickable), body text (clickable when camera/location-tagged) | +| [ChatEditInterface.lua](ChatEditInterface.lua) | `UIChatEditInterface : Group` β€” edit box, recipient label, recipient-picker dropdown, camera-attach checkbox, command-hint popup, command history ring, Tab completion | +| [ChatFeedInterface.lua](ChatFeedInterface.lua) | `UIChatFeedInterface : Group` β€” sibling feed shown while the window is closed; per-row age timer fades old lines | +| [ChatListInterface.lua](ChatListInterface.lua) | `UIChatListInterface : Group` β€” popup recipient picker (all / allies / per-player) | +| [ChatCommandHintInterface.lua](ChatCommandHintInterface.lua) | `UIChatCommandHintInterface : Group` β€” slash-command auto-suggest popup anchored to the edit box | +| [ChatFactionBadge.lua](ChatFactionBadge.lua) | `ChatFactionBadge : Group` β€” faction icon with tooltip; rendered on every line | +| [ChatCompletion.lua](ChatCompletion.lua) | Tab-completion cycle state (no UI; consumed by `ChatEditInterface`) | +| [ChatUtils.lua](ChatUtils.lua) | Module-level helpers (max message length, etc.) | +| [ChatDebug.lua](ChatDebug.lua) | Debug helpers β€” not part of the production tree | + +### `config/` + +| File | Responsibility | +|------|----------------| +| [config/ChatConfigModel.lua](config/ChatConfigModel.lua) | `UIChatConfigModel` singleton + `UIChatOptions` schema + slider ranges; `Committed` (active) and `Pending` (draft) options LazyVars | +| [config/ChatConfigController.lua](config/ChatConfigController.lua) | Apply / Reset / Cancel / SetOption β€” the only writer to `ChatConfigModel` | +| [config/ChatConfigInterface.lua](config/ChatConfigInterface.lua) | `UIChatConfigInterface : Window` β€” options dialog; observes `Pending` to sync controls | + +### `commands/` + +| File | Responsibility | +|------|----------------| +| [commands/ChatCommandRegistry.lua](commands/ChatCommandRegistry.lua) | Registry, tokenizer, dispatcher; legacy fallback to `/lua/ui/notify/commands.lua` | +| [commands/ChatCommandTypes.lua](commands/ChatCommandTypes.lua) | Parameter resolvers: `Recipient`, `Player`, `Int`, `String`, `Rest` | +| [commands/builtin/*.lua](commands/builtin/) | One file per built-in command (`/all`, `/allies`, `/whisper`, `/help`, …); each exports a `Command` table | +| [commands/design.md](commands/design.md) | Slash-command system design β€” read before adding a command | + +To add a slash command, follow the [`add-chat-command`](../../../../.claude/skills/add-chat-command/SKILL.md) skill. + +--- + +## Model + +### `UIChatModel` ([ChatModel.lua](ChatModel.lua)) + +```lua +---@class UIChatModel +---@field History LazyVar # append-only log; Set a new table ref to trigger dirty +---@field Recipient LazyVar # current send target +---@field WindowVisible LazyVar # whether the chat window is open +---@field LastActivity LazyVar # GetSystemTimeSeconds() of the most recent engagement; drives the fade timer +---@field Pinned LazyVar # title-bar pin checkbox; suspends auto-close while true +``` + +`UIChatRecipient` is `'all' | 'allies' | number` β€” the engine-level target. The two string constants are exported as `ChatModel.RecipientAll` / `ChatModel.RecipientAllies` so nothing hardcodes the strings. + +### `UIChatEntry` + +```lua +---@class UIChatEntry +---@field Name string # formatted prefix, e.g. "Sender to allies:" +---@field Text string # raw message body +---@field Color string # ARGB hex of the sender's team colour +---@field BodyColor? string # explicit body ARGB; bypasses palette lookup (system / synthetic lines) +---@field ColorKey? string # palette key resolved against `ChatConfigModel.GetOptions()` at render time +---@field ArmyID number # sender's army index +---@field Faction number # 1-based faction icon index +---@field Recipient UIChatRecipient # original target of the message +---@field Camera? table # SaveSettings snapshot when the sender attached their view +---@field Location? UIChatEntryLocation # lightweight {Position?, Area?} hint from sim-side senders +---@field Id? string # near-unique sender-stamped id; dedupes the Sync.ChatMessages path against SessionSendChatMessage +---@field WrappedText? string[] # view-side wrap cache; populated by ChatLinesInterface +``` + +Display-lifecycle state (per-row `time` / `visible` flags, fade alpha) lives on the **view**, not on entries. `WrappedText` is the one exception β€” the wrap cache attaches to the entry because it depends on the entry's text and the current row width, and avoids re-wrapping every frame. + +### `UIChatConfigModel` ([config/ChatConfigModel.lua](config/ChatConfigModel.lua)) + +```lua +---@class UIChatConfigModel +---@field Committed LazyVar # the active, persisted options observed by the chat tree +---@field Pending LazyVar # the draft being edited in the config dialog +``` + +The two LazyVars exist so the config dialog can preview changes (`Pending`) without affecting live UI (`Committed`) until the user clicks Apply. Views observing chat options always read `Committed`. + +`UIChatOptions` is a plain table; option keys are exported as module globals (`ChatConfigModel.KeyFontSize`, etc.) so call sites don't repeat magic strings. Slider bounds (`FontSizeRange`, `FadeTimeRange`, `WinAlphaSliderRange`) live in the same module. + +| Key | Default | Meaning | +|-----|---------|---------| +| `all_color` | 1 | Palette index (1–8) for "all" messages | +| `allies_color` | 2 | Palette index for ally messages | +| `priv_color` | 3 | Palette index for private messages | +| `link_color` | 4 | Palette index for camera/location-link messages and observer chatter | +| `notify_color` | 8 | Palette index for Notify subsystem messages | +| `font_size` | 14 | Chat font size (12–18) | +| `fade_time` | 15 | Seconds before idle window/feed auto-hides (5–30) | +| `win_alpha` | 1.0 | Window opacity (0.0–1.0; edited via 20–100% slider) | +| `feed_background` | false | Semi-transparent backdrop behind feed lines | +| `send_type` | false | Default recipient: false = all, true = allies | +| `links` | true | Show camera-link messages | +| `muted` | `{}` | Per-army mute filter (`armyID β†’ true` when muted) | + +--- + +## Controller + +### Receiving + +``` +gamemain.ReceiveChat(sender, data) [engine callback] + └── chatFuncs['Chat'](sender, data) [registered by ChatController.Init] + └── ChatController.OnReceive(sender, msg) + β”œβ”€β”€ shape-validate the payload (drop malformed, modded, or hostile) + β”œβ”€β”€ route Notify subsystem messages through their handlers + └── ChatModel.AppendEntry(entry) # writes model.History + stamps LastActivity +``` + +`OnSyncChatMessages` is the parallel path for sim-originated and replay messages β€” it goes through the same `OnReceive` once it has unpacked the sync payload, so live and replay paths converge. + +### Sending + +``` +ChatController.Send(text, attachCamera?) + β”œβ”€β”€ slash-command check β†’ ChatCommandRegistry.Dispatch + β”œβ”€β”€ taunt check β†’ /lua/ui/notify/taunt + β”œβ”€β”€ package message {to, Chat, text, Camera?, Id, Sender} + β”œβ”€β”€ resolve clients β†’ FindClients[AsObserver|AsPlayer] + └── SessionSendChatMessage(clients?, msg) + + SimCallback('SendChatMessage') for the sim/replay path + + locally echo private messages (engine doesn't bounce them back) +``` + +Every public function on `ChatController` either reads input, writes the model, or speaks to the engine β€” there is no UI-side state on the controller. Anything in `/lua/ui/game/chat` that wants to mutate chat state goes through one of these: + +| Function | What it does | +|----------|--------------| +| `OpenWindow` / `CloseWindow` / `ToggleWindow` | Flip `model.WindowVisible` | +| `NotifyActivity` | Stamp `model.LastActivity` β€” the activity heartbeat read by the fade timer | +| `SetPinned(bool)` | Flip `model.Pinned` (and re-stamp activity on unpin) | +| `SetRecipient(target)` | Write `model.Recipient` | +| `AppendEntry(entry)` | Append to `model.History` + stamp activity | +| `AppendLocalSystemMessage(text)` | Synthesize a local-only system line (used by command errors) | +| `Send(text, attachCamera?)` | Slash dispatch / taunt / network send pipeline | +| `ActivateChat(modifiers?)` | Engine hotkey entry: open window with default recipient layered with Shift | +| `RegisterBuiltinCommands` | Re-runs the registry population; idempotent and safe under hot reload | +| `Init` | Registers `OnReceive` with gamemain, populates the registry, ensures the chat tree is mounted | + +### Init + +`ChatController.Init` is called once from `gamemain.lua` during UI setup. Hot reload re-runs `Init` via the `__moduleinfo.OnReload` hook so the gamemain registration rebinds to the freshly imported `OnReceive` closure β€” without that, edits to the controller leave stale code receiving messages. + +--- + +## Views + +Every `*Interface` file follows the rules in [`/lua/ui/AGENTS.md`](../../AGENTS.md) β€” `__init` for state and children, `__post_init` for layout, observers via `Derive`, cleanup via `TrashBag`. The chat-specific bits are which model fields each interface observes and which controller calls it makes. + +| Interface | Observes | Calls into controller | +|-----------|----------|-----------------------| +| `UIChatInterface` ([ChatInterface.lua](ChatInterface.lua)) | `model.WindowVisible`, `model.Pinned`, `ChatConfigModel.Committed.win_alpha` | `CloseWindow`, `SetPinned`, `NotifyActivity` | +| `UIChatLinesInterface` ([ChatLinesInterface.lua](ChatLinesInterface.lua)) | `model.History`, `ChatConfigModel.Committed` (font, palette, mute, links) | (read-only; click forwarders handed in by parent) | +| `UIChatLineInterface` ([ChatLineInterface.lua](ChatLineInterface.lua)) | (per-row; populated by `ChatLinesInterface`) | row click β†’ `SetRecipient`, camera click β†’ `WorldCamera:RestoreSettings` | +| `UIChatEditInterface` ([ChatEditInterface.lua](ChatEditInterface.lua)) | `model.Recipient` | `Send`, `SetRecipient`, `NotifyActivity`, `CloseWindow` | +| `UIChatFeedInterface` ([ChatFeedInterface.lua](ChatFeedInterface.lua)) | `model.History`, `ChatConfigModel.Committed` (palette, fade, feed_background) | (read-only) | +| `UIChatListInterface` ([ChatListInterface.lua](ChatListInterface.lua)) | (driven by edit dropdown) | `SetRecipient` | +| `UIChatCommandHintInterface` ([ChatCommandHintInterface.lua](ChatCommandHintInterface.lua)) | (driven by edit text) | (no controller calls; hint UI only) | +| `UIChatConfigInterface` ([config/ChatConfigInterface.lua](config/ChatConfigInterface.lua)) | `ChatConfigModel.Pending` | `ChatConfigController.SetOption` / `Apply` / `Reset` / `Cancel` | + +### Imports vs callbacks + +Views import models and controllers directly at the top of the file rather than receiving them through constructors: + +```lua +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatController = import("/lua/ui/game/chat/ChatController.lua") +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +``` + +This keeps dependencies visible at the top of the file and avoids the autolobby's "prop drilling" pattern, where state was threaded through every constructor and every change required touching the controller. The MVC discipline is preserved by convention: views still only **read** from the model and **call** the controller β€” they never write to the model directly. + +--- + +## UI Elements + +| Element | File | Parent | +|---------|------|--------| +| Chat window (title bar + drag handles) | [ChatInterface.lua](ChatInterface.lua) | `GetFrame(0)` | +| Line pool + scrollbar | [ChatLinesInterface.lua](ChatLinesInterface.lua) | chat window's client area | +| Single message row | [ChatLineInterface.lua](ChatLineInterface.lua) | line pool | +| Sibling feed (window-hidden mode) | [ChatFeedInterface.lua](ChatFeedInterface.lua) | `GetFrame(0)` (anchored to chat window's lines rect) | +| Edit box, recipient label, recipient picker, camera checkbox | [ChatEditInterface.lua](ChatEditInterface.lua) | chat window's client area | +| Recipient-picker popup | [ChatListInterface.lua](ChatListInterface.lua) | edit interface | +| Slash-command hint popup | [ChatCommandHintInterface.lua](ChatCommandHintInterface.lua) | edit interface | +| Faction icon (per row) | [ChatFactionBadge.lua](ChatFactionBadge.lua) | line interface | +| Options dialog | [config/ChatConfigInterface.lua](config/ChatConfigInterface.lua) | `GetFrame(0)` | + +--- + +## Standalone Invocation + +Every complete UI component in this system (chat window, options dialog, edit area) **must be callable directly from a hotkey** with no prior context. This serves two purposes: + +1. **Debugging** β€” any component can be opened in isolation without launching the full game flow. +2. **Separation of concerns** β€” if a component requires another component to exist before it can be opened, that is a design smell indicating hidden coupling. + +Each top-level view module exports module-level `Toggle()` / `Open()` / `Close()` and an `Instance` local. Bind `chat_toggle` and `chat_config` actions in `keyactions.lua` to `UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").Toggle()` and the corresponding config call. The same `Toggle()` is also what the hot-reload `__moduleinfo.OnReload` block reopens after a save β€” see [`/lua/ui/AGENTS.md Β§ 7.2`](../../AGENTS.md). + +> This convention is currently chat-specific but is a candidate to lift into [`/lua/ui/AGENTS.md`](../../AGENTS.md). Until it does, treat this as the reference for any other top-level UI module. + +--- + +## Don'ts + +- **Don't store UI references in the model.** The model must be constructable with no UI present (and is β€” see the model singleton's hot-reload hook, which rebuilds without touching the view tree). +- **Don't write to the model from a view.** Views call into the controller; the controller writes. +- **Don't call `SessionSendChatMessage` or `gamemain.RegisterChatFunc` from anywhere but `ChatController`.** Network and sim-side traffic is funnelled through that file precisely so legacy/notify/script paths don't fork. +- **Don't mutate `model.History` (or any LazyVar's table) in place.** Build a new table and `Set` it; otherwise dependents never go dirty. See [`/lua/ui/AGENTS.md Β§ 2 Reactivity rules`](../../AGENTS.md). +- **Don't replicate the autolobby's drilling pattern.** State is on the model; views import and subscribe β€” no parent needs to push updates into children. +- **Don't add a slash command by editing the registry directly.** Drop a file in [`commands/builtin/`](commands/builtin/) and add one `Registry.RegisterFromPath` line in `ChatController.RegisterBuiltinCommands` β€” see the [`add-chat-command`](../../../../.claude/skills/add-chat-command/SKILL.md) skill. diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md index 7bcdd0e88e..eef4bd20cf 100644 --- a/lua/ui/game/chat/CLAUDE.md +++ b/lua/ui/game/chat/CLAUDE.md @@ -1,244 +1 @@ -# Chat β€” Refactoring Guide - -This directory contains the refactored in-game chat. The goal is to replace the monolithic legacy `/lua/ui/game/chat.lua` with a clean MVC structure where the **model** is reactive (LazyVar-based), the **view** is dumb (reads from the model, never writes), and the **controller** is the only place that sends or receives messages. - -> **Read first:** [`/lua/ui/CLAUDE.md`](/lua/ui/CLAUDE.md) covers project-wide UI patterns β€” `__init` vs `__post_init`, LazyVars and `Derive`, `TrashBag`, layout, skinning, debug overlays, hot-reload. This doc is chat-specific and assumes those rules. Class field annotation conventions live in [`annotation.md`](annotation.md). - ---- - -## Architecture - -``` -Controller ──writes──► Model (LazyVars) ──OnDirty──► View - β–² β”‚ - └──────────────────── user input β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -- **Model** β€” flat sets of `LazyVar` instances in `ChatModel.lua` (chat state) and `config/ChatConfigModel.lua` (options). No UI, no networking. The single source of truth. -- **View** β€” a tree of `*Interface` Groups and Windows that subscribe to model LazyVars via `Derive`. Views never touch each other or write to the model. -- **Controller** β€” `ChatController.lua` (chat) and `config/ChatConfigController.lua` (options). The only files allowed to send network messages, register receive handlers, or write to the model. - ---- - -## File Map - -The chat tree splits one feature into many small files so each `*Interface` is responsible for a single concern. When adding a feature, this table tells you which file to open. - -### Top-level - -| File | Responsibility | -|------|----------------| -| [ChatModel.lua](ChatModel.lua) | `UIChatModel` singleton + `UIChatEntry` / `UIChatEntryLocation` shapes; recipient + history + window-visible + last-activity + pin LazyVars | -| [ChatController.lua](ChatController.lua) | Send / receive pipelines, slash-command dispatch, activity heartbeat, recipient routing β€” the only file allowed to call `SessionSendChatMessage` or write to the model | -| [ChatInterface.lua](ChatInterface.lua) | `UIChatInterface : Window` β€” main draggable, resizable chat window; owns drag handles, idle/fade `OnFrame` timer, `win_alpha` cascade, standalone-invocation entry points | -| [ChatLinesInterface.lua](ChatLinesInterface.lua) | `UIChatLinesInterface : Group` β€” line pool, scrollbar, wrap/rebuild on resize, observes `model.History` + `ChatConfigModel.Committed` | -| [ChatLineInterface.lua](ChatLineInterface.lua) | `UIChatLineInterface : Group` β€” single message row: faction badge, sender name (clickable), body text (clickable when camera/location-tagged) | -| [ChatEditInterface.lua](ChatEditInterface.lua) | `UIChatEditInterface : Group` β€” edit box, recipient label, recipient-picker dropdown, camera-attach checkbox, command-hint popup, command history ring, Tab completion | -| [ChatFeedInterface.lua](ChatFeedInterface.lua) | `UIChatFeedInterface : Group` β€” sibling feed shown while the window is closed; per-row age timer fades old lines | -| [ChatListInterface.lua](ChatListInterface.lua) | `UIChatListInterface : Group` β€” popup recipient picker (all / allies / per-player) | -| [ChatCommandHintInterface.lua](ChatCommandHintInterface.lua) | `UIChatCommandHintInterface : Group` β€” slash-command auto-suggest popup anchored to the edit box | -| [ChatFactionBadge.lua](ChatFactionBadge.lua) | `ChatFactionBadge : Group` β€” faction icon with tooltip; rendered on every line | -| [ChatCompletion.lua](ChatCompletion.lua) | Tab-completion cycle state (no UI; consumed by `ChatEditInterface`) | -| [ChatUtils.lua](ChatUtils.lua) | Module-level helpers (max message length, etc.) | -| [ChatDebug.lua](ChatDebug.lua) | Debug helpers β€” not part of the production tree | - -### `config/` - -| File | Responsibility | -|------|----------------| -| [config/ChatConfigModel.lua](config/ChatConfigModel.lua) | `UIChatConfigModel` singleton + `UIChatOptions` schema + slider ranges; `Committed` (active) and `Pending` (draft) options LazyVars | -| [config/ChatConfigController.lua](config/ChatConfigController.lua) | Apply / Reset / Cancel / SetOption β€” the only writer to `ChatConfigModel` | -| [config/ChatConfigInterface.lua](config/ChatConfigInterface.lua) | `UIChatConfigInterface : Window` β€” options dialog; observes `Pending` to sync controls | - -### `commands/` - -| File | Responsibility | -|------|----------------| -| [commands/ChatCommandRegistry.lua](commands/ChatCommandRegistry.lua) | Registry, tokenizer, dispatcher; legacy fallback to `/lua/ui/notify/commands.lua` | -| [commands/ChatCommandTypes.lua](commands/ChatCommandTypes.lua) | Parameter resolvers: `Recipient`, `Player`, `Int`, `String`, `Rest` | -| [commands/builtin/*.lua](commands/builtin/) | One file per built-in command (`/all`, `/allies`, `/whisper`, `/help`, …); each exports a `Command` table | -| [commands/design.md](commands/design.md) | Slash-command system design β€” read before adding a command | - -To add a slash command, follow the [`add-chat-command`](../../../../.claude/skills/add-chat-command/SKILL.md) skill. - ---- - -## Model - -### `UIChatModel` ([ChatModel.lua](ChatModel.lua)) - -```lua ----@class UIChatModel ----@field History LazyVar # append-only log; Set a new table ref to trigger dirty ----@field Recipient LazyVar # current send target ----@field WindowVisible LazyVar # whether the chat window is open ----@field LastActivity LazyVar # GetSystemTimeSeconds() of the most recent engagement; drives the fade timer ----@field Pinned LazyVar # title-bar pin checkbox; suspends auto-close while true -``` - -`UIChatRecipient` is `'all' | 'allies' | number` β€” the engine-level target. The two string constants are exported as `ChatModel.RecipientAll` / `ChatModel.RecipientAllies` so nothing hardcodes the strings. - -### `UIChatEntry` - -```lua ----@class UIChatEntry ----@field Name string # formatted prefix, e.g. "Sender to allies:" ----@field Text string # raw message body ----@field Color string # ARGB hex of the sender's team colour ----@field BodyColor? string # explicit body ARGB; bypasses palette lookup (system / synthetic lines) ----@field ColorKey? string # palette key resolved against `ChatConfigModel.GetOptions()` at render time ----@field ArmyID number # sender's army index ----@field Faction number # 1-based faction icon index ----@field Recipient UIChatRecipient # original target of the message ----@field Camera? table # SaveSettings snapshot when the sender attached their view ----@field Location? UIChatEntryLocation # lightweight {Position?, Area?} hint from sim-side senders ----@field Id? string # near-unique sender-stamped id; dedupes the Sync.ChatMessages path against SessionSendChatMessage ----@field WrappedText? string[] # view-side wrap cache; populated by ChatLinesInterface -``` - -Display-lifecycle state (per-row `time` / `visible` flags, fade alpha) lives on the **view**, not on entries. `WrappedText` is the one exception β€” the wrap cache attaches to the entry because it depends on the entry's text and the current row width, and avoids re-wrapping every frame. - -### `UIChatConfigModel` ([config/ChatConfigModel.lua](config/ChatConfigModel.lua)) - -```lua ----@class UIChatConfigModel ----@field Committed LazyVar # the active, persisted options observed by the chat tree ----@field Pending LazyVar # the draft being edited in the config dialog -``` - -The two LazyVars exist so the config dialog can preview changes (`Pending`) without affecting live UI (`Committed`) until the user clicks Apply. Views observing chat options always read `Committed`. - -`UIChatOptions` is a plain table; option keys are exported as module globals (`ChatConfigModel.KeyFontSize`, etc.) so call sites don't repeat magic strings. Slider bounds (`FontSizeRange`, `FadeTimeRange`, `WinAlphaSliderRange`) live in the same module. - -| Key | Default | Meaning | -|-----|---------|---------| -| `all_color` | 1 | Palette index (1–8) for "all" messages | -| `allies_color` | 2 | Palette index for ally messages | -| `priv_color` | 3 | Palette index for private messages | -| `link_color` | 4 | Palette index for camera/location-link messages and observer chatter | -| `notify_color` | 8 | Palette index for Notify subsystem messages | -| `font_size` | 14 | Chat font size (12–18) | -| `fade_time` | 15 | Seconds before idle window/feed auto-hides (5–30) | -| `win_alpha` | 1.0 | Window opacity (0.0–1.0; edited via 20–100% slider) | -| `feed_background` | false | Semi-transparent backdrop behind feed lines | -| `send_type` | false | Default recipient: false = all, true = allies | -| `links` | true | Show camera-link messages | -| `muted` | `{}` | Per-army mute filter (`armyID β†’ true` when muted) | - ---- - -## Controller - -### Receiving - -``` -gamemain.ReceiveChat(sender, data) [engine callback] - └── chatFuncs['Chat'](sender, data) [registered by ChatController.Init] - └── ChatController.OnReceive(sender, msg) - β”œβ”€β”€ shape-validate the payload (drop malformed, modded, or hostile) - β”œβ”€β”€ route Notify subsystem messages through their handlers - └── ChatModel.AppendEntry(entry) # writes model.History + stamps LastActivity -``` - -`OnSyncChatMessages` is the parallel path for sim-originated and replay messages β€” it goes through the same `OnReceive` once it has unpacked the sync payload, so live and replay paths converge. - -### Sending - -``` -ChatController.Send(text, attachCamera?) - β”œβ”€β”€ slash-command check β†’ ChatCommandRegistry.Dispatch - β”œβ”€β”€ taunt check β†’ /lua/ui/notify/taunt - β”œβ”€β”€ package message {to, Chat, text, Camera?, Id, Sender} - β”œβ”€β”€ resolve clients β†’ FindClients[AsObserver|AsPlayer] - └── SessionSendChatMessage(clients?, msg) - + SimCallback('SendChatMessage') for the sim/replay path - + locally echo private messages (engine doesn't bounce them back) -``` - -Every public function on `ChatController` either reads input, writes the model, or speaks to the engine β€” there is no UI-side state on the controller. Anything in `/lua/ui/game/chat` that wants to mutate chat state goes through one of these: - -| Function | What it does | -|----------|--------------| -| `OpenWindow` / `CloseWindow` / `ToggleWindow` | Flip `model.WindowVisible` | -| `NotifyActivity` | Stamp `model.LastActivity` β€” the activity heartbeat read by the fade timer | -| `SetPinned(bool)` | Flip `model.Pinned` (and re-stamp activity on unpin) | -| `SetRecipient(target)` | Write `model.Recipient` | -| `AppendEntry(entry)` | Append to `model.History` + stamp activity | -| `AppendLocalSystemMessage(text)` | Synthesize a local-only system line (used by command errors) | -| `Send(text, attachCamera?)` | Slash dispatch / taunt / network send pipeline | -| `ActivateChat(modifiers?)` | Engine hotkey entry: open window with default recipient layered with Shift | -| `RegisterBuiltinCommands` | Re-runs the registry population; idempotent and safe under hot reload | -| `Init` | Registers `OnReceive` with gamemain, populates the registry, ensures the chat tree is mounted | - -### Init - -`ChatController.Init` is called once from `gamemain.lua` during UI setup. Hot reload re-runs `Init` via the `__moduleinfo.OnReload` hook so the gamemain registration rebinds to the freshly imported `OnReceive` closure β€” without that, edits to the controller leave stale code receiving messages. - ---- - -## Views - -Every `*Interface` file follows the rules in [`/lua/ui/CLAUDE.md`](../../CLAUDE.md) β€” `__init` for state and children, `__post_init` for layout, observers via `Derive`, cleanup via `TrashBag`. The chat-specific bits are which model fields each interface observes and which controller calls it makes. - -| Interface | Observes | Calls into controller | -|-----------|----------|-----------------------| -| `UIChatInterface` ([ChatInterface.lua](ChatInterface.lua)) | `model.WindowVisible`, `model.Pinned`, `ChatConfigModel.Committed.win_alpha` | `CloseWindow`, `SetPinned`, `NotifyActivity` | -| `UIChatLinesInterface` ([ChatLinesInterface.lua](ChatLinesInterface.lua)) | `model.History`, `ChatConfigModel.Committed` (font, palette, mute, links) | (read-only; click forwarders handed in by parent) | -| `UIChatLineInterface` ([ChatLineInterface.lua](ChatLineInterface.lua)) | (per-row; populated by `ChatLinesInterface`) | row click β†’ `SetRecipient`, camera click β†’ `WorldCamera:RestoreSettings` | -| `UIChatEditInterface` ([ChatEditInterface.lua](ChatEditInterface.lua)) | `model.Recipient` | `Send`, `SetRecipient`, `NotifyActivity`, `CloseWindow` | -| `UIChatFeedInterface` ([ChatFeedInterface.lua](ChatFeedInterface.lua)) | `model.History`, `ChatConfigModel.Committed` (palette, fade, feed_background) | (read-only) | -| `UIChatListInterface` ([ChatListInterface.lua](ChatListInterface.lua)) | (driven by edit dropdown) | `SetRecipient` | -| `UIChatCommandHintInterface` ([ChatCommandHintInterface.lua](ChatCommandHintInterface.lua)) | (driven by edit text) | (no controller calls; hint UI only) | -| `UIChatConfigInterface` ([config/ChatConfigInterface.lua](config/ChatConfigInterface.lua)) | `ChatConfigModel.Pending` | `ChatConfigController.SetOption` / `Apply` / `Reset` / `Cancel` | - -### Imports vs callbacks - -Views import models and controllers directly at the top of the file rather than receiving them through constructors: - -```lua -local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") -local ChatController = import("/lua/ui/game/chat/ChatController.lua") -local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") -``` - -This keeps dependencies visible at the top of the file and avoids the autolobby's "prop drilling" pattern, where state was threaded through every constructor and every change required touching the controller. The MVC discipline is preserved by convention: views still only **read** from the model and **call** the controller β€” they never write to the model directly. - ---- - -## UI Elements - -| Element | File | Parent | -|---------|------|--------| -| Chat window (title bar + drag handles) | [ChatInterface.lua](ChatInterface.lua) | `GetFrame(0)` | -| Line pool + scrollbar | [ChatLinesInterface.lua](ChatLinesInterface.lua) | chat window's client area | -| Single message row | [ChatLineInterface.lua](ChatLineInterface.lua) | line pool | -| Sibling feed (window-hidden mode) | [ChatFeedInterface.lua](ChatFeedInterface.lua) | `GetFrame(0)` (anchored to chat window's lines rect) | -| Edit box, recipient label, recipient picker, camera checkbox | [ChatEditInterface.lua](ChatEditInterface.lua) | chat window's client area | -| Recipient-picker popup | [ChatListInterface.lua](ChatListInterface.lua) | edit interface | -| Slash-command hint popup | [ChatCommandHintInterface.lua](ChatCommandHintInterface.lua) | edit interface | -| Faction icon (per row) | [ChatFactionBadge.lua](ChatFactionBadge.lua) | line interface | -| Options dialog | [config/ChatConfigInterface.lua](config/ChatConfigInterface.lua) | `GetFrame(0)` | - ---- - -## Standalone Invocation - -Every complete UI component in this system (chat window, options dialog, edit area) **must be callable directly from a hotkey** with no prior context. This serves two purposes: - -1. **Debugging** β€” any component can be opened in isolation without launching the full game flow. -2. **Separation of concerns** β€” if a component requires another component to exist before it can be opened, that is a design smell indicating hidden coupling. - -Each top-level view module exports module-level `Toggle()` / `Open()` / `Close()` and an `Instance` local. Bind `chat_toggle` and `chat_config` actions in `keyactions.lua` to `UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").Toggle()` and the corresponding config call. The same `Toggle()` is also what the hot-reload `__moduleinfo.OnReload` block reopens after a save β€” see [`/lua/ui/CLAUDE.md Β§ 7.2`](../../CLAUDE.md). - -> This convention is currently chat-specific but is a candidate to lift into [`/lua/ui/CLAUDE.md`](../../CLAUDE.md). Until it does, treat this as the reference for any other top-level UI module. - ---- - -## Don'ts - -- **Don't store UI references in the model.** The model must be constructable with no UI present (and is β€” see the model singleton's hot-reload hook, which rebuilds without touching the view tree). -- **Don't write to the model from a view.** Views call into the controller; the controller writes. -- **Don't call `SessionSendChatMessage` or `gamemain.RegisterChatFunc` from anywhere but `ChatController`.** Network and sim-side traffic is funnelled through that file precisely so legacy/notify/script paths don't fork. -- **Don't mutate `model.History` (or any LazyVar's table) in place.** Build a new table and `Set` it; otherwise dependents never go dirty. See [`/lua/ui/CLAUDE.md Β§ 2 Reactivity rules`](../../CLAUDE.md). -- **Don't replicate the autolobby's drilling pattern.** State is on the model; views import and subscribe β€” no parent needs to push updates into children. -- **Don't add a slash command by editing the registry directly.** Drop a file in [`commands/builtin/`](commands/builtin/) and add one `Registry.RegisterFromPath` line in `ChatController.RegisterBuiltinCommands` β€” see the [`add-chat-command`](../../../../.claude/skills/add-chat-command/SKILL.md) skill. +@AGENTS.md \ No newline at end of file diff --git a/lua/ui/game/chat/LAYOUT.md b/lua/ui/game/chat/LAYOUT.md index 2887e9ecdd..121058868f 100644 --- a/lua/ui/game/chat/LAYOUT.md +++ b/lua/ui/game/chat/LAYOUT.md @@ -1,6 +1,6 @@ # Chat layout β€” component tree and scaling -Anchors-and-dependencies map of the chat UI tree, used to reason about UI-scale behaviour. Read alongside [CLAUDE.md](CLAUDE.md) for the chat MVC contract and [`/lua/ui/CLAUDE.md`](../../CLAUDE.md) Β§Β§ 4 (Layout, UI scaling) and 5 (Skinning) for the project-wide rules. +Anchors-and-dependencies map of the chat UI tree, used to reason about UI-scale behaviour. Read alongside [AGENTS.md](AGENTS.md) for the chat MVC contract and [`/lua/ui/AGENTS.md`](../../AGENTS.md) Β§Β§ 4 (Layout, UI scaling) and 5 (Skinning) for the project-wide rules. Notation throughout: