From 52f3e0a00cefc5805952cabcbaa4837d27f912e2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 18 Apr 2026 21:29:16 +0200 Subject: [PATCH 001/130] Add design document of existing chat functionality --- lua/ui/game/chat/design.md | 336 +++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 lua/ui/game/chat/design.md diff --git a/lua/ui/game/chat/design.md b/lua/ui/game/chat/design.md new file mode 100644 index 00000000000..8d44b6c4cf6 --- /dev/null +++ b/lua/ui/game/chat/design.md @@ -0,0 +1,336 @@ +# Chat System — Design Document + +**Purpose:** Captures the existing functionality of the in-game chat system as a basis for a refactoring effort. + +--- + +## 1. Entry Points and Lifecycle + +### 1.1 Initialization + +`gamemain.lua` drives the full lifecycle: + +1. **`SetLayout()`** — called from `gamemain.lua:SetLayout()`. Delegates to the skin-specific layout file (`layouts/chat_layout.lua`) which positions the chat `Window` and its sub-controls relative to the screen frame. +2. **`SetupChatLayout(mapGroup)`** — called from `gamemain.lua` at game-start. Calls `CreateChat()` and then registers `ReceiveChat` as the handler for the `'Chat'` identifier via `gamemain.RegisterChatFunc`. + +### 1.2 `CreateChat()` + +Builds the full chat UI tree: +- `CreateChatBackground()` → a draggable, resizable `Window` (`GUI.bg`) +- `CreateChatEdit()` → the text-input group (`GUI.chatEdit`) +- `CreateChatLines()` → the array of display lines (`GUI.chatLines[]`) +- Wires up all `OnResize`, `OnMove`, `OnFrame`, `OnClose`, `OnPinCheck`, and `OnConfigClick` callbacks +- Calls `ToggleChat()` at the end (so the window starts hidden) + +--- + +## 2. Message Transport + +### 2.1 Sending — `SessionSendChatMessage` + +The engine function `SessionSendChatMessage(clients?, msg)` delivers a Lua table as a chat message to one or more peers. The `clients` argument is either omitted (broadcast to all) or a list of client indices returned by `FindClients`. + +All callers encode meaning in specific fields of `msg`. The following fields are observed across the codebase: + +| Field | Type | Meaning | +|-------|------|---------| +| `to` | `'all'` \| `'allies'` \| `'notify'` \| number | Recipient scope | +| `Chat` | bool | Must be `true` for the standard chat display path | +| `text` | string | Message body | +| `from` | string | Override sender name (used for private-message echo) | +| `echo` | bool | Marks a message that was sent to a specific player (shown to the sender) | +| `Observer` | bool | Marks an observer-originated message | +| `camera` | table | Camera state (from `WorldCamera:SaveSettings()`) attached to a "ping" link | +| `ConsoleOutput` | string | Alternative payload — printed to console, not displayed in chat feed | +| `Taunt` | bool | Routes message to the taunt subsystem | +| `Template` | bool | Build-template share (from `build_templates.lua`) | +| `SendResumedBy` | bool | Game-resume notification (from `pause.lua`) | +| `ShareablePainting` | table | Painting data (from `PaintingCanvasAdapter.lua`) | +| `Identifier` | string | Modern routing key for `RegisterChatFunc` dispatch (preferred) | +| `data` | table | Payload for Notify messages (`{category, source, trigger, time?}`) | + +> **Size limit:** `SessionSendChatMessage` silently errors out above ~4 KB per message. Paintings chunk their payload to stay under this limit. + +### 2.2 `FindClients(id?)` + +Utility in `chat.lua` that returns a list of client indices. + +- No argument → allied clients of the focus army (or all observer clients if observing) +- `id` (army number) → clients controlling that specific army + +Imported directly by `score.lua`, `notify.lua`, and `painting/ShareAdapters/PaintingCanvasAdapter.lua`. + +--- + +## 3. Receiving — The Dispatch Chain + +### 3.1 Engine callback: `gamemain.ReceiveChat(sender, data)` + +This is the **single engine callback** invoked whenever any peer calls `SessionSendChatMessage`. It dispatches via the `chatFuncs` registry: + +``` +gamemain.ReceiveChat(sender, data) + │ + ├─ data.Identifier present → chatFuncs[data.Identifier](sender, data) [preferred path] + │ + └─ legacy fallback → iterate chatFuncs, call func if data[identifier] is truthy +``` + +### 3.2 `RegisterChatFunc(func, identifier)` + +Registers a handler. Current registrations: + +| Identifier | Handler | Registered in | +|------------|---------|---------------| +| `'Chat'` | `chat.ReceiveChat` | `chat.SetupChatLayout` | +| `'SendResumedBy'` | `SendResumedBy` (local fn) | `gamemain` init | +| `'Taunt'` | (legacy field match) | via field `data.Taunt` | +| `'Template'` | build-template handler | via field `data.Template` | +| `'ShareablePainting'` | painting adapter | via field `data.ShareablePainting` | + +### 3.3 `chat.ReceiveChat(sender, msg)` + +The `'Chat'`-identifier handler. Two responsibilities: + +1. **Sim callback** (non-replay, non-console): fires `GiveResourcesToPlayer` with zero resources as a sim-side hook to synchronise chat receipt across the sim boundary. +2. Delegates to `ReceiveChatFromSim(sender, msg)` for all display logic (skipped during replay — the replay system drives `ReceiveChatFromSim` directly from `gamemain`). + +### 3.4 `ReceiveChatFromSim(sender, msg)` + +Performs final validation and appends to `chatHistory`: + +1. `msg.ConsoleOutput` → `print()` only, no chat display, early return. +2. `msg.Chat ~= true` → dropped silently. +3. `msg.to == 'notify'` → routed through `notify.processIncomingMessage`; if that returns `false` the message is suppressed. +4. `armyData` lookup by sender name; drops unknown senders in non-replay multiplayer. +5. Builds an `entry` record with `name`, `tokey`, `color`, `armyID`, `faction`, `text`, `wrappedtext`, `new`, `camera`. +6. Inserts into `chatHistory` and triggers a scroll-to-bottom refresh. + +--- + +## 4. AI Chat Path + +AI chat bypasses `SessionSendChatMessage` entirely. + +`AIChatSorian.AISendChatMessage(towho, msg)` calls `chat.ReceiveChat(msg.aisender, msg)` directly on the local client — no network round-trip. The `aisender` field carries the AI player's name string. Taunts from AI are routed to `taunt.RecieveAITaunt` instead. + +--- + +## 5. Chat Display + +### 5.1 `chatHistory` + +A module-local table of entry records. Each record: + +```lua +{ + name = string, -- formatted "sender to-string" + tokey = string, -- key into ChatOptions for color lookup + color = string, -- ARGB hex of sender's team color + armyID = number, -- index for per-army filter + faction = number, -- faction icon index + text = string, -- raw message text + wrappedtext = string[], -- text wrapped to current window width + new = bool, -- true until first displayed + camera = table|nil, -- camera settings if a ping link is attached + time = number|nil -- fade timer (seconds since display, set lazily) +} +``` + +### 5.2 Chat Lines (`GUI.chatLines[]`) + +A pool of `Group` controls, one per visible row. Each row contains: +- `teamColor` — solid-colour bitmap (team colour border) +- `factionIcon` — faction logo bitmap +- `name` — clickable text label (clicking sets `ChatTo` to that player for private reply) +- `text` — message text; clickable if the entry has `camera` data (restores camera on click) +- `lineStickybg` — semi-transparent background shown in feed mode + +Line count is recalculated on resize to fill the container exactly. + +### 5.3 Scrolling + +The `chatContainer` implements the standard MAUI scrollable interface (`GetScrollValues`, `ScrollLines`, `ScrollPages`, `ScrollSetTop`, `IsScrollable`). The virtual size is the total wrapped-line count across all *filtered* history entries. + +`CalcVisible()` maps the current scroll position into `chatHistory`, handles line wrapping, and populates each `GUI.chatLines[i]` accordingly. + +### 5.4 Feed Mode (window hidden) + +When `GUI.bg` is hidden, the most recent lines are shown directly over the game world without the window frame. Each line: +- Shows until `curHistory.time >= ChatOptions.fade_time`, then hides itself via `OnFrame`. +- Optionally shows `lineStickybg` for readability (controlled by `ChatOptions.feed_background`). +- `ChatOptions.feed_persist` controls whether lines are force-expired when the window is manually closed. + +### 5.5 Text Wrapping + +`WrapText(data)` delegates to `maui/text.lua.WrapText`. Width is measured in screen pixels by querying `GUI.chatLines[1]`'s actual pixel width, accounting for the name prefix on the first continuation line. All history entries are re-wrapped on resize (`RewrapLog()`). + +--- + +## 6. Sending — Input Flow + +### 6.1 Recipient Selection + +`ChatTo` is a `lazyvar` holding: +- `'all'` — all players + observers +- `'allies'` — allied players only +- A number (army index) — private message to one player + +`ActivateChat(modifiers)` resolves the initial value based on `ChatOptions.send_type` and the Shift modifier. + +The chat-bubble button (`group.chatBubble`) opens `CreateChatList`, a dropdown showing all armies plus "All" and "Allies". Selecting an entry sets `ChatTo`. + +Clicking a **name** in the feed also sets `ChatTo` to that player's army index. + +### 6.2 `OnEnterPressed(text)` + +Executed when the user submits a message: + +1. **Slash commands** — if text starts with `/`, parse words, call `RunChatCommand(args)` (from `notify/commands.lua`); if handled, return early. +2. Empty text or whitespace-only → `ToggleChat()` (closes window). +3. Taunt check — `taunt.CheckForAndHandleTaunt(text)`; if it matches a taunt string, send via taunt path and return. +4. Build `msg` table: `{to = ChatTo(), Chat = true, text = text}`. +5. Attach `camera` if the camData checkbox is checked or `tempCam` is set. +6. Dispatch via `SessionSendChatMessage` with appropriate client list based on `ChatTo()` and `GetFocusArmy()`: + - `'allies'` + player → `FindClients()` + - `'allies'` + observer → `FindClients()` + `msg.Observer = true` + - number (private) → `FindClients(ChatTo())`, then echo locally via `ReceiveChat` + - `'all'` + player → `SessionSendChatMessage(msg)` (no explicit client list = broadcast) + - `'all'` + observer → `FindClients()` + `msg.Observer = true` +7. Append to `commandHistory`. + +### 6.3 Command History + +Arrow-Up/Down in the edit box cycles through `commandHistory`. Each entry is the full `msg` table, so camera state is restored alongside text. + +### 6.4 Camera Attachment + +The camData `Checkbox` in the edit area lets the user attach the current `WorldCamera` settings to a message. Clicking a received message that has `camera` data calls `WorldCamera:RestoreSettings(cameraData)`. + +### 6.5 Keyboard Shortcuts + +Registered in `keymap/keyactions.lua`: + +| Key | Action | +|-----|--------| +| Page Up | `chat.ChatPageUp(10)` | +| Page Down | `chat.ChatPageDown(10)` | +| Shift+Page Up | `chat.ChatPageUp(1)` | +| Shift+Page Down | `chat.ChatPageDown(1)` | +| Page Up (in edit box) | same as above | +| Page Down (in edit box) | same as above | + +--- + +## 7. Chat Options (Preferences) + +`ChatOptions` is loaded from the profile at module initialisation and saved back on Apply/OK. + +| Key | Type | Default | Meaning | +|-----|------|---------|---------| +| `all_color` | 1–8 | 1 | Color index for "all" messages | +| `allies_color` | 1–8 | 2 | Color index for ally messages | +| `priv_color` | 1–8 | 3 | Color index for private messages | +| `link_color` | 1–8 | 4 | Color index for camera-link messages | +| `notify_color` | 1–8 | 8 | Color index for Notify messages | +| `font_size` | 12–18 | 14 | Chat font size in points | +| `fade_time` | 5–30 | 15 | Seconds before feed lines/window auto-hide | +| `win_alpha` | 0.2–1.0 | 1 | Window opacity (stored as 0–100, normalized on use) | +| `feed_background` | bool | false | Show semi-transparent bg behind feed lines | +| `feed_persist` | bool | true | Keep feed lines visible until they individually time out | +| `send_type` | bool | false | Default recipient: false = all, true = allies | +| `links` | bool | true | Show camera-link messages | +| `[armyID]` | bool | true | Per-army message filter (one key per army, set at game start) | + +**Color palette:** 8 fixed ARGB hex values (`chatColors[]`), shown as swatches in the config window. + +**Callback system:** External code can subscribe to options changes via `AddChatOptionSetCallback(callback, id?)`. The callback is called immediately with current options and again whenever the user applies new options. + +--- + +## 8. Window Behaviour + +- **Pin button** — prevents the auto-hide timer from running. +- **Auto-hide timer** — `GUI.bg.OnFrame` increments `curTime`; when it exceeds `fade_time` the window is hidden via `ToggleChat()`. Any user activity (typing, scrolling, receiving a message) resets `curTime`. +- **Resize** — drag handles at all four corners. `OnResizeSet` triggers `RewrapLog()` + `CreateChatLines()` + `CalcVisible()`. +- **Reset position button** — snaps the window back to its default screen position. +- **Config button** — opens/closes `CreateConfigWindow()` (a separate draggable `Window`). +- **Close button** — calls `ToggleChat()`. +- **Mouse wheel** on hidden window — forwarded to the world-view zoom. + +--- + +## 9. Notify Subsystem Integration + +`notify.lua` registers ACU-upgrade messages with `to = 'notify'` and `Chat = true`. + +In `ReceiveChatFromSim`, the `to == 'notify'` check calls `notify.processIncomingMessage(sender, msg)`: +- Returns `false` → message suppressed (category disabled, rate-limited, or NIS mode). +- Returns `true` (possibly mutating `msg.text` to a default message) → falls through to normal display. + +Notify messages use `notify_color` for their text color. + +--- + +## 10. Special Message Types Bypassing the Chat Feed + +These use `SessionSendChatMessage` as transport but do **not** display in the chat window: + +| Sender | Fields | Effect | +|--------|--------|--------| +| `diplomacy.lua` | `{to='all', ConsoleOutput=msg}` | Printed to game console only | +| `pause.lua` | `{SendResumedBy=true}` | Triggers `SendResumedBy` handler in gamemain | +| `build_templates.lua` | `{Template=true, data=…}` | Shares a build template to an ally | +| `casting/mouse.lua` | custom fields | Observer mouse-position broadcast | +| `painting/PaintingCanvasAdapter.lua` | `{ShareablePainting=…}` | Painting canvas data (chunked, ~4 KB limit) | + +--- + +## 11. Taunt Integration + +`taunt.lua` intercepts text entered in the chat edit box via `CheckForAndHandleTaunt(text)` before the message is sent. If matched: +- Sends `{Taunt=true, data=tauntIndex}` via `SessionSendChatMessage` (no explicit client list). +- The receiving side handles this through the `Taunt` field match in the legacy chatFuncs dispatch. +- On receipt, the taunt text is fed back into `chat.ReceiveChat` as a normal `Chat=true` message for display. + +--- + +## 12. File Map + +| File | Role | +|------|------| +| `lua/ui/game/chat.lua` | Core module: UI creation, history, display, sending | +| `lua/ui/game/gamemain.lua` | Engine callback (`ReceiveChat`), `RegisterChatFunc` registry, lifecycle calls | +| `lua/ui/game/layouts/chat_layout.lua` | Layout skin: positions the chat Window | +| `lua/ui/notify/notify.lua` | Notify subsystem: ACU upgrade messages, filter state | +| `lua/ui/notify/commands.lua` | `/command` dispatch table | +| `lua/AIChatSorian.lua` | AI chat: bypasses network, calls `chat.ReceiveChat` directly | +| `lua/ui/game/taunt.lua` | Taunt interception and display | +| `lua/ui/game/ping.lua` | Map-ping messages with attached camera state | +| `lua/ui/game/score.lua` | Resource-sharing chat notifications | +| `lua/ui/game/pause.lua` | Pause/resume chat notifications | +| `lua/ui/game/diplomacy.lua` | Draw-offer console output via chat transport | +| `lua/ui/game/build_templates.lua` | Build-template sharing via chat transport | +| `lua/ui/game/casting/mouse.lua` | Observer mouse-position broadcast via chat transport | +| `lua/ui/game/painting/…/PaintingCanvasAdapter.lua` | Painting sharing via chunked chat messages | +| `lua/keymap/keyactions.lua` | Keyboard shortcut bindings for chat scroll | + +--- + +## 13. Known Design Issues (Refactoring Targets) + +1. **Single monolithic file** — `chat.lua` mixes UI creation, layout, message-routing logic, history management, text wrapping, options persistence, and the config dialog into one ~1560-line file. + +2. **`GUI` is a module-global shared with the layout file** — `GUI` is obtained from `/lua/ui/controls.lua` and mutated freely; there is no clear ownership boundary. + +3. **`chatHistory` entries carry display state** — `time` and `new` are display-lifecycle fields stored on data records, coupling the history model to the feed renderer. + +4. **Dual receive paths** — `ReceiveChat` and `ReceiveChatFromSim` exist because of the sim-callback detour; the naming is confusing and the split responsibilities are not obvious. + +5. **`msg` table schema is implicit** — no type annotations or schema definition; each caller adds ad-hoc fields. The `Identifier` field was added as a preferred routing key but most senders still rely on the legacy field-match fallback. + +6. **`FindClients` is imported by multiple unrelated modules** — its coupling to army/team logic could be separated into a `clientutils`-style module (a partial precedent exists in `gamemain.lua` which already imports `clientutils.GetAll()`). + +7. **`ChatOptions` is read at module load time** — changes require a full `GUI.bg:OnOptionsSet()` cycle; there is no reactive binding except via `AddChatOptionSetCallback`. + +8. **Window state leaks into history entries** — `entry.time` and `entry.new` are display-lifecycle fields stored on data records; they should be display-side state. From 08fbf058b22471aebd359ace816a33d2ee6ef0d5 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 18 Apr 2026 21:30:26 +0200 Subject: [PATCH 002/130] Add initial CLAUDE.md to help code the new chat implementation --- lua/ui/game/chat/CLAUDE.md | 223 +++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 lua/ui/game/chat/CLAUDE.md diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md new file mode 100644 index 00000000000..85e403cc5c2 --- /dev/null +++ b/lua/ui/game/chat/CLAUDE.md @@ -0,0 +1,223 @@ +# Chat — Refactoring Guide + +This directory contains the refactored in-game chat system. The goal is to replace the monolithic `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. + +--- + +## Architecture + +``` +Controller ──writes──► Model (LazyVars) ──OnDirty──► View + ▲ │ + └──────────────────── user input ──────────────────────┘ +``` + +- **Model** — a flat set of `LazyVar` instances. No UI, no networking. The single source of truth. +- **View** — UI controls that subscribe to model LazyVars via `OnDirty`. They never touch each other or call back into the controller. +- **Controller** — receives network messages and user input, validates them, and writes to the model. + +--- + +## Reactive State — How LazyVar Works + +`LazyVar` (`/lua/lazyvar.lua`) is the reactive primitive in this codebase. + +```lua +local Create = import("/lua/lazyvar.lua").Create + +-- A LazyVar holding a plain value +local recipient = Create('all') -- initial value + +-- Read the value by calling it +print(recipient()) -- 'all' + +-- Write a new value +recipient:Set('allies') -- triggers OnDirty on recipient and dependents + +-- React to changes +recipient.OnDirty = function(self) + toText:SetText(self()) -- view pulls the new value +end + +-- A LazyVar that derives from another LazyVar (computed) +local label = Create() +label:Set(function() + return 'Sending to: ' .. recipient() -- re-evaluates whenever recipient changes +end) +label.OnDirty = function(self) + someText:SetText(self()) +end +``` + +### Rules + +1. **Never cache a LazyVar's value in a local.** Always call it (`lv()`) at the moment you need it so the dependency graph stays correct. +2. **`OnDirty` is a pull notification, not a push.** It tells you the value *may* have changed; you call `self()` inside `OnDirty` to get the new value. +3. **One `OnDirty` per LazyVar instance.** Assigning `OnDirty` again overwrites the previous one. If you need multiple observers, derive a second LazyVar that reads the first. +4. **Never write to the model inside an `OnDirty`.** That is controller logic; keep views read-only. +5. **Destroy LazyVars when the owning control is destroyed** to avoid dangling `OnDirty` callbacks. + +### What the autolobby got wrong + +The autolobby passed `State` tables down through constructors and method calls (prop drilling). When state changed, the controller had to know which child controls needed updating and call them explicitly. This is brittle — adding a new view element means touching the controller. With LazyVars, the view self-subscribes; the controller stays ignorant of the view entirely. + +--- + +## Model + +Defined in `ChatModel.lua`. All fields are LazyVars. No UI imports allowed in this file. + +```lua +---@class UIChatModel +---@field recipient LazyVar<'all'|'allies'|number> # current send target +---@field history LazyVar # append-only; Set a new table ref to trigger dirty +---@field options LazyVar # persisted chat preferences +---@field windowVisible LazyVar # whether the chat window is open +``` + +`UIChatEntry` (plain table, not a LazyVar itself): + +```lua +---@class UIChatEntry +---@field name string # formatted "Sender to allies:" +---@field text string # raw message body +---@field tokey string # ChatOptions key for color lookup +---@field color string # ARGB hex team color +---@field armyID number # for per-army filter +---@field faction number # faction icon index +---@field camera? table # WorldCamera settings if this is a ping link +``` + +Display-lifecycle state (`time`, `new`) belongs to the **view**, not to entries. + +--- + +## Controller + +Defined in `ChatController.lua`. The only file allowed to call `SessionSendChatMessage`, write to the model, or register with `gamemain.RegisterChatFunc`. + +### Receiving + +``` +gamemain.ReceiveChat(sender, data) [engine callback] + └── chatFuncs['Chat'](sender, data) [registered by controller on init] + └── ChatController:OnReceive(sender, msg) + ├── validate (drop non-Chat, unknown senders) + ├── handle notify subsystem + └── model.history:Set(appendedTable) +``` + +### Sending + +``` +ChatController:Send(text) + ├── slash-command check → commands.RunChatCommand + ├── taunt check → taunt.CheckForAndHandleTaunt + ├── build msg table {to, Chat, text, camera?} + ├── resolve client list → FindClients / FindClients(id) + └── SessionSendChatMessage(clients?, msg) + + echo locally for private messages +``` + +### Init + +```lua +function ChatController:Init(mapGroup) + -- build the model + -- build the view, passing the model + -- register with gamemain + import("/lua/ui/game/gamemain.lua").RegisterChatFunc( + function(sender, data) self:OnReceive(sender, data) end, + 'Chat' + ) +end +``` + +--- + +## View + +Defined in `ChatView.lua` (window + feed) and `ChatEditView.lua` (input area). Views receive the model at construction and subscribe via `OnDirty`. They never import the controller. + +### ChatView + +Observes: +- `model.history.OnDirty` → re-render visible lines +- `model.windowVisible.OnDirty` → show/hide `GUI.bg` +- `model.options.OnDirty` → apply font size, colors, alpha, rewrap text + +Owns internally: +- `chatHistory` display-side shadow: a parallel array of `{time, visible}` per entry — **not** stored on the entries themselves +- The line pool (`GUI.chatLines[]`) and scroll container +- The fade timer (`OnFrame` on `GUI.bg`) + +### ChatEditView + +Observes: +- `model.recipient.OnDirty` → update the "To Allies:" label + +Emits (to controller, via a callback registered at construction): +- `onSend(text, cameraState?)` — user pressed Enter +- `onRecipientChange(target)` — user picked from the dropdown or clicked a name in the feed + +### ChatConfigView + +Observes: +- `model.options.OnDirty` → sync control states + +Writes (to model via controller callback): +- `onOptionsApply(newOptions)` + +--- + +## UI Elements + +| Element | File | Parent | +|---------|------|--------| +| Chat window (`GUI.bg`) | `ChatView.lua` | `GetFrame(0)` | +| Scroll container + line pool | `ChatView.lua` | `GUI.bg` client area | +| Feed lines (hidden-window mode) | `ChatView.lua` | same line pool | +| Input edit box | `ChatEditView.lua` | `GUI.bg` client area | +| Recipient label ("To Allies:") | `ChatEditView.lua` | edit group | +| Chat-bubble dropdown | `ChatEditView.lua` | edit group | +| Camera-attach checkbox | `ChatEditView.lua` | edit group | +| Options config window | `ChatConfigView.lua` | `GetFrame(0)` | + +Each chat line (`GUI.chatLines[i]`) contains: +- `teamColor` — solid-colour bitmap (team colour) +- `factionIcon` — faction logo +- `name` — clickable text; click → `onRecipientChange(armyID)` +- `text` — message body; click (if `entry.camera`) → `WorldCamera:RestoreSettings` +- `lineStickybg` — feed-mode readability background + +--- + +## Options + +`UIChatOptions` is a plain table loaded from and saved to the player profile. The model holds one LazyVar for the whole options table. A new table reference must be `Set` to trigger dirty (do not mutate in place). + +| Key | Default | Meaning | +|-----|---------|---------| +| `all_color` | 1 | Color index (1–8) for "all" messages | +| `allies_color` | 2 | Color index for ally messages | +| `priv_color` | 3 | Color index for private messages | +| `link_color` | 4 | Color index for camera-link messages | +| `notify_color` | 8 | Color index for Notify messages | +| `font_size` | 14 | Chat font size (12–18) | +| `fade_time` | 15 | Seconds before feed/window auto-hides | +| `win_alpha` | 1.0 | Window opacity (stored 0–100, normalized on use) | +| `feed_background` | false | Semi-transparent bg behind feed lines | +| `feed_persist` | true | Keep feed lines until individually timed out | +| `send_type` | false | Default recipient: false = all, true = allies | +| `links` | true | Show camera-link messages | +| `[armyID]` | true | Per-army message filter | + +--- + +## What Not To Do + +- **Do not store UI references in the model.** The model must be constructable with no UI present. +- **Do not write to the model from a view.** User actions fire a controller callback; the controller writes. +- **Do not pass the controller into views.** Pass narrow callbacks (`onSend`, `onRecipientChange`) instead. +- **Do not mutate a LazyVar's held table in place.** Create a new table and `Set` it; otherwise dependents never go dirty. +- **Do not replicate the autolobby's drilling pattern.** State is on the model; views subscribe — no parent needs to push updates into children. From 2a66bfafbbc0acc192d9051e7b676c8aadf53a83 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 18 Apr 2026 21:33:05 +0200 Subject: [PATCH 003/130] Add idea of being able to launch dialogs from a hotkey to make testing easier --- lua/ui/game/chat/CLAUDE.md | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md index 85e403cc5c2..2598affbf16 100644 --- a/lua/ui/game/chat/CLAUDE.md +++ b/lua/ui/game/chat/CLAUDE.md @@ -135,6 +135,83 @@ end --- +## Standalone Invocation + +Every complete UI component in this system (chat window, config dialog, edit view) **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. + +### How hotkeys work in this codebase + +`keyactions.lua` defines an action table. Each entry's `action` string is evaluated by the engine: + +```lua +-- keyactions.lua +local keyActionsChat = { + ['chat_toggle'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatView.lua").Toggle()', + category = 'chat', + }, + ['chat_config'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatConfigView.lua").Toggle()', + category = 'chat', + }, +} +``` + +`keydescriptions.lua` provides the display name shown in the key-binding settings UI: + +```lua +['chat_toggle'] = 'Toggle chat window', +['chat_config'] = 'Toggle chat options', +``` + +### Convention for every view module + +Each view file must export a `Toggle()` function (and optionally `Open()` / `Close()`) at module level. The function must be safe to call at any time: + +```lua +-- ChatConfigView.lua + +local instance = nil + +function Toggle() + if instance then + instance:Destroy() + instance = nil + else + Open() + end +end + +function Open() + if instance then return end + -- obtain or create the model singleton, then build the view + local model = import("/lua/ui/game/chat/ChatModel.lua").GetSingleton() + instance = CreateConfigWindow(GetFrame(0), model) +end + +function Close() + if instance then + instance:Destroy() + instance = nil + end +end +``` + +`GetFrame(0)` is always available in a UI context, so no parent reference needs to be threaded in. A component that cannot be opened this way is not truly standalone. + +### No default key bindings required + +You do not need to assign a default key to every component — the binding table entry is enough to make it available in the key-binding UI and invocable from the console during development: + +``` +UI_Lua import("/lua/ui/game/chat/ChatConfigView.lua").Toggle() +``` + +--- + ## View Defined in `ChatView.lua` (window + feed) and `ChatEditView.lua` (input area). Views receive the model at construction and subscribe via `OnDirty`. They never import the controller. From d37e1d56b83f9870ec3d3ad20ab9cce4c6500b05 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 18 Apr 2026 21:58:55 +0200 Subject: [PATCH 004/130] Experiment with generating chat config as MVC --- .vscode/settings.json | 2 +- lua/keymap/keyactions.lua | 4 + lua/keymap/keydescriptions.lua | 1 + lua/ui/game/chat/CLAUDE.md | 47 +++ .../game/chat/config/ChatConfigController.lua | 51 +++ .../game/chat/config/ChatConfigInterface.lua | 366 ++++++++++++++++++ lua/ui/game/chat/config/ChatConfigModel.lua | 96 +++++ 7 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 lua/ui/game/chat/config/ChatConfigController.lua create mode 100644 lua/ui/game/chat/config/ChatConfigInterface.lua create mode 100644 lua/ui/game/chat/config/ChatConfigModel.lua diff --git a/.vscode/settings.json b/.vscode/settings.json index 583e242167d..10e96ffa076 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,7 +23,7 @@ "Lua.runtime.pathStrict": false, "Lua.runtime.exportEnvDefault": true, "Lua.completion.autoRequire": false, - "Lua.diagnostics.globals": ["ScenarioInfo"], + "Lua.diagnostics.globals": ["ScenarioInfo", "__moduleinfo"], "Lua.format.defaultConfig": { "max_line_length": "unset", }, diff --git a/lua/keymap/keyactions.lua b/lua/keymap/keyactions.lua index 77270012582..712ba1ceb8a 100755 --- a/lua/keymap/keyactions.lua +++ b/lua/keymap/keyactions.lua @@ -1878,6 +1878,10 @@ local keyActionsChat = { action = 'UI_Lua import("/lua/ui/game/chat.lua").ChatPageDown(1)', category = 'chat', }, + ['chat_config'] = { + action = 'UI_Lua import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle()', + category = 'chat', + }, } ---@type table diff --git a/lua/keymap/keydescriptions.lua b/lua/keymap/keydescriptions.lua index 7ff7890c52f..153a8c193ef 100755 --- a/lua/keymap/keydescriptions.lua +++ b/lua/keymap/keydescriptions.lua @@ -197,6 +197,7 @@ keyDescriptions = { ['chat_page_down'] = 'Chat page down', ['chat_line_up'] = 'Chat line up', ['chat_line_down'] = 'Chat line down', + ['chat_config'] = 'Toggle chat options', ['switch_skin_up'] = 'Rotate skins up', ['switch_skin_down'] = 'Rotate skins down', diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md index 2598affbf16..e6e9d13ff29 100644 --- a/lua/ui/game/chat/CLAUDE.md +++ b/lua/ui/game/chat/CLAUDE.md @@ -291,6 +291,53 @@ Each chat line (`GUI.chatLines[i]`) contains: --- +## Class Field Annotations + +Every field assigned to `self` inside `__init` must have a matching `---@field` annotation on the class definition. This gives the language server full type information across the whole file and makes the class self-documenting at a glance. + +### Rule + +Annotate the class immediately above the `ClassUI(...)` call. List every `self.X` field in the order it appears in `__init`. For fields whose type is an array of a named struct, define that struct as its own `---@class` above the main class. + +### Example + +```lua +---@class UIChatConfigColorRow +---@field label Text +---@field combo BitmapCombo +---@field key string + +---@class UIChatConfigInterface : Window +---@field LabelColors Text +---@field ColorRows UIChatConfigColorRow[] +---@field LabelFontSize Text +---@field SliderFontSize IntegerSlider +---@field LabelBehavior Text +---@field Checkboxes Checkbox[] +---@field BtnApply Button +---@field BtnOk Button +local ChatConfigInterface = ClassUI(Window) { + __init = function(self, parent, ...) + self.LabelColors = UIUtil.CreateText(...) + self.ColorRows = {} + self.LabelFontSize = UIUtil.CreateText(...) + self.SliderFontSize = IntegerSlider(...) + self.LabelBehavior = UIUtil.CreateText(...) + self.Checkboxes = {} + self.BtnApply = UIUtil.CreateButtonStd(...) + self.BtnOk = UIUtil.CreateButtonStd(...) + end, +} +``` + +### What counts as a field + +- Every `self.Foo` written in `__init` or `__post_init`. +- Fields inherited from the parent class (e.g. `Window`) do **not** need repeating — the `: Window` in the class declaration inherits them. +- Temporary locals inside a method are not fields and need no annotation. + +--- + ## What Not To Do - **Do not store UI references in the model.** The model must be constructable with no UI present. diff --git a/lua/ui/game/chat/config/ChatConfigController.lua b/lua/ui/game/chat/config/ChatConfigController.lua new file mode 100644 index 00000000000..75d43882dcd --- /dev/null +++ b/lua/ui/game/chat/config/ChatConfigController.lua @@ -0,0 +1,51 @@ + +local Prefs = import("/lua/user/prefs.lua") + +local function Model() + return import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetSingleton() +end + +--- Commits the pending options: saves them to the profile and marks them active. +function Apply() + local model = Model() + local options = table.copy(model.Pending()) + model.Committed:Set(options) + Prefs.SetToCurrentProfile( + import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetProfileKey(), + options + ) +end + +--- Resets the pending options back to the built-in defaults. +function Reset() + Model().Pending:Set( + import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetDefaults() + ) +end + +--- Discards all pending edits, reverting to the last committed options. +function Cancel() + local model = Model() + model.Pending:Set(table.copy(model.Committed())) +end + +--- Updates a single field in the pending options. +--- Creates a new table copy to ensure the Pending LazyVar goes dirty. +---@param key string +---@param value any +function SetOption(key, value) + local model = Model() + local draft = table.copy(model.Pending()) + draft[key] = value + model.Pending:Set(draft) +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Called by the module manager when this module becomes dirty. +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua new file mode 100644 index 00000000000..8979aaa3fe6 --- /dev/null +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -0,0 +1,366 @@ +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Window = import("/lua/maui/window.lua").Window +local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo +local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- 8 ARGB solid colors selectable as message color swatches. +local Colors = { 'ffffffff', 'ffff4242', 'ffefff42', 'ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42' } + +local ColorDefs = { + { key = 'all_color', text = "All" }, + { key = 'allies_color', text = "Allies" }, + { key = 'priv_color', text = "Private" }, + { key = 'link_color', text = "Links" }, + { key = 'notify_color', text = "Notify" }, +} + +local CheckboxDefs = { + { key = 'send_type', text = "Default recipient: allies" }, + { key = 'feed_background', text = "Show feed background" }, + { key = 'feed_persist', text = "Persist feed timeout" }, + { key = 'links', text = "Show camera links" }, +} + +------------------------------------------------------------------------------- +-- Window class + +---@class UIChatConfigColorRow +---@field label Text +---@field combo BitmapCombo +---@field key string + +---@class UIChatConfigInterface : Window +---@field LabelColors Text +---@field ColorRows UIChatConfigColorRow[] +---@field LabelFontSize Text +---@field SliderFontSize IntegerSlider +---@field LabelFadeTime Text +---@field SliderFadeTime IntegerSlider +---@field LabelWinAlpha Text +---@field SliderWinAlpha IntegerSlider +---@field LabelBehavior Text +---@field Checkboxes Checkbox[] +---@field BtnApply Button +---@field BtnReset Button +---@field BtnOk Button +---@field BtnCancel Button +local ChatConfigInterface = ClassUI(Window) { + + ---@param self UIChatConfigInterface + ---@param parent Control + ---@param model UIChatConfigModel + ---@param callbacks { onApply: function, onReset: function, onCancel: function, onClose: function, onOptionChange: fun(key: string, value: any) } + __init = function(self, parent, model, callbacks) + Window.__init(self, parent, "Chat Configuration", false, false, false, true, false, "chat_config_v7", { + Left = 200, Top = 200, Right = 524, Bottom = 640, + }) + + local client = self:GetClientGroup() + + -- ---- Color rows ---- + self.LabelColors = UIUtil.CreateText(client, "Message Colors", 12, UIUtil.titleFont) + + self.ColorRows = {} + for i, def in ipairs(ColorDefs) do + local row = { + label = UIUtil.CreateText(client, def.text, 10, UIUtil.bodyFont), + combo = BitmapCombo(client, Colors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01"), + key = def.key, + } + local key = def.key + row.combo.OnClick = function(_, index) + callbacks.onOptionChange(key, index) + end + self.ColorRows[i] = row + end + + -- ---- Sliders ---- + local sliderBitmaps = { + UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), + UIUtil.SkinnableFile('/dialogs/options-02/slider-back_bmp.dds'), + } + + self.LabelFontSize = UIUtil.CreateText(client, "Font Size: 14", 10, UIUtil.bodyFont) + self.SliderFontSize = IntegerSlider(client, false, 12, 18, 1, unpack(sliderBitmaps)) + self.SliderFontSize.OnValueSet = function(_, value) + callbacks.onOptionChange('font_size', value) + end + self.SliderFontSize.OnValueChanged = function(_, value) + self.LabelFontSize:SetText(string.format("Font Size: %d", value)) + end + + self.LabelFadeTime = UIUtil.CreateText(client, "Fade Time: 15s", 10, UIUtil.bodyFont) + self.SliderFadeTime = IntegerSlider(client, false, 5, 30, 1, unpack(sliderBitmaps)) + self.SliderFadeTime.OnValueSet = function(_, value) + callbacks.onOptionChange('fade_time', value) + end + self.SliderFadeTime.OnValueChanged = function(_, value) + self.LabelFadeTime:SetText(string.format("Fade Time: %ds", value)) + end + + self.LabelWinAlpha = UIUtil.CreateText(client, "Window Alpha: 100%", 10, UIUtil.bodyFont) + self.SliderWinAlpha = IntegerSlider(client, false, 20, 100, 1, unpack(sliderBitmaps)) + self.SliderWinAlpha.OnValueSet = function(_, value) + callbacks.onOptionChange('win_alpha', value / 100) + end + self.SliderWinAlpha.OnValueChanged = function(_, value) + self.LabelWinAlpha:SetText(string.format("Window Alpha: %d%%", value)) + end + + -- ---- Checkboxes ---- + self.LabelBehavior = UIUtil.CreateText(client, "Behavior", 12, UIUtil.titleFont) + + self.Checkboxes = {} + for i, def in ipairs(CheckboxDefs) do + local cb = UIUtil.CreateCheckbox(client, '/dialogs/check-box_btn/', def.text, true) + local key = def.key + cb.OnCheck = function(_, checked) + callbacks.onOptionChange(key, checked) + end + self.Checkboxes[i] = cb + end + + -- ---- Buttons ---- + self.BtnApply = UIUtil.CreateButtonStd(client, '/widgets02/small', "Apply", 14) + self.BtnApply.OnClick = function() callbacks.onApply() end + + self.BtnReset = UIUtil.CreateButtonStd(client, '/widgets02/small', "Reset", 14) + self.BtnReset.OnClick = function() callbacks.onReset() end + + self.BtnOk = UIUtil.CreateButtonStd(client, '/widgets02/small', "OK", 14) + self.BtnOk.OnClick = function() + callbacks.onApply() + callbacks.onClose() + end + + self.BtnCancel = UIUtil.CreateButtonStd(client, '/widgets02/small', "Cancel", 14) + self.BtnCancel.OnClick = function() + callbacks.onCancel() + callbacks.onClose() + end + + -- ---- Reactive: sync all controls whenever pending options change ---- + model.Pending.OnDirty = function(lv) + self:RefreshFromOptions(lv()) + end + self:RefreshFromOptions(model.Pending()) + end, + + ---@param self UIChatConfigInterface + ---@param parent Control + ---@param model UIChatConfigModel + ---@param callbacks table + __post_init = function(self, parent, model, callbacks) + local client = self:GetClientGroup() + local pad = 8 + + -- Colors section header + Layouter(self.LabelColors) + :AtLeftTopIn(client, pad, pad) + :End() + + -- Color rows: label left, combo to its right + ---@type Control + local prev = self.LabelColors + for _, row in ipairs(self.ColorRows) do + Layouter(row.label) + :Below(prev, 6) + :AtLeftIn(client, pad) + :End() + + Layouter(row.combo) + :RightOf(row.label, 8) + :AtVerticalCenterIn(row.label) + :Width(60) + :End() + + prev = row.label + end + + -- Sliders + Layouter(self.LabelFontSize) + :Below(prev, 12) + :AtLeftIn(client, pad) + :End() + + Layouter(self.SliderFontSize) + :Below(self.LabelFontSize, 4) + :AtLeftIn(client, pad) + :Width(200) + :End() + + Layouter(self.LabelFadeTime) + :Below(self.SliderFontSize, 8) + :AtLeftIn(client, pad) + :End() + + Layouter(self.SliderFadeTime) + :Below(self.LabelFadeTime, 4) + :AtLeftIn(client, pad) + :Width(200) + :End() + + Layouter(self.LabelWinAlpha) + :Below(self.SliderFadeTime, 8) + :AtLeftIn(client, pad) + :End() + + Layouter(self.SliderWinAlpha) + :Below(self.LabelWinAlpha, 4) + :AtLeftIn(client, pad) + :Width(200) + :End() + + -- Behavior section header + Layouter(self.LabelBehavior) + :Below(self.SliderWinAlpha, 12) + :AtLeftIn(client, pad) + :End() + + -- Checkboxes + prev = self.LabelBehavior + for _, cb in ipairs(self.Checkboxes) do + Layouter(cb) + :Below(prev, 6) + :AtLeftIn(client, pad) + :End() + prev = cb + end + + -- Buttons: Apply | Reset on one row, OK | Cancel on the next + Layouter(self.BtnApply) + :Below(prev, 12) + :AtLeftIn(client, pad) + :End() + + Layouter(self.BtnReset) + :RightOf(self.BtnApply, 4) + :AtVerticalCenterIn(self.BtnApply) + :End() + + Layouter(self.BtnOk) + :Below(self.BtnApply, 4) + :AtLeftIn(client, pad) + :End() + + Layouter(self.BtnCancel) + :RightOf(self.BtnOk, 4) + :AtVerticalCenterIn(self.BtnOk) + :End() + + -- Fit the window height to its content + self.Bottom:Set(function() return self.BtnCancel.Bottom() + 16 end) + + Layouter(self) + :Width(300) + :End() + end, + + --- Syncs every control to reflect the given options table. + ---@param self UIChatConfigInterface + ---@param options UIChatOptions + RefreshFromOptions = function(self, options) + for _, row in ipairs(self.ColorRows) do + row.combo:SetItem(options[row.key] or 1) + end + + self.SliderFontSize:SetValue(options.font_size or 14) + self.SliderFadeTime:SetValue(options.fade_time or 15) + self.SliderWinAlpha:SetValue(math.floor((options.win_alpha or 1.0) * 100)) + + for i, def in ipairs(CheckboxDefs) do + -- treat absent value as the default (send_type/feed_background default false, the rest true) + local value = options[def.key] + if value == nil then + value = (def.key == 'feed_persist' or def.key == 'links') + end + self.Checkboxes[i]:SetCheck(value, true) + end + end, + + OnClose = function(self) + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() + end, +} + +------------------------------------------------------------------------------- +-- Module-level singleton and standalone entry points + +---@type UIChatConfigInterface | nil +local Instance = nil + +--- Opens the config dialog, creating it if it does not exist yet. +function Open() + if Instance then + Instance:Show() + return + end + + local model = import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetSingleton() + local controller = import("/lua/ui/game/chat/config/ChatConfigController.lua") + + Instance = ChatConfigInterface(GetFrame(0), model, { + onOptionChange = function(key, value) controller.SetOption(key, value) end, + onApply = function() controller.Apply() end, + onReset = function() controller.Reset() end, + onCancel = function() controller.Cancel() end, + onClose = function() Close() end, + }) +end + +--- Closes and destroys the config dialog. +function Close() + if Instance then + -- Remove the reactive subscription before destroying to avoid stale callbacks. + local model = import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetSingleton() + model.Pending.OnDirty = nil + + Instance:Destroy() + Instance = nil + end +end + +--- Toggles the config dialog open or closed. +function Toggle() + if Instance then + Close() + else + Open() + end +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Called by the module manager when this module is reloaded. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + newModule.Open() + end +end + +--- Called by the module manager when this module becomes dirty. +function __moduleinfo.OnDirty() + if Instance then + Instance:Destroy() + Instance = nil + end + + LOG(__moduleinfo.name .. " is dirty, re-importing...") + ForkThread( + function() + WaitSeconds(0.1) + local module = import(__moduleinfo.name) + module.Open() + end + ) +end + +--#endregion diff --git a/lua/ui/game/chat/config/ChatConfigModel.lua b/lua/ui/game/chat/config/ChatConfigModel.lua new file mode 100644 index 00000000000..5aa3f1b3f5f --- /dev/null +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -0,0 +1,96 @@ + +local Prefs = import("/lua/user/prefs.lua") +local Create = import("/lua/lazyvar.lua").Create + +local ProfileKey = "chatoptions" + +---@class UIChatOptions +---@field all_color number # color index 1-8 for "all" messages +---@field allies_color number # color index 1-8 for ally messages +---@field priv_color number # color index 1-8 for private messages +---@field link_color number # color index 1-8 for camera-link messages +---@field notify_color number # color index 1-8 for notify messages +---@field font_size number # 12-18 +---@field fade_time number # seconds, 5-30 +---@field win_alpha number # 0.0-1.0 +---@field feed_background boolean +---@field feed_persist boolean +---@field send_type boolean # false = all, true = allies +---@field links boolean # show camera-link messages + +---@type UIChatOptions +local DefaultOptions = { + all_color = 1, + allies_color = 2, + priv_color = 3, + link_color = 4, + notify_color = 8, + font_size = 14, + fade_time = 15, + win_alpha = 1.0, + feed_background = false, + feed_persist = true, + send_type = false, + links = true, +} + +---@class UIChatConfigModel +---@field Committed LazyVar # the active, saved options observed by the chat feed +---@field Pending LazyVar # the draft being edited in the config dialog + +---@type UIChatConfigModel | nil +local ModelInstance = nil + +--- Returns the model singleton, creating it if it does not exist yet. +---@return UIChatConfigModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance +end + +--- Creates and initializes the model singleton from the player profile. +---@return UIChatConfigModel +function SetupSingleton() + local saved = Prefs.GetFieldFromCurrentProfile(ProfileKey) or {} + local committed = table.merged(DefaultOptions, saved) + + ModelInstance = { + Committed = Create(committed), + Pending = Create(table.copy(committed)), + } + + return ModelInstance +end + +--- Returns a fresh copy of the built-in defaults. +---@return UIChatOptions +function GetDefaults() + return table.copy(DefaultOptions) +end + +---@return string +function GetProfileKey() + return ProfileKey +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Called by the module manager when this module is reloaded. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton() + handle.Committed:Set(table.copy(ModelInstance.Committed())) + handle.Pending:Set(table.copy(ModelInstance.Pending())) + end +end + +--- Called by the module manager when this module becomes dirty. +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From 01e218394afc46437bf345945c720377b90c0b2a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 19 Apr 2026 07:56:23 +0200 Subject: [PATCH 005/130] Directly call the controller --- lua/ui/game/chat/CLAUDE.md | 27 +++++++---- .../game/chat/config/ChatConfigInterface.lua | 48 ++++++++----------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md index e6e9d13ff29..5621e99e90d 100644 --- a/lua/ui/game/chat/CLAUDE.md +++ b/lua/ui/game/chat/CLAUDE.md @@ -233,17 +233,29 @@ Owns internally: Observes: - `model.recipient.OnDirty` → update the "To Allies:" label -Emits (to controller, via a callback registered at construction): -- `onSend(text, cameraState?)` — user pressed Enter -- `onRecipientChange(target)` — user picked from the dropdown or clicked a name in the feed +Calls directly: +- `ChatController.Send(text, cameraState?)` — user pressed Enter +- `ChatController.SetRecipient(target)` — user picked from the dropdown or clicked a name in the feed ### ChatConfigView Observes: -- `model.options.OnDirty` → sync control states +- `model.Pending.OnDirty` → sync control states -Writes (to model via controller callback): -- `onOptionsApply(newOptions)` +Calls directly: +- `ChatConfigController.SetOption(key, value)` — user changed a control +- `ChatConfigController.Apply / Reset / Cancel` — user clicked the corresponding button + +### Imports vs callbacks + +Views import the model and controller modules directly at the top of the file rather than receiving callback tables in their constructor: + +```lua +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") +``` + +This keeps dependencies visible at the top of the file and avoids the boilerplate of threading callback tables through constructors. 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. --- @@ -341,7 +353,6 @@ local ChatConfigInterface = ClassUI(Window) { ## What Not To Do - **Do not store UI references in the model.** The model must be constructable with no UI present. -- **Do not write to the model from a view.** User actions fire a controller callback; the controller writes. -- **Do not pass the controller into views.** Pass narrow callbacks (`onSend`, `onRecipientChange`) instead. +- **Do not write to the model from a view.** Views call into the controller; the controller writes. - **Do not mutate a LazyVar's held table in place.** Create a new table and `Set` it; otherwise dependents never go dirty. - **Do not replicate the autolobby's drilling pattern.** State is on the model; views subscribe — no parent needs to push updates into children. diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 8979aaa3fe6..ce10acf8298 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -5,6 +5,9 @@ local Window = import("/lua/maui/window.lua").Window local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") + local Layouter = LayoutHelpers.ReusedLayoutFor -- 8 ARGB solid colors selectable as message color swatches. @@ -52,9 +55,7 @@ local ChatConfigInterface = ClassUI(Window) { ---@param self UIChatConfigInterface ---@param parent Control - ---@param model UIChatConfigModel - ---@param callbacks { onApply: function, onReset: function, onCancel: function, onClose: function, onOptionChange: fun(key: string, value: any) } - __init = function(self, parent, model, callbacks) + __init = function(self, parent) Window.__init(self, parent, "Chat Configuration", false, false, false, true, false, "chat_config_v7", { Left = 200, Top = 200, Right = 524, Bottom = 640, }) @@ -73,7 +74,7 @@ local ChatConfigInterface = ClassUI(Window) { } local key = def.key row.combo.OnClick = function(_, index) - callbacks.onOptionChange(key, index) + ChatConfigController.SetOption(key, index) end self.ColorRows[i] = row end @@ -89,7 +90,7 @@ local ChatConfigInterface = ClassUI(Window) { self.LabelFontSize = UIUtil.CreateText(client, "Font Size: 14", 10, UIUtil.bodyFont) self.SliderFontSize = IntegerSlider(client, false, 12, 18, 1, unpack(sliderBitmaps)) self.SliderFontSize.OnValueSet = function(_, value) - callbacks.onOptionChange('font_size', value) + ChatConfigController.SetOption('font_size', value) end self.SliderFontSize.OnValueChanged = function(_, value) self.LabelFontSize:SetText(string.format("Font Size: %d", value)) @@ -98,7 +99,7 @@ local ChatConfigInterface = ClassUI(Window) { self.LabelFadeTime = UIUtil.CreateText(client, "Fade Time: 15s", 10, UIUtil.bodyFont) self.SliderFadeTime = IntegerSlider(client, false, 5, 30, 1, unpack(sliderBitmaps)) self.SliderFadeTime.OnValueSet = function(_, value) - callbacks.onOptionChange('fade_time', value) + ChatConfigController.SetOption('fade_time', value) end self.SliderFadeTime.OnValueChanged = function(_, value) self.LabelFadeTime:SetText(string.format("Fade Time: %ds", value)) @@ -107,7 +108,7 @@ local ChatConfigInterface = ClassUI(Window) { self.LabelWinAlpha = UIUtil.CreateText(client, "Window Alpha: 100%", 10, UIUtil.bodyFont) self.SliderWinAlpha = IntegerSlider(client, false, 20, 100, 1, unpack(sliderBitmaps)) self.SliderWinAlpha.OnValueSet = function(_, value) - callbacks.onOptionChange('win_alpha', value / 100) + ChatConfigController.SetOption('win_alpha', value / 100) end self.SliderWinAlpha.OnValueChanged = function(_, value) self.LabelWinAlpha:SetText(string.format("Window Alpha: %d%%", value)) @@ -121,31 +122,32 @@ local ChatConfigInterface = ClassUI(Window) { local cb = UIUtil.CreateCheckbox(client, '/dialogs/check-box_btn/', def.text, true) local key = def.key cb.OnCheck = function(_, checked) - callbacks.onOptionChange(key, checked) + ChatConfigController.SetOption(key, checked) end self.Checkboxes[i] = cb end -- ---- Buttons ---- self.BtnApply = UIUtil.CreateButtonStd(client, '/widgets02/small', "Apply", 14) - self.BtnApply.OnClick = function() callbacks.onApply() end + self.BtnApply.OnClick = function() ChatConfigController.Apply() end self.BtnReset = UIUtil.CreateButtonStd(client, '/widgets02/small', "Reset", 14) - self.BtnReset.OnClick = function() callbacks.onReset() end + self.BtnReset.OnClick = function() ChatConfigController.Reset() end self.BtnOk = UIUtil.CreateButtonStd(client, '/widgets02/small', "OK", 14) self.BtnOk.OnClick = function() - callbacks.onApply() - callbacks.onClose() + ChatConfigController.Apply() + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() end self.BtnCancel = UIUtil.CreateButtonStd(client, '/widgets02/small', "Cancel", 14) self.BtnCancel.OnClick = function() - callbacks.onCancel() - callbacks.onClose() + ChatConfigController.Cancel() + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() end -- ---- Reactive: sync all controls whenever pending options change ---- + local model = ChatConfigModel.GetSingleton() model.Pending.OnDirty = function(lv) self:RefreshFromOptions(lv()) end @@ -154,9 +156,7 @@ local ChatConfigInterface = ClassUI(Window) { ---@param self UIChatConfigInterface ---@param parent Control - ---@param model UIChatConfigModel - ---@param callbacks table - __post_init = function(self, parent, model, callbacks) + __post_init = function(self, parent) local client = self:GetClientGroup() local pad = 8 @@ -302,24 +302,14 @@ function Open() return end - local model = import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetSingleton() - local controller = import("/lua/ui/game/chat/config/ChatConfigController.lua") - - Instance = ChatConfigInterface(GetFrame(0), model, { - onOptionChange = function(key, value) controller.SetOption(key, value) end, - onApply = function() controller.Apply() end, - onReset = function() controller.Reset() end, - onCancel = function() controller.Cancel() end, - onClose = function() Close() end, - }) + Instance = ChatConfigInterface(GetFrame(0)) end --- Closes and destroys the config dialog. function Close() if Instance then -- Remove the reactive subscription before destroying to avoid stale callbacks. - local model = import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetSingleton() - model.Pending.OnDirty = nil + ChatConfigModel.GetSingleton().Pending.OnDirty = nil Instance:Destroy() Instance = nil From 63ca384b2d9c1198f35cf4cb7f48b6a45020d17f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 19 Apr 2026 08:02:42 +0200 Subject: [PATCH 006/130] Reduce magic values --- .../game/chat/config/ChatConfigInterface.lua | 55 ++++++++++++------- lua/ui/game/chat/config/ChatConfigModel.lua | 38 +++++++++++++ 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index ce10acf8298..df959f4722d 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -14,18 +14,18 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local Colors = { 'ffffffff', 'ffff4242', 'ffefff42', 'ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42' } local ColorDefs = { - { key = 'all_color', text = "All" }, - { key = 'allies_color', text = "Allies" }, - { key = 'priv_color', text = "Private" }, - { key = 'link_color', text = "Links" }, - { key = 'notify_color', text = "Notify" }, + { key = ChatConfigModel.KeyAllColor, text = "All" }, + { key = ChatConfigModel.KeyAlliesColor, text = "Allies" }, + { key = ChatConfigModel.KeyPrivColor, text = "Private" }, + { key = ChatConfigModel.KeyLinkColor, text = "Links" }, + { key = ChatConfigModel.KeyNotifyColor, text = "Notify" }, } local CheckboxDefs = { - { key = 'send_type', text = "Default recipient: allies" }, - { key = 'feed_background', text = "Show feed background" }, - { key = 'feed_persist', text = "Persist feed timeout" }, - { key = 'links', text = "Show camera links" }, + { key = ChatConfigModel.KeySendType, text = "Default recipient: allies" }, + { key = ChatConfigModel.KeyFeedBackground, text = "Show feed background" }, + { key = ChatConfigModel.KeyFeedPersist, text = "Persist feed timeout" }, + { key = ChatConfigModel.KeyLinks, text = "Show camera links" }, } ------------------------------------------------------------------------------- @@ -88,27 +88,39 @@ local ChatConfigInterface = ClassUI(Window) { } self.LabelFontSize = UIUtil.CreateText(client, "Font Size: 14", 10, UIUtil.bodyFont) - self.SliderFontSize = IntegerSlider(client, false, 12, 18, 1, unpack(sliderBitmaps)) + self.SliderFontSize = IntegerSlider(client, false, + ChatConfigModel.FontSizeRange.min, + ChatConfigModel.FontSizeRange.max, + ChatConfigModel.FontSizeRange.inc, + unpack(sliderBitmaps)) self.SliderFontSize.OnValueSet = function(_, value) - ChatConfigController.SetOption('font_size', value) + ChatConfigController.SetOption(ChatConfigModel.KeyFontSize, value) end self.SliderFontSize.OnValueChanged = function(_, value) self.LabelFontSize:SetText(string.format("Font Size: %d", value)) end self.LabelFadeTime = UIUtil.CreateText(client, "Fade Time: 15s", 10, UIUtil.bodyFont) - self.SliderFadeTime = IntegerSlider(client, false, 5, 30, 1, unpack(sliderBitmaps)) + self.SliderFadeTime = IntegerSlider(client, false, + ChatConfigModel.FadeTimeRange.min, + ChatConfigModel.FadeTimeRange.max, + ChatConfigModel.FadeTimeRange.inc, + unpack(sliderBitmaps)) self.SliderFadeTime.OnValueSet = function(_, value) - ChatConfigController.SetOption('fade_time', value) + ChatConfigController.SetOption(ChatConfigModel.KeyFadeTime, value) end self.SliderFadeTime.OnValueChanged = function(_, value) self.LabelFadeTime:SetText(string.format("Fade Time: %ds", value)) end self.LabelWinAlpha = UIUtil.CreateText(client, "Window Alpha: 100%", 10, UIUtil.bodyFont) - self.SliderWinAlpha = IntegerSlider(client, false, 20, 100, 1, unpack(sliderBitmaps)) + self.SliderWinAlpha = IntegerSlider(client, false, + ChatConfigModel.WinAlphaSliderRange.min, + ChatConfigModel.WinAlphaSliderRange.max, + ChatConfigModel.WinAlphaSliderRange.inc, + unpack(sliderBitmaps)) self.SliderWinAlpha.OnValueSet = function(_, value) - ChatConfigController.SetOption('win_alpha', value / 100) + ChatConfigController.SetOption(ChatConfigModel.KeyWinAlpha, value / 100) end self.SliderWinAlpha.OnValueChanged = function(_, value) self.LabelWinAlpha:SetText(string.format("Window Alpha: %d%%", value)) @@ -266,19 +278,20 @@ local ChatConfigInterface = ClassUI(Window) { ---@param self UIChatConfigInterface ---@param options UIChatOptions RefreshFromOptions = function(self, options) + local defaults = ChatConfigModel.GetDefaults() + for _, row in ipairs(self.ColorRows) do - row.combo:SetItem(options[row.key] or 1) + row.combo:SetItem(options[row.key] or defaults[row.key]) end - self.SliderFontSize:SetValue(options.font_size or 14) - self.SliderFadeTime:SetValue(options.fade_time or 15) - self.SliderWinAlpha:SetValue(math.floor((options.win_alpha or 1.0) * 100)) + self.SliderFontSize:SetValue(options.font_size or defaults.font_size) + self.SliderFadeTime:SetValue(options.fade_time or defaults.fade_time) + self.SliderWinAlpha:SetValue(math.floor((options.win_alpha or defaults.win_alpha) * 100)) for i, def in ipairs(CheckboxDefs) do - -- treat absent value as the default (send_type/feed_background default false, the rest true) local value = options[def.key] if value == nil then - value = (def.key == 'feed_persist' or def.key == 'links') + value = defaults[def.key] end self.Checkboxes[i]:SetCheck(value, true) end diff --git a/lua/ui/game/chat/config/ChatConfigModel.lua b/lua/ui/game/chat/config/ChatConfigModel.lua index 5aa3f1b3f5f..351e8b4cb8b 100644 --- a/lua/ui/game/chat/config/ChatConfigModel.lua +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -34,6 +34,44 @@ local DefaultOptions = { links = true, } + +------------------------------------------------------------------------------- +-- Option keys exported as module globals so views and controllers can address +-- fields without magic strings. Each constant's value matches the field name +-- on `UIChatOptions`. + +KeyAllColor = 'all_color' +KeyAlliesColor = 'allies_color' +KeyPrivColor = 'priv_color' +KeyLinkColor = 'link_color' +KeyNotifyColor = 'notify_color' +KeyFontSize = 'font_size' +KeyFadeTime = 'fade_time' +KeyWinAlpha = 'win_alpha' +KeyFeedBackground = 'feed_background' +KeyFeedPersist = 'feed_persist' +KeySendType = 'send_type' +KeyLinks = 'links' + +------------------------------------------------------------------------------- +-- Value ranges for numeric options. Exported as module globals so the view +-- can construct sliders without duplicating the limits. + +---@class UIChatSliderRange +---@field min number +---@field max number +---@field inc number + +---@type UIChatSliderRange +FontSizeRange = { min = 12, max = 18, inc = 1 } + +---@type UIChatSliderRange +FadeTimeRange = { min = 5, max = 30, inc = 1 } + +--- Window alpha is stored as 0.0-1.0 but edited via an integer percent slider. +---@type UIChatSliderRange +WinAlphaSliderRange = { min = 20, max = 100, inc = 1 } + ---@class UIChatConfigModel ---@field Committed LazyVar # the active, saved options observed by the chat feed ---@field Pending LazyVar # the draft being edited in the config dialog From fc6148743827ef2a0fb82a8c68e54d54bcf869f4 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 22 Apr 2026 22:05:22 +0200 Subject: [PATCH 007/130] First draft of the chat dialog --- lua/keymap/keyactions.lua | 4 + lua/keymap/keydescriptions.lua | 1 + lua/ui/game/chat/ChatController.lua | 59 ++++++++ lua/ui/game/chat/ChatEditInterface.lua | 101 +++++++++++++ lua/ui/game/chat/ChatInterface.lua | 195 +++++++++++++++++++++++++ lua/ui/game/chat/ChatLineInterface.lua | 118 +++++++++++++++ lua/ui/game/chat/ChatModel.lua | 75 ++++++++++ 7 files changed, 553 insertions(+) create mode 100644 lua/ui/game/chat/ChatController.lua create mode 100644 lua/ui/game/chat/ChatEditInterface.lua create mode 100644 lua/ui/game/chat/ChatInterface.lua create mode 100644 lua/ui/game/chat/ChatLineInterface.lua create mode 100644 lua/ui/game/chat/ChatModel.lua diff --git a/lua/keymap/keyactions.lua b/lua/keymap/keyactions.lua index 712ba1ceb8a..9b7768efe5e 100755 --- a/lua/keymap/keyactions.lua +++ b/lua/keymap/keyactions.lua @@ -1882,6 +1882,10 @@ local keyActionsChat = { action = 'UI_Lua import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle()', category = 'chat', }, + ['chat_window'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").Toggle()', + category = 'chat', + }, } ---@type table diff --git a/lua/keymap/keydescriptions.lua b/lua/keymap/keydescriptions.lua index 153a8c193ef..edae3d86c26 100755 --- a/lua/keymap/keydescriptions.lua +++ b/lua/keymap/keydescriptions.lua @@ -198,6 +198,7 @@ keyDescriptions = { ['chat_line_up'] = 'Chat line up', ['chat_line_down'] = 'Chat line down', ['chat_config'] = 'Toggle chat options', + ['chat_window'] = 'Toggle chat window', ['switch_skin_up'] = 'Rotate skins up', ['switch_skin_down'] = 'Rotate skins down', diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua new file mode 100644 index 00000000000..1236fc734ce --- /dev/null +++ b/lua/ui/game/chat/ChatController.lua @@ -0,0 +1,59 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +------------------------------------------------------------------------------- +-- Window visibility + +--- Shows the chat window. +function OpenWindow() + ChatModel.GetSingleton().WindowVisible:Set(true) +end + +--- Hides the chat window. +function CloseWindow() + ChatModel.GetSingleton().WindowVisible:Set(false) +end + +--- Toggles the chat window open or closed. +function ToggleWindow() + local lv = ChatModel.GetSingleton().WindowVisible + lv:Set(not lv()) +end + +------------------------------------------------------------------------------- +-- Recipient + +--- Sets the current send target. +---@param target UIChatRecipient +function SetRecipient(target) + ChatModel.GetSingleton().Recipient:Set(target) +end + +------------------------------------------------------------------------------- +-- Messages + +--- Appends an entry to the history log. Called by the receive path as well as +--- by locally-echoed outgoing messages. +---@param entry UIChatEntry +function AppendEntry(entry) + local model = ChatModel.GetSingleton() + local history = table.copy(model.History()) + table.insert(history, entry) + model.History:Set(history) +end + +--- Sends a message to the current recipient. +--- Stubbed — the network layer will be wired up in a follow-up step. +---@param text string +function Send(text) + WARN("ChatController.Send not yet implemented: " .. tostring(text)) +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua new file mode 100644 index 00000000000..cddc9a4ce6e --- /dev/null +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -0,0 +1,101 @@ + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Edit = import("/lua/maui/edit.lua").Edit + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatController = import("/lua/ui/game/chat/ChatController.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local MaxChars = 200 + +------------------------------------------------------------------------------- +-- The chat input area: a recipient label followed by an edit box. Pressing +-- Enter dispatches the text to the controller. + +---@class UIChatEditInterface : Group +---@field RecipientLabel Text +---@field EditBox Edit +ChatEditInterface = ClassUI(Group) { + + ---@param self UIChatEditInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatEditInterface") + + self.RecipientLabel = UIUtil.CreateText(self, "To All:", 14, 'Arial') + self.RecipientLabel:SetDropShadow(true) + + self.EditBox = Edit(self) + UIUtil.SetupEditStd(self.EditBox, + "ff00ff00", nil, "ffffffff", + UIUtil.highlightColor, UIUtil.bodyFont, 14, MaxChars) + self.EditBox:SetDropShadow(true) + self.EditBox:ShowBackground(false) + self.EditBox:SetText('') + + self.EditBox.OnEnterPressed = function(edit, text) + if text and text ~= '' then + ChatController.Send(text) + edit:SetText('') + end + end + + -- Keep the label in sync with the model. + local model = ChatModel.GetSingleton() + model.Recipient.OnDirty = function(lv) + self:RefreshRecipient(lv()) + end + self:RefreshRecipient(model.Recipient()) + end, + + ---@param self UIChatEditInterface + ---@param parent Control + __post_init = function(self, parent) + Layouter(self.RecipientLabel) + :AtLeftIn(self, 8) + :AtVerticalCenterIn(self) + :End() + + Layouter(self.EditBox) + :AnchorToRight(self.RecipientLabel, 8) + :AtRightIn(self, 8) + :AtVerticalCenterIn(self) + :Height(function() return self.EditBox:GetFontHeight() end) + :End() + end, + + --- Updates the label from the current recipient value. + ---@param self UIChatEditInterface + ---@param recipient UIChatRecipient + RefreshRecipient = function(self, recipient) + if recipient == ChatModel.RecipientAll then + self.RecipientLabel:SetText("To All:") + elseif recipient == ChatModel.RecipientAllies then + self.RecipientLabel:SetText("To Allies:") + elseif type(recipient) == 'number' then + local armies = GetArmiesTable() + local army = armies and armies.armiesTable and armies.armiesTable[recipient] + local name = army and army.nickname or tostring(recipient) + self.RecipientLabel:SetText("To " .. name .. ":") + end + end, + + --- Moves keyboard focus into the edit box. + ---@param self UIChatEditInterface + AcquireFocus = function(self) + self.EditBox:AcquireFocus() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua new file mode 100644 index 00000000000..7d2ec07e0a9 --- /dev/null +++ b/lua/ui/game/chat/ChatInterface.lua @@ -0,0 +1,195 @@ + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Window = import("/lua/maui/window.lua").Window +local Group = import("/lua/maui/group.lua").Group + +local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface +local ChatEditInterface = import("/lua/ui/game/chat/ChatEditInterface.lua").ChatEditInterface + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatController = import("/lua/ui/game/chat/ChatController.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- Fixed-size line pool for the scaffold. A later pass will size this +-- dynamically from the window height and wire up scrolling. +local LineHeight = 18 +local LineCount = 12 + +------------------------------------------------------------------------------- +-- The main chat window: the draggable frame that hosts the line pool and the +-- edit area. Subscribes to the model for history and visibility changes. + +---@class UIChatInterface : Window +---@field LinesContainer Group +---@field Lines UIChatLineInterface[] +---@field Edit UIChatEditInterface +local ChatInterface = ClassUI(Window) { + + ---@param self UIChatInterface + ---@param parent Control + __init = function(self, parent) + Window.__init(self, parent, "Chat", false, true, true, false, false, "chat_window_v2", { + Left = 8, Top = 460, Right = 430, Bottom = 720, + }) + + local client = self:GetClientGroup() + + -- Container for the line pool. + self.LinesContainer = Group(client, "ChatLinesContainer") + + -- Fixed-size pool of line rows. + self.Lines = {} + for i = 1, LineCount do + self.Lines[i] = ChatLineInterface(self.LinesContainer) + end + + -- The edit area sits at the bottom of the client region. + self.Edit = ChatEditInterface(client) + + -- Reactive: history → refresh visible rows. + local model = ChatModel.GetSingleton() + model.History.OnDirty = function(lv) + self:RefreshLines(lv()) + end + self:RefreshLines(model.History()) + + -- Reactive: window visibility → show / hide the frame. + model.WindowVisible.OnDirty = function(lv) + if lv() then + self:Show() + self.Edit:AcquireFocus() + else + self:Hide() + end + end + if model.WindowVisible() then + self:Show() + else + self:Hide() + end + end, + + ---@param self UIChatInterface + ---@param parent Control + __post_init = function(self, parent) + local client = self:GetClientGroup() + local pad = 4 + + Layouter(self.Edit) + :AtLeftIn(client, pad) + :AtRightIn(client, pad) + :AtBottomIn(client, pad) + :Height(24) + :End() + + Layouter(self.LinesContainer) + :AtLeftIn(client, pad) + :AtRightIn(client, pad) + :AtTopIn(client, pad) + :AnchorToTop(self.Edit, pad) + :End() + + -- Stack lines top-down inside the container. + local prev = nil + for _, line in ipairs(self.Lines) do + if prev then + Layouter(line) + :Below(prev) + :AtLeftIn(self.LinesContainer) + :AtRightIn(self.LinesContainer) + :Height(LineHeight) + :End() + else + Layouter(line) + :AtLeftTopIn(self.LinesContainer) + :AtRightIn(self.LinesContainer) + :Height(LineHeight) + :End() + end + prev = line + end + end, + + --- Fills the line pool with the most-recent history entries. + ---@param self UIChatInterface + ---@param history UIChatEntry[] + RefreshLines = function(self, history) + local count = table.getn(history) + local poolSize = table.getn(self.Lines) + local start = math.max(1, count - poolSize + 1) + + for i = 1, poolSize do + local line = self.Lines[i] + local entry = history[start + i - 1] + if entry then + line:SetEntry(entry) + line:Show() + else + line:Clear() + line:Hide() + end + end + end, + + --- Engine-invoked when the user clicks the close button on the window frame. + OnClose = function(self) + ChatController.CloseWindow() + end, +} + +------------------------------------------------------------------------------- +-- Module-level singleton and standalone entry points. + +---@type UIChatInterface | nil +local Instance = nil + +--- Shows the chat window, creating it on first call. +function Open() + if not Instance then + Instance = ChatInterface(GetFrame(0)) + end + ChatController.OpenWindow() +end + +--- Hides the chat window (the instance is kept around). +function Close() + ChatController.CloseWindow() +end + +--- Toggles the chat window, creating it on first call. +function Toggle() + if not Instance then + Instance = ChatInterface(GetFrame(0)) + end + ChatController.ToggleWindow() +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Called by the module manager when this module is reloaded. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + newModule.Open() + end +end + +--- Called by the module manager when this module becomes dirty. +function __moduleinfo.OnDirty() + if Instance then + -- Clear subscriptions to avoid dangling callbacks into a destroyed view. + local model = ChatModel.GetSingleton() + model.History.OnDirty = nil + model.WindowVisible.OnDirty = nil + + Instance:Destroy() + Instance = nil + end + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua new file mode 100644 index 00000000000..4bc1760fcb9 --- /dev/null +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -0,0 +1,118 @@ + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local Factions = import("/lua/factions.lua").Factions + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- Collect faction icons up-front; append an observer icon as the final entry +-- so non-player senders can be represented too. +local FactionIcons = {} +for _, data in Factions do + table.insert(FactionIcons, data.Icon) +end +table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') + +------------------------------------------------------------------------------- +-- A single chat row: team-coloured faction icon, sender name, message text, +-- and a semi-transparent background that shows in feed mode. + +---@class UIChatLineInterface : Group +---@field StickyBg Bitmap +---@field TeamColor Bitmap +---@field FactionIcon Bitmap +---@field Name Text +---@field Text Text +ChatLineInterface = ClassUI(Group) { + + ---@param self UIChatLineInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatLineInterface") + + self.StickyBg = Bitmap(self) + self.StickyBg:SetSolidColor('aa000000') + self.StickyBg:DisableHitTest() + self.StickyBg:Hide() + + self.TeamColor = Bitmap(self) + self.TeamColor:SetSolidColor('00000000') + + self.FactionIcon = Bitmap(self.TeamColor) + self.FactionIcon:SetSolidColor('00000000') + + self.Name = UIUtil.CreateText(self, '', 14, 'Arial Bold') + self.Name:SetColor('ffffffff') + self.Name:SetDropShadow(true) + self.Name:DisableHitTest() + + self.Text = UIUtil.CreateText(self, '', 14, 'Arial') + self.Text:SetColor('ffc2f6ff') + self.Text:SetDropShadow(true) + self.Text:SetClipToWidth(true) + self.Text:DisableHitTest() + end, + + ---@param self UIChatLineInterface + ---@param parent Control + __post_init = function(self, parent) + LayoutHelpers.FillParent(self.StickyBg, self) + LayoutHelpers.DepthUnderParent(self.StickyBg, self) + + Layouter(self.TeamColor) + :AtLeftTopIn(self) + :Width(self.Height) + :Height(self.Height) + :End() + + Layouter(self.FactionIcon) + :Fill(self.TeamColor) + :End() + + Layouter(self.Name) + :CenteredRightOf(self.TeamColor, 4) + :Over(self, 10) + :End() + + Layouter(self.Text) + :Left(function() return self.Name.Right() + 2 end) + :Right(self.Right) + :AtVerticalCenterIn(self.TeamColor) + :Over(self, 10) + :End() + end, + + --- Populates the line from a history entry. + ---@param self UIChatLineInterface + ---@param entry UIChatEntry + SetEntry = function(self, entry) + self.Name:SetText(entry.name or '') + self.Text:SetText(entry.text or '') + self.TeamColor:SetSolidColor(entry.color or '00000000') + + local iconIndex = entry.faction or table.getn(FactionIcons) + self.FactionIcon:SetTexture(UIUtil.UIFile(FactionIcons[iconIndex])) + end, + + --- Clears all content so the row can stand empty. + ---@param self UIChatLineInterface + Clear = function(self) + self.Name:SetText('') + self.Text:SetText('') + self.TeamColor:SetSolidColor('00000000') + self.FactionIcon:SetSolidColor('00000000') + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua new file mode 100644 index 00000000000..d7b88544c05 --- /dev/null +++ b/lua/ui/game/chat/ChatModel.lua @@ -0,0 +1,75 @@ + +local Create = import("/lua/lazyvar.lua").Create + +------------------------------------------------------------------------------- +-- Recipient constants, exported so the rest of the system never hardcodes them. + +--- Broadcast to every connected client. +RecipientAll = 'all' + +--- Broadcast to allied players (or all observers when observing). +RecipientAllies = 'allies' + +---@alias UIChatRecipient 'all' | 'allies' | number # number = army ID for a private message + +------------------------------------------------------------------------------- +-- History entry. + +---@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 color +---@field armyID number # sender's army index +---@field faction number # faction icon index (1-based) +---@field recipient UIChatRecipient # the target this message was directed to +---@field camera? table # camera state when the message is a ping link + +------------------------------------------------------------------------------- +-- Model. + +---@class UIChatModel +---@field History LazyVar # append-only message log (set a new table ref to trigger dirty) +---@field Recipient LazyVar # current send target +---@field WindowVisible LazyVar # whether the chat window is open + +---@type UIChatModel | nil +local ModelInstance = nil + +--- Returns the model singleton, creating it if it does not exist yet. +---@return UIChatModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance +end + +--- Creates and initializes the model singleton. +---@return UIChatModel +function SetupSingleton() + ModelInstance = { + History = Create({}), + Recipient = Create(RecipientAll), + WindowVisible = Create(false), + } + return ModelInstance +end + +------------------------------------------------------------------------------- +--#region Debugging + +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton() + handle.History:Set(ModelInstance.History()) + handle.Recipient:Set(ModelInstance.Recipient()) + handle.WindowVisible:Set(ModelInstance.WindowVisible()) + end +end + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From 9d8553bac9ffeb0def42231843713ebfec2aced0 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Wed, 22 Apr 2026 23:34:57 +0200 Subject: [PATCH 008/130] Use standard chat dialog textures --- lua/ui/game/chat/ChatEditInterface.lua | 26 +++++++++++++++++++--- lua/ui/game/chat/ChatInterface.lua | 30 ++++++++++++++++++++------ lua/ui/game/chat/ChatLineInterface.lua | 17 ++++++--------- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index cddc9a4ce6e..21ecd78b348 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -30,6 +30,19 @@ ChatEditInterface = ClassUI(Group) { self.RecipientLabel:SetDropShadow(true) self.EditBox = Edit(self) + + -- Placeholder bounds so that `SetupEditStd` below, which internally + -- calls `SetFont` and reads the control's Left/Right, can evaluate + -- the layout without tripping the default circular Left/Right/Width + -- chain set up by `Control.ResetLayout`. `__post_init` replaces these + -- with the real layout. + Layouter(self.EditBox) + :Left(0) + :Top(0) + :Width(200) + :Height(20) + :End() + UIUtil.SetupEditStd(self.EditBox, "ff00ff00", nil, "ffffffff", UIUtil.highlightColor, UIUtil.bodyFont, 14, MaxChars) @@ -56,16 +69,23 @@ ChatEditInterface = ClassUI(Group) { ---@param parent Control __post_init = function(self, parent) Layouter(self.RecipientLabel) - :AtLeftIn(self, 8) + :AtLeftIn(self, 2) :AtVerticalCenterIn(self) :End() Layouter(self.EditBox) - :AnchorToRight(self.RecipientLabel, 8) - :AtRightIn(self, 8) + :AnchorToRight(self.RecipientLabel, 4) + :AtRightIn(self, 2) :AtVerticalCenterIn(self) :Height(function() return self.EditBox:GetFontHeight() end) :End() + + -- The group sizes itself to the edit's font height; the parent + -- positions it (Left/Right/Bottom) and leaves Height alone. This + -- mirrors the original `group.Height:Set(function() return group.edit.Height() end)`. + Layouter(self) + :Height(function() return self.EditBox.Height() end) + :End() end, --- Updates the label from the current recipient value. diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 7d2ec07e0a9..0de24cfcb78 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -18,6 +18,22 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local LineHeight = 18 local LineCount = 12 +--- Skin textures for the chat window frame. Mirrors the layout that +--- `/lua/ui/game/layouts/chat_layout.lua` applies to the legacy chat Window +--- so the new window matches the original visual style. +local WindowTextures = { + tl = UIUtil.UIFile('/game/chat_brd/chat_brd_ul.dds'), + tr = UIUtil.UIFile('/game/chat_brd/chat_brd_ur.dds'), + tm = UIUtil.UIFile('/game/chat_brd/chat_brd_horz_um.dds'), + ml = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_l.dds'), + m = UIUtil.UIFile('/game/chat_brd/chat_brd_m.dds'), + mr = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_r.dds'), + bl = UIUtil.UIFile('/game/chat_brd/chat_brd_ll.dds'), + bm = UIUtil.UIFile('/game/chat_brd/chat_brd_lm.dds'), + br = UIUtil.UIFile('/game/chat_brd/chat_brd_lr.dds'), + borderColor = 'ff415055', +} + ------------------------------------------------------------------------------- -- The main chat window: the draggable frame that hosts the line pool and the -- edit area. Subscribes to the model for history and visibility changes. @@ -31,9 +47,9 @@ local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface ---@param parent Control __init = function(self, parent) - Window.__init(self, parent, "Chat", false, true, true, false, false, "chat_window_v2", { + Window.__init(self, parent, "", false, true, true, false, false, "chat_window_v2", { Left = 8, Top = 460, Right = 430, Bottom = 720, - }) + }, WindowTextures) local client = self:GetClientGroup() @@ -78,11 +94,13 @@ local ChatInterface = ClassUI(Window) { local client = self:GetClientGroup() local pad = 4 + -- Full width, flush with the bottom of the client area. The edit + -- group derives its own height (see ChatEditInterface.__post_init). Layouter(self.Edit) - :AtLeftIn(client, pad) - :AtRightIn(client, pad) - :AtBottomIn(client, pad) - :Height(24) + :AtLeftIn(client, 30) + :AtRightIn(client) + :AtBottomIn(client) + :Over(client, 200) :End() Layouter(self.LinesContainer) diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index 4bc1760fcb9..861054efc31 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -18,11 +18,14 @@ end table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') ------------------------------------------------------------------------------- --- A single chat row: team-coloured faction icon, sender name, message text, --- and a semi-transparent background that shows in feed mode. +-- A single chat row: team-coloured faction icon, sender name and message text. +-- +-- The semi-transparent "feed mode" background (shown when the window chrome +-- is hidden) will be added back together with the feed-mode implementation — +-- having it here while `line:Show()` cascades to children caused it to double +-- up over the chat-window background. ---@class UIChatLineInterface : Group ----@field StickyBg Bitmap ---@field TeamColor Bitmap ---@field FactionIcon Bitmap ---@field Name Text @@ -34,11 +37,6 @@ ChatLineInterface = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent, "ChatLineInterface") - self.StickyBg = Bitmap(self) - self.StickyBg:SetSolidColor('aa000000') - self.StickyBg:DisableHitTest() - self.StickyBg:Hide() - self.TeamColor = Bitmap(self) self.TeamColor:SetSolidColor('00000000') @@ -60,9 +58,6 @@ ChatLineInterface = ClassUI(Group) { ---@param self UIChatLineInterface ---@param parent Control __post_init = function(self, parent) - LayoutHelpers.FillParent(self.StickyBg, self) - LayoutHelpers.DepthUnderParent(self.StickyBg, self) - Layouter(self.TeamColor) :AtLeftTopIn(self) :Width(self.Height) From b805a94472967346d9332776e846182de36ccba8 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:20:04 +0200 Subject: [PATCH 009/130] Add chat list interface to select recipient(s) --- lua/ui/game/chat/ChatEditInterface.lua | 54 ++++++- lua/ui/game/chat/ChatInterface.lua | 4 +- lua/ui/game/chat/ChatListInterface.lua | 196 +++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 lua/ui/game/chat/ChatListInterface.lua diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 21ecd78b348..bc1dfbc5b7c 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -4,21 +4,26 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Edit = import("/lua/maui/edit.lua").Edit +local Button = import("/lua/maui/button.lua").Button local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") +local ChatListInterface = import("/lua/ui/game/chat/ChatListInterface.lua").ChatListInterface local Layouter = LayoutHelpers.ReusedLayoutFor local MaxChars = 200 ------------------------------------------------------------------------------- --- The chat input area: a recipient label followed by an edit box. Pressing --- Enter dispatches the text to the controller. +-- The chat input area: a chat-bubble button, a recipient label, and an edit +-- box. Pressing Enter dispatches the text to the controller. Clicking the +-- chat-bubble button or the label opens the recipient picker (ChatListInterface). ---@class UIChatEditInterface : Group +---@field ChatBubble Button ---@field RecipientLabel Text ---@field EditBox Edit +---@field ChatList UIChatListInterface | nil ChatEditInterface = ClassUI(Group) { ---@param self UIChatEditInterface @@ -26,9 +31,25 @@ ChatEditInterface = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent, "ChatEditInterface") + self.ChatBubble = Button(self, + UIUtil.UIFile('/game/chat-box_btn/radio_btn_up.dds'), + UIUtil.UIFile('/game/chat-box_btn/radio_btn_down.dds'), + UIUtil.UIFile('/game/chat-box_btn/radio_btn_over.dds'), + UIUtil.UIFile('/game/chat-box_btn/radio_btn_dis.dds')) + self.ChatBubble.OnClick = function() + self:ToggleList() + end + self.RecipientLabel = UIUtil.CreateText(self, "To All:", 14, 'Arial') self.RecipientLabel:SetDropShadow(true) + -- Clicking the label also opens the recipient picker. + self.RecipientLabel.HandleEvent = function(_, event) + if event.Type == 'ButtonPress' then + self:ToggleList() + end + end + self.EditBox = Edit(self) -- Placeholder bounds so that `SetupEditStd` below, which internally @@ -68,8 +89,13 @@ ChatEditInterface = ClassUI(Group) { ---@param self UIChatEditInterface ---@param parent Control __post_init = function(self, parent) + Layouter(self.ChatBubble) + :AtLeftIn(self, 3) + :AtVerticalCenterIn(self) + :End() + Layouter(self.RecipientLabel) - :AtLeftIn(self, 2) + :AnchorToRight(self.ChatBubble, 2) :AtVerticalCenterIn(self) :End() @@ -88,6 +114,28 @@ ChatEditInterface = ClassUI(Group) { :End() end, + --- Opens the recipient picker popup, or closes it if it is already open. + ---@param self UIChatEditInterface + ToggleList = function(self) + if self.ChatList then + local list = self.ChatList --[[@as UIChatListInterface]] + self.ChatList = nil + list:Destroy() + self:AcquireFocus() + else + local list = ChatListInterface(self) + self.ChatList = list + -- Position the popup above-left of the chat-bubble button. + -- Depth is handled by the list itself (see ChatListInterface.__init). + LayoutHelpers.Above(list, self.ChatBubble, 15) + LayoutHelpers.AtLeftIn(list, self.ChatBubble, 15) + list:SetOnClosed(function() + self.ChatList = nil + self:AcquireFocus() + end) + end + end, + --- Updates the label from the current recipient value. ---@param self UIChatEditInterface ---@param recipient UIChatRecipient diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 0de24cfcb78..37aa8b4bf53 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -97,10 +97,10 @@ local ChatInterface = ClassUI(Window) { -- Full width, flush with the bottom of the client area. The edit -- group derives its own height (see ChatEditInterface.__post_init). Layouter(self.Edit) - :AtLeftIn(client, 30) + :AtLeftIn(client) :AtRightIn(client) :AtBottomIn(client) - :Over(client, 200) + :Over(client) :End() Layouter(self.LinesContainer) diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua new file mode 100644 index 00000000000..0594694908d --- /dev/null +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -0,0 +1,196 @@ + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local UIMain = import("/lua/ui/uimain.lua") + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatController = import("/lua/ui/game/chat/ChatController.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UIChatListEntry +---@field text Text +---@field bg Bitmap +---@field target UIChatRecipient + +------------------------------------------------------------------------------- +-- A popup recipient picker. Lists "All", "Allies", and every non-focus, +-- non-civilian army. Clicking an entry calls `ChatController.SetRecipient` +-- and destroys the popup. Clicking anywhere outside also destroys the popup. + +---@class UIChatListInterface : Group +---@field Entries UIChatListEntry[] +---@field LTBG Bitmap +---@field RTBG Bitmap +---@field RBBG Bitmap +---@field RLBG Bitmap +---@field LBG Bitmap +---@field RBG Bitmap +---@field TBG Bitmap +---@field BBG Bitmap +ChatListInterface = ClassUI(Group) { + + ---@param self UIChatListInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatListInterface") + self:DisableHitTest() + + -- Popups must sit above the chat window's inner content (line rows, + -- edit area) to receive hover and click events. A plain +1 offset + -- ties with the line rows, which default to LinesContainer+1 — + -- matching the list's default depth. +100 gives unambiguous headroom. + LayoutHelpers.DepthOverParent(self, parent, 100) + + -- Build the list of selectable targets: All, Allies, then one entry + -- per non-focus, non-civilian army. + local armies = GetArmiesTable() + local focusArmy = armies.focusArmy + local defs = { + { nickname = "All", target = ChatModel.RecipientAll }, + { nickname = "Allies", target = ChatModel.RecipientAllies }, + } + for armyID, armyData in armies.armiesTable do + if armyID ~= focusArmy and not armyData.civilian then + table.insert(defs, { nickname = armyData.nickname, target = armyID }) + end + end + + self.Entries = {} + for _, def in ipairs(defs) do + local entry = { + target = def.target, + text = UIUtil.CreateText(self, def.nickname, 12, "Arial"), + } + entry.text:SetColor('ffffffff') + entry.text:DisableHitTest() + + entry.bg = Bitmap(entry.text) + entry.bg:SetSolidColor('ff000000') + + -- Capture target in a local so each entry closes over its own value. + local target = def.target + entry.bg.HandleEvent = function(bg, event) + if event.Type == 'MouseEnter' then + bg:SetSolidColor('ff666666') + elseif event.Type == 'MouseExit' then + bg:SetSolidColor('ff000000') + elseif event.Type == 'ButtonPress' then + ChatController.SetRecipient(target) + self:Destroy() + end + end + + table.insert(self.Entries, entry) + end + + -- Decorative border bitmaps that sit outside the popup's bounds. + self.LTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) + self.LTBG:DisableHitTest() + self.RTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ur.dds')) + self.RTBG:DisableHitTest() + self.RBBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lr.dds')) + self.RBBG:DisableHitTest() + self.RLBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ll.dds')) + self.RLBG:DisableHitTest() + self.LBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_l.dds')) + self.LBG:DisableHitTest() + self.RBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_r.dds')) + self.RBG:DisableHitTest() + self.TBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_horz_um.dds')) + self.TBG:DisableHitTest() + self.BBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) + self.BBG:DisableHitTest() + + -- Close on any mouse click outside the popup. + local function onOutsideClick() self:Destroy() end + UIMain.AddOnMouseClickedFunc(onOutsideClick) + + self.OnDestroy = function(dself) + UIMain.RemoveOnMouseClickedFunc(onOutsideClick) + if dself._onClosed then + local cb = dself._onClosed + dself._onClosed = nil + cb() + end + end + end, + + ---@param self UIChatListInterface + ---@param parent Control + __post_init = function(self, parent) + -- Measure the text entries so we can size ourselves to fit. + local maxWidth = 0 + local totalHeight = 0 + for _, entry in ipairs(self.Entries) do + local w = entry.text.Width() + if w > maxWidth then maxWidth = w end + totalHeight = totalHeight + entry.text.Height() + end + + Layouter(self) + :Width(maxWidth + 40) + :Height(totalHeight) + :End() + + -- Stack entries bottom-up: first entry at the bottom-left, each + -- subsequent entry above the previous. + for i, entry in ipairs(self.Entries) do + if i == 1 then + Layouter(entry.text) + :AtLeftBottomIn(self) + :Over(self, 1) + :End() + else + Layouter(entry.text) + :Above(self.Entries[i-1].text) + :AtLeftIn(self) + :Over(self, 1) + :End() + end + + -- The highlight bar extends slightly past the text in every + -- direction and sits behind it in the depth order. Direct + -- LazyVar `:SetFunction` calls match the original `chat.lua` + -- pattern and avoid Layouter's reused-state quirks. + local text = entry.text + ---@diagnostic disable: undefined-field + entry.bg.Depth:SetFunction(function() return text.Depth() - 1 end) + entry.bg.Left:SetFunction(function() return text.Left() - 6 end) + entry.bg.Top:SetFunction(function() return text.Top() - 1 end) + entry.bg.Width:SetFunction(function() return self.Width() + 8 end) + entry.bg.Bottom:SetFunction(function() return text.Bottom() + 1 end) + ---@diagnostic enable: undefined-field + end + + -- Border bitmaps hug the outside of self on all eight sides. + Layouter(self.LTBG):Right(self.Left):Bottom(self.Top):End() + Layouter(self.RTBG):Left(self.Right):Bottom(self.Top):End() + Layouter(self.RBBG):Left(self.Right):Top(self.Bottom):End() + Layouter(self.RLBG):Right(self.Left):Top(self.Bottom):End() + Layouter(self.LBG):Right(self.Left):Top(self.Top):Bottom(self.Bottom):End() + Layouter(self.RBG):Left(self.Right):Top(self.Top):Bottom(self.Bottom):End() + Layouter(self.TBG):Left(self.Left):Right(self.Right):Bottom(self.Top):End() + Layouter(self.BBG):Left(self.Left):Right(self.Right):Top(self.Bottom):End() + end, + + --- Registers a callback that fires when the popup closes for any reason. + ---@param self UIChatListInterface + ---@param callback function + SetOnClosed = function(self, callback) + self._onClosed = callback + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From 7d012a1920d8c9fc671d73c11b9d7bdf48f24bbb Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:26:57 +0200 Subject: [PATCH 010/130] Determine chat list interface based on clients and not armies --- lua/ui/game/chat/ChatListInterface.lua | 31 ++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index 0594694908d..65250346e9b 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -18,9 +18,12 @@ local Layouter = LayoutHelpers.ReusedLayoutFor ---@field target UIChatRecipient ------------------------------------------------------------------------------- --- A popup recipient picker. Lists "All", "Allies", and every non-focus, --- non-civilian army. Clicking an entry calls `ChatController.SetRecipient` --- and destroys the popup. Clicking anywhere outside also destroys the popup. +-- A popup recipient picker. Lists "All", "Allies", and one entry per +-- connected non-local human player (sourced from `GetSessionClients`, so +-- bots and disconnected players are excluded). Clicking an entry calls +-- `ChatController.SetRecipient` and destroys the popup. Clicking anywhere +-- outside also destroys the popup — every open of the list rebuilds from +-- fresh session state. ---@class UIChatListInterface : Group ---@field Entries UIChatListEntry[] @@ -47,16 +50,26 @@ ChatListInterface = ClassUI(Group) { LayoutHelpers.DepthOverParent(self, parent, 100) -- Build the list of selectable targets: All, Allies, then one entry - -- per non-focus, non-civilian army. - local armies = GetArmiesTable() - local focusArmy = armies.focusArmy + -- per connected human player. `GetSessionClients` naturally excludes + -- bots (they are not session clients); we additionally skip the + -- local client (you can't privately message yourself) and any + -- disconnected player. A client's army is found by matching + -- nickname — the target stays an army ID so the send path continues + -- to work unchanged. local defs = { { nickname = "All", target = ChatModel.RecipientAll }, { nickname = "Allies", target = ChatModel.RecipientAllies }, } - for armyID, armyData in armies.armiesTable do - if armyID ~= focusArmy and not armyData.civilian then - table.insert(defs, { nickname = armyData.nickname, target = armyID }) + + local armies = GetArmiesTable().armiesTable + for _, client in GetSessionClients() do + if client.connected and not client['local'] then + for armyID, armyData in armies do + if not armyData.civilian and armyData.nickname == client.name then + table.insert(defs, { nickname = client.name, target = armyID }) + break + end + end end end From 0f31318a998803c0e8899cb29c9892adbbd31656 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:31:29 +0200 Subject: [PATCH 011/130] Add faction badge to chat list interface --- lua/ui/game/chat/ChatFactionBadge.lua | 80 ++++++++++++++++++++++++++ lua/ui/game/chat/ChatListInterface.lua | 54 +++++++++++++---- 2 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 lua/ui/game/chat/ChatFactionBadge.lua diff --git a/lua/ui/game/chat/ChatFactionBadge.lua b/lua/ui/game/chat/ChatFactionBadge.lua new file mode 100644 index 00000000000..1c0a7de9a72 --- /dev/null +++ b/lua/ui/game/chat/ChatFactionBadge.lua @@ -0,0 +1,80 @@ + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local ObserverIcon = '/widgets/faction-icons-alpha_bmp/observer_ico.dds' + +------------------------------------------------------------------------------- +-- A small badge showing a player's faction icon over their team colour, used +-- to identify players in the chat recipient picker and anywhere else in the +-- chat UI that wants to surface who a message is from or going to. Matches +-- the visual style used by the score panel (see `score.lua`). Consumers can +-- override the default 14x14 size via `LayoutHelpers.SetDimensions` or a +-- Layouter chain, and update the contents via `SetFaction` / `SetColor`. + +---@class ChatFactionBadge : Group +---@field Color Bitmap # team-colour tile behind the icon +---@field Icon Bitmap # faction icon on top of the colour tile +ChatFactionBadge = ClassUI(Group) { + + ---@param self ChatFactionBadge + ---@param parent Control + ---@param factionIndex? number 0-based faction index (UEF=0, Aeon=1, …); nil → observer icon + ---@param color? string ARGB hex string; defaults to white + __init = function(self, parent, factionIndex, color) + Group.__init(self, parent, "ChatFactionBadge") + + self.Color = Bitmap(self) + self.Color:SetSolidColor(color or 'ffffffff') + self.Color:DisableHitTest() + + self.Icon = Bitmap(self) + self.Icon:DisableHitTest() + self:SetFaction(factionIndex) + + -- Default square size; consumers can override via Layouter / SetDimensions. + LayoutHelpers.SetDimensions(self, 14, 14) + end, + + ---@param self ChatFactionBadge + ---@param parent Control + __post_init = function(self, parent) + LayoutHelpers.FillParent(self.Color, self) + LayoutHelpers.FillParent(self.Icon, self) + + -- Icon renders on top of the colour tile; the tile shows through the + -- icon's transparent pixels. + LayoutHelpers.DepthOverParent(self.Color, self, 1) + LayoutHelpers.DepthOverParent(self.Icon, self, 2) + end, + + --- Updates the faction icon. Pass `nil` to show the observer icon. + ---@param self ChatFactionBadge + ---@param factionIndex? number 0-based faction index + SetFaction = function(self, factionIndex) + if factionIndex then + self.Icon:SetTexture(UIUtil.UIFile(UIUtil.GetFactionIcon(factionIndex))) + else + self.Icon:SetTexture(UIUtil.UIFile(ObserverIcon)) + end + end, + + --- Updates the team colour tile. + ---@param self ChatFactionBadge + ---@param color string ARGB hex, e.g. 'ffff4242' + SetColor = function(self, color) + self.Color:SetSolidColor(color or 'ffffffff') + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index 65250346e9b..94f233aa2d8 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -5,6 +5,8 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local ChatFactionBadge = import("/lua/ui/game/chat/ChatFactionBadge.lua").ChatFactionBadge + local UIMain = import("/lua/ui/uimain.lua") local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") @@ -15,15 +17,17 @@ local Layouter = LayoutHelpers.ReusedLayoutFor ---@class UIChatListEntry ---@field text Text ---@field bg Bitmap +---@field badge? ChatFactionBadge # only present on player entries ---@field target UIChatRecipient ------------------------------------------------------------------------------- -- A popup recipient picker. Lists "All", "Allies", and one entry per -- connected non-local human player (sourced from `GetSessionClients`, so --- bots and disconnected players are excluded). Clicking an entry calls --- `ChatController.SetRecipient` and destroys the popup. Clicking anywhere --- outside also destroys the popup — every open of the list rebuilds from --- fresh session state. +-- bots and disconnected players are excluded). Player rows show a small +-- faction + team-colour badge next to the name so the right recipient is +-- easy to spot. Clicking an entry calls `ChatController.SetRecipient` and +-- destroys the popup. Clicking anywhere outside also destroys the popup — +-- every open of the list rebuilds from fresh session state. ---@class UIChatListInterface : Group ---@field Entries UIChatListEntry[] @@ -66,7 +70,12 @@ ChatListInterface = ClassUI(Group) { if client.connected and not client['local'] then for armyID, armyData in armies do if not armyData.civilian and armyData.nickname == client.name then - table.insert(defs, { nickname = client.name, target = armyID }) + table.insert(defs, { + nickname = client.name, + target = armyID, + faction = armyData.faction, + color = armyData.color, + }) break end end @@ -85,6 +94,13 @@ ChatListInterface = ClassUI(Group) { entry.bg = Bitmap(entry.text) entry.bg:SetSolidColor('ff000000') + -- Player entries get a faction+colour badge to the left of the + -- name; All / Allies rows have no badge but share the same text + -- indent (applied in `__post_init`) so the column stays aligned. + if def.color then + entry.badge = ChatFactionBadge(self, def.faction, def.color) + end + -- Capture target in a local so each entry closes over its own value. local target = def.target entry.bg.HandleEvent = function(bg, event) @@ -150,30 +166,46 @@ ChatListInterface = ClassUI(Group) { :Height(totalHeight) :End() + -- Left indent that reserves room for the faction badge on player + -- rows and keeps All / Allies text aligned with the player names. + local textIndent = 20 + -- Stack entries bottom-up: first entry at the bottom-left, each - -- subsequent entry above the previous. + -- subsequent entry above the previous. Text is indented to leave + -- room for the badge. for i, entry in ipairs(self.Entries) do if i == 1 then Layouter(entry.text) - :AtLeftBottomIn(self) + :AtBottomIn(self) + :AtLeftIn(self, textIndent) :Over(self, 1) :End() else Layouter(entry.text) :Above(self.Entries[i-1].text) - :AtLeftIn(self) + :AtLeftIn(self, textIndent) :Over(self, 1) :End() end - -- The highlight bar extends slightly past the text in every - -- direction and sits behind it in the depth order. Direct + -- Badge (player rows only) sits in the reserved indent, centred + -- vertically on the text row. + if entry.badge then + Layouter(entry.badge) + :AtLeftIn(self, 3) + :AtVerticalCenterIn(entry.text) + :Over(self, 2) + :End() + end + + -- The highlight bar spans the full row (including the badge + -- area) and sits behind everything in the depth order. Direct -- LazyVar `:SetFunction` calls match the original `chat.lua` -- pattern and avoid Layouter's reused-state quirks. local text = entry.text ---@diagnostic disable: undefined-field entry.bg.Depth:SetFunction(function() return text.Depth() - 1 end) - entry.bg.Left:SetFunction(function() return text.Left() - 6 end) + entry.bg.Left:SetFunction(function() return self.Left() - 6 end) entry.bg.Top:SetFunction(function() return text.Top() - 1 end) entry.bg.Width:SetFunction(function() return self.Width() + 8 end) entry.bg.Bottom:SetFunction(function() return text.Bottom() + 1 end) From 8e1cb17ff9d9026cb22687e3fe776e5f873883b2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:33:48 +0200 Subject: [PATCH 012/130] Add basic support for chat commands --- lua/ui/game/chat/ChatController.lua | 25 ++ lua/ui/game/chat/commands/BuiltinCommands.lua | 83 +++++++ .../chat/commands/ChatCommandRegistry.lua | 232 ++++++++++++++++++ .../game/chat/commands/ChatCommandTypes.lua | 84 +++++++ lua/ui/game/chat/commands/design.md | 166 +++++++++++++ 5 files changed, 590 insertions(+) create mode 100644 lua/ui/game/chat/commands/BuiltinCommands.lua create mode 100644 lua/ui/game/chat/commands/ChatCommandRegistry.lua create mode 100644 lua/ui/game/chat/commands/ChatCommandTypes.lua create mode 100644 lua/ui/game/chat/commands/design.md diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 1236fc734ce..90fb10bd7c9 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -42,10 +42,35 @@ function AppendEntry(entry) model.History:Set(history) end +--- Appends a synthetic, local-only system line to the history. Used by the +--- slash-command dispatcher to surface parse/accept errors in the chat feed +--- without sending anything over the network. +---@param text string +function AppendLocalSystemMessage(text) + AppendEntry { + name = "System:", + text = text, + color = 'ffff6666', + armyID = 0, + recipient = ChatModel.RecipientAll, + } +end + --- Sends a message to the current recipient. --- Stubbed — the network layer will be wired up in a follow-up step. ---@param text string function Send(text) + if text and string.sub(text, 1, 1) == '/' then + local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + local handled, err = Registry.Dispatch(text) + if handled then return end + if err then + AppendLocalSystemMessage(err) + return + end + -- Lone '/' or whitespace-only body falls through to the normal path. + end + WARN("ChatController.Send not yet implemented: " .. tostring(text)) end diff --git a/lua/ui/game/chat/commands/BuiltinCommands.lua b/lua/ui/game/chat/commands/BuiltinCommands.lua new file mode 100644 index 00000000000..c8cf44a4e05 --- /dev/null +++ b/lua/ui/game/chat/commands/BuiltinCommands.lua @@ -0,0 +1,83 @@ + +local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +------------------------------------------------------------------------------- +-- Recipient switching + +Registry.Register { + name = 'all', + description = 'Send to all players and observers.', + execute = function(_, ctx) + ctx.controller.SetRecipient(ChatModel.RecipientAll) + end, +} + +Registry.Register { + name = 'allies', + aliases = { 'team' }, + description = 'Send to allies only.', + execute = function(_, ctx) + ctx.controller.SetRecipient(ChatModel.RecipientAllies) + end, +} + +Registry.Register { + name = 'whisper', + aliases = { 'w', 'pm' }, + description = 'Whisper to a specific player (by nickname or army ID).', + params = { + { name = 'target', type = 'player' }, + }, + accept = function(args) + local armies = GetArmiesTable() + if armies and args.target == armies.focusArmy then + return false, "/whisper: can't whisper yourself." + end + return true + end, + execute = function(args, ctx) + ctx.controller.SetRecipient(args.target) + end, +} + +------------------------------------------------------------------------------- +-- Introspection + +Registry.Register { + name = 'help', + aliases = { '?' }, + description = 'Lists available chat commands.', + execute = function(_, ctx) + local controller = ctx.controller + controller.AppendLocalSystemMessage("Available chat commands:") + + for _, cmd in ipairs(Registry.GetAll()) do + local params = '' + if cmd.params then + for _, p in ipairs(cmd.params) do + local fmt = p.optional and ' [%s]' or ' <%s>' + params = params .. string.format(fmt, p.name) + end + end + + local aliases = '' + if cmd.aliases and table.getn(cmd.aliases) > 0 then + aliases = ' (aka /' .. table.concat(cmd.aliases, ', /') .. ')' + end + + controller.AppendLocalSystemMessage( + string.format(" /%s%s%s — %s", cmd.name, params, aliases, cmd.description or '') + ) + end + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua new file mode 100644 index 00000000000..419f7dc2ec3 --- /dev/null +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -0,0 +1,232 @@ + +local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") + +------------------------------------------------------------------------------- +-- Registry + parser + dispatcher for chat slash-commands. +-- +-- See design.md for the full shape. The short version: +-- Register(cmd) adds a UIChatCommand to the registry +-- Dispatch(text) parses and runs a "/…" line, returning (handled, errorText) + +---@class UIChatCommandParam +---@field name string +---@field type UIChatCommandParamType +---@field optional boolean? + +---@class UIChatCommandContext +---@field model UIChatModel +---@field controller table +---@field sourceText string + +---@class UIChatCommand +---@field name string +---@field aliases? string[] +---@field description string +---@field params? UIChatCommandParam[] +---@field accept? fun(args: table, ctx: UIChatCommandContext): boolean, string? +---@field execute fun(args: table, ctx: UIChatCommandContext) + +---@type table +local Commands = {} + +---@type table +local Aliases = {} + +local BuiltinsLoaded = false + +------------------------------------------------------------------------------- +-- Registration + +--- Registers a command. Overwrites any previous registration with the same +--- canonical name; aliases from the previous registration are cleared first. +---@param cmd UIChatCommand +function Register(cmd) + assert(cmd and cmd.name, "Chat command requires a name.") + assert(cmd.execute, "Chat command requires an execute function.") + + local key = string.lower(cmd.name) + + local previous = Commands[key] + if previous and previous.aliases then + for _, alias in ipairs(previous.aliases) do + Aliases[string.lower(alias)] = nil + end + end + + Commands[key] = cmd + if cmd.aliases then + for _, alias in ipairs(cmd.aliases) do + Aliases[string.lower(alias)] = key + end + end +end + +--- Removes a command and its aliases. +---@param name string +function Unregister(name) + local key = string.lower(name) + local cmd = Commands[key] + if not cmd then return end + if cmd.aliases then + for _, alias in ipairs(cmd.aliases) do + Aliases[string.lower(alias)] = nil + end + end + Commands[key] = nil +end + +--- Returns a flat list of every registered command (canonical entries only). +---@return UIChatCommand[] +function GetAll() + local result = {} + for _, cmd in Commands do + table.insert(result, cmd) + end + return result +end + +--- Looks up a command by name or alias. Case-insensitive. +---@param name string +---@return UIChatCommand? +function Lookup(name) + local key = string.lower(name) + local cmd = Commands[key] + if cmd then return cmd end + local canonical = Aliases[key] + if canonical then return Commands[canonical] end + return nil +end + +------------------------------------------------------------------------------- +-- Parsing + +--- Splits the body of a slash-command into (name, remainingTokens). +--- "whisper Jip hello" → "whisper", {"Jip", "hello"} +---@param body string +---@return string?, string[] +local function Tokenize(body) + local tokens = {} + for word in string.gfind(body, "%S+") do + table.insert(tokens, word) + end + if table.getn(tokens) == 0 then + return nil, {} + end + local name = table.remove(tokens, 1) + return name, tokens +end + +--- Walks a command's declared parameters, pulling tokens and invoking the +--- matching resolver. Returns the populated args table or a user-facing error. +---@param cmd UIChatCommand +---@param tokens string[] +---@return table?, string? +local function ParseArgs(cmd, tokens) + local args = { _raw = tokens } + if not cmd.params then return args, nil end + + local idx = 1 + for _, param in ipairs(cmd.params) do + if param.type == 'rest' then + local remaining = {} + while tokens[idx] do + table.insert(remaining, tokens[idx]) + idx = idx + 1 + end + if table.getn(remaining) == 0 then + if not param.optional then + return nil, string.format("/%s: missing argument <%s>.", cmd.name, param.name) + end + else + args[param.name] = table.concat(remaining, ' ') + end + else + local token = tokens[idx] + if not token then + if param.optional then + idx = idx + 1 + else + return nil, string.format("/%s: missing argument <%s>.", cmd.name, param.name) + end + else + local resolver = Types.Resolvers[param.type] + if not resolver then + return nil, string.format("/%s: unknown parameter type '%s'.", cmd.name, tostring(param.type)) + end + local ok, value = resolver(token) + if not ok then + return nil, string.format("/%s: %s", cmd.name, value or ("invalid <" .. param.name .. ">.")) + end + args[param.name] = value + idx = idx + 1 + end + end + end + + return args, nil +end + +------------------------------------------------------------------------------- +-- Dispatch + +--- Parses a chat line that starts with '/' and invokes the matching command. +--- Return values: +--- (true, nil) → command ran (or was accept-rejected and already reported) +--- (false, errText) → slash-prefixed but failed; caller should surface errText +--- (false, nil) → lone '/' or whitespace; caller may treat as normal text +---@param text string +---@return boolean handled +---@return string? errorText +function Dispatch(text) + if not text or string.sub(text, 1, 1) ~= '/' then + return false, nil + end + + if not BuiltinsLoaded then + BuiltinsLoaded = true + import("/lua/ui/game/chat/commands/BuiltinCommands.lua") + end + + local body = string.sub(text, 2) + local name, tokens = Tokenize(body) + if not name then + return false, nil + end + + local cmd = Lookup(name) + if not cmd then + return false, string.format("Invalid command: /%s. Type /help for a list.", name) + end + + local args, parseErr = ParseArgs(cmd, tokens) + if parseErr then + return false, parseErr + end + + local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + local ChatController = import("/lua/ui/game/chat/ChatController.lua") + local ctx = { + model = ChatModel.GetSingleton(), + controller = ChatController, + sourceText = text, + } + + if cmd.accept then + local ok, reason = cmd.accept(args, ctx) + if not ok then + return false, reason or string.format("/%s: command rejected.", cmd.name) + end + end + + cmd.execute(args, ctx) + return true, nil +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/ChatCommandTypes.lua b/lua/ui/game/chat/commands/ChatCommandTypes.lua new file mode 100644 index 00000000000..385339a48cf --- /dev/null +++ b/lua/ui/game/chat/commands/ChatCommandTypes.lua @@ -0,0 +1,84 @@ + +------------------------------------------------------------------------------- +-- Parameter-type resolvers for chat commands. Each resolver takes a raw token +-- (string) and returns (ok, value_or_error): +-- true, value → successfully coerced/validated +-- false, errorString → rejected; errorString is user-facing +-- +-- Resolvers are intentionally pure: they read from the session (armies table) +-- but never write state. Adding a new type means adding one function to the +-- `Resolvers` table. + +--- Looks up an army by nickname or by numeric army ID. Civilian armies are +--- excluded to match the behaviour of the recipient picker. +---@param token string +---@return boolean ok +---@return number | string armyIDOrError +local function ResolveArmy(token) + local armies = GetArmiesTable() + if not armies or not armies.armiesTable then + return false, "no army table available." + end + + local asNum = tonumber(token) + if asNum then + local army = armies.armiesTable[asNum] + if army and not army.civilian then + return true, asNum + end + return false, string.format("no army with ID %s.", tostring(asNum)) + end + + for armyID, army in armies.armiesTable do + if army.nickname == token and not army.civilian then + return true, armyID + end + end + return false, string.format("no player named '%s'.", token) +end + +---@alias UIChatCommandParamType 'recipient' | 'player' | 'int' | 'string' | 'rest' + +---@type table +Resolvers = {} + +--- Accepts "all", "allies"/"team", a nickname, or an army ID. +--- Resolves to a `UIChatRecipient` (the same type the model stores). +Resolvers.recipient = function(token) + local lower = string.lower(token) + if lower == 'all' then + return true, 'all' + elseif lower == 'allies' or lower == 'team' then + return true, 'allies' + end + return ResolveArmy(token) +end + +--- Accepts a nickname or army ID. Rejects "all"/"allies". +--- Resolves to a numeric army ID. +Resolvers.player = function(token) + return ResolveArmy(token) +end + +--- Integer literal. +Resolvers.int = function(token) + local n = tonumber(token) + if not n or math.floor(n) ~= n then + return false, string.format("'%s' is not an integer.", token) + end + return true, n +end + +--- Single whitespace-delimited token. +Resolvers.string = function(token) + return true, token +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/design.md b/lua/ui/game/chat/commands/design.md new file mode 100644 index 00000000000..a28a859e298 --- /dev/null +++ b/lua/ui/game/chat/commands/design.md @@ -0,0 +1,166 @@ +# Chat Commands — Design + +**Purpose:** Handle `/slash` commands entered in the chat edit box. Parses the command, validates arguments, optionally checks legitimacy, executes side effects (usually on the controller), and surfaces failures as local-only system lines in the chat feed. + +--- + +## 1. Architecture + +``` +ChatEditInterface.EditBox:OnEnterPressed(text) + └── ChatController.Send(text) + └── text starts with '/'? + └── ChatCommandRegistry.Dispatch(text) + ├── Tokenize(text) -- "/whisper Jip" → ("whisper", {"Jip"}) + ├── Lookup(name) -- name + aliases + ├── ParseArgs(cmd, tokens) -- typed, coerced, validated + ├── cmd.accept(args, ctx) -- semantic legitimacy check + └── cmd.execute(args, ctx) -- run side effect (usually ctx.controller.*) +``` + +- **Registry** — flat `name → command` table plus `alias → name`. Exports `Register`, `Unregister`, `Lookup`, `GetAll`, `Dispatch`. +- **Types** — a table of `{recipient, player, int, string, rest}` resolvers. Each takes a raw token and returns `(ok, value_or_error)`. +- **Builtins** — `/all`, `/allies`, `/whisper`, `/help`. Loaded lazily on the first `Dispatch` call. + +Commands do not touch the model directly. They call through `ctx.controller`, preserving the MVC rule from `CLAUDE.md`. + +--- + +## 2. Command Descriptor + +```lua +---@class UIChatCommand +---@field name string # canonical name without leading slash +---@field aliases? string[] # alternative names (e.g. {'w','pm'} for whisper) +---@field description string # one-line summary shown by /help +---@field params? UIChatCommandParam[] # declarative parameter schema +---@field accept? fun(args, ctx): boolean, string? # runtime legitimacy check +---@field execute fun(args, ctx) # the actual side effect + +---@class UIChatCommandParam +---@field name string +---@field type 'recipient' | 'player' | 'int' | 'string' | 'rest' +---@field optional boolean? +``` + +Rules: + +- `name` and every entry of `aliases` are case-insensitive. +- `params` order is the order tokens will be consumed. +- Only the last param may be `rest`; it greedy-consumes every remaining token, joining them with single spaces. +- `accept` and `execute` both receive the already-typed `args` table and a shared `ctx`. + +## 3. Parameter Types + +Each resolver is `fun(token: string): ok, value | error`. + +| Type | Accepts | Resolves to | +|------|---------|-------------| +| `recipient` | `"all"`, `"allies"`, `"team"`, nickname, army ID | `UIChatRecipient` (`'all' \| 'allies' \| number`) | +| `player` | nickname or army ID | `number` (army ID) — same rules as `recipient` but rejects `all`/`allies` | +| `int` | integer literal | `number` | +| `string` | a single whitespace-delimited token | `string` | +| `rest` | one or more remaining tokens | `string` (tokens joined by single spaces) | + +Army lookup goes through `GetArmiesTable()`, matching the source `ChatListInterface` already uses for the recipient picker. Civilian armies are excluded. + +## 4. Execution Context + +```lua +---@class UIChatCommandContext +---@field model UIChatModel +---@field controller table -- ChatController module +---@field sourceText string -- the original "/whisper Jip" text +``` + +Passing `ctx` rather than each command importing the controller/model keeps commands decoupled from the chat tree and trivially testable. + +## 5. Error Surfaces + +`Dispatch` returns `(handled, errorText)`: + +| Return | Meaning | Caller action | +|--------|---------|---------------| +| `(true, nil)` | command ran | return | +| `(false, errText)` | parse/accept/unknown error | print `errText` as a local system line, return | +| `(false, nil)` | lone `/` or empty body | treat as normal text | + +Error strings are produced at a single site in the registry so they stay uniform: + +| Cause | Example | +|-------|---------| +| Unknown name | `Invalid command: /xyz. Type /help for a list.` | +| Missing arg | `/whisper: missing argument .` | +| Bad arg | `/whisper: no player named 'bob'.` | +| Rejected by `accept` | whatever string `accept` returned | + +Printing goes through `ChatController.AppendLocalSystemMessage(text)`, which appends a synthetic `UIChatEntry` to `model.History`. No network traffic; the line renders through the existing `ChatListInterface` path with no view changes. + +## 6. Adding a Command + +```lua +local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +Registry.Register { + name = 'whisper', + aliases = { 'w', 'pm' }, + description = 'Whisper to a specific player.', + params = { + { name = 'target', type = 'player' }, + }, + accept = function(args) + local armies = GetArmiesTable() + if armies and args.target == armies.focusArmy then + return false, "/whisper: can't whisper yourself." + end + return true + end, + execute = function(args, ctx) + ctx.controller.SetRecipient(args.target) + end, +} +``` + +`/whisper Jip` and `/whisper 3` both route here with `args.target` already normalized to an army ID — one command definition, two user-facing forms. + +## 7. `accept` vs. `execute` + +- **Parser** handles *structural* errors: missing args, wrong types, unknown name. +- **`accept`** handles *semantic* errors that depend on runtime state: whispering yourself, command disabled in replay, target just disconnected. +- **`execute`** runs the side effect and trusts its inputs. + +Splitting `accept` out keeps the failure path uniform (always surfaces as a system feed line with the reason) and leaves room for things like tab-completion previews that call `accept` without `execute`. + +## 8. Bootstrap + +`BuiltinCommands.lua` is imported lazily by `ChatCommandRegistry.Dispatch` on first call. External modules (notify, mods, future subsystems) call `Registry.Register` directly from their own init. + +`lua/ui/notify/commands.lua` remains as a thin backwards-compatibility shim that maps the old positional `AddChatCommand(name, fn)` call onto `Registry.Register`, reconstructing the legacy `{name, arg1, ...}` args shape before calling `fn`. Existing `notify`/`notifyoverlay` registrations keep working unchanged. + +## 9. Integration with `ChatController.Send` + +```lua +function Send(text) + if text and string.sub(text, 1, 1) == '/' then + local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + local handled, err = Registry.Dispatch(text) + if handled then return end + if err then + AppendLocalSystemMessage(err) + return + end + -- lone '/' falls through + end + -- ... taunt check, network send ... +end +``` + +The slash branch is the first step of the send pipeline, matching `CLAUDE.md §Sending`. + +## 10. Open Questions + +1. **Nicknames with spaces.** Current tokenizer splits on whitespace. If nicknames with spaces are real, we need either quoted strings (`/whisper "Jip E"`) or a smarter `player` resolver that greedy-matches across tokens. Left as future work. +2. **Localization.** Error strings and command descriptions should go through `` like other chat text; currently hardcoded English. +3. **Replay/observer gating.** Some commands are meaningless in replay. `accept` can enforce per-command; a shared `ctx.mode` flag (`'live' | 'replay' | 'observer'`) would avoid each command re-deriving it. +4. **Tab completion / history.** The registry exposes `GetAll()` so an edit-view enhancement can offer completion for command names and (via `params[i].type`) argument suggestions. Not wired up here. From cecbe0180803f7ba52a9d0688627d526c600b166 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:37:26 +0200 Subject: [PATCH 013/130] Do not load commands as a side effect of importing a file --- lua/ui/game/chat/ChatController.lua | 24 +++++++++++++++++++ lua/ui/game/chat/commands/BuiltinCommands.lua | 22 +++++++++++++---- .../chat/commands/ChatCommandRegistry.lua | 9 +------ lua/ui/game/chat/commands/design.md | 6 +++-- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 90fb10bd7c9..63d299ebb39 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -56,11 +56,35 @@ function AppendLocalSystemMessage(text) } end +------------------------------------------------------------------------------- +-- Slash commands + +local BuiltinsRegistered = false + +--- Registers every built-in chat command with the registry. Idempotent, so +--- it is safe to call from multiple init paths. External callers that want +--- their own commands registered alongside the built-ins should call this +--- once at startup before the first message is sent. +function RegisterBuiltinCommands() + if BuiltinsRegistered then return end + BuiltinsRegistered = true + + local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + local Builtins = import("/lua/ui/game/chat/commands/BuiltinCommands.lua") + + Registry.Register(Builtins.All) + Registry.Register(Builtins.Allies) + Registry.Register(Builtins.Whisper) + Registry.Register(Builtins.Help) +end + --- Sends a message to the current recipient. --- Stubbed — the network layer will be wired up in a follow-up step. ---@param text string function Send(text) if text and string.sub(text, 1, 1) == '/' then + RegisterBuiltinCommands() + local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") local handled, err = Registry.Dispatch(text) if handled then return end diff --git a/lua/ui/game/chat/commands/BuiltinCommands.lua b/lua/ui/game/chat/commands/BuiltinCommands.lua index c8cf44a4e05..7c76d04e8ec 100644 --- a/lua/ui/game/chat/commands/BuiltinCommands.lua +++ b/lua/ui/game/chat/commands/BuiltinCommands.lua @@ -1,11 +1,18 @@ -local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +------------------------------------------------------------------------------- +-- Built-in chat commands. +-- +-- Each command is exported as a top-level `UIChatCommand` table. Importing +-- this module has no side effects; `ChatController.RegisterBuiltinCommands` +-- is responsible for handing these off to the registry. + ------------------------------------------------------------------------------- -- Recipient switching -Registry.Register { +---@type UIChatCommand +All = { name = 'all', description = 'Send to all players and observers.', execute = function(_, ctx) @@ -13,7 +20,8 @@ Registry.Register { end, } -Registry.Register { +---@type UIChatCommand +Allies = { name = 'allies', aliases = { 'team' }, description = 'Send to allies only.', @@ -22,7 +30,8 @@ Registry.Register { end, } -Registry.Register { +---@type UIChatCommand +Whisper = { name = 'whisper', aliases = { 'w', 'pm' }, description = 'Whisper to a specific player (by nickname or army ID).', @@ -44,12 +53,15 @@ Registry.Register { ------------------------------------------------------------------------------- -- Introspection -Registry.Register { +---@type UIChatCommand +Help = { name = 'help', aliases = { '?' }, description = 'Lists available chat commands.', execute = function(_, ctx) local controller = ctx.controller + local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + controller.AppendLocalSystemMessage("Available chat commands:") for _, cmd in ipairs(Registry.GetAll()) do diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 419f7dc2ec3..059d206d8fd 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -32,8 +32,6 @@ local Commands = {} ---@type table local Aliases = {} -local BuiltinsLoaded = false - ------------------------------------------------------------------------------- -- Registration @@ -182,11 +180,6 @@ function Dispatch(text) return false, nil end - if not BuiltinsLoaded then - BuiltinsLoaded = true - import("/lua/ui/game/chat/commands/BuiltinCommands.lua") - end - local body = string.sub(text, 2) local name, tokens = Tokenize(body) if not name then @@ -199,7 +192,7 @@ function Dispatch(text) end local args, parseErr = ParseArgs(cmd, tokens) - if parseErr then + if not args then return false, parseErr end diff --git a/lua/ui/game/chat/commands/design.md b/lua/ui/game/chat/commands/design.md index a28a859e298..de7dbe6a5b2 100644 --- a/lua/ui/game/chat/commands/design.md +++ b/lua/ui/game/chat/commands/design.md @@ -134,9 +134,11 @@ Splitting `accept` out keeps the failure path uniform (always surfaces as a syst ## 8. Bootstrap -`BuiltinCommands.lua` is imported lazily by `ChatCommandRegistry.Dispatch` on first call. External modules (notify, mods, future subsystems) call `Registry.Register` directly from their own init. +`BuiltinCommands.lua` has no side effects on import — it just exports each command as a named `UIChatCommand` table (`All`, `Allies`, `Whisper`, `Help`). Importing the module does not register anything. -`lua/ui/notify/commands.lua` remains as a thin backwards-compatibility shim that maps the old positional `AddChatCommand(name, fn)` call onto `Registry.Register`, reconstructing the legacy `{name, arg1, ...}` args shape before calling `fn`. Existing `notify`/`notifyoverlay` registrations keep working unchanged. +`ChatController.RegisterBuiltinCommands()` is the single registration site: it pulls the named exports from `BuiltinCommands` and hands them to `ChatCommandRegistry.Register`. It is idempotent, so it can be called from multiple init paths without harm. `ChatController.Send` invokes it lazily on the first slash-prefixed message so the feature works without an explicit init hook; once a proper `ChatController:Init` exists (see `CLAUDE.md §Init`), the call should move there. + +External modules (notify, mods, future subsystems) register their own commands by calling `Registry.Register` directly, independent of the builtins. ## 9. Integration with `ChatController.Send` From dac1888588489ad94f32b909836aa72ce6d7ac92 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:41:25 +0200 Subject: [PATCH 014/130] Document functionality of chat lines --- lua/ui/game/chat/chat-line-functionality.md | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 lua/ui/game/chat/chat-line-functionality.md diff --git a/lua/ui/game/chat/chat-line-functionality.md b/lua/ui/game/chat/chat-line-functionality.md new file mode 100644 index 00000000000..8cb33d9b33d --- /dev/null +++ b/lua/ui/game/chat/chat-line-functionality.md @@ -0,0 +1,113 @@ +# Chat Line Functionality — Inventory + +Catalogue of every behaviour related to chat lines in the original +`lua/ui/game/chat.lua`, grouped by concern. Intended as the planning input for +decomposing the chat-line code into the MVC structure used elsewhere in +`lua/ui/game/chat/`. + +--- + +## 1. Line construction (`CreateChatLine`, chat.lua:178–240) + +Each line is a `Group` with five child controls: + +- **`teamColor`** — a solid-colour square on the left, coloured with the sender's team colour. +- **`factionIcon`** — a bitmap overlaid on the team-colour square, showing the sender's faction (or an observer icon for observers). +- **`name`** — bold text prefix showing the sender and who the message is for (e.g. `"PlayerX to Allies:"`). Clickable for private reply. +- **`text`** — the message body. Clickable *only* when the entry carries a `camera` payload. +- **`lineStickybg`** — a semi-transparent `aa000000` bar that fills the line, depth-under the content, hidden by default. Shown in feed mode when `feed_background` is on, so lines stay readable over the game world. +- **`camIcon`** — optional camera-pin bitmap inserted between `name` and `text` when the entry has a camera link; created lazily in `CalcVisible`, destroyed when the line's entry no longer has one. + +--- + +## 2. Pool sizing — dynamic line count (`CreateChatLines`, chat.lua:177–290) + +- Computes how many lines fit in `chatContainer` as `floor(container.Height / line.Height)`. +- Adds new lines (`Below` the previous) when the window grows; destroys excess lines when it shrinks. +- First-time setup places line 1 at `AtLeftTopIn(container, 0, 0)`, then stacks more while `prev.Bottom + line.Height < container.Bottom`. +- Each line's `Height` is set lazily to `name.Height + 2` (so it scales with `font_size`). + +--- + +## 3. Scroll container (`SetupChatScroll`, chat.lua:296–364) + +Implements the standard MAUI scrollable interface on `chatContainer`: + +- **Virtual size** = sum of `wrappedtext` lengths across only *filtered* entries (`IsValidEntry`: per-army filter + link filter). Cached in `prevsize` / `prevtabsize`. +- `GetScrollValues`, `ScrollLines`, `ScrollPages`, `ScrollSetTop`, `IsScrollable` — the usual scrollbar API. +- `ScrollToBottom` — jumps to the most-recent line. +- Mouse wheel on the chat window maps to `ScrollSetTop` (in `CreateChat` / `OnMouseWheel`). +- Page Up / Page Down hotkeys (`ChatPageUp(mod)`, `ChatPageDown(mod)`), with Shift reducing the page size to 1. + +--- + +## 4. Visibility mapping (`CalcVisible`, chat.lua:366–507) + +Projects the history onto the line pool: + +- Walks `chatHistory` skipping filtered-out entries until the target scroll offset is reached. +- First wrapped line of an entry shows the name, team-color square, faction icon, etc.; continuation lines show only indented text with an empty name slot. +- Text colour per line picked from `ChatOptions[tokey]` (`all_color` / `allies_color` / `priv_color` / `link_color` / `notify_color`); camera-link lines use `link_color`. +- Name is disabled (greyed) when the line's `armyID` matches the focus army (your own messages). +- Camera icon inserted / removed based on whether the current entry has a `camera` field; also shifts the text's `Left` over by the icon's width. +- `line:SetAlpha(ChatOptions.win_alpha)` applied every refresh so opacity changes take effect immediately. + +--- + +## 5. Text wrapping (`WrapText` / `RewrapLog`, chat.lua:1223–1247) + +- `WrapText(data)` delegates to `maui/text.lua.WrapText` and returns an array of wrapped lines. +- Width callback uses `chatLines[1]`'s actual pixel width: the **first** wrapped line reserves space for the name prefix (measured via `text:GetStringAdvance(name)`), subsequent lines indent past just the team-color/faction column. +- Called once when a message arrives (in `ReceiveChatFromSim`). +- `RewrapLog()` re-wraps every entry in `chatHistory` on window resize or option change. + +--- + +## 6. Feed mode (window hidden, chat.lua:455–494) + +- When `GUI.bg` is hidden, the most recent lines render over the game world via the existing pool (the window chrome is just hidden, the line controls stay). +- Each visible line gets an `OnFrame` that increments `curHistory.time`; once `time > fade_time`, the line hides itself. +- Continuation lines of a wrapped entry don't tick their own timer — they wait on the first wrapped line's timer (special-cased). +- `feed_background` option controls the `lineStickybg`'s visibility per line. +- `feed_persist` option: when the window is manually closed, decides whether still-visible feed lines fade out naturally or get force-expired. +- `ToggleChat` un-hides every line and hides every `lineStickybg` when opening the window; does the inverse on close. + +--- + +## 7. Filtering (chat.lua:304–323) + +- Per-army toggle: `ChatOptions[entry.armyID]` (one checkbox per non-civilian army in the config dialog). +- Link filter: camera-link entries require `ChatOptions.links` to display. +- Filtered entries are excluded from both the virtual scroll size and from `CalcVisible`'s walk. +- When a new entry arrives whose army is filtered out, `ScrollToBottom` is skipped. + +--- + +## 8. Interactivity + +- **Name `ButtonPress`** (chat.lua:199–211) → if the line has a `chatID`, it shows the window (if hidden), sets `ChatTo` to that army, focuses the edit box, and ticks the "private" checkbox. +- **Text `ButtonPress`** (chat.lua:223–229) → if the entry carries `cameraData`, `GetCamera('WorldCamera'):RestoreSettings(cameraData)` — the "camera link" feature that lets a sender point teammates at a position. + +--- + +## 9. Display-lifecycle state stored on history entries (the design-doc smell) + +These three fields currently live on `chatHistory` entries, mixing data with view state: + +- `new` — true until the entry has been shown once (controls whether the fade timer starts from zero or from the entry's existing time). +- `time` — seconds-since-displayed fade counter. +- `wrappedtext` — per-width wrapped text array; rebuilt by `RewrapLog` on resize. + +--- + +## 10. Styling tied to `ChatOptions` + +Every display option from the config dialog hits a chat-line property somewhere: + +- `font_size` → line `Height` and text point size. +- `win_alpha` → `line:SetAlpha` on refresh. +- `fade_time` → feed-mode timeout comparator. +- Colour indices (`*_color`) → text colour per line. +- `feed_background` → per-line `lineStickybg` visibility. +- `feed_persist` → feed-mode close behaviour. +- `links` + per-army filters → inclusion/exclusion from `IsValidEntry`. From af5d7537bfc4354b7f5626031bf5f29a627d0357 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:50:02 +0200 Subject: [PATCH 015/130] Add support for text wrapping and scrolling --- lua/ui/game/chat/ChatInterface.lua | 374 ++++++++++++++++++++++--- lua/ui/game/chat/ChatLineInterface.lua | 29 +- lua/ui/game/chat/ChatModel.lua | 33 +-- 3 files changed, 372 insertions(+), 64 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 37aa8b4bf53..c55d0de8ef9 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -11,12 +11,9 @@ local ChatEditInterface = import("/lua/ui/game/chat/ChatEditInterface.lua").Chat local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") -local Layouter = LayoutHelpers.ReusedLayoutFor +local MauiWrapText = import("/lua/maui/text.lua").WrapText --- Fixed-size line pool for the scaffold. A later pass will size this --- dynamically from the window height and wire up scrolling. -local LineHeight = 18 -local LineCount = 12 +local Layouter = LayoutHelpers.ReusedLayoutFor --- Skin textures for the chat window frame. Mirrors the layout that --- `/lua/ui/game/layouts/chat_layout.lua` applies to the legacy chat Window @@ -35,13 +32,33 @@ local WindowTextures = { } ------------------------------------------------------------------------------- --- The main chat window: the draggable frame that hosts the line pool and the --- edit area. Subscribes to the model for history and visibility changes. +-- The main chat window: a draggable, resizable frame hosting a dynamically +-- sized pool of chat line rows plus the edit area at the bottom. +-- +-- This class owns four related concerns lifted from the legacy chat.lua: +-- +-- 1. Pool sizing (`RebuildPool`) — line count follows +-- the container height. +-- 2. Text wrapping (`WrapEntry` / `RewrapAll`) — wraps message text to +-- the current row width; +-- results cached on the +-- entry itself. +-- 3. Scroll container (`GetScrollValues`, …) — virtual size = total +-- wrapped-line count +-- across *valid* entries. +-- 4. Visibility mapping (`CalcVisible`) — projects the scroll +-- position onto the pool. +-- +-- Filtering (per-army / camera-link / feed mode) is stubbed via +-- `IsValidEntry` which always returns true for now — wiring it up to +-- `ChatConfigModel` is a follow-up step. ---@class UIChatInterface : Window ---@field LinesContainer Group ---@field Lines UIChatLineInterface[] ---@field Edit UIChatEditInterface +---@field ScrollTop number # 1-based virtual position of the top visible row +---@field VirtualSize number # total wrapped lines across valid entries local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface @@ -53,24 +70,26 @@ local ChatInterface = ClassUI(Window) { local client = self:GetClientGroup() - -- Container for the line pool. + -- Container for the line pool. Stays empty until __post_init can + -- measure its laid-out height and build the pool from that. self.LinesContainer = Group(client, "ChatLinesContainer") - -- Fixed-size pool of line rows. - self.Lines = {} - for i = 1, LineCount do - self.Lines[i] = ChatLineInterface(self.LinesContainer) - end + self.Lines = {} + self.ScrollTop = 1 + self.VirtualSize = 0 -- The edit area sits at the bottom of the client region. self.Edit = ChatEditInterface(client) - -- Reactive: history → refresh visible rows. + -- Reactive: history → wrap new entries, refresh size, stick to + -- bottom. The initial firing happens before __post_init so the + -- wrap call has no pool to measure against; that's fine — + -- RewrapAll runs once the pool exists. local model = ChatModel.GetSingleton() model.History.OnDirty = function(lv) - self:RefreshLines(lv()) + self:OnHistoryChanged(lv()) end - self:RefreshLines(model.History()) + self:OnHistoryChanged(model.History()) -- Reactive: window visibility → show / hide the frame. model.WindowVisible.OnDirty = function(lv) @@ -110,46 +129,311 @@ local ChatInterface = ClassUI(Window) { :AnchorToTop(self.Edit, pad) :End() - -- Stack lines top-down inside the container. - local prev = nil - for _, line in ipairs(self.Lines) do - if prev then - Layouter(line) - :Below(prev) - :AtLeftIn(self.LinesContainer) - :AtRightIn(self.LinesContainer) - :Height(LineHeight) - :End() - else - Layouter(line) - :AtLeftTopIn(self.LinesContainer) - :AtRightIn(self.LinesContainer) - :Height(LineHeight) - :End() + -- Now that the container has a real size, build the pool and do + -- a first wrap + render pass. + self:RebuildPool() + self:RewrapAll() + self:ScrollToBottom() + end, + + --------------------------------------------------------------------------- + -- Pool sizing + --------------------------------------------------------------------------- + + --- Rebuilds the line pool to fit the current container height. Adds rows + --- at the bottom when the window grows, destroys the tail when it shrinks. + --- Safe to call repeatedly; callers are expected to follow up with + --- `CalcVisible` (and `RewrapAll` on a true resize). + ---@param self UIChatInterface + RebuildPool = function(self) + local container = self.LinesContainer + + -- Need one line to establish the row height. The row's Height is a + -- lazy function of the name-text font (see ChatLineInterface). + if not self.Lines[1] then + self.Lines[1] = ChatLineInterface(container) + Layouter(self.Lines[1]) + :AtLeftTopIn(container) + :Right(container.Right) + :End() + end + + local rowHeight = self.Lines[1].Height() + if rowHeight < 1 then rowHeight = 18 end -- safety fallback + + local neededLines = math.max(1, math.floor(container.Height() / rowHeight)) + local currentCount = table.getn(self.Lines) + + -- Grow: append rows below the previous one. + for i = currentCount + 1, neededLines do + self.Lines[i] = ChatLineInterface(container) + Layouter(self.Lines[i]) + :Below(self.Lines[i - 1]) + :AtLeftIn(container) + :Right(container.Right) + :End() + end + + -- Shrink: destroy the surplus tail. + for i = currentCount, neededLines + 1, -1 do + self.Lines[i]:Destroy() + self.Lines[i] = nil + end + end, + + --------------------------------------------------------------------------- + -- Text wrapping + --------------------------------------------------------------------------- + + --- Wraps a single entry's text to fit the current row width. Results are + --- cached on the entry itself as `entry.wrappedText`. The first wrapped + --- line reserves space for the name prefix; continuation lines span the + --- wider area to the right of the team-colour column. + ---@param self UIChatInterface + ---@param entry UIChatEntry + WrapEntry = function(self, entry) + local measureLine = self.Lines[1] + if not measureLine then + entry.wrappedText = { entry.text or '' } + return + end + + local name = entry.name or '' + local lines = MauiWrapText(entry.text or '', + function(lineIndex) + if lineIndex == 1 then + return measureLine.Right() + - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) + else + return measureLine.Right() + - (measureLine.Name.Left() + 4) + end + end, + function(textChunk) + return measureLine.Text:GetStringAdvance(textChunk) + end) + + if table.empty(lines) then lines = { '' } end + entry.wrappedText = lines + end, + + --- Re-wraps every entry in the history. Used on resize (width change) + --- and on option changes that affect the measuring font. + ---@param self UIChatInterface + RewrapAll = function(self) + local history = ChatModel.GetSingleton().History() + for _, entry in ipairs(history) do + self:WrapEntry(entry) + end + self:RefreshVirtualSize(history) + end, + + --------------------------------------------------------------------------- + -- Filtering + --------------------------------------------------------------------------- + + --- Whether an entry counts toward the virtual scroll size and should + --- appear in `CalcVisible`. Stubbed: wiring the per-army filter and + --- the camera-link filter to `ChatConfigModel.Committed` is a later step. + ---@param self UIChatInterface + ---@param entry UIChatEntry + ---@return boolean + IsValidEntry = function(self, entry) + return entry ~= nil + end, + + --------------------------------------------------------------------------- + -- Scroll container + --------------------------------------------------------------------------- + + --- Recomputes `VirtualSize` = total wrapped lines across all valid entries. + ---@param self UIChatInterface + ---@param history? UIChatEntry[] + RefreshVirtualSize = function(self, history) + history = history or ChatModel.GetSingleton().History() + local size = 0 + for _, entry in ipairs(history) do + if self:IsValidEntry(entry) then + size = size + ((entry.wrappedText and table.getn(entry.wrappedText)) or 1) end - prev = line end + self.VirtualSize = size end, - --- Fills the line pool with the most-recent history entries. + --- Standard MAUI scrollable interface: returns (rangeMin, rangeMax, visibleMin, visibleMax). ---@param self UIChatInterface - ---@param history UIChatEntry[] - RefreshLines = function(self, history) - local count = table.getn(history) + ---@param axis string # "Vert" or "Horz" + GetScrollValues = function(self, axis) local poolSize = table.getn(self.Lines) - local start = math.max(1, count - poolSize + 1) + local top = self.ScrollTop + return 1, self.VirtualSize, top, math.min(top + poolSize, self.VirtualSize) + end - for i = 1, poolSize do - local line = self.Lines[i] - local entry = history[start + i - 1] - if entry then - line:SetEntry(entry) - line:Show() - else + , + --- Scrolls by a number of rows (negative = toward older messages). + ---@param self UIChatInterface + ---@param axis string + ---@param delta number + ScrollLines = function(self, axis, delta) + self:SetScrollTop(self.ScrollTop + math.floor(delta)) + end, + + --- Scrolls by a page (pool-size worth of rows). + ---@param self UIChatInterface + ---@param axis string + ---@param delta number + ScrollPages = function(self, axis, delta) + self:SetScrollTop(self.ScrollTop + math.floor(delta) * table.getn(self.Lines)) + end, + + --- Jumps to an absolute virtual position, clamped to the valid range. + ---@param self UIChatInterface + ---@param top number + SetScrollTop = function(self, top) + top = math.floor(top or 1) + local poolSize = table.getn(self.Lines) + local maxTop = math.max(1, self.VirtualSize - poolSize + 1) + local clamped = math.max(1, math.min(maxTop, top)) + if clamped == self.ScrollTop then return end + self.ScrollTop = clamped + self:CalcVisible() + end, + + --- Standard MAUI scrollable interface: whether scrolling is possible on + --- the given axis. + ---@param self UIChatInterface + ---@param axis string + ---@return boolean + IsScrollable = function(self, axis) + return true + end, + + --- Snaps to the bottom of the virtual list. + ---@param self UIChatInterface + ScrollToBottom = function(self) + self:SetScrollTop(self.VirtualSize) + -- SetScrollTop short-circuits when the position doesn't change, but + -- the pool still needs a render pass after a rebuild / rewrap. + self:CalcVisible() + end, + + --------------------------------------------------------------------------- + -- Visibility mapping + --------------------------------------------------------------------------- + + --- Projects `[ScrollTop, ScrollTop + poolSize)` in virtual space onto the + --- line pool. Skips over filtered-out entries, uses `SetHeader` for the + --- first wrapped line of an entry and `SetContinuation` for the rest. + ---@param self UIChatInterface + CalcVisible = function(self) + if not self.Lines[1] then return end + + local history = ChatModel.GetSingleton().History() + local historyCount = table.getn(history) + local poolSize = table.getn(self.Lines) + local scrollTop = self.ScrollTop + + -- Walk to the entry + wrapped-line that covers virtual position `scrollTop`. + local entryIdx = 1 + local wrappedIdx = 1 + local virtualPos = 0 + + while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do + entryIdx = entryIdx + 1 + end + + while entryIdx <= historyCount do + local entry = history[entryIdx] + local wrapCount = (entry.wrappedText and table.getn(entry.wrappedText)) or 1 + if virtualPos + wrapCount >= scrollTop then + wrappedIdx = scrollTop - virtualPos + if wrappedIdx < 1 then wrappedIdx = 1 end + break + end + virtualPos = virtualPos + wrapCount + entryIdx = entryIdx + 1 + while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do + entryIdx = entryIdx + 1 + end + end + + -- Fill each pool row; advance the cursor through wrapped lines and + -- skip filtered entries as we go. + for poolIdx = 1, poolSize do + local line = self.Lines[poolIdx] + if entryIdx > historyCount then line:Clear() line:Hide() + else + local entry = history[entryIdx] + local wrapped = entry.wrappedText + local wrappedText = (wrapped and wrapped[wrappedIdx]) or entry.text or '' + + if wrappedIdx == 1 then + line:SetHeader(entry, wrappedText) + else + line:SetContinuation(wrappedText) + end + line:Show() + + local wrapCount = (wrapped and table.getn(wrapped)) or 1 + if wrappedIdx < wrapCount then + wrappedIdx = wrappedIdx + 1 + else + wrappedIdx = 1 + entryIdx = entryIdx + 1 + while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do + entryIdx = entryIdx + 1 + end + end + end + end + end, + + --------------------------------------------------------------------------- + -- Model reactions + --------------------------------------------------------------------------- + + --- Called whenever `model.History` fires dirty. Wraps entries we haven't + --- wrapped yet (new arrivals), refreshes the virtual size, and snaps to + --- the bottom so the new line is visible. + ---@param self UIChatInterface + ---@param history UIChatEntry[] + OnHistoryChanged = function(self, history) + for _, entry in ipairs(history) do + if not entry.wrappedText then + self:WrapEntry(entry) end end + self:RefreshVirtualSize(history) + if self.Lines[1] then + self:ScrollToBottom() + end + end, + + --------------------------------------------------------------------------- + -- Window event hooks + --------------------------------------------------------------------------- + + --- Fired continuously during a resize drag. Keep it cheap: just resize + --- the pool and re-render against existing wraps. + OnResize = function(self, width, height, firstFrame) + self:RebuildPool() + self:CalcVisible() + end, + + --- Fired when a resize drag ends. Rewrapping is expensive, so it only + --- happens here rather than on every drag frame. + OnResizeSet = function(self) + self:RebuildPool() + self:RewrapAll() + self:CalcVisible() + end, + + --- Mouse wheel over the window scrolls the chat. `rotation` is in wheel + --- units (usually ±120 per notch); one notch ≈ one line. + OnMouseWheel = function(self, rotation) + self:ScrollLines(nil, -math.floor(rotation / 100)) end, --- Engine-invoked when the user clicks the close button on the window frame. diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index 861054efc31..d50425b283a 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -58,6 +58,12 @@ ChatLineInterface = ClassUI(Group) { ---@param self UIChatLineInterface ---@param parent Control __post_init = function(self, parent) + -- Derive the row's height from the name font so pool sizing and + -- scroll positions scale automatically with `ChatOptions.font_size`. + Layouter(self) + :Height(function() return self.Name.Height() + 2 end) + :End() + Layouter(self.TeamColor) :AtLeftTopIn(self) :Width(self.Height) @@ -81,18 +87,35 @@ ChatLineInterface = ClassUI(Group) { :End() end, - --- Populates the line from a history entry. + --- Populates the row as the FIRST wrapped line of an entry: shows the + --- team-colour square, faction icon, the name prefix, and the first + --- wrapped chunk of message text. ---@param self UIChatLineInterface ---@param entry UIChatEntry - SetEntry = function(self, entry) + ---@param wrappedText string # the first wrapped chunk of `entry.text` + SetHeader = function(self, entry, wrappedText) self.Name:SetText(entry.name or '') - self.Text:SetText(entry.text or '') + self.Text:SetText(wrappedText or entry.text or '') self.TeamColor:SetSolidColor(entry.color or '00000000') local iconIndex = entry.faction or table.getn(FactionIcons) self.FactionIcon:SetTexture(UIUtil.UIFile(FactionIcons[iconIndex])) end, + --- Populates the row as a CONTINUATION of a wrapped entry: the name slot + --- and team-colour square stay empty, only the wrapped text is shown. + --- The text control remains anchored to `Name.Right + 2`; with an empty + --- name that resolves to the left of the row, so continuation lines + --- naturally line up under the first wrapped chunk. + ---@param self UIChatLineInterface + ---@param wrappedText string + SetContinuation = function(self, wrappedText) + self.Name:SetText('') + self.Text:SetText(wrappedText or '') + self.TeamColor:SetSolidColor('00000000') + self.FactionIcon:SetSolidColor('00000000') + end, + --- Clears all content so the row can stand empty. ---@param self UIChatLineInterface Clear = function(self) diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index d7b88544c05..a4cceb53952 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -16,13 +16,14 @@ RecipientAllies = 'allies' -- History entry. ---@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 color ----@field armyID number # sender's army index ----@field faction number # faction icon index (1-based) ----@field recipient UIChatRecipient # the target this message was directed to ----@field camera? table # camera state when the message is a ping link +---@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 color +---@field armyID number # sender's army index +---@field faction number # faction icon index (1-based) +---@field recipient UIChatRecipient # the target this message was directed to +---@field camera? table # camera state when the message is a ping link +---@field wrappedText? string[] # view-side cache: text wrapped to the current row width (populated by ChatInterface) ------------------------------------------------------------------------------- -- Model. @@ -35,15 +36,6 @@ RecipientAllies = 'allies' ---@type UIChatModel | nil local ModelInstance = nil ---- Returns the model singleton, creating it if it does not exist yet. ----@return UIChatModel -function GetSingleton() - if not ModelInstance then - SetupSingleton() - end - return ModelInstance -end - --- Creates and initializes the model singleton. ---@return UIChatModel function SetupSingleton() @@ -55,6 +47,15 @@ function SetupSingleton() return ModelInstance end +--- Returns the model singleton, creating it if it does not exist yet. +---@return UIChatModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UIChatModel]] +end + ------------------------------------------------------------------------------- --#region Debugging From 36a19fca7cb7380b18e7a9defd521a8a29efac47 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 00:57:04 +0200 Subject: [PATCH 016/130] Add support for scroll bar --- lua/ui/game/chat/ChatInterface.lua | 35 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index c55d0de8ef9..7056471b4d8 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -57,6 +57,7 @@ local WindowTextures = { ---@field LinesContainer Group ---@field Lines UIChatLineInterface[] ---@field Edit UIChatEditInterface +---@field Scrollbar Scrollbar ---@field ScrollTop number # 1-based virtual position of the top visible row ---@field VirtualSize number # total wrapped lines across valid entries local ChatInterface = ClassUI(Window) { @@ -78,6 +79,15 @@ local ChatInterface = ClassUI(Window) { self.ScrollTop = 1 self.VirtualSize = 0 + -- Expose the scrollable interface on the container so + -- `UIUtil.CreateVertScrollbarFor(LinesContainer)` binds correctly. + -- The logic and state live on `self`; the container just delegates. + self.LinesContainer.GetScrollValues = function(_, axis) return self:GetScrollValues(axis) end + self.LinesContainer.ScrollLines = function(_, axis, delta) self:ScrollLines(axis, delta) end + self.LinesContainer.ScrollPages = function(_, axis, delta) self:ScrollPages(axis, delta) end + self.LinesContainer.ScrollSetTop = function(_, axis, top) self:ScrollSetTop(axis, top) end + self.LinesContainer.IsScrollable = function(_, axis) return self:IsScrollable(axis) end + -- The edit area sits at the bottom of the client region. self.Edit = ChatEditInterface(client) @@ -122,13 +132,21 @@ local ChatInterface = ClassUI(Window) { :Over(client) :End() + -- Leave a ~20px gap on the right for the scrollbar, which sits + -- anchored to the container's right edge (see below). Layouter(self.LinesContainer) :AtLeftIn(client, pad) - :AtRightIn(client, pad) + :AtRightIn(client, 36) :AtTopIn(client, pad) - :AnchorToTop(self.Edit, pad) + :AnchorToTop(self.Edit, 12) :End() + -- Create the vertical scrollbar. `CreateVertScrollbarFor` calls + -- `Scrollbar:SetScrollable(control)` on the passed control, so the + -- scrollable interface has to live on `LinesContainer` (as + -- delegates to self — see __init). + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.LinesContainer) + -- Now that the container has a real size, build the pool and do -- a first wrap + render pass. self:RebuildPool() @@ -275,7 +293,7 @@ local ChatInterface = ClassUI(Window) { ---@param axis string ---@param delta number ScrollLines = function(self, axis, delta) - self:SetScrollTop(self.ScrollTop + math.floor(delta)) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) end, --- Scrolls by a page (pool-size worth of rows). @@ -283,13 +301,16 @@ local ChatInterface = ClassUI(Window) { ---@param axis string ---@param delta number ScrollPages = function(self, axis, delta) - self:SetScrollTop(self.ScrollTop + math.floor(delta) * table.getn(self.Lines)) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * table.getn(self.Lines)) end, --- Jumps to an absolute virtual position, clamped to the valid range. + --- Name and signature match the engine's `ScrollSetTop(axis, top)` contract + --- so `Scrollbar:SetScrollable` can call it directly. ---@param self UIChatInterface + ---@param axis string ---@param top number - SetScrollTop = function(self, top) + ScrollSetTop = function(self, axis, top) top = math.floor(top or 1) local poolSize = table.getn(self.Lines) local maxTop = math.max(1, self.VirtualSize - poolSize + 1) @@ -311,8 +332,8 @@ local ChatInterface = ClassUI(Window) { --- Snaps to the bottom of the virtual list. ---@param self UIChatInterface ScrollToBottom = function(self) - self:SetScrollTop(self.VirtualSize) - -- SetScrollTop short-circuits when the position doesn't change, but + self:ScrollSetTop(nil, self.VirtualSize) + -- ScrollSetTop short-circuits when the position doesn't change, but -- the pool still needs a render pass after a rebuild / rewrap. self:CalcVisible() end, From 64d3b66f031addfdec1a53c04a2fbf298d7a7474 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 01:09:24 +0200 Subject: [PATCH 017/130] Add support to toggle chat configuration --- lua/ui/game/chat/ChatInterface.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 7056471b4d8..7a76d214776 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -461,6 +461,12 @@ local ChatInterface = ClassUI(Window) { OnClose = function(self) ChatController.CloseWindow() end, + + --- Engine-invoked when the user clicks the config button on the window + --- frame. Opens (or closes, if already open) the chat options dialog. + OnConfigClick = function(self) + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() + end, } ------------------------------------------------------------------------------- From 503040a6abd999d7a5faa6f9d2b997a3cd068fbb Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 01:14:50 +0200 Subject: [PATCH 018/130] Add basic support for command hint --- lua/ui/game/chat/ChatCommandHintInterface.lua | 302 ++++++++++++++++++ lua/ui/game/chat/ChatEditInterface.lua | 56 ++++ .../chat/commands/ChatCommandRegistry.lua | 33 ++ 3 files changed, 391 insertions(+) create mode 100644 lua/ui/game/chat/ChatCommandHintInterface.lua diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua new file mode 100644 index 00000000000..d98b7d99627 --- /dev/null +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -0,0 +1,302 @@ + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Create = import("/lua/lazyvar.lua").Create + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowFontSize = 12 +local RowFontName = 'Arial' +local HorizontalPadding = 12 +local VerticalPadding = 2 +local DividerHeight = 2 + +--- Renders a command the same way `/help` does: name, params, aliases, description. +---@param cmd UIChatCommand +---@return string +local function FormatCommand(cmd) + local params = '' + if cmd.params then + for _, p in ipairs(cmd.params) do + local fmt = p.optional and ' [%s]' or ' <%s>' + params = params .. string.format(fmt, p.name) + end + end + + local aliases = '' + if cmd.aliases and table.getn(cmd.aliases) > 0 then + aliases = ' (aka /' .. table.concat(cmd.aliases, ', /') .. ')' + end + + return string.format("/%s%s%s — %s", cmd.name, params, aliases, cmd.description or '') +end + +------------------------------------------------------------------------------- +-- Command-hint popup. Shows commands whose name or aliases prefix-match the +-- user's input. Reuses a pool of row controls across refreshes — entries are +-- shown/hidden and re-positioned via a per-row `ordinal` LazyVar rather than +-- rebuilt from scratch. +-- +-- A pinned `/help` footer is always visible at the bottom. + +---@class UIChatHintRow +---@field text Text +---@field bg Bitmap +---@field ordinal LazyVar # 0 = hidden, 1 = row closest to footer, etc. +---@field target UIChatCommand | nil + +---@class UIChatCommandHintInterface : Group +---@field Edit Edit +---@field OnSelect? fun(cmd: UIChatCommand) +---@field Rows UIChatHintRow[] # reusable pool, indexed by ordinal +---@field Footer UIChatHintRow # always-visible /help row +---@field Divider Bitmap +---@field RowHeight LazyVar +---@field VisibleCount LazyVar +---@field LastText string +---@field LTBG Bitmap +---@field RTBG Bitmap +---@field RBBG Bitmap +---@field RLBG Bitmap +---@field LBG Bitmap +---@field RBG Bitmap +---@field TBG Bitmap +---@field BBG Bitmap +ChatCommandHintInterface = ClassUI(Group) { + + ---@param self UIChatCommandHintInterface + ---@param parent Control + ---@param edit Edit + __init = function(self, parent, edit) + Group.__init(self, parent, "ChatCommandHintInterface") + self:DisableHitTest() + LayoutHelpers.DepthOverParent(self, parent, 100) + + self.Edit = edit + self.Rows = {} + self.LastText = '' + self.VisibleCount = Create(0) + + -- Sample the row height from a throwaway Text. + ---@diagnostic disable-next-line: param-type-mismatch + local probe = UIUtil.CreateText(self, '/sample', RowFontSize, RowFontName) + ---@diagnostic disable-next-line: undefined-field + self.RowHeight = Create(probe.Height() + VerticalPadding) + probe:Destroy() + + -- Footer (always-visible /help row). + self.Footer = self:BuildRow() + local help = Registry.Lookup('help') + self.Footer.target = help + self.Footer.text:SetText(help and FormatCommand(help) or '/help') + self.Footer.text:SetColor('ffbbbbbb') + + self.Divider = Bitmap(self) + self.Divider:SetSolidColor('ff444444') + self.Divider:DisableHitTest() + + -- Decorative borders (same skin as ChatListInterface). + self.LTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) + self.LTBG:DisableHitTest() + self.RTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ur.dds')) + self.RTBG:DisableHitTest() + self.RBBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lr.dds')) + self.RBBG:DisableHitTest() + self.RLBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ll.dds')) + self.RLBG:DisableHitTest() + self.LBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_l.dds')) + self.LBG:DisableHitTest() + self.RBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_r.dds')) + self.RBG:DisableHitTest() + self.TBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_horz_um.dds')) + self.TBG:DisableHitTest() + self.BBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) + self.BBG:DisableHitTest() + end, + + ---@param self UIChatCommandHintInterface + ---@param parent Control + __post_init = function(self, parent) + -- Width: fit the widest fully-formatted row (/name (aka …) — desc) + -- so the popup doesn't reflow horizontally as rows change. + local probeText = '/help' + for _, cmd in ipairs(Registry.GetAll()) do + local candidate = FormatCommand(cmd) + if string.len(candidate) > string.len(probeText) then + probeText = candidate + end + end + ---@diagnostic disable-next-line: param-type-mismatch + local probe = UIUtil.CreateText(self, probeText, RowFontSize, RowFontName) + ---@diagnostic disable-next-line: undefined-field + local textWidth = probe.Width() + probe:Destroy() + + Layouter(self) + :Width(textWidth + HorizontalPadding * 2) + :End() + + ---@diagnostic disable: undefined-field + self.Height:SetFunction(function() + return (self.VisibleCount() + 1) * self.RowHeight() + DividerHeight + end) + + -- Footer pinned to the bottom of self. + self.Footer.text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) + self.Footer.text.Bottom:SetFunction(function() return self.Bottom() end) + self:LayoutRowBackground(self.Footer) + + -- Divider sits directly above the footer. + self.Divider.Left:SetFunction(function() return self.Left() end) + self.Divider.Right:SetFunction(function() return self.Right() end) + self.Divider.Bottom:SetFunction(function() return self.Footer.text.Top() end) + self.Divider.Height:SetFunction(function() return DividerHeight end) + + -- Borders hug the outside of self on all eight sides. + Layouter(self.LTBG):Right(self.Left):Bottom(self.Top):End() + Layouter(self.RTBG):Left(self.Right):Bottom(self.Top):End() + Layouter(self.RBBG):Left(self.Right):Top(self.Bottom):End() + Layouter(self.RLBG):Right(self.Left):Top(self.Bottom):End() + Layouter(self.LBG):Right(self.Left):Top(self.Top):Bottom(self.Bottom):End() + Layouter(self.RBG):Left(self.Right):Top(self.Top):Bottom(self.Bottom):End() + Layouter(self.TBG):Left(self.Left):Right(self.Right):Bottom(self.Top):End() + Layouter(self.BBG):Left(self.Left):Right(self.Right):Top(self.Bottom):End() + ---@diagnostic enable: undefined-field + end, + + --- Builds a reusable row (text + highlight bitmap + hover handler). + --- The row is laid out lazily by `LayoutRow` / `LayoutRowBackground`. + ---@param self UIChatCommandHintInterface + ---@return UIChatHintRow + BuildRow = function(self) + ---@type UIChatHintRow + local row = { + ordinal = Create(0), + target = nil, + } + ---@diagnostic disable-next-line: param-type-mismatch + row.text = UIUtil.CreateText(self, '', RowFontSize, RowFontName) + row.text:SetColor('ffffffff') + row.text:SetDropShadow(true) + row.text:DisableHitTest() + + row.bg = Bitmap(row.text) + row.bg:SetSolidColor('ff000000') + + local owner = self + row.bg.HandleEvent = function(bg, event) + if event.Type == 'MouseEnter' then + bg:SetSolidColor('ff666666') + elseif event.Type == 'MouseExit' then + bg:SetSolidColor('ff000000') + elseif event.Type == 'ButtonPress' then + if row.target and owner.OnSelect then + owner.OnSelect(row.target) + end + end + end + + return row + end, + + --- Lazily pulls a dynamic row out of the pool, creating it if needed and + --- wiring its position binding to its own ordinal LazyVar. + ---@param self UIChatCommandHintInterface + ---@param idx number + ---@return UIChatHintRow + GetOrCreateRow = function(self, idx) + local existing = self.Rows[idx] + if existing then return existing end + + local row = self:BuildRow() + self.Rows[idx] = row + + ---@diagnostic disable: undefined-field + row.text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) + row.text.Bottom:SetFunction(function() + local ord = row.ordinal() + if ord <= 0 then return self.Top() end + return self.Divider.Top() - (ord - 1) * self.RowHeight() + end) + ---@diagnostic enable: undefined-field + + self:LayoutRowBackground(row) + return row + end, + + --- Binds the row's highlight bitmap to span the popup width at the row's + --- vertical position, one depth below the text so clicks hit the bitmap. + ---@param self UIChatCommandHintInterface + ---@param row UIChatHintRow + LayoutRowBackground = function(self, row) + ---@diagnostic disable: undefined-field + row.bg.Left:SetFunction(function() return self.Left() end) + row.bg.Right:SetFunction(function() return self.Right() end) + row.bg.Top:SetFunction(function() return row.text.Top() - 1 end) + row.bg.Bottom:SetFunction(function() return row.text.Bottom() + 1 end) + row.bg.Depth:SetFunction(function() return row.text.Depth() - 1 end) + ---@diagnostic enable: undefined-field + end, + + --- Updates the popup to reflect the current edit-box text. Reuses existing + --- rows: each matching command is assigned to the row at its ordinal, and + --- rows beyond the match count are hidden (ordinal = 0). + ---@param self UIChatCommandHintInterface + ---@param text string + Refresh = function(self, text) + local matches = {} + if text and string.sub(text, 1, 1) == '/' then + local prefix = string.sub(text, 2) + -- Only the first word is the command name. + local space = string.find(prefix, '%s') + if space then prefix = string.sub(prefix, 1, space - 1) end + + for _, cmd in ipairs(Registry.FindMatching(prefix)) do + if cmd.name ~= 'help' then -- help lives in the footer + table.insert(matches, cmd) + end + end + end + + ---@diagnostic disable: undefined-field + for i, cmd in ipairs(matches) do + local row = self:GetOrCreateRow(i) + row.target = cmd + row.text:SetText(FormatCommand(cmd)) + row.text:Show() + row.bg:Show() + row.ordinal:Set(i) + end + for i = table.getn(matches) + 1, table.getn(self.Rows) do + local row = self.Rows[i] + row.target = nil + row.text:Hide() + row.bg:Hide() + row.ordinal:Set(0) + end + + self.VisibleCount:Set(table.getn(matches)) + ---@diagnostic enable: undefined-field + end, + + --- Registers the callback invoked when the user clicks a hint row. + ---@param self UIChatCommandHintInterface + ---@param callback fun(cmd: UIChatCommand) + SetOnSelect = function(self, callback) + self.OnSelect = callback + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index bc1dfbc5b7c..92894090e53 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -9,6 +9,7 @@ local Button = import("/lua/maui/button.lua").Button local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") local ChatListInterface = import("/lua/ui/game/chat/ChatListInterface.lua").ChatListInterface +local ChatCommandHintInterface = import("/lua/ui/game/chat/ChatCommandHintInterface.lua").ChatCommandHintInterface local Layouter = LayoutHelpers.ReusedLayoutFor @@ -24,6 +25,8 @@ local MaxChars = 200 ---@field RecipientLabel Text ---@field EditBox Edit ---@field ChatList UIChatListInterface | nil +---@field CommandHint UIChatCommandHintInterface | nil +---@field LastEditText string ChatEditInterface = ClassUI(Group) { ---@param self UIChatEditInterface @@ -76,6 +79,7 @@ ChatEditInterface = ClassUI(Group) { ChatController.Send(text) edit:SetText('') end + self:CloseCommandHint() end -- Keep the label in sync with the model. @@ -84,6 +88,58 @@ ChatEditInterface = ClassUI(Group) { self:RefreshRecipient(lv()) end self:RefreshRecipient(model.Recipient()) + + -- Drive the command-hint popup from the edit-box contents. We poll + -- once per frame because MAUI's Edit has no "text changed" callback + -- that fires reliably after both typed chars and backspaces. + self.LastEditText = '' + self:SetNeedsFrameUpdate(true) + end, + + ---@param self UIChatEditInterface + OnFrame = function(self) + local text = self.EditBox:GetText() or '' + if text == self.LastEditText then return end + self.LastEditText = text + + if string.sub(text, 1, 1) == '/' then + if not self.CommandHint then + self:OpenCommandHint() + end + local hint = self.CommandHint --[[@as UIChatCommandHintInterface]] + hint:Refresh(text) + else + self:CloseCommandHint() + end + end, + + --- Creates the hint popup and anchors it directly above the edit box. + ---@param self UIChatEditInterface + OpenCommandHint = function(self) + if self.CommandHint then return end + + -- Ensure the built-ins exist before the hint queries the registry; + -- otherwise we'd only see the footer fallback on the first open. + ChatController.RegisterBuiltinCommands() + + local hint = ChatCommandHintInterface(self, self.EditBox) + self.CommandHint = hint + LayoutHelpers.Above(hint, self.EditBox, 4) + LayoutHelpers.AtLeftIn(hint, self.EditBox) + hint:SetOnSelect(function(cmd) + self.EditBox:SetText('/' .. cmd.name .. ' ') + self:AcquireFocus() + end) + end, + + --- Tears down the hint popup if it exists. Called when the user sends a + --- message, clears the prefix, or otherwise leaves command-entry mode. + ---@param self UIChatEditInterface + CloseCommandHint = function(self) + if not self.CommandHint then return end + local hint = self.CommandHint --[[@as UIChatCommandHintInterface]] + self.CommandHint = nil + hint:Destroy() end, ---@param self UIChatEditInterface diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 059d206d8fd..362e270cb27 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -95,6 +95,38 @@ function Lookup(name) return nil end +--- Returns every registered command whose canonical name or any alias begins +--- with the given prefix (case-insensitive). Each command appears at most +--- once even if multiple of its aliases match. Results are sorted by name. +---@param prefix string +---@return UIChatCommand[] +function FindMatching(prefix) + local lower = string.lower(prefix or '') + local len = string.len(lower) + local seen = {} + local result = {} + + for name, cmd in Commands do + if string.sub(name, 1, len) == lower then + seen[cmd] = true + table.insert(result, cmd) + end + end + + for alias, canonical in Aliases do + if string.sub(alias, 1, len) == lower then + local cmd = Commands[canonical] + if cmd and not seen[cmd] then + seen[cmd] = true + table.insert(result, cmd) + end + end + end + + table.sort(result, function(a, b) return a.name < b.name end) + return result +end + ------------------------------------------------------------------------------- -- Parsing @@ -120,6 +152,7 @@ end ---@param tokens string[] ---@return table?, string? local function ParseArgs(cmd, tokens) + ---@type table local args = { _raw = tokens } if not cmd.params then return args, nil end From 33a75246533ac3b840da63e8b97a1b3fe5688bad Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 07:02:05 +0200 Subject: [PATCH 019/130] Reactive to font size, introduce use of trashbag for lazyvars --- lua/lazyvar.lua | 37 ++++++++ lua/ui/game/chat/CLAUDE.md | 37 +++++++- lua/ui/game/chat/ChatController.lua | 40 ++++++--- lua/ui/game/chat/ChatEditInterface.lua | 37 +++++--- lua/ui/game/chat/ChatInterface.lua | 89 ++++++++++++++----- lua/ui/game/chat/ChatLineInterface.lua | 9 ++ .../game/chat/config/ChatConfigInterface.lua | 28 ++++-- 7 files changed, 229 insertions(+), 48 deletions(-) diff --git a/lua/lazyvar.lua b/lua/lazyvar.lua index 1097734c905..ca7e1fefab3 100644 --- a/lua/lazyvar.lua +++ b/lua/lazyvar.lua @@ -307,3 +307,40 @@ function Create(initial) return result end + +--- Creates a LazyVar that derives its value from `source` and subscribes your +--- `onDirty` handler to changes in `source`. This is the safe way to observe a +--- LazyVar you don't own: the new LazyVar gets its own `OnDirty` slot, so +--- assigning your handler can never stomp another subscriber on `source`. +--- +--- How it differs from `Create`: +--- +--- * `Create(value)` allocates a LazyVar holding a static initial **value** +--- (or, with no argument, `0`). It has no compute function, no upstream +--- dependency, and no way to be notified when anything else changes. If you +--- pass a LazyVar or a function, it is stored verbatim as the cached value — +--- not interpreted as a dependency. +--- +--- * `Derive(source, onDirty)` allocates a new LazyVar, hangs `onDirty` on it, +--- and calls `:Set(function() return source() end)`. The first evaluation +--- reads `source` inside an `EvalContext`, which registers the derived +--- LazyVar in `source`'s used-by table (see the `__call` metamethod). From +--- then on, any `source:Set(...)` propagates a dirty signal to the derived +--- LazyVar and fires your handler. +--- +--- The handler receives the derived LazyVar; call it (`lv()`) to read the +--- current value, which transparently re-evaluates the `source()` compute. +--- +--- Destroy the returned LazyVar when the owner is torn down so its `OnDirty` +--- can't fire into a dead receiver. +--- +--- @generic T +--- @param source fun(): T | LazyVar # upstream value provider +--- @param onDirty fun(lv: LazyVar) # fires whenever `source` changes +--- @return LazyVar +function Derive(source, onDirty) + local lv = Create() + lv.OnDirty = onDirty + lv:Set(function() return source() end) + return lv +end diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md index 5621e99e90d..6ff5255cc96 100644 --- a/lua/ui/game/chat/CLAUDE.md +++ b/lua/ui/game/chat/CLAUDE.md @@ -53,9 +53,42 @@ end 1. **Never cache a LazyVar's value in a local.** Always call it (`lv()`) at the moment you need it so the dependency graph stays correct. 2. **`OnDirty` is a pull notification, not a push.** It tells you the value *may* have changed; you call `self()` inside `OnDirty` to get the new value. -3. **One `OnDirty` per LazyVar instance.** Assigning `OnDirty` again overwrites the previous one. If you need multiple observers, derive a second LazyVar that reads the first. +3. **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 a fresh LazyVar, hang your handler on **that**, and read the upstream LazyVar from its compute. The first read registers your observer in the upstream's `used_by` table, so future changes propagate to your handler without ever touching the upstream's `OnDirty` slot. + +Use `Derive(source, onDirty)` from `/lua/lazyvar.lua` — it bundles the three-step dance (create, set OnDirty, `Set` a reader) into one call: + +```lua +-- DON'T — clobbers any other subscriber: +model.History.OnDirty = function(lv) self:OnHistoryChanged(lv()) end + +-- DO — derive a per-subscriber LazyVar: +self.HistoryObserver = Derive(model.History, function(lv) + self:OnHistoryChanged(lv()) +end) + +-- And on teardown: +self.HistoryObserver:Destroy() +``` + +**`Create` vs `Derive`** — `Create(value)` makes a LazyVar holding a static initial value. If you pass a function or another LazyVar, it is stored *verbatim* as the cached value — not interpreted as a dependency. `Derive(source, onDirty)` makes a LazyVar that tracks `source` and fires `onDirty` whenever it changes. When you want to observe an existing LazyVar, you want `Derive`; `Create` won't wire up the dependency edge. + +This rule applies to every LazyVar in the system — including ones in our own `Model` files. Treat `Foo.OnDirty` as private to whoever creates `Foo`. 4. **Never write to the model inside an `OnDirty`.** That is controller logic; keep views read-only. -5. **Destroy LazyVars when the owning control is destroyed** to avoid dangling `OnDirty` callbacks. +5. **Destroy LazyVars when the owning control is destroyed** to avoid dangling `OnDirty` callbacks. The standard pattern is a `TrashBag` (see `/lua/system/trashbag.lua`): allocate `self.Trash = TrashBag()` in `__init`, hand every derived observer to it via `self.Trash:Add(...)`, and destroy the bag in `OnDestroy`: + + ```lua + __init = function(self, ...) + self.Trash = TrashBag() + self.HistoryObserver = self.Trash:Add(Derive(model.History, function(lv) + self:OnHistoryChanged(lv()) + end)) + end, + OnDestroy = function(self) + self.Trash:Destroy() + end, + ``` + + `Trash:Add` returns what you pass it, so the assignment stays a one-liner. ### What the autolobby got wrong diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 63d299ebb39..92cbc12bdce 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -1,11 +1,26 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") ------------------------------------------------------------------------------- -- Window visibility +--- Applies the `send_type` default-recipient option when the chat window +--- opens. If the user has already selected a specific player for a private +--- message, their choice is left alone. +local function ApplyDefaultRecipient() + local model = ChatModel.GetSingleton() + if type(model.Recipient()) == 'number' then + return + end + local options = ChatConfigModel.GetSingleton().Committed() + local target = options.send_type and ChatModel.RecipientAllies or ChatModel.RecipientAll + model.Recipient:Set(target) +end + --- Shows the chat window. function OpenWindow() + ApplyDefaultRecipient() ChatModel.GetSingleton().WindowVisible:Set(true) end @@ -17,7 +32,11 @@ end --- Toggles the chat window open or closed. function ToggleWindow() local lv = ChatModel.GetSingleton().WindowVisible - lv:Set(not lv()) + local willOpen = not lv() + if willOpen then + ApplyDefaultRecipient() + end + lv:Set(willOpen) end ------------------------------------------------------------------------------- @@ -59,16 +78,11 @@ end ------------------------------------------------------------------------------- -- Slash commands -local BuiltinsRegistered = false - ---- Registers every built-in chat command with the registry. Idempotent, so ---- it is safe to call from multiple init paths. External callers that want ---- their own commands registered alongside the built-ins should call this ---- once at startup before the first message is sent. +--- (Re-)registers every built-in chat command with the registry. `Register` +--- overwrites, so calling this repeatedly is safe and cheap — we do so on +--- every slash-entry path so hot-reloading `ChatCommandRegistry.lua` (which +--- resets its internal tables) doesn't leave us with an empty registry. function RegisterBuiltinCommands() - if BuiltinsRegistered then return end - BuiltinsRegistered = true - local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") local Builtins = import("/lua/ui/game/chat/commands/BuiltinCommands.lua") @@ -98,6 +112,12 @@ function Send(text) WARN("ChatController.Send not yet implemented: " .. tostring(text)) end +-- Register at load time so the registry is populated before the first hint +-- opens or the first slash command is sent. The function is idempotent, so +-- re-imports and the belt-and-suspenders calls from `Send` / `OpenCommandHint` +-- all converge on the same state. +RegisterBuiltinCommands() + ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 92894090e53..df75610f697 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -11,6 +11,8 @@ local ChatController = import("/lua/ui/game/chat/ChatController.lua") local ChatListInterface = import("/lua/ui/game/chat/ChatListInterface.lua").ChatListInterface local ChatCommandHintInterface = import("/lua/ui/game/chat/ChatCommandHintInterface.lua").ChatCommandHintInterface +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + local Layouter = LayoutHelpers.ReusedLayoutFor local MaxChars = 200 @@ -21,12 +23,14 @@ local MaxChars = 200 -- chat-bubble button or the label opens the recipient picker (ChatListInterface). ---@class UIChatEditInterface : Group ----@field ChatBubble Button ----@field RecipientLabel Text ----@field EditBox Edit ----@field ChatList UIChatListInterface | nil ----@field CommandHint UIChatCommandHintInterface | nil ----@field LastEditText string +---@field Trash TrashBag # owns every derived subscription-LazyVar +---@field ChatBubble Button +---@field RecipientLabel Text +---@field EditBox Edit +---@field ChatList UIChatListInterface | nil +---@field CommandHint UIChatCommandHintInterface | nil +---@field LastEditText string +---@field RecipientObserver LazyVar # derived from ChatModel.Recipient ChatEditInterface = ClassUI(Group) { ---@param self UIChatEditInterface @@ -34,6 +38,11 @@ ChatEditInterface = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent, "ChatEditInterface") + -- Single trash bag for everything we allocate that needs explicit + -- destruction — currently just the derived observer LazyVars. + -- Emptied in `OnDestroy`. + self.Trash = TrashBag() + self.ChatBubble = Button(self, UIUtil.UIFile('/game/chat-box_btn/radio_btn_up.dds'), UIUtil.UIFile('/game/chat-box_btn/radio_btn_down.dds'), @@ -82,12 +91,13 @@ ChatEditInterface = ClassUI(Group) { self:CloseCommandHint() end - -- Keep the label in sync with the model. + -- Keep the label in sync with the model. `LazyVarDerive` gives us a + -- fresh per-subscriber LazyVar so we don't stomp any other observer + -- of `model.Recipient` (see the chat CLAUDE.md for the pattern). local model = ChatModel.GetSingleton() - model.Recipient.OnDirty = function(lv) + self.RecipientObserver = self.Trash:Add(LazyVarDerive(model.Recipient, function(lv) self:RefreshRecipient(lv()) - end - self:RefreshRecipient(model.Recipient()) + end)) -- Drive the command-hint popup from the edit-box contents. We poll -- once per frame because MAUI's Edit has no "text changed" callback @@ -213,6 +223,13 @@ ChatEditInterface = ClassUI(Group) { AcquireFocus = function(self) self.EditBox:AcquireFocus() end, + + --- Empties our trash bag so every derived observer we allocated is + --- destroyed — no `OnDirty` can fire into a torn-down `self`. + ---@param self UIChatEditInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, } ------------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 7a76d214776..79f6eec34ff 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -10,8 +10,10 @@ local ChatEditInterface = import("/lua/ui/game/chat/ChatEditInterface.lua").Chat 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") local MauiWrapText = import("/lua/maui/text.lua").WrapText +local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor @@ -54,12 +56,17 @@ local WindowTextures = { -- `ChatConfigModel` is a follow-up step. ---@class UIChatInterface : Window +---@field Trash TrashBag # owns every subscription-LazyVar we create ---@field LinesContainer Group ---@field Lines UIChatLineInterface[] ---@field Edit UIChatEditInterface ---@field Scrollbar Scrollbar ---@field ScrollTop number # 1-based virtual position of the top visible row ---@field VirtualSize number # total wrapped lines across valid entries +---@field FontSize number # current font size (from ChatOptions.font_size) +---@field HistoryObserver LazyVar # derived from ChatModel.History +---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible +---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface @@ -71,6 +78,11 @@ local ChatInterface = ClassUI(Window) { local client = self:GetClientGroup() + -- Single trash bag for everything we allocate that needs explicit + -- destruction — currently just the derived observer LazyVars. + -- Emptied in `OnDestroy`. + self.Trash = TrashBag() + -- Container for the line pool. Stays empty until __post_init can -- measure its laid-out height and build the pool from that. self.LinesContainer = Group(client, "ChatLinesContainer") @@ -78,6 +90,7 @@ local ChatInterface = ClassUI(Window) { self.Lines = {} self.ScrollTop = 1 self.VirtualSize = 0 + self.FontSize = ChatConfigModel.GetSingleton().Committed().font_size or 14 -- Expose the scrollable interface on the container so -- `UIUtil.CreateVertScrollbarFor(LinesContainer)` binds correctly. @@ -91,30 +104,37 @@ local ChatInterface = ClassUI(Window) { -- The edit area sits at the bottom of the client region. self.Edit = ChatEditInterface(client) - -- Reactive: history → wrap new entries, refresh size, stick to - -- bottom. The initial firing happens before __post_init so the - -- wrap call has no pool to measure against; that's fine — - -- RewrapAll runs once the pool exists. + -- Reactive subscriptions use `LazyVarDerive` so each observer is a + -- fresh LazyVar that reads from an upstream model field — setting + -- our handler can never stomp another subscriber's (see the chat + -- CLAUDE.md for the pattern). local model = ChatModel.GetSingleton() - model.History.OnDirty = function(lv) + local configModel = ChatConfigModel.GetSingleton() + + -- History → wrap new entries, refresh size, stick to bottom. The + -- initial firing happens before __post_init so the wrap call has + -- no pool to measure against; that's fine — RewrapAll runs once + -- the pool exists. + self.HistoryObserver = self.Trash:Add(LazyVarDerive(model.History, function(lv) self:OnHistoryChanged(lv()) - end - self:OnHistoryChanged(model.History()) + end)) - -- Reactive: window visibility → show / hide the frame. - model.WindowVisible.OnDirty = function(lv) + -- Committed chat options → apply font size, rebuild the pool (line + -- height tracks the font), rewrap all entries (wrap widths depend + -- on font metrics), and re-render. + self.OptionsObserver = self.Trash:Add(LazyVarDerive(configModel.Committed, function(lv) + self:ApplyOptions(lv()) + end)) + + -- Window visibility → show / hide the frame. + self.WindowVisibleObserver = self.Trash:Add(LazyVarDerive(model.WindowVisible, function(lv) if lv() then self:Show() self.Edit:AcquireFocus() else self:Hide() end - end - if model.WindowVisible() then - self:Show() - else - self:Hide() - end + end)) end, ---@param self UIChatInterface @@ -170,6 +190,7 @@ local ChatInterface = ClassUI(Window) { -- lazy function of the name-text font (see ChatLineInterface). if not self.Lines[1] then self.Lines[1] = ChatLineInterface(container) + self.Lines[1]:SetFontSize(self.FontSize) Layouter(self.Lines[1]) :AtLeftTopIn(container) :Right(container.Right) @@ -185,6 +206,7 @@ local ChatInterface = ClassUI(Window) { -- Grow: append rows below the previous one. for i = currentCount + 1, neededLines do self.Lines[i] = ChatLineInterface(container) + self.Lines[i]:SetFontSize(self.FontSize) Layouter(self.Lines[i]) :Below(self.Lines[i - 1]) :AtLeftIn(container) @@ -199,6 +221,30 @@ local ChatInterface = ClassUI(Window) { end end, + --------------------------------------------------------------------------- + -- Options application + --------------------------------------------------------------------------- + + --- Applies a `UIChatOptions` snapshot to the window. Currently handles + --- `font_size`; future options (colours, alpha, feed-mode flags) will + --- extend this method. + ---@param self UIChatInterface + ---@param options UIChatOptions + ApplyOptions = function(self, options) + local size = options.font_size or 14 + if size ~= self.FontSize then + self.FontSize = size + for _, line in ipairs(self.Lines) do + line:SetFontSize(size) + end + -- Row height tracks the font, so the pool may need resizing; + -- wrap widths depend on font metrics, so rewrap all entries. + self:RebuildPool() + self:RewrapAll() + self:CalcVisible() + end + end, + --------------------------------------------------------------------------- -- Text wrapping --------------------------------------------------------------------------- @@ -467,6 +513,12 @@ local ChatInterface = ClassUI(Window) { OnConfigClick = function(self) import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() end, + + --- Empties our trash bag so every derived observer we allocated is + --- destroyed — no `OnDirty` can fire into a torn-down `self`. + OnDestroy = function(self) + self.Trash:Destroy() + end, } ------------------------------------------------------------------------------- @@ -510,11 +562,8 @@ end --- Called by the module manager when this module becomes dirty. function __moduleinfo.OnDirty() if Instance then - -- Clear subscriptions to avoid dangling callbacks into a destroyed view. - local model = ChatModel.GetSingleton() - model.History.OnDirty = nil - model.WindowVisible.OnDirty = nil - + -- `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 diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index d50425b283a..998253371d2 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -124,6 +124,15 @@ ChatLineInterface = ClassUI(Group) { self.TeamColor:SetSolidColor('00000000') self.FactionIcon:SetSolidColor('00000000') end, + + --- Updates the font size for both name and body text. The row's `Height` + --- LazyVar is derived from `Name.Height`, so the row resizes automatically. + ---@param self UIChatLineInterface + ---@param size number # point size + SetFontSize = function(self, size) + self.Name:SetFont('Arial Bold', size) + self.Text:SetFont('Arial', size) + end, } ------------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index df959f4722d..2cd394ea3fc 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -8,6 +8,8 @@ local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + local Layouter = LayoutHelpers.ReusedLayoutFor -- 8 ARGB solid colors selectable as message color swatches. @@ -37,6 +39,7 @@ local CheckboxDefs = { ---@field key string ---@class UIChatConfigInterface : Window +---@field Trash TrashBag # owns every derived subscription-LazyVar ---@field LabelColors Text ---@field ColorRows UIChatConfigColorRow[] ---@field LabelFontSize Text @@ -51,6 +54,7 @@ local CheckboxDefs = { ---@field BtnReset Button ---@field BtnOk Button ---@field BtnCancel Button +---@field PendingObserver LazyVar # derived from ChatConfigModel.Pending local ChatConfigInterface = ClassUI(Window) { ---@param self UIChatConfigInterface @@ -60,6 +64,11 @@ local ChatConfigInterface = ClassUI(Window) { Left = 200, Top = 200, Right = 524, Bottom = 640, }) + -- Single trash bag for everything we allocate that needs explicit + -- destruction — currently just the derived observer LazyVars. + -- Emptied in `OnDestroy`. + self.Trash = TrashBag() + local client = self:GetClientGroup() -- ---- Color rows ---- @@ -159,11 +168,12 @@ local ChatConfigInterface = ClassUI(Window) { end -- ---- Reactive: sync all controls whenever pending options change ---- + -- `LazyVarDerive` gives us a fresh per-subscriber LazyVar so we don't + -- stomp other subscribers on Pending (see the chat CLAUDE.md). local model = ChatConfigModel.GetSingleton() - model.Pending.OnDirty = function(lv) + self.PendingObserver = self.Trash:Add(LazyVarDerive(model.Pending, function(lv) self:RefreshFromOptions(lv()) - end - self:RefreshFromOptions(model.Pending()) + end)) end, ---@param self UIChatConfigInterface @@ -300,6 +310,13 @@ local ChatConfigInterface = ClassUI(Window) { OnClose = function(self) import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() end, + + --- Empties our trash bag so every derived observer we allocated is + --- destroyed — no `OnDirty` can fire into a torn-down `self`. + ---@param self UIChatConfigInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, } ------------------------------------------------------------------------------- @@ -321,9 +338,8 @@ end --- Closes and destroys the config dialog. function Close() if Instance then - -- Remove the reactive subscription before destroying to avoid stale callbacks. - ChatConfigModel.GetSingleton().Pending.OnDirty = nil - + -- `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 From 75f232d1bd9c8aec2a48beb7bde3623cf7a83caa Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 07:07:16 +0200 Subject: [PATCH 020/130] Formatting --- lua/ui/game/chat/ChatInterface.lua | 70 ++++++++++++------- .../game/chat/config/ChatConfigInterface.lua | 12 +++- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 79f6eec34ff..0bc635f9a37 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -1,4 +1,3 @@ - local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") @@ -21,15 +20,15 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- `/lua/ui/game/layouts/chat_layout.lua` applies to the legacy chat Window --- so the new window matches the original visual style. local WindowTextures = { - tl = UIUtil.UIFile('/game/chat_brd/chat_brd_ul.dds'), - tr = UIUtil.UIFile('/game/chat_brd/chat_brd_ur.dds'), - tm = UIUtil.UIFile('/game/chat_brd/chat_brd_horz_um.dds'), - ml = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_l.dds'), - m = UIUtil.UIFile('/game/chat_brd/chat_brd_m.dds'), - mr = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_r.dds'), - bl = UIUtil.UIFile('/game/chat_brd/chat_brd_ll.dds'), - bm = UIUtil.UIFile('/game/chat_brd/chat_brd_lm.dds'), - br = UIUtil.UIFile('/game/chat_brd/chat_brd_lr.dds'), + tl = UIUtil.UIFile('/game/chat_brd/chat_brd_ul.dds'), + tr = UIUtil.UIFile('/game/chat_brd/chat_brd_ur.dds'), + tm = UIUtil.UIFile('/game/chat_brd/chat_brd_horz_um.dds'), + ml = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_l.dds'), + m = UIUtil.UIFile('/game/chat_brd/chat_brd_m.dds'), + mr = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_r.dds'), + bl = UIUtil.UIFile('/game/chat_brd/chat_brd_ll.dds'), + bm = UIUtil.UIFile('/game/chat_brd/chat_brd_lm.dds'), + br = UIUtil.UIFile('/game/chat_brd/chat_brd_lr.dds'), borderColor = 'ff415055', } @@ -115,26 +114,43 @@ local ChatInterface = ClassUI(Window) { -- initial firing happens before __post_init so the wrap call has -- no pool to measure against; that's fine — RewrapAll runs once -- the pool exists. - self.HistoryObserver = self.Trash:Add(LazyVarDerive(model.History, function(lv) - self:OnHistoryChanged(lv()) - end)) + self.HistoryObserver = self.Trash:Add( + LazyVarDerive( + model.History, + function(lv) + self:OnHistoryChanged(lv() + ) + end + ) + ) -- Committed chat options → apply font size, rebuild the pool (line -- height tracks the font), rewrap all entries (wrap widths depend -- on font metrics), and re-render. - self.OptionsObserver = self.Trash:Add(LazyVarDerive(configModel.Committed, function(lv) - self:ApplyOptions(lv()) - end)) + self.OptionsObserver = self.Trash:Add( + LazyVarDerive( + configModel.Committed, + function(lv) + self:ApplyOptions(lv() + ) + end + ) + ) -- Window visibility → show / hide the frame. - self.WindowVisibleObserver = self.Trash:Add(LazyVarDerive(model.WindowVisible, function(lv) - if lv() then - self:Show() - self.Edit:AcquireFocus() - else - self:Hide() - end - end)) + self.WindowVisibleObserver = self.Trash:Add( + LazyVarDerive( + model.WindowVisible, + function(lv) + if lv() then + self:Show() + self.Edit:AcquireFocus() + else + self:Hide() + end + end + ) + ) end, ---@param self UIChatInterface @@ -198,7 +214,7 @@ local ChatInterface = ClassUI(Window) { end local rowHeight = self.Lines[1].Height() - if rowHeight < 1 then rowHeight = 18 end -- safety fallback + if rowHeight < 1 then rowHeight = 18 end -- safety fallback local neededLines = math.max(1, math.floor(container.Height() / rowHeight)) local currentCount = table.getn(self.Lines) @@ -267,10 +283,10 @@ local ChatInterface = ClassUI(Window) { function(lineIndex) if lineIndex == 1 then return measureLine.Right() - - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) + - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) else return measureLine.Right() - - (measureLine.Name.Left() + 4) + - (measureLine.Name.Left() + 4) end end, function(textChunk) diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 2cd394ea3fc..217b54b53a0 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -171,9 +171,15 @@ local ChatConfigInterface = ClassUI(Window) { -- `LazyVarDerive` gives us a fresh per-subscriber LazyVar so we don't -- stomp other subscribers on Pending (see the chat CLAUDE.md). local model = ChatConfigModel.GetSingleton() - self.PendingObserver = self.Trash:Add(LazyVarDerive(model.Pending, function(lv) - self:RefreshFromOptions(lv()) - end)) + self.PendingObserver = self.Trash:Add( + LazyVarDerive( + model.Pending, + function(lv) + self:RefreshFromOptions(lv() + ) + end + ) + ) end, ---@param self UIChatConfigInterface From 8761a8b590abe6072d35eb265466b6f1764723e2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 07:15:47 +0200 Subject: [PATCH 021/130] Refactor key/value pairs --- lua/ui/game/chat/ChatCommandHintInterface.lua | 92 +++--- lua/ui/game/chat/ChatController.lua | 10 +- lua/ui/game/chat/ChatInterface.lua | 20 +- lua/ui/game/chat/ChatLineInterface.lua | 10 +- lua/ui/game/chat/ChatListInterface.lua | 270 ++++++++++-------- lua/ui/game/chat/ChatModel.lua | 16 +- 6 files changed, 225 insertions(+), 193 deletions(-) diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index d98b7d99627..e002d0cb008 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -21,19 +21,19 @@ local DividerHeight = 2 ---@return string local function FormatCommand(cmd) local params = '' - if cmd.params then - for _, p in ipairs(cmd.params) do - local fmt = p.optional and ' [%s]' or ' <%s>' - params = params .. string.format(fmt, p.name) + if cmd.Params then + for _, p in ipairs(cmd.Params) do + local fmt = p.Optional and ' [%s]' or ' <%s>' + params = params .. string.format(fmt, p.Name) end end local aliases = '' - if cmd.aliases and table.getn(cmd.aliases) > 0 then - aliases = ' (aka /' .. table.concat(cmd.aliases, ', /') .. ')' + if cmd.Aliases and table.getn(cmd.Aliases) > 0 then + aliases = ' (aka /' .. table.concat(cmd.Aliases, ', /') .. ')' end - return string.format("/%s%s%s — %s", cmd.name, params, aliases, cmd.description or '') + return string.format("/%s%s%s — %s", cmd.Name, params, aliases, cmd.Description or '') end ------------------------------------------------------------------------------- @@ -45,10 +45,10 @@ end -- A pinned `/help` footer is always visible at the bottom. ---@class UIChatHintRow ----@field text Text ----@field bg Bitmap ----@field ordinal LazyVar # 0 = hidden, 1 = row closest to footer, etc. ----@field target UIChatCommand | nil +---@field Text Text +---@field BG Bitmap +---@field Ordinal LazyVar # 0 = hidden, 1 = row closest to footer, etc. +---@field Target UIChatCommand | nil ---@class UIChatCommandHintInterface : Group ---@field Edit Edit @@ -92,9 +92,9 @@ ChatCommandHintInterface = ClassUI(Group) { -- Footer (always-visible /help row). self.Footer = self:BuildRow() local help = Registry.Lookup('help') - self.Footer.target = help - self.Footer.text:SetText(help and FormatCommand(help) or '/help') - self.Footer.text:SetColor('ffbbbbbb') + self.Footer.Target = help + self.Footer.Text:SetText(help and FormatCommand(help) or '/help') + self.Footer.Text:SetColor('ffbbbbbb') self.Divider = Bitmap(self) self.Divider:SetSolidColor('ff444444') @@ -147,14 +147,14 @@ ChatCommandHintInterface = ClassUI(Group) { end) -- Footer pinned to the bottom of self. - self.Footer.text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) - self.Footer.text.Bottom:SetFunction(function() return self.Bottom() end) + self.Footer.Text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) + self.Footer.Text.Bottom:SetFunction(function() return self.Bottom() end) self:LayoutRowBackground(self.Footer) -- Divider sits directly above the footer. self.Divider.Left:SetFunction(function() return self.Left() end) self.Divider.Right:SetFunction(function() return self.Right() end) - self.Divider.Bottom:SetFunction(function() return self.Footer.text.Top() end) + self.Divider.Bottom:SetFunction(function() return self.Footer.Text.Top() end) self.Divider.Height:SetFunction(function() return DividerHeight end) -- Borders hug the outside of self on all eight sides. @@ -176,27 +176,27 @@ ChatCommandHintInterface = ClassUI(Group) { BuildRow = function(self) ---@type UIChatHintRow local row = { - ordinal = Create(0), - target = nil, + Ordinal = Create(0), + Target = nil, } ---@diagnostic disable-next-line: param-type-mismatch - row.text = UIUtil.CreateText(self, '', RowFontSize, RowFontName) - row.text:SetColor('ffffffff') - row.text:SetDropShadow(true) - row.text:DisableHitTest() + row.Text = UIUtil.CreateText(self, '', RowFontSize, RowFontName) + row.Text:SetColor('ffffffff') + row.Text:SetDropShadow(true) + row.Text:DisableHitTest() - row.bg = Bitmap(row.text) - row.bg:SetSolidColor('ff000000') + row.BG = Bitmap(row.Text) + row.BG:SetSolidColor('ff000000') local owner = self - row.bg.HandleEvent = function(bg, event) + row.BG.HandleEvent = function(bg, event) if event.Type == 'MouseEnter' then bg:SetSolidColor('ff666666') elseif event.Type == 'MouseExit' then bg:SetSolidColor('ff000000') elseif event.Type == 'ButtonPress' then - if row.target and owner.OnSelect then - owner.OnSelect(row.target) + if row.Target and owner.OnSelect then + owner.OnSelect(row.Target) end end end @@ -217,9 +217,9 @@ ChatCommandHintInterface = ClassUI(Group) { self.Rows[idx] = row ---@diagnostic disable: undefined-field - row.text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) - row.text.Bottom:SetFunction(function() - local ord = row.ordinal() + row.Text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) + row.Text.Bottom:SetFunction(function() + local ord = row.Ordinal() if ord <= 0 then return self.Top() end return self.Divider.Top() - (ord - 1) * self.RowHeight() end) @@ -235,11 +235,11 @@ ChatCommandHintInterface = ClassUI(Group) { ---@param row UIChatHintRow LayoutRowBackground = function(self, row) ---@diagnostic disable: undefined-field - row.bg.Left:SetFunction(function() return self.Left() end) - row.bg.Right:SetFunction(function() return self.Right() end) - row.bg.Top:SetFunction(function() return row.text.Top() - 1 end) - row.bg.Bottom:SetFunction(function() return row.text.Bottom() + 1 end) - row.bg.Depth:SetFunction(function() return row.text.Depth() - 1 end) + row.BG.Left:SetFunction(function() return self.Left() end) + row.BG.Right:SetFunction(function() return self.Right() end) + row.BG.Top:SetFunction(function() return row.Text.Top() - 1 end) + row.BG.Bottom:SetFunction(function() return row.Text.Bottom() + 1 end) + row.BG.Depth:SetFunction(function() return row.Text.Depth() - 1 end) ---@diagnostic enable: undefined-field end, @@ -257,7 +257,7 @@ ChatCommandHintInterface = ClassUI(Group) { if space then prefix = string.sub(prefix, 1, space - 1) end for _, cmd in ipairs(Registry.FindMatching(prefix)) do - if cmd.name ~= 'help' then -- help lives in the footer + if cmd.Name ~= 'help' then -- help lives in the footer table.insert(matches, cmd) end end @@ -266,18 +266,18 @@ ChatCommandHintInterface = ClassUI(Group) { ---@diagnostic disable: undefined-field for i, cmd in ipairs(matches) do local row = self:GetOrCreateRow(i) - row.target = cmd - row.text:SetText(FormatCommand(cmd)) - row.text:Show() - row.bg:Show() - row.ordinal:Set(i) + row.Target = cmd + row.Text:SetText(FormatCommand(cmd)) + row.Text:Show() + row.BG:Show() + row.Ordinal:Set(i) end for i = table.getn(matches) + 1, table.getn(self.Rows) do local row = self.Rows[i] - row.target = nil - row.text:Hide() - row.bg:Hide() - row.ordinal:Set(0) + row.Target = nil + row.Text:Hide() + row.BG:Hide() + row.Ordinal:Set(0) end self.VisibleCount:Set(table.getn(matches)) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 92cbc12bdce..d53f6eba81c 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -67,11 +67,11 @@ end ---@param text string function AppendLocalSystemMessage(text) AppendEntry { - name = "System:", - text = text, - color = 'ffff6666', - armyID = 0, - recipient = ChatModel.RecipientAll, + Name = "System:", + Text = text, + Color = 'ffff6666', + ArmyID = 0, + Recipient = ChatModel.RecipientAll, } end diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 0bc635f9a37..18f65a22975 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -266,7 +266,7 @@ local ChatInterface = ClassUI(Window) { --------------------------------------------------------------------------- --- Wraps a single entry's text to fit the current row width. Results are - --- cached on the entry itself as `entry.wrappedText`. The first wrapped + --- cached on the entry itself as `entry.WrappedText`. The first wrapped --- line reserves space for the name prefix; continuation lines span the --- wider area to the right of the team-colour column. ---@param self UIChatInterface @@ -274,12 +274,12 @@ local ChatInterface = ClassUI(Window) { WrapEntry = function(self, entry) local measureLine = self.Lines[1] if not measureLine then - entry.wrappedText = { entry.text or '' } + entry.WrappedText = { entry.Text or '' } return end - local name = entry.name or '' - local lines = MauiWrapText(entry.text or '', + local name = entry.Name or '' + local lines = MauiWrapText(entry.Text or '', function(lineIndex) if lineIndex == 1 then return measureLine.Right() @@ -294,7 +294,7 @@ local ChatInterface = ClassUI(Window) { end) if table.empty(lines) then lines = { '' } end - entry.wrappedText = lines + entry.WrappedText = lines end, --- Re-wraps every entry in the history. Used on resize (width change) @@ -334,7 +334,7 @@ local ChatInterface = ClassUI(Window) { local size = 0 for _, entry in ipairs(history) do if self:IsValidEntry(entry) then - size = size + ((entry.wrappedText and table.getn(entry.wrappedText)) or 1) + size = size + ((entry.WrappedText and table.getn(entry.WrappedText)) or 1) end end self.VirtualSize = size @@ -427,7 +427,7 @@ local ChatInterface = ClassUI(Window) { while entryIdx <= historyCount do local entry = history[entryIdx] - local wrapCount = (entry.wrappedText and table.getn(entry.wrappedText)) or 1 + local wrapCount = (entry.WrappedText and table.getn(entry.WrappedText)) or 1 if virtualPos + wrapCount >= scrollTop then wrappedIdx = scrollTop - virtualPos if wrappedIdx < 1 then wrappedIdx = 1 end @@ -449,8 +449,8 @@ local ChatInterface = ClassUI(Window) { line:Hide() else local entry = history[entryIdx] - local wrapped = entry.wrappedText - local wrappedText = (wrapped and wrapped[wrappedIdx]) or entry.text or '' + local wrapped = entry.WrappedText + local wrappedText = (wrapped and wrapped[wrappedIdx]) or entry.Text or '' if wrappedIdx == 1 then line:SetHeader(entry, wrappedText) @@ -484,7 +484,7 @@ local ChatInterface = ClassUI(Window) { ---@param history UIChatEntry[] OnHistoryChanged = function(self, history) for _, entry in ipairs(history) do - if not entry.wrappedText then + if not entry.WrappedText then self:WrapEntry(entry) end end diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index 998253371d2..aa0ba721328 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -92,13 +92,13 @@ ChatLineInterface = ClassUI(Group) { --- wrapped chunk of message text. ---@param self UIChatLineInterface ---@param entry UIChatEntry - ---@param wrappedText string # the first wrapped chunk of `entry.text` + ---@param wrappedText string # the first wrapped chunk of `entry.Text` SetHeader = function(self, entry, wrappedText) - self.Name:SetText(entry.name or '') - self.Text:SetText(wrappedText or entry.text or '') - self.TeamColor:SetSolidColor(entry.color or '00000000') + self.Name:SetText(entry.Name or '') + self.Text:SetText(wrappedText or entry.Text or '') + self.TeamColor:SetSolidColor(entry.Color or '00000000') - local iconIndex = entry.faction or table.getn(FactionIcons) + local iconIndex = entry.Faction or table.getn(FactionIcons) self.FactionIcon:SetTexture(UIUtil.UIFile(FactionIcons[iconIndex])) end, diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index 94f233aa2d8..8cdca494e76 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -15,10 +15,10 @@ local ChatController = import("/lua/ui/game/chat/ChatController.lua") local Layouter = LayoutHelpers.ReusedLayoutFor ---@class UIChatListEntry ----@field text Text ----@field bg Bitmap ----@field badge? ChatFactionBadge # only present on player entries ----@field target UIChatRecipient +---@field Text Text +---@field BG Bitmap +---@field Badge? ChatFactionBadge # only present on player entries +---@field Target UIChatRecipient ------------------------------------------------------------------------------- -- A popup recipient picker. Lists "All", "Allies", and one entry per @@ -53,16 +53,39 @@ ChatListInterface = ClassUI(Group) { -- matching the list's default depth. +100 gives unambiguous headroom. LayoutHelpers.DepthOverParent(self, parent, 100) - -- Build the list of selectable targets: All, Allies, then one entry - -- per connected human player. `GetSessionClients` naturally excludes - -- bots (they are not session clients); we additionally skip the - -- local client (you can't privately message yourself) and any - -- disconnected player. A client's army is found by matching - -- nickname — the target stays an army ID so the send path continues - -- to work unchanged. + self.Entries = {} + for _, def in ipairs(self:BuildTargetDefs()) do + table.insert(self.Entries, self:CreateEntry(def)) + end + + self:CreateBorder() + + -- Close on any mouse click outside the popup. + local function onOutsideClick() self:Destroy() end + UIMain.AddOnMouseClickedFunc(onOutsideClick) + + self.OnDestroy = function(dself) + UIMain.RemoveOnMouseClickedFunc(onOutsideClick) + if dself._OnClosed then + local cb = dself._OnClosed + dself._OnClosed = nil + cb() + end + end + end, + + --- Builds the list of selectable targets: All, Allies, then one entry + --- per connected human player. `GetSessionClients` naturally excludes + --- bots (they are not session clients); we additionally skip the local + --- client (you can't privately message yourself) and any disconnected + --- player. A client's army is found by matching nickname — the target + --- stays an army ID so the send path continues to work unchanged. + ---@param self UIChatListInterface + ---@return table[] + BuildTargetDefs = function(self) local defs = { - { nickname = "All", target = ChatModel.RecipientAll }, - { nickname = "Allies", target = ChatModel.RecipientAllies }, + { Nickname = "All", Target = ChatModel.RecipientAll }, + { Nickname = "Allies", Target = ChatModel.RecipientAllies }, } local armies = GetArmiesTable().armiesTable @@ -71,10 +94,10 @@ ChatListInterface = ClassUI(Group) { for armyID, armyData in armies do if not armyData.civilian and armyData.nickname == client.name then table.insert(defs, { - nickname = client.name, - target = armyID, - faction = armyData.faction, - color = armyData.color, + Nickname = client.name, + Target = armyID, + Faction = armyData.faction, + Color = armyData.color, }) break end @@ -82,83 +105,76 @@ ChatListInterface = ClassUI(Group) { end end - self.Entries = {} - for _, def in ipairs(defs) do - local entry = { - target = def.target, - text = UIUtil.CreateText(self, def.nickname, 12, "Arial"), - } - entry.text:SetColor('ffffffff') - entry.text:DisableHitTest() - - entry.bg = Bitmap(entry.text) - entry.bg:SetSolidColor('ff000000') - - -- Player entries get a faction+colour badge to the left of the - -- name; All / Allies rows have no badge but share the same text - -- indent (applied in `__post_init`) so the column stays aligned. - if def.color then - entry.badge = ChatFactionBadge(self, def.faction, def.color) - end + return defs + end, - -- Capture target in a local so each entry closes over its own value. - local target = def.target - entry.bg.HandleEvent = function(bg, event) - if event.Type == 'MouseEnter' then - bg:SetSolidColor('ff666666') - elseif event.Type == 'MouseExit' then - bg:SetSolidColor('ff000000') - elseif event.Type == 'ButtonPress' then - ChatController.SetRecipient(target) - self:Destroy() - end - end + --- Creates a single row: text, highlight bitmap, optional faction badge, + --- and a hover/click handler that dispatches to `ChatController` and + --- closes the popup. Player rows carry a badge; All / Allies rows don't. + ---@param self UIChatListInterface + ---@param def table + ---@return UIChatListEntry + CreateEntry = function(self, def) + local entry = { + Target = def.Target, + Text = UIUtil.CreateText(self, def.Nickname, 12, "Arial"), + } + entry.Text:SetColor('ffffffff') + entry.Text:DisableHitTest() - table.insert(self.Entries, entry) + entry.BG = Bitmap(entry.Text) + entry.BG:SetSolidColor('ff000000') + + if def.Color then + entry.Badge = ChatFactionBadge(self, def.Faction, def.Color) end - -- Decorative border bitmaps that sit outside the popup's bounds. - self.LTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) - self.LTBG:DisableHitTest() - self.RTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ur.dds')) - self.RTBG:DisableHitTest() - self.RBBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lr.dds')) - self.RBBG:DisableHitTest() - self.RLBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ll.dds')) - self.RLBG:DisableHitTest() - self.LBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_l.dds')) - self.LBG:DisableHitTest() - self.RBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_r.dds')) - self.RBG:DisableHitTest() - self.TBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_horz_um.dds')) - self.TBG:DisableHitTest() - self.BBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) - self.BBG:DisableHitTest() + -- Capture target in a local so each entry closes over its own value. + local target = def.Target + entry.BG.HandleEvent = function(bg, event) + if event.Type == 'MouseEnter' then + bg:SetSolidColor('ff666666') + elseif event.Type == 'MouseExit' then + bg:SetSolidColor('ff000000') + elseif event.Type == 'ButtonPress' then + ChatController.SetRecipient(target) + self:Destroy() + end + end - -- Close on any mouse click outside the popup. - local function onOutsideClick() self:Destroy() end - UIMain.AddOnMouseClickedFunc(onOutsideClick) + return entry + end, - self.OnDestroy = function(dself) - UIMain.RemoveOnMouseClickedFunc(onOutsideClick) - if dself._onClosed then - local cb = dself._onClosed - dself._onClosed = nil - cb() - end + --- Creates the eight decorative border bitmaps that hug the outside of + --- the popup. Layout is applied in `LayoutBorder` from `__post_init`. + ---@param self UIChatListInterface + CreateBorder = function(self) + local function makeBitmap(file) + local bmp = Bitmap(self, UIUtil.UIFile(file)) + bmp:DisableHitTest() + return bmp end + + self.LTBG = makeBitmap('/game/chat_brd/drop-box_brd_ul.dds') + self.RTBG = makeBitmap('/game/chat_brd/drop-box_brd_ur.dds') + self.RBBG = makeBitmap('/game/chat_brd/drop-box_brd_lr.dds') + self.RLBG = makeBitmap('/game/chat_brd/drop-box_brd_ll.dds') + self.LBG = makeBitmap('/game/chat_brd/drop-box_brd_vert_l.dds') + self.RBG = makeBitmap('/game/chat_brd/drop-box_brd_vert_r.dds') + self.TBG = makeBitmap('/game/chat_brd/drop-box_brd_horz_um.dds') + self.BBG = makeBitmap('/game/chat_brd/drop-box_brd_lm.dds') end, ---@param self UIChatListInterface ---@param parent Control __post_init = function(self, parent) - -- Measure the text entries so we can size ourselves to fit. + -- Size self to fit the widest row and the stacked heights. local maxWidth = 0 local totalHeight = 0 for _, entry in ipairs(self.Entries) do - local w = entry.text.Width() + local w = entry.Text.Width() if w > maxWidth then maxWidth = w end - totalHeight = totalHeight + entry.text.Height() + totalHeight = totalHeight + entry.Text.Height() end Layouter(self) @@ -166,53 +182,69 @@ ChatListInterface = ClassUI(Group) { :Height(totalHeight) :End() - -- Left indent that reserves room for the faction badge on player - -- rows and keeps All / Allies text aligned with the player names. + -- Left indent reserves room for the faction badge on player rows + -- and keeps All / Allies text aligned with the player names. local textIndent = 20 - -- Stack entries bottom-up: first entry at the bottom-left, each - -- subsequent entry above the previous. Text is indented to leave - -- room for the badge. + -- Stack entries bottom-up: first at the bottom, each subsequent + -- entry above the previous. for i, entry in ipairs(self.Entries) do - if i == 1 then - Layouter(entry.text) - :AtBottomIn(self) - :AtLeftIn(self, textIndent) - :Over(self, 1) - :End() - else - Layouter(entry.text) - :Above(self.Entries[i-1].text) - :AtLeftIn(self, textIndent) - :Over(self, 1) - :End() - end + local below = i > 1 and self.Entries[i - 1] or nil + self:LayoutEntry(entry, below, textIndent) + end - -- Badge (player rows only) sits in the reserved indent, centred - -- vertically on the text row. - if entry.badge then - Layouter(entry.badge) - :AtLeftIn(self, 3) - :AtVerticalCenterIn(entry.text) - :Over(self, 2) - :End() - end + self:LayoutBorder() + end, - -- The highlight bar spans the full row (including the badge - -- area) and sits behind everything in the depth order. Direct - -- LazyVar `:SetFunction` calls match the original `chat.lua` - -- pattern and avoid Layouter's reused-state quirks. - local text = entry.text - ---@diagnostic disable: undefined-field - entry.bg.Depth:SetFunction(function() return text.Depth() - 1 end) - entry.bg.Left:SetFunction(function() return self.Left() - 6 end) - entry.bg.Top:SetFunction(function() return text.Top() - 1 end) - entry.bg.Width:SetFunction(function() return self.Width() + 8 end) - entry.bg.Bottom:SetFunction(function() return text.Bottom() + 1 end) - ---@diagnostic enable: undefined-field + --- Lays out one row: the text anchored above `below` (or at the bottom + --- if `below` is nil), an optional faction badge in the indent column, + --- and a highlight bitmap whose bounds track the text row. + ---@param self UIChatListInterface + ---@param entry UIChatListEntry + ---@param below UIChatListEntry | nil + ---@param textIndent number + LayoutEntry = function(self, entry, below, textIndent) + if below then + Layouter(entry.Text) + :Above(below.Text) + :AtLeftIn(self, textIndent) + :Over(self, 1) + :End() + else + Layouter(entry.Text) + :AtBottomIn(self) + :AtLeftIn(self, textIndent) + :Over(self, 1) + :End() end - -- Border bitmaps hug the outside of self on all eight sides. + -- Badge (player rows only) sits in the reserved indent, centred + -- vertically on the text row. + if entry.Badge then + Layouter(entry.Badge) + :AtLeftIn(self, 3) + :AtVerticalCenterIn(entry.Text) + :Over(self, 2) + :End() + end + + -- The highlight bar spans the full row (including the badge area) + -- and sits behind everything in the depth order. Direct LazyVar + -- `:SetFunction` calls match the original `chat.lua` pattern and + -- avoid Layouter's reused-state quirks. + local text = entry.Text + ---@diagnostic disable: undefined-field + entry.BG.Depth:SetFunction(function() return text.Depth() - 1 end) + entry.BG.Left:SetFunction(function() return self.Left() - 6 end) + entry.BG.Top:SetFunction(function() return text.Top() - 1 end) + entry.BG.Width:SetFunction(function() return self.Width() + 8 end) + entry.BG.Bottom:SetFunction(function() return text.Bottom() + 1 end) + ---@diagnostic enable: undefined-field + end, + + --- Pins the eight decorative border bitmaps to the outside of self. + ---@param self UIChatListInterface + LayoutBorder = function(self) Layouter(self.LTBG):Right(self.Left):Bottom(self.Top):End() Layouter(self.RTBG):Left(self.Right):Bottom(self.Top):End() Layouter(self.RBBG):Left(self.Right):Top(self.Bottom):End() @@ -227,7 +259,7 @@ ChatListInterface = ClassUI(Group) { ---@param self UIChatListInterface ---@param callback function SetOnClosed = function(self, callback) - self._onClosed = callback + self._OnClosed = callback end, } diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index a4cceb53952..ea995b4efc9 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -16,14 +16,14 @@ RecipientAllies = 'allies' -- History entry. ---@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 color ----@field armyID number # sender's army index ----@field faction number # faction icon index (1-based) ----@field recipient UIChatRecipient # the target this message was directed to ----@field camera? table # camera state when the message is a ping link ----@field wrappedText? string[] # view-side cache: text wrapped to the current row width (populated by ChatInterface) +---@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 color +---@field ArmyID number # sender's army index +---@field Faction number # faction icon index (1-based) +---@field Recipient UIChatRecipient # the target this message was directed to +---@field Camera? table # camera state when the message is a ping link +---@field WrappedText? string[] # view-side cache: text wrapped to the current row width (populated by ChatInterface) ------------------------------------------------------------------------------- -- Model. From e249ded5b27c7d3cccf7303e6305e6123ab6e3ea Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 07:17:41 +0200 Subject: [PATCH 022/130] Refactor key/value pairs to use pascal case --- lua/ui/game/chat/ChatEditInterface.lua | 2 +- lua/ui/game/chat/commands/BuiltinCommands.lua | 58 ++++++------- .../chat/commands/ChatCommandRegistry.lua | 84 +++++++++---------- .../game/chat/commands/ChatCommandTypes.lua | 10 +-- lua/ui/game/chat/commands/design.md | 76 ++++++++--------- .../game/chat/config/ChatConfigInterface.lua | 72 ++++++++-------- lua/ui/game/chat/config/ChatConfigModel.lua | 12 +-- 7 files changed, 157 insertions(+), 157 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index df75610f697..5bdf2d01df3 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -137,7 +137,7 @@ ChatEditInterface = ClassUI(Group) { LayoutHelpers.Above(hint, self.EditBox, 4) LayoutHelpers.AtLeftIn(hint, self.EditBox) hint:SetOnSelect(function(cmd) - self.EditBox:SetText('/' .. cmd.name .. ' ') + self.EditBox:SetText('/' .. cmd.Name .. ' ') self:AcquireFocus() end) end, diff --git a/lua/ui/game/chat/commands/BuiltinCommands.lua b/lua/ui/game/chat/commands/BuiltinCommands.lua index 7c76d04e8ec..cc47659bf1a 100644 --- a/lua/ui/game/chat/commands/BuiltinCommands.lua +++ b/lua/ui/game/chat/commands/BuiltinCommands.lua @@ -13,40 +13,40 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") ---@type UIChatCommand All = { - name = 'all', - description = 'Send to all players and observers.', - execute = function(_, ctx) - ctx.controller.SetRecipient(ChatModel.RecipientAll) + Name = 'all', + Description = 'Send to all players and observers.', + Execute = function(_, ctx) + ctx.Controller.SetRecipient(ChatModel.RecipientAll) end, } ---@type UIChatCommand Allies = { - name = 'allies', - aliases = { 'team' }, - description = 'Send to allies only.', - execute = function(_, ctx) - ctx.controller.SetRecipient(ChatModel.RecipientAllies) + Name = 'allies', + Aliases = { 'team' }, + Description = 'Send to allies only.', + Execute = function(_, ctx) + ctx.Controller.SetRecipient(ChatModel.RecipientAllies) end, } ---@type UIChatCommand Whisper = { - name = 'whisper', - aliases = { 'w', 'pm' }, - description = 'Whisper to a specific player (by nickname or army ID).', - params = { - { name = 'target', type = 'player' }, + Name = 'whisper', + Aliases = { 'w', 'pm' }, + Description = 'Whisper to a specific player (by nickname or army ID).', + Params = { + { Name = 'target', Type = 'Player' }, }, - accept = function(args) + Accept = function(args) local armies = GetArmiesTable() if armies and args.target == armies.focusArmy then return false, "/whisper: can't whisper yourself." end return true end, - execute = function(args, ctx) - ctx.controller.SetRecipient(args.target) + Execute = function(args, ctx) + ctx.Controller.SetRecipient(args.target) end, } @@ -55,31 +55,31 @@ Whisper = { ---@type UIChatCommand Help = { - name = 'help', - aliases = { '?' }, - description = 'Lists available chat commands.', - execute = function(_, ctx) - local controller = ctx.controller + Name = 'help', + Aliases = { '?' }, + Description = 'Lists available chat commands.', + Execute = function(_, ctx) + local controller = ctx.Controller local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") controller.AppendLocalSystemMessage("Available chat commands:") for _, cmd in ipairs(Registry.GetAll()) do local params = '' - if cmd.params then - for _, p in ipairs(cmd.params) do - local fmt = p.optional and ' [%s]' or ' <%s>' - params = params .. string.format(fmt, p.name) + if cmd.Params then + for _, p in ipairs(cmd.Params) do + local fmt = p.Optional and ' [%s]' or ' <%s>' + params = params .. string.format(fmt, p.Name) end end local aliases = '' - if cmd.aliases and table.getn(cmd.aliases) > 0 then - aliases = ' (aka /' .. table.concat(cmd.aliases, ', /') .. ')' + if cmd.Aliases and table.getn(cmd.Aliases) > 0 then + aliases = ' (aka /' .. table.concat(cmd.Aliases, ', /') .. ')' end controller.AppendLocalSystemMessage( - string.format(" /%s%s%s — %s", cmd.name, params, aliases, cmd.description or '') + string.format(" /%s%s%s — %s", cmd.Name, params, aliases, cmd.Description or '') ) end end, diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 362e270cb27..bb02711b904 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -9,22 +9,22 @@ local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") -- Dispatch(text) parses and runs a "/…" line, returning (handled, errorText) ---@class UIChatCommandParam ----@field name string ----@field type UIChatCommandParamType ----@field optional boolean? +---@field Name string +---@field Type UIChatCommandParamType +---@field Optional boolean? ---@class UIChatCommandContext ----@field model UIChatModel ----@field controller table ----@field sourceText string +---@field Model UIChatModel +---@field Controller table +---@field SourceText string ---@class UIChatCommand ----@field name string ----@field aliases? string[] ----@field description string ----@field params? UIChatCommandParam[] ----@field accept? fun(args: table, ctx: UIChatCommandContext): boolean, string? ----@field execute fun(args: table, ctx: UIChatCommandContext) +---@field Name string +---@field Aliases? string[] +---@field Description string +---@field Params? UIChatCommandParam[] +---@field Accept? fun(args: table, ctx: UIChatCommandContext): boolean, string? +---@field Execute fun(args: table, ctx: UIChatCommandContext) ---@type table local Commands = {} @@ -39,21 +39,21 @@ local Aliases = {} --- canonical name; aliases from the previous registration are cleared first. ---@param cmd UIChatCommand function Register(cmd) - assert(cmd and cmd.name, "Chat command requires a name.") - assert(cmd.execute, "Chat command requires an execute function.") + assert(cmd and cmd.Name, "Chat command requires a name.") + assert(cmd.Execute, "Chat command requires an execute function.") - local key = string.lower(cmd.name) + local key = string.lower(cmd.Name) local previous = Commands[key] - if previous and previous.aliases then - for _, alias in ipairs(previous.aliases) do + if previous and previous.Aliases then + for _, alias in ipairs(previous.Aliases) do Aliases[string.lower(alias)] = nil end end Commands[key] = cmd - if cmd.aliases then - for _, alias in ipairs(cmd.aliases) do + if cmd.Aliases then + for _, alias in ipairs(cmd.Aliases) do Aliases[string.lower(alias)] = key end end @@ -65,8 +65,8 @@ function Unregister(name) local key = string.lower(name) local cmd = Commands[key] if not cmd then return end - if cmd.aliases then - for _, alias in ipairs(cmd.aliases) do + if cmd.Aliases then + for _, alias in ipairs(cmd.Aliases) do Aliases[string.lower(alias)] = nil end end @@ -123,7 +123,7 @@ function FindMatching(prefix) end end - table.sort(result, function(a, b) return a.name < b.name end) + table.sort(result, function(a, b) return a.Name < b.Name end) return result end @@ -153,42 +153,42 @@ end ---@return table?, string? local function ParseArgs(cmd, tokens) ---@type table - local args = { _raw = tokens } - if not cmd.params then return args, nil end + local args = { _Raw = tokens } + if not cmd.Params then return args, nil end local idx = 1 - for _, param in ipairs(cmd.params) do - if param.type == 'rest' then + for _, param in ipairs(cmd.Params) do + if param.Type == 'Rest' then local remaining = {} while tokens[idx] do table.insert(remaining, tokens[idx]) idx = idx + 1 end if table.getn(remaining) == 0 then - if not param.optional then - return nil, string.format("/%s: missing argument <%s>.", cmd.name, param.name) + if not param.Optional then + return nil, string.format("/%s: missing argument <%s>.", cmd.Name, param.Name) end else - args[param.name] = table.concat(remaining, ' ') + args[param.Name] = table.concat(remaining, ' ') end else local token = tokens[idx] if not token then - if param.optional then + if param.Optional then idx = idx + 1 else - return nil, string.format("/%s: missing argument <%s>.", cmd.name, param.name) + return nil, string.format("/%s: missing argument <%s>.", cmd.Name, param.Name) end else - local resolver = Types.Resolvers[param.type] + local resolver = Types.Resolvers[param.Type] if not resolver then - return nil, string.format("/%s: unknown parameter type '%s'.", cmd.name, tostring(param.type)) + return nil, string.format("/%s: unknown parameter type '%s'.", cmd.Name, tostring(param.Type)) end local ok, value = resolver(token) if not ok then - return nil, string.format("/%s: %s", cmd.name, value or ("invalid <" .. param.name .. ">.")) + return nil, string.format("/%s: %s", cmd.Name, value or ("invalid <" .. param.Name .. ">.")) end - args[param.name] = value + args[param.Name] = value idx = idx + 1 end end @@ -232,19 +232,19 @@ function Dispatch(text) local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") local ctx = { - model = ChatModel.GetSingleton(), - controller = ChatController, - sourceText = text, + Model = ChatModel.GetSingleton(), + Controller = ChatController, + SourceText = text, } - if cmd.accept then - local ok, reason = cmd.accept(args, ctx) + if cmd.Accept then + local ok, reason = cmd.Accept(args, ctx) if not ok then - return false, reason or string.format("/%s: command rejected.", cmd.name) + return false, reason or string.format("/%s: command rejected.", cmd.Name) end end - cmd.execute(args, ctx) + cmd.Execute(args, ctx) return true, nil end diff --git a/lua/ui/game/chat/commands/ChatCommandTypes.lua b/lua/ui/game/chat/commands/ChatCommandTypes.lua index 385339a48cf..73bfe5fe51a 100644 --- a/lua/ui/game/chat/commands/ChatCommandTypes.lua +++ b/lua/ui/game/chat/commands/ChatCommandTypes.lua @@ -37,14 +37,14 @@ local function ResolveArmy(token) return false, string.format("no player named '%s'.", token) end ----@alias UIChatCommandParamType 'recipient' | 'player' | 'int' | 'string' | 'rest' +---@alias UIChatCommandParamType 'Recipient' | 'Player' | 'Int' | 'String' | 'Rest' ---@type table Resolvers = {} --- Accepts "all", "allies"/"team", a nickname, or an army ID. --- Resolves to a `UIChatRecipient` (the same type the model stores). -Resolvers.recipient = function(token) +Resolvers.Recipient = function(token) local lower = string.lower(token) if lower == 'all' then return true, 'all' @@ -56,12 +56,12 @@ end --- Accepts a nickname or army ID. Rejects "all"/"allies". --- Resolves to a numeric army ID. -Resolvers.player = function(token) +Resolvers.Player = function(token) return ResolveArmy(token) end --- Integer literal. -Resolvers.int = function(token) +Resolvers.Int = function(token) local n = tonumber(token) if not n or math.floor(n) ~= n then return false, string.format("'%s' is not an integer.", token) @@ -70,7 +70,7 @@ Resolvers.int = function(token) end --- Single whitespace-delimited token. -Resolvers.string = function(token) +Resolvers.String = function(token) return true, token end diff --git a/lua/ui/game/chat/commands/design.md b/lua/ui/game/chat/commands/design.md index de7dbe6a5b2..d9e785638c1 100644 --- a/lua/ui/game/chat/commands/design.md +++ b/lua/ui/game/chat/commands/design.md @@ -14,15 +14,15 @@ ChatEditInterface.EditBox:OnEnterPressed(text) ├── Tokenize(text) -- "/whisper Jip" → ("whisper", {"Jip"}) ├── Lookup(name) -- name + aliases ├── ParseArgs(cmd, tokens) -- typed, coerced, validated - ├── cmd.accept(args, ctx) -- semantic legitimacy check - └── cmd.execute(args, ctx) -- run side effect (usually ctx.controller.*) + ├── cmd.Accept(args, ctx) -- semantic legitimacy check + └── cmd.Execute(args, ctx) -- run side effect (usually ctx.Controller.*) ``` - **Registry** — flat `name → command` table plus `alias → name`. Exports `Register`, `Unregister`, `Lookup`, `GetAll`, `Dispatch`. - **Types** — a table of `{recipient, player, int, string, rest}` resolvers. Each takes a raw token and returns `(ok, value_or_error)`. - **Builtins** — `/all`, `/allies`, `/whisper`, `/help`. Loaded lazily on the first `Dispatch` call. -Commands do not touch the model directly. They call through `ctx.controller`, preserving the MVC rule from `CLAUDE.md`. +Commands do not touch the model directly. They call through `ctx.Controller`, preserving the MVC rule from `CLAUDE.md`. --- @@ -30,25 +30,25 @@ Commands do not touch the model directly. They call through `ctx.controller`, pr ```lua ---@class UIChatCommand ----@field name string # canonical name without leading slash ----@field aliases? string[] # alternative names (e.g. {'w','pm'} for whisper) ----@field description string # one-line summary shown by /help ----@field params? UIChatCommandParam[] # declarative parameter schema ----@field accept? fun(args, ctx): boolean, string? # runtime legitimacy check ----@field execute fun(args, ctx) # the actual side effect +---@field Name string # canonical name without leading slash +---@field Aliases? string[] # alternative names (e.g. {'w','pm'} for whisper) +---@field Description string # one-line summary shown by /help +---@field Params? UIChatCommandParam[] # declarative parameter schema +---@field Accept? fun(args, ctx): boolean, string? # runtime legitimacy check +---@field Execute fun(args, ctx) # the actual side effect ---@class UIChatCommandParam ----@field name string ----@field type 'recipient' | 'player' | 'int' | 'string' | 'rest' ----@field optional boolean? +---@field Name string +---@field Type 'Recipient' | 'Player' | 'Int' | 'String' | 'Rest' +---@field Optional boolean? ``` Rules: -- `name` and every entry of `aliases` are case-insensitive. -- `params` order is the order tokens will be consumed. -- Only the last param may be `rest`; it greedy-consumes every remaining token, joining them with single spaces. -- `accept` and `execute` both receive the already-typed `args` table and a shared `ctx`. +- `Name` and every entry of `Aliases` are case-insensitive. +- `Params` order is the order tokens will be consumed. +- Only the last param may be `Rest`; it greedy-consumes every remaining token, joining them with single spaces. +- `Accept` and `Execute` both receive the already-typed `args` table and a shared `ctx`. ## 3. Parameter Types @@ -56,11 +56,11 @@ Each resolver is `fun(token: string): ok, value | error`. | Type | Accepts | Resolves to | |------|---------|-------------| -| `recipient` | `"all"`, `"allies"`, `"team"`, nickname, army ID | `UIChatRecipient` (`'all' \| 'allies' \| number`) | -| `player` | nickname or army ID | `number` (army ID) — same rules as `recipient` but rejects `all`/`allies` | -| `int` | integer literal | `number` | -| `string` | a single whitespace-delimited token | `string` | -| `rest` | one or more remaining tokens | `string` (tokens joined by single spaces) | +| `Recipient` | `"all"`, `"allies"`, `"team"`, nickname, army ID | `UIChatRecipient` (`'all' \| 'allies' \| number`) | +| `Player` | nickname or army ID | `number` (army ID) — same rules as `Recipient` but rejects `all`/`allies` | +| `Int` | integer literal | `number` | +| `String` | a single whitespace-delimited token | `string` | +| `Rest` | one or more remaining tokens | `string` (tokens joined by single spaces) | Army lookup goes through `GetArmiesTable()`, matching the source `ChatListInterface` already uses for the recipient picker. Civilian armies are excluded. @@ -68,9 +68,9 @@ Army lookup goes through `GetArmiesTable()`, matching the source `ChatListInterf ```lua ---@class UIChatCommandContext ----@field model UIChatModel ----@field controller table -- ChatController module ----@field sourceText string -- the original "/whisper Jip" text +---@field Model UIChatModel +---@field Controller table -- ChatController module +---@field SourceText string -- the original "/whisper Jip" text ``` Passing `ctx` rather than each command importing the controller/model keeps commands decoupled from the chat tree and trivially testable. @@ -92,7 +92,7 @@ Error strings are produced at a single site in the registry so they stay uniform | Unknown name | `Invalid command: /xyz. Type /help for a list.` | | Missing arg | `/whisper: missing argument .` | | Bad arg | `/whisper: no player named 'bob'.` | -| Rejected by `accept` | whatever string `accept` returned | +| Rejected by `Accept` | whatever string `Accept` returned | Printing goes through `ChatController.AppendLocalSystemMessage(text)`, which appends a synthetic `UIChatEntry` to `model.History`. No network traffic; the line renders through the existing `ChatListInterface` path with no view changes. @@ -103,34 +103,34 @@ local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") Registry.Register { - name = 'whisper', - aliases = { 'w', 'pm' }, - description = 'Whisper to a specific player.', - params = { - { name = 'target', type = 'player' }, + Name = 'whisper', + Aliases = { 'w', 'pm' }, + Description = 'Whisper to a specific player.', + Params = { + { Name = 'target', Type = 'Player' }, }, - accept = function(args) + Accept = function(args) local armies = GetArmiesTable() if armies and args.target == armies.focusArmy then return false, "/whisper: can't whisper yourself." end return true end, - execute = function(args, ctx) - ctx.controller.SetRecipient(args.target) + Execute = function(args, ctx) + ctx.Controller.SetRecipient(args.target) end, } ``` `/whisper Jip` and `/whisper 3` both route here with `args.target` already normalized to an army ID — one command definition, two user-facing forms. -## 7. `accept` vs. `execute` +## 7. `Accept` vs. `Execute` - **Parser** handles *structural* errors: missing args, wrong types, unknown name. -- **`accept`** handles *semantic* errors that depend on runtime state: whispering yourself, command disabled in replay, target just disconnected. -- **`execute`** runs the side effect and trusts its inputs. +- **`Accept`** handles *semantic* errors that depend on runtime state: whispering yourself, command disabled in replay, target just disconnected. +- **`Execute`** runs the side effect and trusts its inputs. -Splitting `accept` out keeps the failure path uniform (always surfaces as a system feed line with the reason) and leaves room for things like tab-completion previews that call `accept` without `execute`. +Splitting `Accept` out keeps the failure path uniform (always surfaces as a system feed line with the reason) and leaves room for things like tab-completion previews that call `Accept` without `Execute`. ## 8. Bootstrap @@ -165,4 +165,4 @@ The slash branch is the first step of the send pipeline, matching `CLAUDE.md §S 1. **Nicknames with spaces.** Current tokenizer splits on whitespace. If nicknames with spaces are real, we need either quoted strings (`/whisper "Jip E"`) or a smarter `player` resolver that greedy-matches across tokens. Left as future work. 2. **Localization.** Error strings and command descriptions should go through `` like other chat text; currently hardcoded English. 3. **Replay/observer gating.** Some commands are meaningless in replay. `accept` can enforce per-command; a shared `ctx.mode` flag (`'live' | 'replay' | 'observer'`) would avoid each command re-deriving it. -4. **Tab completion / history.** The registry exposes `GetAll()` so an edit-view enhancement can offer completion for command names and (via `params[i].type`) argument suggestions. Not wired up here. +4. **Tab completion / history.** The registry exposes `GetAll()` so an edit-view enhancement can offer completion for command names and (via `Params[i].Type`) argument suggestions. Not wired up here. diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 217b54b53a0..c52836b81e5 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -16,27 +16,27 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local Colors = { 'ffffffff', 'ffff4242', 'ffefff42', 'ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42' } local ColorDefs = { - { key = ChatConfigModel.KeyAllColor, text = "All" }, - { key = ChatConfigModel.KeyAlliesColor, text = "Allies" }, - { key = ChatConfigModel.KeyPrivColor, text = "Private" }, - { key = ChatConfigModel.KeyLinkColor, text = "Links" }, - { key = ChatConfigModel.KeyNotifyColor, text = "Notify" }, + { Key = ChatConfigModel.KeyAllColor, Text = "All" }, + { Key = ChatConfigModel.KeyAlliesColor, Text = "Allies" }, + { Key = ChatConfigModel.KeyPrivColor, Text = "Private" }, + { Key = ChatConfigModel.KeyLinkColor, Text = "Links" }, + { Key = ChatConfigModel.KeyNotifyColor, Text = "Notify" }, } local CheckboxDefs = { - { key = ChatConfigModel.KeySendType, text = "Default recipient: allies" }, - { key = ChatConfigModel.KeyFeedBackground, text = "Show feed background" }, - { key = ChatConfigModel.KeyFeedPersist, text = "Persist feed timeout" }, - { key = ChatConfigModel.KeyLinks, text = "Show camera links" }, + { Key = ChatConfigModel.KeySendType, Text = "Default recipient: allies" }, + { Key = ChatConfigModel.KeyFeedBackground, Text = "Show feed background" }, + { Key = ChatConfigModel.KeyFeedPersist, Text = "Persist feed timeout" }, + { Key = ChatConfigModel.KeyLinks, Text = "Show camera links" }, } ------------------------------------------------------------------------------- -- Window class ---@class UIChatConfigColorRow ----@field label Text ----@field combo BitmapCombo ----@field key string +---@field Label Text +---@field Combo BitmapCombo +---@field Key string ---@class UIChatConfigInterface : Window ---@field Trash TrashBag # owns every derived subscription-LazyVar @@ -77,12 +77,12 @@ local ChatConfigInterface = ClassUI(Window) { self.ColorRows = {} for i, def in ipairs(ColorDefs) do local row = { - label = UIUtil.CreateText(client, def.text, 10, UIUtil.bodyFont), - combo = BitmapCombo(client, Colors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01"), - key = def.key, + Label = UIUtil.CreateText(client, def.Text, 10, UIUtil.bodyFont), + Combo = BitmapCombo(client, Colors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01"), + Key = def.Key, } - local key = def.key - row.combo.OnClick = function(_, index) + local key = def.Key + row.Combo.OnClick = function(_, index) ChatConfigController.SetOption(key, index) end self.ColorRows[i] = row @@ -98,9 +98,9 @@ local ChatConfigInterface = ClassUI(Window) { self.LabelFontSize = UIUtil.CreateText(client, "Font Size: 14", 10, UIUtil.bodyFont) self.SliderFontSize = IntegerSlider(client, false, - ChatConfigModel.FontSizeRange.min, - ChatConfigModel.FontSizeRange.max, - ChatConfigModel.FontSizeRange.inc, + ChatConfigModel.FontSizeRange.Min, + ChatConfigModel.FontSizeRange.Max, + ChatConfigModel.FontSizeRange.Inc, unpack(sliderBitmaps)) self.SliderFontSize.OnValueSet = function(_, value) ChatConfigController.SetOption(ChatConfigModel.KeyFontSize, value) @@ -111,9 +111,9 @@ local ChatConfigInterface = ClassUI(Window) { self.LabelFadeTime = UIUtil.CreateText(client, "Fade Time: 15s", 10, UIUtil.bodyFont) self.SliderFadeTime = IntegerSlider(client, false, - ChatConfigModel.FadeTimeRange.min, - ChatConfigModel.FadeTimeRange.max, - ChatConfigModel.FadeTimeRange.inc, + ChatConfigModel.FadeTimeRange.Min, + ChatConfigModel.FadeTimeRange.Max, + ChatConfigModel.FadeTimeRange.Inc, unpack(sliderBitmaps)) self.SliderFadeTime.OnValueSet = function(_, value) ChatConfigController.SetOption(ChatConfigModel.KeyFadeTime, value) @@ -124,9 +124,9 @@ local ChatConfigInterface = ClassUI(Window) { self.LabelWinAlpha = UIUtil.CreateText(client, "Window Alpha: 100%", 10, UIUtil.bodyFont) self.SliderWinAlpha = IntegerSlider(client, false, - ChatConfigModel.WinAlphaSliderRange.min, - ChatConfigModel.WinAlphaSliderRange.max, - ChatConfigModel.WinAlphaSliderRange.inc, + ChatConfigModel.WinAlphaSliderRange.Min, + ChatConfigModel.WinAlphaSliderRange.Max, + ChatConfigModel.WinAlphaSliderRange.Inc, unpack(sliderBitmaps)) self.SliderWinAlpha.OnValueSet = function(_, value) ChatConfigController.SetOption(ChatConfigModel.KeyWinAlpha, value / 100) @@ -140,8 +140,8 @@ local ChatConfigInterface = ClassUI(Window) { self.Checkboxes = {} for i, def in ipairs(CheckboxDefs) do - local cb = UIUtil.CreateCheckbox(client, '/dialogs/check-box_btn/', def.text, true) - local key = def.key + local cb = UIUtil.CreateCheckbox(client, '/dialogs/check-box_btn/', def.Text, true) + local key = def.Key cb.OnCheck = function(_, checked) ChatConfigController.SetOption(key, checked) end @@ -197,18 +197,18 @@ local ChatConfigInterface = ClassUI(Window) { ---@type Control local prev = self.LabelColors for _, row in ipairs(self.ColorRows) do - Layouter(row.label) + Layouter(row.Label) :Below(prev, 6) :AtLeftIn(client, pad) :End() - Layouter(row.combo) - :RightOf(row.label, 8) - :AtVerticalCenterIn(row.label) + Layouter(row.Combo) + :RightOf(row.Label, 8) + :AtVerticalCenterIn(row.Label) :Width(60) :End() - prev = row.label + prev = row.Label end -- Sliders @@ -297,7 +297,7 @@ local ChatConfigInterface = ClassUI(Window) { local defaults = ChatConfigModel.GetDefaults() for _, row in ipairs(self.ColorRows) do - row.combo:SetItem(options[row.key] or defaults[row.key]) + row.Combo:SetItem(options[row.Key] or defaults[row.Key]) end self.SliderFontSize:SetValue(options.font_size or defaults.font_size) @@ -305,9 +305,9 @@ local ChatConfigInterface = ClassUI(Window) { self.SliderWinAlpha:SetValue(math.floor((options.win_alpha or defaults.win_alpha) * 100)) for i, def in ipairs(CheckboxDefs) do - local value = options[def.key] + local value = options[def.Key] if value == nil then - value = defaults[def.key] + value = defaults[def.Key] end self.Checkboxes[i]:SetCheck(value, true) end diff --git a/lua/ui/game/chat/config/ChatConfigModel.lua b/lua/ui/game/chat/config/ChatConfigModel.lua index 351e8b4cb8b..4e0ea349578 100644 --- a/lua/ui/game/chat/config/ChatConfigModel.lua +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -58,19 +58,19 @@ KeyLinks = 'links' -- can construct sliders without duplicating the limits. ---@class UIChatSliderRange ----@field min number ----@field max number ----@field inc number +---@field Min number +---@field Max number +---@field Inc number ---@type UIChatSliderRange -FontSizeRange = { min = 12, max = 18, inc = 1 } +FontSizeRange = { Min = 12, Max = 18, Inc = 1 } ---@type UIChatSliderRange -FadeTimeRange = { min = 5, max = 30, inc = 1 } +FadeTimeRange = { Min = 5, Max = 30, Inc = 1 } --- Window alpha is stored as 0.0-1.0 but edited via an integer percent slider. ---@type UIChatSliderRange -WinAlphaSliderRange = { min = 20, max = 100, inc = 1 } +WinAlphaSliderRange = { Min = 20, Max = 100, Inc = 1 } ---@class UIChatConfigModel ---@field Committed LazyVar # the active, saved options observed by the chat feed From 726734fc10cc25e0e8e083828fc554349ba397d1 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 07:33:29 +0200 Subject: [PATCH 023/130] Refactor use of OnFrame to event-driven functions of Edit --- lua/ui/game/chat/ChatEditInterface.lua | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 5bdf2d01df3..f86af358329 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -29,7 +29,6 @@ local MaxChars = 200 ---@field EditBox Edit ---@field ChatList UIChatListInterface | nil ---@field CommandHint UIChatCommandHintInterface | nil ----@field LastEditText string ---@field RecipientObserver LazyVar # derived from ChatModel.Recipient ChatEditInterface = ClassUI(Group) { @@ -91,6 +90,13 @@ ChatEditInterface = ClassUI(Group) { self:CloseCommandHint() end + -- Drive the command-hint popup from the edit-box contents. + -- `OnTextChanged` fires after every insertion, deletion, or `SetText`, + -- so we don't need to poll each frame. + self.EditBox.OnTextChanged = function(_, newText, _) + self:RefreshCommandHint(newText or '') + end + -- Keep the label in sync with the model. `LazyVarDerive` gives us a -- fresh per-subscriber LazyVar so we don't stomp any other observer -- of `model.Recipient` (see the chat CLAUDE.md for the pattern). @@ -98,20 +104,12 @@ ChatEditInterface = ClassUI(Group) { self.RecipientObserver = self.Trash:Add(LazyVarDerive(model.Recipient, function(lv) self:RefreshRecipient(lv()) end)) - - -- Drive the command-hint popup from the edit-box contents. We poll - -- once per frame because MAUI's Edit has no "text changed" callback - -- that fires reliably after both typed chars and backspaces. - self.LastEditText = '' - self:SetNeedsFrameUpdate(true) end, + --- Shows or hides the command hint based on the current edit-box text. ---@param self UIChatEditInterface - OnFrame = function(self) - local text = self.EditBox:GetText() or '' - if text == self.LastEditText then return end - self.LastEditText = text - + ---@param text string + RefreshCommandHint = function(self, text) if string.sub(text, 1, 1) == '/' then if not self.CommandHint then self:OpenCommandHint() From 6e66b1559c413e80c89c03a08af3d99318d60cc5 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 07:36:02 +0200 Subject: [PATCH 024/130] Add behavior on use of escape --- lua/ui/game/chat/ChatEditInterface.lua | 33 ++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index f86af358329..db35be51f20 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -97,6 +97,20 @@ ChatEditInterface = ClassUI(Group) { self:RefreshCommandHint(newText or '') end + -- Escape priorities: (1) close an open command hint, (2) clear any + -- text, (3) close the chat window. + self.EditBox.OnEscPressed = function(_, text) + if self.CommandHint then + self:CloseCommandHint() + return true + end + if text and text ~= '' then + return false -- let the engine clear the text box + end + ChatController.CloseWindow() + return true + end + -- Keep the label in sync with the model. `LazyVarDerive` gives us a -- fresh per-subscriber LazyVar so we don't stomp any other observer -- of `model.Recipient` (see the chat CLAUDE.md for the pattern). @@ -107,17 +121,22 @@ ChatEditInterface = ClassUI(Group) { end, --- Shows or hides the command hint based on the current edit-box text. + --- Only opens when the text transitions to exactly `/` — so closing the + --- hint via Escape leaves it closed while the user keeps typing past the + --- slash. An already-open hint keeps refreshing as long as text starts + --- with `/`. ---@param self UIChatEditInterface ---@param text string RefreshCommandHint = function(self, text) - if string.sub(text, 1, 1) == '/' then - if not self.CommandHint then - self:OpenCommandHint() + if self.CommandHint then + if string.sub(text, 1, 1) == '/' then + self.CommandHint:Refresh(text) + else + self:CloseCommandHint() end - local hint = self.CommandHint --[[@as UIChatCommandHintInterface]] - hint:Refresh(text) - else - self:CloseCommandHint() + elseif text == '/' then + self:OpenCommandHint() + self.CommandHint:Refresh(text) end end, From 2e23ebb8ea8d7499987869c3d125aaa9d9e99c18 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 07:43:18 +0200 Subject: [PATCH 025/130] Add support for page up/down --- lua/ui/game/chat/ChatEditInterface.lua | 13 +++++++++++++ lua/ui/game/chat/ChatInterface.lua | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index db35be51f20..e3d89e57285 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -111,6 +111,19 @@ ChatEditInterface = ClassUI(Group) { return true end + -- Page Up / Page Down scroll the chat feed. Shift narrows to one row. + -- Matches the legacy chat.lua binding so muscle memory carries over. + -- Lazy import of ChatInterface avoids the import cycle: ChatInterface + -- imports this module at load time, so the reverse edge has to defer. + self.EditBox.OnNonTextKeyPressed = function(_, keycode, modifiers) + local step = modifiers and modifiers.Shift and 1 or 10 + if keycode == UIUtil.VK_PRIOR then + import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(-step) + elseif keycode == UIUtil.VK_NEXT then + import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(step) + end + end + -- Keep the label in sync with the model. `LazyVarDerive` gives us a -- fresh per-subscriber LazyVar so we don't stomp any other observer -- of `model.Recipient` (see the chat CLAUDE.md for the pattern). diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 18f65a22975..a86769ae3b8 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -564,6 +564,24 @@ function Toggle() ChatController.ToggleWindow() end +--- Scrolls the chat feed by `delta` rows (negative = toward older messages). +--- No-op if the window has never been opened. +---@param delta number +function ScrollLines(delta) + if Instance then + Instance:ScrollLines(nil, delta) + end +end + +--- Scrolls the chat feed by `delta` pages (negative = toward older messages). +--- No-op if the window has never been opened. +---@param delta number +function ScrollPages(delta) + if Instance then + Instance:ScrollPages(nil, delta) + end +end + ------------------------------------------------------------------------------- --#region Debugging From 101f6e20b23d8b0be7a573f14d109add15aaeadc Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 18:15:26 +0200 Subject: [PATCH 026/130] Respect maximum number of characters per message --- lua/ui/game/chat/ChatEditInterface.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index e3d89e57285..d9b6126ecad 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -97,6 +97,19 @@ ChatEditInterface = ClassUI(Group) { self:RefreshCommandHint(newText or '') end + -- Swallow Tab so focus can't leave the edit box, and ring the error + -- cue when the user types against the character cap. `OnCharPressed` + -- fires before insertion, so `>=` here matches the already-committed + -- length *before* this keystroke — i.e. we beep on the rejected char. + self.EditBox.OnCharPressed = function(edit, charcode) + if charcode == UIUtil.VK_TAB then + return true + end + if STR_Utf8Len(edit:GetText()) >= edit:GetMaxChars() then + PlaySound(Sound({ Cue = 'UI_Menu_Error_01', Bank = 'Interface' })) + end + end + -- Escape priorities: (1) close an open command hint, (2) clear any -- text, (3) close the chat window. self.EditBox.OnEscPressed = function(_, text) From b2b802e4877527f717cb73d3c9f4aea2d9d0376b Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 18:17:27 +0200 Subject: [PATCH 027/130] Set minimum width/height of chat dialog --- lua/ui/game/chat/ChatInterface.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index a86769ae3b8..f89cdad7634 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -74,6 +74,7 @@ local ChatInterface = ClassUI(Window) { Window.__init(self, parent, "", false, true, true, false, false, "chat_window_v2", { Left = 8, Top = 460, Right = 430, Bottom = 720, }, WindowTextures) + self:SetMinimumResize(400, 160) local client = self:GetClientGroup() From a7c70f7f59fbcdd56bb18d591f93fa6c0d145c9d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 18:23:00 +0200 Subject: [PATCH 028/130] Fix snap van chat configuratie bij bewegen --- lua/ui/game/chat/config/ChatConfigInterface.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index c52836b81e5..a1ecd8f2f75 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -61,7 +61,7 @@ local ChatConfigInterface = ClassUI(Window) { ---@param parent Control __init = function(self, parent) Window.__init(self, parent, "Chat Configuration", false, false, false, true, false, "chat_config_v7", { - Left = 200, Top = 200, Right = 524, Bottom = 640, + Left = 200, Top = 200, Right = 500, Bottom = 640, }) -- Single trash bag for everything we allocate that needs explicit @@ -282,12 +282,12 @@ local ChatConfigInterface = ClassUI(Window) { :AtVerticalCenterIn(self.BtnOk) :End() - -- Fit the window height to its content + -- Fit the window height to its content. Width stays driven by + -- Left/Right from the default rect — don't pin Width here, or the + -- drag handler's Right:Set(Left + Width) will snap the window to + -- whatever Width was pinned to (the textures render against Right, + -- so a Width/Right mismatch is invisible until the first drag). self.Bottom:Set(function() return self.BtnCancel.Bottom() + 16 end) - - Layouter(self) - :Width(300) - :End() end, --- Syncs every control to reflect the given options table. From 52b7b2210dfecdb44c549aefebe539282f2d4c03 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 18:30:42 +0200 Subject: [PATCH 029/130] Add drag handles and reset button --- lua/ui/game/chat/ChatInterface.lua | 117 ++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 4 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index f89cdad7634..7a248ee38d9 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -3,6 +3,8 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Window = import("/lua/maui/window.lua").Window local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Button = import("/lua/maui/button.lua").Button local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface local ChatEditInterface = import("/lua/ui/game/chat/ChatEditInterface.lua").ChatEditInterface @@ -32,6 +34,21 @@ local WindowTextures = { borderColor = 'ff415055', } +--- Corner grip textures for the four resize handles sticking out of the +--- window corners. Each handle carries `up` / `over` / `down` states that +--- the `RolloverHandler` swaps through during hover-and-resize. +local function DragHandleTextures(corner) + return { + up = UIUtil.UIFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_up.dds'), + over = UIUtil.UIFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_over.dds'), + down = UIUtil.UIFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_down.dds'), + } +end + +--- Default window rect, kept as a module local so `ResetPosition` can +--- restore it after the user has moved the window around. +local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } + ------------------------------------------------------------------------------- -- The main chat window: a draggable, resizable frame hosting a dynamically -- sized pool of chat line rows plus the edit area at the bottom. @@ -63,6 +80,11 @@ local WindowTextures = { ---@field ScrollTop number # 1-based virtual position of the top visible row ---@field VirtualSize number # total wrapped lines across valid entries ---@field FontSize number # current font size (from ChatOptions.font_size) +---@field DragTL Bitmap # top-left corner resize grip +---@field DragTR Bitmap # top-right corner resize grip +---@field DragBL Bitmap # bottom-left corner resize grip +---@field DragBR Bitmap # bottom-right corner resize grip +---@field ResetPositionBtn Button # titlebar button that restores DefaultRect ---@field HistoryObserver LazyVar # derived from ChatModel.History ---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible ---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed @@ -71,11 +93,79 @@ local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface ---@param parent Control __init = function(self, parent) - Window.__init(self, parent, "", false, true, true, false, false, "chat_window_v2", { - Left = 8, Top = 460, Right = 430, Bottom = 720, - }, WindowTextures) + Window.__init(self, parent, "", false, true, true, false, false, "chat_window_v2", DefaultRect, WindowTextures) self:SetMinimumResize(400, 160) + -- Corner grips: Bitmaps overlayed just outside each window corner. + -- They are purely cosmetic — hit-test is disabled so resize events + -- still flow to the Window's own resize bitmaps (tl/tr/bl/br/…). + -- The RolloverHandler swap below is what makes them light up. + self.DragTL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) + self.DragTR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) + self.DragBL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) + self.DragBR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) + + self.DragTL.textures = DragHandleTextures('ul') + self.DragTR.textures = DragHandleTextures('ur') + self.DragBL.textures = DragHandleTextures('ll') + self.DragBR.textures = DragHandleTextures('lr') + + for _, grip in { self.DragTL, self.DragTR, self.DragBL, self.DragBR } do + grip:DisableHitTest() + end + + -- Replace the Window's default RolloverHandler so our corner grips + -- light up during hover / press instead of the base implementation's + -- faint resize-group outline. + local controlMap = { + tl = { self.DragTL }, + tr = { self.DragTR }, + bl = { self.DragBL }, + br = { self.DragBR }, + mr = { self.DragBR, self.DragTR }, + ml = { self.DragBL, self.DragTL }, + tm = { self.DragTL, self.DragTR }, + bm = { self.DragBL, self.DragBR }, + } + self.RolloverHandler = function(_, event, xControl, yControl, cursor, controlID) + if self._lockSize or self._sizeLock then return end + local grips = controlMap[controlID] + if event.Type == 'MouseEnter' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.over) end + end + GetCursor():SetTexture(UIUtil.GetCursor(cursor)) + elseif event.Type == 'MouseExit' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.up) end + end + GetCursor():Reset() + elseif event.Type == 'ButtonPress' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.down) end + end + self.StartSizing(event, xControl, yControl) + self._sizeLock = true + end + end + + -- Titlebar button that snaps the window back to DefaultRect. Placed + -- immediately to the left of the Window's built-in _configBtn. + self.ResetPositionBtn = Button(self, + UIUtil.SkinnableFile('/game/menu-btns/default_btn_up.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_down.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_over.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_dis.dds')) + self.ResetPositionBtn.Depth:Set(function() return self.Depth() + 10 end) + self.ResetPositionBtn.OnClick = function() + local scaled = LayoutHelpers.ScaleNumber + self.Left:Set(scaled(DefaultRect.Left)) + self.Top:Set(scaled(DefaultRect.Top)) + self.Right:Set(scaled(DefaultRect.Right)) + self.Bottom:Set(scaled(DefaultRect.Bottom)) + self:SaveWindowLocation() + end + local client = self:GetClientGroup() -- Single trash bag for everything we allocate that needs explicit @@ -160,6 +250,19 @@ local ChatInterface = ClassUI(Window) { local client = self:GetClientGroup() local pad = 4 + -- Corner grips. Offsets copied from legacy chat.lua so the grips + -- overhang the chat border at the same pixels the original did. + Layouter(self.DragTL):AtLeftTopIn(self, -26, -8):Over(self, 100):End() + Layouter(self.DragTR):AtRightTopIn(self, -22, -8):Over(self, 100):End() + Layouter(self.DragBL):AtLeftBottomIn(self, -26, -8):Over(self, 100):End() + Layouter(self.DragBR):AtRightBottomIn(self, -22, -8):Over(self, 100):End() + + -- Reset-position button: sits to the left of the Window's built-in + -- config button on the title strip. + Layouter(self.ResetPositionBtn) + :LeftOf(self._configBtn) + :End() + -- Full width, flush with the bottom of the client area. The edit -- group derives its own height (see ChatEditInterface.__post_init). Layouter(self.Edit) @@ -507,11 +610,17 @@ local ChatInterface = ClassUI(Window) { end, --- Fired when a resize drag ends. Rewrapping is expensive, so it only - --- happens here rather than on every drag frame. + --- happens here rather than on every drag frame. Also snaps the corner + --- grips back to their `up` texture — the RolloverHandler leaves them + --- on `down` when StartSizing took over. OnResizeSet = function(self) self:RebuildPool() self:RewrapAll() self:CalcVisible() + self.DragTL:SetTexture(self.DragTL.textures.up) + self.DragTR:SetTexture(self.DragTR.textures.up) + self.DragBL:SetTexture(self.DragBL.textures.up) + self.DragBR:SetTexture(self.DragBR.textures.up) end, --- Mouse wheel over the window scrolls the chat. `rotation` is in wheel From dfeecac862125ade7313092936922c19001fe828 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 18:33:40 +0200 Subject: [PATCH 030/130] Refactor to make code easier to read --- lua/ui/game/chat/ChatInterface.lua | 168 +++++++++++++++-------------- 1 file changed, 87 insertions(+), 81 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 7a248ee38d9..a82eefedb56 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -96,75 +96,8 @@ local ChatInterface = ClassUI(Window) { Window.__init(self, parent, "", false, true, true, false, false, "chat_window_v2", DefaultRect, WindowTextures) self:SetMinimumResize(400, 160) - -- Corner grips: Bitmaps overlayed just outside each window corner. - -- They are purely cosmetic — hit-test is disabled so resize events - -- still flow to the Window's own resize bitmaps (tl/tr/bl/br/…). - -- The RolloverHandler swap below is what makes them light up. - self.DragTL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) - self.DragTR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) - self.DragBL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) - self.DragBR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) - - self.DragTL.textures = DragHandleTextures('ul') - self.DragTR.textures = DragHandleTextures('ur') - self.DragBL.textures = DragHandleTextures('ll') - self.DragBR.textures = DragHandleTextures('lr') - - for _, grip in { self.DragTL, self.DragTR, self.DragBL, self.DragBR } do - grip:DisableHitTest() - end - - -- Replace the Window's default RolloverHandler so our corner grips - -- light up during hover / press instead of the base implementation's - -- faint resize-group outline. - local controlMap = { - tl = { self.DragTL }, - tr = { self.DragTR }, - bl = { self.DragBL }, - br = { self.DragBR }, - mr = { self.DragBR, self.DragTR }, - ml = { self.DragBL, self.DragTL }, - tm = { self.DragTL, self.DragTR }, - bm = { self.DragBL, self.DragBR }, - } - self.RolloverHandler = function(_, event, xControl, yControl, cursor, controlID) - if self._lockSize or self._sizeLock then return end - local grips = controlMap[controlID] - if event.Type == 'MouseEnter' then - if grips then - for _, grip in grips do grip:SetTexture(grip.textures.over) end - end - GetCursor():SetTexture(UIUtil.GetCursor(cursor)) - elseif event.Type == 'MouseExit' then - if grips then - for _, grip in grips do grip:SetTexture(grip.textures.up) end - end - GetCursor():Reset() - elseif event.Type == 'ButtonPress' then - if grips then - for _, grip in grips do grip:SetTexture(grip.textures.down) end - end - self.StartSizing(event, xControl, yControl) - self._sizeLock = true - end - end - - -- Titlebar button that snaps the window back to DefaultRect. Placed - -- immediately to the left of the Window's built-in _configBtn. - self.ResetPositionBtn = Button(self, - UIUtil.SkinnableFile('/game/menu-btns/default_btn_up.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_down.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_over.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_dis.dds')) - self.ResetPositionBtn.Depth:Set(function() return self.Depth() + 10 end) - self.ResetPositionBtn.OnClick = function() - local scaled = LayoutHelpers.ScaleNumber - self.Left:Set(scaled(DefaultRect.Left)) - self.Top:Set(scaled(DefaultRect.Top)) - self.Right:Set(scaled(DefaultRect.Right)) - self.Bottom:Set(scaled(DefaultRect.Bottom)) - self:SaveWindowLocation() - end + self:SetupDragHandles() + self:SetupResetPositionButton() local client = self:GetClientGroup() @@ -244,24 +177,97 @@ local ChatInterface = ClassUI(Window) { ) end, + --- Creates the four corner resize grips, wires the window's + --- `RolloverHandler` to swap their textures on hover / press, and lays + --- them out overhanging the window corners. Hit-test is disabled on the + --- grips so resize events still reach the Window's own resize bitmaps. ---@param self UIChatInterface - ---@param parent Control - __post_init = function(self, parent) - local client = self:GetClientGroup() - local pad = 4 + SetupDragHandles = function(self) + self.DragTL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) + self.DragTR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) + self.DragBL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) + self.DragBR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) + + self.DragTL.textures = DragHandleTextures('ul') + self.DragTR.textures = DragHandleTextures('ur') + self.DragBL.textures = DragHandleTextures('ll') + self.DragBR.textures = DragHandleTextures('lr') + + for _, grip in { self.DragTL, self.DragTR, self.DragBL, self.DragBR } do + grip:DisableHitTest() + end + + Layouter(self.DragTL):AtLeftTopIn(self, -26, -8):Over(self, 5):End() + Layouter(self.DragTR):AtRightTopIn(self, -22, -8):Over(self, 5):End() + Layouter(self.DragBL):AtLeftBottomIn(self, -26, -8):Over(self, 5):End() + Layouter(self.DragBR):AtRightBottomIn(self, -22, -8):Over(self, 5):End() + + -- Each `controlID` the Window delivers maps to the grip(s) that + -- visually represent that edge: side edges light both adjacent + -- corners. + local controlMap = { + tl = { self.DragTL }, + tr = { self.DragTR }, + bl = { self.DragBL }, + br = { self.DragBR }, + mr = { self.DragBR, self.DragTR }, + ml = { self.DragBL, self.DragTL }, + tm = { self.DragTL, self.DragTR }, + bm = { self.DragBL, self.DragBR }, + } + self.RolloverHandler = function(_, event, xControl, yControl, cursor, controlID) + if self._lockSize or self._sizeLock then return end + local grips = controlMap[controlID] + if event.Type == 'MouseEnter' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.over) end + end + GetCursor():SetTexture(UIUtil.GetCursor(cursor)) + elseif event.Type == 'MouseExit' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.up) end + end + GetCursor():Reset() + elseif event.Type == 'ButtonPress' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.down) end + end + self.StartSizing(event, xControl, yControl) + self._sizeLock = true + end + end + end, - -- Corner grips. Offsets copied from legacy chat.lua so the grips - -- overhang the chat border at the same pixels the original did. - Layouter(self.DragTL):AtLeftTopIn(self, -26, -8):Over(self, 100):End() - Layouter(self.DragTR):AtRightTopIn(self, -22, -8):Over(self, 100):End() - Layouter(self.DragBL):AtLeftBottomIn(self, -26, -8):Over(self, 100):End() - Layouter(self.DragBR):AtRightBottomIn(self, -22, -8):Over(self, 100):End() + --- Creates the reset-position button on the title strip (immediately to + --- the left of the Window's built-in `_configBtn`). Clicking it snaps + --- every rect edge back to `DefaultRect` and persists the location. + ---@param self UIChatInterface + SetupResetPositionButton = function(self) + self.ResetPositionBtn = Button(self, + UIUtil.SkinnableFile('/game/menu-btns/default_btn_up.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_down.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_over.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_dis.dds')) + self.ResetPositionBtn.Depth:Set(function() return self.Depth() + 10 end) + self.ResetPositionBtn.OnClick = function() + local scaled = LayoutHelpers.ScaleNumber + self.Left:Set(scaled(DefaultRect.Left)) + self.Top:Set(scaled(DefaultRect.Top)) + self.Right:Set(scaled(DefaultRect.Right)) + self.Bottom:Set(scaled(DefaultRect.Bottom)) + self:SaveWindowLocation() + end - -- Reset-position button: sits to the left of the Window's built-in - -- config button on the title strip. Layouter(self.ResetPositionBtn) :LeftOf(self._configBtn) :End() + end, + + ---@param self UIChatInterface + ---@param parent Control + __post_init = function(self, parent) + local client = self:GetClientGroup() + local pad = 4 -- Full width, flush with the bottom of the client area. The edit -- group derives its own height (see ChatEditInterface.__post_init). From ea0a6dd8249d15325b745f26d71e450893dc4e1a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 18:38:36 +0200 Subject: [PATCH 031/130] Properly implement on roll over --- lua/ui/game/chat/ChatInterface.lua | 69 +++++++++++++++++++----------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index a82eefedb56..233ed54ced5 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -80,11 +80,12 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@field ScrollTop number # 1-based virtual position of the top visible row ---@field VirtualSize number # total wrapped lines across valid entries ---@field FontSize number # current font size (from ChatOptions.font_size) ----@field DragTL Bitmap # top-left corner resize grip ----@field DragTR Bitmap # top-right corner resize grip ----@field DragBL Bitmap # bottom-left corner resize grip ----@field DragBR Bitmap # bottom-right corner resize grip ----@field ResetPositionBtn Button # titlebar button that restores DefaultRect +---@field DragTL Bitmap # top-left corner resize grip +---@field DragTR Bitmap # top-right corner resize grip +---@field DragBL Bitmap # bottom-left corner resize grip +---@field DragBR Bitmap # bottom-right corner resize grip +---@field DragHandleControlMap table # resize-bitmap id → grips to highlight +---@field ResetPositionBtn Button # titlebar button that restores DefaultRect ---@field HistoryObserver LazyVar # derived from ChatModel.History ---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible ---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed @@ -205,7 +206,7 @@ local ChatInterface = ClassUI(Window) { -- Each `controlID` the Window delivers maps to the grip(s) that -- visually represent that edge: side edges light both adjacent -- corners. - local controlMap = { + self.DragHandleControlMap = { tl = { self.DragTL }, tr = { self.DragTR }, bl = { self.DragBL }, @@ -215,26 +216,46 @@ local ChatInterface = ClassUI(Window) { tm = { self.DragTL, self.DragTR }, bm = { self.DragBL, self.DragBR }, } + + -- Window calls the instance field `self.RolloverHandler(control, ...)` + -- as a plain function (no method syntax) — install a thin forwarder + -- here that binds `self` and dispatches to `OnRollover`. The class + -- method deliberately uses a different name: sharing `RolloverHandler` + -- would let the instance field shadow the class method, so + -- `self:RolloverHandler(...)` from within the forwarder would recurse. self.RolloverHandler = function(_, event, xControl, yControl, cursor, controlID) - if self._lockSize or self._sizeLock then return end - local grips = controlMap[controlID] - if event.Type == 'MouseEnter' then - if grips then - for _, grip in grips do grip:SetTexture(grip.textures.over) end - end - GetCursor():SetTexture(UIUtil.GetCursor(cursor)) - elseif event.Type == 'MouseExit' then - if grips then - for _, grip in grips do grip:SetTexture(grip.textures.up) end - end - GetCursor():Reset() - elseif event.Type == 'ButtonPress' then - if grips then - for _, grip in grips do grip:SetTexture(grip.textures.down) end - end - self.StartSizing(event, xControl, yControl) - self._sizeLock = true + self:OnRollover(event, xControl, yControl, cursor, controlID) + end + end, + + --- Handles a rollover / press event delivered through the Window's + --- resize bitmaps (tl / tm / tr / ml / mr / bl / bm / br). Lights the + --- matching corner grip(s) and hands off to `StartSizing` on press. + ---@param self UIChatInterface + ---@param event KeyEvent + ---@param xControl? LazyVar # Left or Right LazyVar to drive on drag + ---@param yControl? LazyVar # Top or Bottom LazyVar to drive on drag + ---@param cursor string # cursor-kind id (e.g. 'NW_SE') + ---@param controlID string # id of the resize bitmap (e.g. 'tl') + OnRollover = function(self, event, xControl, yControl, cursor, controlID) + if self._lockSize or self._sizeLock then return end + local grips = self.DragHandleControlMap[controlID] + if event.Type == 'MouseEnter' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.over) end + end + GetCursor():SetTexture(UIUtil.GetCursor(cursor)) + elseif event.Type == 'MouseExit' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.up) end + end + GetCursor():Reset() + elseif event.Type == 'ButtonPress' then + if grips then + for _, grip in grips do grip:SetTexture(grip.textures.down) end end + self.StartSizing(event, xControl, yControl) + self._sizeLock = true end end, From 7de85b33aae56131bffbe90669312c2ab465d8ab Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 19:03:08 +0200 Subject: [PATCH 032/130] Implement sending chat messages --- lua/ui/game/chat/ChatController.lua | 313 +++++++++++++++++++++++++++- lua/ui/game/gamemain.lua | 7 + 2 files changed, 310 insertions(+), 10 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index d53f6eba81c..6264ad3db15 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -92,13 +92,255 @@ function RegisterBuiltinCommands() Registry.Register(Builtins.Help) end ---- Sends a message to the current recipient. ---- Stubbed — the network layer will be wired up in a follow-up step. +------------------------------------------------------------------------------- +-- Address book +-- +-- These helpers resolve engine-level routing info (session-client indices, +-- army-data lookups) without depending on the legacy `chat.lua`. They are +-- the only place in the refactored chat system that touches +-- `GetSessionClients` / `GetArmiesTable`. + +--- Observer-mode branch of `FindClients`: every connected observer client, +--- plus any disconnected-but-recognised human player, is included. The +--- per-client inner loop tries to find a matching army by nickname — if +--- none matches, the client is treated as an observer and included. +---@param armiesTable table +---@return number[] +local function FindClientsAsObserver(armiesTable) + local result = {} + for index, client in GetSessionClients() do + if not client.connected then continue end + local playerIsObserver = true + for _, player in armiesTable do + if player.outOfGame and player.human and player.nickname == client.name then + table.insert(result, index) + playerIsObserver = false + break + elseif player.nickname == client.name then + playerIsObserver = false + break + end + end + if playerIsObserver then + table.insert(result, index) + end + end + return result +end + +--- In-play branch of `FindClients`: gathers the `authorizedCommandSources` +--- of the target army (private-message case) or every focus-army ally +--- (`allies`-broadcast case), then returns the clients whose sources +--- intersect that set. +---@param armiesTable table +---@param focus number +---@param armyID? number +---@return number[] +local function FindClientsAsPlayer(armiesTable, focus, armyID) + local result = {} + local srcs = {} + for army, info in armiesTable do + if armyID then + if army == armyID then + for _, cmdsrc in info.authorizedCommandSources do + srcs[cmdsrc] = true + end + break + end + else + if IsAlly(focus, army) then + for _, cmdsrc in info.authorizedCommandSources do + srcs[cmdsrc] = true + end + end + end + end + for index, client in GetSessionClients() do + for _, cmdsrc in client.authorizedCommandSources do + if srcs[cmdsrc] then + table.insert(result, index) + break + end + end + end + return result +end + +--- Resolves the session-client indices for a given chat target. Mirrors the +--- legacy `chat.lua` behavior: +--- +--- * Observing (focus == -1): every connected observer client, plus any +--- disconnected-but-recognised human player, is included. +--- * Playing with an `armyID`: the clients authorised for that specific army +--- — used for private messages. +--- * Playing with no `armyID`: the clients authorised for any of the focus +--- army's allies — used for `allies` broadcasts. +--- +--- Exported so other UI modules (notify, score, painting canvas) can migrate +--- away from `/lua/ui/game/chat.lua`'s copy. +---@param armyID? number +---@return number[] +function FindClients(armyID) + local t = GetArmiesTable() + if t.focusArmy == -1 then + return FindClientsAsObserver(t.armiesTable) + end + return FindClientsAsPlayer(t.armiesTable, t.focusArmy, armyID) +end + +--- Looks up army data by army index (number) or nickname (string). Returns +--- the entry from `armiesTable` or nil if no match; for nickname lookups the +--- returned table has `ArmyID` set to the matching index. +---@param army number | string +---@return table | nil +local function GetArmyData(army) + local armies = GetArmiesTable() + if type(army) == 'number' then + return armies.armiesTable[army] + elseif type(army) == 'string' then + for i, v in armies.armiesTable do + if v.nickname == army then + v.ArmyID = i + return v + end + end + end +end + +------------------------------------------------------------------------------- +-- Recipient label formatting +-- +-- Keyed by recipient value so both `RecipientAll` ('all') and +-- `RecipientAllies` ('allies') index directly, with 'private'/'notify'/'to' +-- as named fallbacks. Loc keys mirror the legacy `chat.lua` table so the +-- rendered prefix reads identically. + +local ToStrings = { + [ChatModel.RecipientAll] = { text = 'to all:', caps = 'To All:', colorkey = 'all_color' }, + [ChatModel.RecipientAllies] = { text = 'to allies:', caps = 'To Allies:', colorkey = 'allies_color' }, + private = { text = 'to you:', caps = 'To You:', colorkey = 'priv_color' }, + notify = { text = 'to allies:', caps = 'To Allies:', colorkey = 'notify_color' }, + to = { text = 'to', caps = 'To', colorkey = 'all_color' }, +} + +------------------------------------------------------------------------------- +-- Chat line construction +-- +-- Receive and echo share the same "package sender + message into a +-- `UIChatEntry` and push it onto history" work. The only thing they +-- disagree on is how the name prefix reads and whose army data they pull +-- from — so that's where they diverge; everything else goes through +-- `AppendChatLine`. + +--- Builds a `UIChatEntry` from a sender's army data + message metadata and +--- appends it to the model history. Fields with natural defaults (colour, +--- army ID, faction icon) fall back when the army data is missing or the +--- sender is an observer. +---@param args { Name: string, Text?: string, ArmyData?: table, IsObserver?: boolean, Recipient: UIChatRecipient, Camera?: table } +local function AppendChatLine(args) + local armyData = args.ArmyData or {} + -- Observers have no `faction`; fall through to the tail icon in + -- `ChatLineInterface.FactionIcons` (observer). Real factions are 0..N-1 + -- in engine data; the view expects 1-based indices. + local faction = not args.IsObserver and armyData.faction or nil + AppendEntry { + Name = args.Name, + Text = args.Text or '', + Color = armyData.color or 'ffffffff', + ArmyID = armyData.ArmyID or 1, + Faction = (faction or 4) + 1, + Recipient = args.Recipient, + Camera = args.Camera, + } +end + +------------------------------------------------------------------------------- +-- Receiving (network) + +--- Handler registered with `gamemain.RegisterChatFunc`. Normalises the +--- message, delegates Notify-subsystem messages, resolves the sender's army +--- data, and appends a chat line. +---@param sender string +---@param msg table +function OnReceive(sender, msg) + sender = sender or "nil sender" + + if not msg.Chat then return end + + -- Notify routing: the Notify subsystem tags messages with `to='notify'` + -- and owns the display decision. Only fall through to rendering a chat + -- line if Notify declines (returns false). + if msg.to == 'notify' and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then + return + end + + local armyData = GetArmyData(sender) + if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then + return + end + + local to = msg.to + local descriptor = ToStrings[to] or ToStrings.private + local towho = msg.Observer and LOC("to observers:") or LOC(descriptor.text) + + local name + if type(to) == 'number' and SessionIsReplay() then + -- In a replay, private messages need the full routing so spectators + -- can attribute the conversation. + name = string.format("%s %s %s:", sender, LOC(ToStrings.to.text), + (GetArmyData(to) or {}).nickname or tostring(to)) + else + name = sender .. ' ' .. towho + end + + AppendChatLine { + Name = name, + Text = msg.text, + ArmyData = armyData, + IsObserver = msg.Observer, + Recipient = to, + Camera = msg.camera, + } +end + +------------------------------------------------------------------------------- +-- Echoing (local synthesis for outgoing privates) +-- +-- The engine only routes a private message to its target, so the sender +-- would otherwise never see their own whispers. `OnEcho` synthesises a +-- "To :" line from the send-side data directly — no round-trip +-- through a fake `msg.echo` field, no pretending the message was received +-- from the network. + +--- Appends a locally-echoed line for a private message the local player +--- just sent. Called only from `Send`; not registered with gamemain. +---@param senderData table # local player's army data +---@param recipientData table # target of the private message +---@param msg table # outgoing message (uses `text`, `to`, `camera`) +local function OnEcho(senderData, recipientData, msg) + local name = string.format("%s %s:", LOC(ToStrings.to.caps), recipientData.nickname) + AppendChatLine { + Name = name, + Text = msg.text, + ArmyData = senderData, + Recipient = msg.to, + Camera = msg.camera, + } +end + +------------------------------------------------------------------------------- +-- Sending + +--- Sends a chat message to the current recipient. Dispatches slash commands, +--- drops all-whitespace bodies, short-circuits taunts, then routes the +--- payload to the engine based on the recipient and whether the local player +--- is observing. ---@param text string function Send(text) - if text and string.sub(text, 1, 1) == '/' then - RegisterBuiltinCommands() + if not text or text == '' then return end + if string.sub(text, 1, 1) == '/' then + RegisterBuiltinCommands() local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") local handled, err = Registry.Dispatch(text) if handled then return end @@ -106,16 +348,67 @@ function Send(text) AppendLocalSystemMessage(err) return end - -- Lone '/' or whitespace-only body falls through to the normal path. + -- Lone '/' or a slash-prefixed body with no matching command falls + -- through to the normal send path. + end + + -- Drop all-whitespace bodies. `string.find` with `%s+` returns the first + -- run of whitespace; if it spans the entire text there's nothing to send. + local wsStart, wsEnd = string.find(text, "%s+") + if wsStart == 1 and wsEnd == string.len(text) then return end + + if import("/lua/ui/game/taunt.lua").CheckForAndHandleTaunt(text) then + return end - WARN("ChatController.Send not yet implemented: " .. tostring(text)) + local recipient = ChatModel.GetSingleton().Recipient() + local focusArmy = GetFocusArmy() + local msg = { + to = recipient, + Chat = true, + Identifier = 'Chat', + text = text, + } + + if recipient == ChatModel.RecipientAllies then + if focusArmy == -1 then msg.Observer = true end + SessionSendChatMessage(FindClients(), msg) + elseif type(recipient) == 'number' then + -- Observers can't target a private recipient; silently drop (old + -- chat.lua did the same — the command simply had no effect). + if focusArmy == -1 then return end + SessionSendChatMessage(FindClients(recipient), msg) + + -- The engine does not bounce private messages back to the sender; + -- locally synthesise a line so the sender sees what they just wrote. + local senderData = GetArmyData(focusArmy) + local targetData = GetArmyData(recipient) + if senderData and targetData then + OnEcho(senderData, targetData, msg) + end + else + if focusArmy == -1 then + msg.Observer = true + SessionSendChatMessage(FindClients(), msg) + else + SessionSendChatMessage(msg) + end + end end --- Register at load time so the registry is populated before the first hint --- opens or the first slash command is sent. The function is idempotent, so --- re-imports and the belt-and-suspenders calls from `Send` / `OpenCommandHint` --- all converge on the same state. +------------------------------------------------------------------------------- +-- Engine registration +-- +-- Registered at module load. `RegisterChatFunc` keys by identifier and +-- overwrites, so re-imports (hot reload) simply replace the previous handler +-- with the freshly-loaded one — no duplicate dispatches. + +import("/lua/ui/game/gamemain.lua").RegisterChatFunc(OnReceive, 'Chat') + +-- Slash-command registry also needs to be populated before the first hint +-- opens or the first `/cmd` is typed. `RegisterBuiltinCommands` is +-- idempotent, so re-imports and the belt-and-suspenders calls from `Send` / +-- `OpenCommandHint` all converge on the same state. RegisterBuiltinCommands() ------------------------------------------------------------------------------- diff --git a/lua/ui/game/gamemain.lua b/lua/ui/game/gamemain.lua index 84bb0c26fee..5de1e43289b 100644 --- a/lua/ui/game/gamemain.lua +++ b/lua/ui/game/gamemain.lua @@ -1071,6 +1071,13 @@ end ---@param sender string # username ---@param data table function ReceiveChat(sender, data) + + -- early exit for console output + if data.ConsoleOutput then + print(LOCF("%s %s", sender, data.ConsoleOutput)) + return + end + if data.Identifier then -- we highly encourage to use the 'Identifier' field to quickly identify the correct function From c6a79e8b208586da0716ca6ab30520900e7b3956 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 19:05:48 +0200 Subject: [PATCH 033/130] Init the chat functionality when creating the UI --- lua/ui/game/chat/ChatController.lua | 26 ++++++++++++++------------ lua/ui/game/gamemain.lua | 1 + 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 6264ad3db15..e09c330b3bf 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -397,19 +397,21 @@ function Send(text) end ------------------------------------------------------------------------------- --- Engine registration --- --- Registered at module load. `RegisterChatFunc` keys by identifier and --- overwrites, so re-imports (hot reload) simply replace the previous handler --- with the freshly-loaded one — no duplicate dispatches. - -import("/lua/ui/game/gamemain.lua").RegisterChatFunc(OnReceive, 'Chat') +-- Lifecycle --- Slash-command registry also needs to be populated before the first hint --- opens or the first `/cmd` is typed. `RegisterBuiltinCommands` is --- idempotent, so re-imports and the belt-and-suspenders calls from `Send` / --- `OpenCommandHint` all converge on the same state. -RegisterBuiltinCommands() +--- One-shot initialisation: registers the receive handler with gamemain and +--- populates the slash-command registry with the built-ins. Called from +--- `gamemain.lua` during UI setup — kept out of module-load so mods can hook +--- the controller (replacing `Init`, `OnReceive`, or `RegisterBuiltinCommands`) +--- before any wiring happens. +--- +--- `RegisterChatFunc` keys by identifier and overwrites, so calling `Init` +--- more than once simply replaces the previous handler — no duplicate +--- dispatches, safe under hot reload. +function Init() + import("/lua/ui/game/gamemain.lua").RegisterChatFunc(OnReceive, 'Chat') + RegisterBuiltinCommands() +end ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/game/gamemain.lua b/lua/ui/game/gamemain.lua index 5de1e43289b..82611f7647a 100644 --- a/lua/ui/game/gamemain.lua +++ b/lua/ui/game/gamemain.lua @@ -298,6 +298,7 @@ function CreateUI(isReplay) import("/lua/ui/game/consoleecho.lua").CreateConsoleEcho(mapGroup) import("/lua/ui/game/build_templates.lua").Init() import("/lua/ui/game/taunt.lua").Init() + import("/lua/ui/game/chat/ChatController.lua").Init() import("/lua/ui/game/chat.lua").SetupChatLayout(windowGroup) import("/lua/ui/game/minimap.lua").CreateMinimap(windowGroup) From 8bd3e4af2a44ca0e458294df93096696a498f52d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 19:13:42 +0200 Subject: [PATCH 034/130] Update imports of chat --- lua/ui/game/chat/ChatEditInterface.lua | 5 +++++ lua/ui/game/painting/ShareAdapters/PaintingCanvasAdapter.lua | 2 +- lua/ui/game/score.lua | 2 +- lua/ui/notify/notify.lua | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index d9b6126ecad..26043589ab8 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -82,10 +82,15 @@ ChatEditInterface = ClassUI(Group) { self.EditBox:ShowBackground(false) self.EditBox:SetText('') + -- Pressing Enter on an empty edit box closes the window — matches + -- the legacy `chat.lua` shortcut where Enter serves as both "send" + -- and "dismiss" depending on whether there's anything to send. self.EditBox.OnEnterPressed = function(edit, text) if text and text ~= '' then ChatController.Send(text) edit:SetText('') + else + ChatController.CloseWindow() end self:CloseCommandHint() end diff --git a/lua/ui/game/painting/ShareAdapters/PaintingCanvasAdapter.lua b/lua/ui/game/painting/ShareAdapters/PaintingCanvasAdapter.lua index 9edf3a64186..4edfc822d5d 100644 --- a/lua/ui/game/painting/ShareAdapters/PaintingCanvasAdapter.lua +++ b/lua/ui/game/painting/ShareAdapters/PaintingCanvasAdapter.lua @@ -466,7 +466,7 @@ PaintingCanvasAdapter = Class(DebugComponent) { -- samples once it's reached a certain threshold. With normal use, -- this guarantees that the brushStroke does not exceed the limit. - local FindClients = import('/lua/ui/game/chat.lua').FindClients + local FindClients = import('/lua/ui/game/chat/ChatController.lua').FindClients local clients = FindClients() local messages = self:SplitBrushStrokes(shareablePainting) diff --git a/lua/ui/game/score.lua b/lua/ui/game/score.lua index 3455a7b79c5..245ef56f7a7 100644 --- a/lua/ui/game/score.lua +++ b/lua/ui/game/score.lua @@ -20,7 +20,7 @@ local Grid = import("/lua/maui/grid.lua").Grid local Prefs = import("/lua/user/prefs.lua") local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider local Tooltip = import("/lua/ui/game/tooltip.lua") -local FindClients = import("/lua/ui/game/chat.lua").FindClients +local FindClients = import("/lua/ui/game/chat/ChatController.lua").FindClients local scoreMini = import(UIUtil.GetLayoutFilename('score')) controls = import("/lua/ui/controls.lua").Get() diff --git a/lua/ui/notify/notify.lua b/lua/ui/notify/notify.lua index b776174dc78..e9ea4abc799 100644 --- a/lua/ui/notify/notify.lua +++ b/lua/ui/notify/notify.lua @@ -2,7 +2,7 @@ -- when you order and complete ACU upgrades local Prefs = import("/lua/user/prefs.lua") -local FindClients = import("/lua/ui/game/chat.lua").FindClients +local FindClients = import("/lua/ui/game/chat/ChatController.lua").FindClients local defaultMessages = import("/lua/ui/notify/defaultmessages.lua").defaultMessages local AddChatCommand = import("/lua/ui/notify/commands.lua").AddChatCommand local NotifyOverlay = import("/lua/ui/notify/notifyoverlay.lua") From 80d76e3bd8fafa70425525bff4dffd6376bcb1ee Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 19:23:30 +0200 Subject: [PATCH 035/130] Rewire other existing references to the old chat --- lua/AIChatSorian.lua | 6 ++-- lua/keymap/keyactions.lua | 8 +++--- lua/skins/layouts.lua | 3 -- lua/ui/game/chat/ChatInterface.lua | 10 +++++++ lua/ui/game/gamemain.lua | 16 +++++++---- lua/ui/game/layouts/chat_layout.lua | 44 ----------------------------- lua/ui/game/multifunction.lua | 2 +- lua/ui/game/tabs.lua | 2 +- lua/ui/game/taunt.lua | 6 ++-- 9 files changed, 32 insertions(+), 65 deletions(-) delete mode 100644 lua/ui/game/layouts/chat_layout.lua diff --git a/lua/AIChatSorian.lua b/lua/AIChatSorian.lua index 584e0930940..f263b54fe99 100644 --- a/lua/AIChatSorian.lua +++ b/lua/AIChatSorian.lua @@ -66,11 +66,11 @@ function AISendChatMessage(towho, msg) if towho then for k,v in towho do if v == focus then - import("/lua/ui/game/chat.lua").ReceiveChat(msg.aisender, msg) + import("/lua/ui/game/chat/ChatController.lua").OnReceive(msg.aisender, msg) end end else - import("/lua/ui/game/chat.lua").ReceiveChat(msg.aisender, msg) + import("/lua/ui/game/chat/ChatController.lua").OnReceive(msg.aisender, msg) end elseif msg.Taunt then import("/lua/ui/game/taunt.lua").RecieveAITaunt(msg.aisender, msg) @@ -93,5 +93,3 @@ function GetArmyData(army) return result end ---- Kept for backwards compatibility -local Chat = import("/lua/ui/game/chat.lua") \ No newline at end of file diff --git a/lua/keymap/keyactions.lua b/lua/keymap/keyactions.lua index 9b7768efe5e..bc5fbe699af 100755 --- a/lua/keymap/keyactions.lua +++ b/lua/keymap/keyactions.lua @@ -1863,19 +1863,19 @@ local keyActionsGame = { ---@type table local keyActionsChat = { ['chat_page_up'] = { - action = 'UI_Lua import("/lua/ui/game/chat.lua").ChatPageUp(10)', + action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").OpenAndScrollLines(-10)', category = 'chat', }, ['chat_page_down'] = { - action = 'UI_Lua import("/lua/ui/game/chat.lua").ChatPageDown(10)', + action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").OpenAndScrollLines(10)', category = 'chat', }, ['chat_line_up'] = { - action = 'UI_Lua import("/lua/ui/game/chat.lua").ChatPageUp(1)', + action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").OpenAndScrollLines(-1)', category = 'chat', }, ['chat_line_down'] = { - action = 'UI_Lua import("/lua/ui/game/chat.lua").ChatPageDown(1)', + action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").OpenAndScrollLines(1)', category = 'chat', }, ['chat_config'] = { diff --git a/lua/skins/layouts.lua b/lua/skins/layouts.lua index 6821558958c..5b7377ff307 100644 --- a/lua/skins/layouts.lua +++ b/lua/skins/layouts.lua @@ -2,7 +2,6 @@ bottom = { avatars = '/lua/ui/game/layouts/avatars_mini.lua', borders = '/lua/ui/game/layouts/borders_mini.lua', buildmode = '/lua/ui/game/layouts/buildmode_layout.lua', - chat = '/lua/ui/game/layouts/chat_layout.lua', confirmunitxfer = '/lua/ui/game/layouts/confirmunitxfer_layout.lua', connectivity = '/lua/ui/game/layouts/connectivity_layout.lua', consoleecho = '/lua/ui/game/layouts/consoleecho_layout.lua', @@ -42,7 +41,6 @@ left = { avatars = '/lua/ui/game/layouts/avatars_mini.lua', borders = '/lua/ui/game/layouts/borders_left.lua', buildmode = '/lua/ui/game/layouts/buildmode_layout.lua', - chat = '/lua/ui/game/layouts/chat_layout.lua', confirmunitxfer = '/lua/ui/game/layouts/confirmunitxfer_layout.lua', connectivity = '/lua/ui/game/layouts/connectivity_layout.lua', consoleecho = '/lua/ui/game/layouts/consoleecho_layout.lua', @@ -82,7 +80,6 @@ right = { avatars = '/lua/ui/game/layouts/avatars_mini.lua', borders = '/lua/ui/game/layouts/borders_mini.lua', buildmode = '/lua/ui/game/layouts/buildmode_layout.lua', - chat = '/lua/ui/game/layouts/chat_layout.lua', confirmunitxfer = '/lua/ui/game/layouts/confirmunitxfer_layout.lua', connectivity = '/lua/ui/game/layouts/connectivity_layout.lua', consoleecho = '/lua/ui/game/layouts/consoleecho_layout.lua', diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 233ed54ced5..451e33df000 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -719,6 +719,16 @@ function ScrollPages(delta) end end +--- Opens the chat window (creating it on first call) and scrolls the feed +--- by `delta` rows. Entry point for the global PgUp / PgDn key bindings — +--- so pressing PgUp with the window hidden both reveals it and starts +--- scrolling toward older messages. +---@param delta number +function OpenAndScrollLines(delta) + Open() + ScrollLines(delta) +end + ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/game/gamemain.lua b/lua/ui/game/gamemain.lua index 82611f7647a..320c3b1e3d2 100644 --- a/lua/ui/game/gamemain.lua +++ b/lua/ui/game/gamemain.lua @@ -32,7 +32,7 @@ local NISActive = false local isReplay = false local waitingDialog = false -local sendChat = import("/lua/ui/game/chat.lua").ReceiveChatFromSim +local sendChat = import("/lua/ui/game/chat/ChatController.lua").OnReceive local oldData = {} local lastObserving @@ -87,7 +87,6 @@ function SetLayout(layout) import("/lua/ui/game/score.lua").SetLayout() import("/lua/ui/game/tabs.lua").SetLayout() import("/lua/ui/game/controlgroups.lua").SetLayout() - import("/lua/ui/game/chat.lua").SetLayout() import("/lua/ui/game/minimap.lua").SetLayout() import("/lua/ui/game/massfabs.lua").SetLayout() import("/lua/ui/game/recall.lua").SetLayout() @@ -300,7 +299,6 @@ function CreateUI(isReplay) import("/lua/ui/game/taunt.lua").Init() import("/lua/ui/game/chat/ChatController.lua").Init() - import("/lua/ui/game/chat.lua").SetupChatLayout(windowGroup) import("/lua/ui/game/minimap.lua").CreateMinimap(windowGroup) if import("/lua/ui/campaign/campaignmanager.lua").campaignMode then @@ -942,7 +940,7 @@ function NISMode(state) import("/lua/ui/game/consoleecho.lua").ToggleOutput(false) import("/lua/ui/game/multifunction.lua").PreNIS() import("/lua/ui/game/tooltip.lua").DestroyMouseoverDisplay() - import("/lua/ui/game/chat.lua").OnNISBegin() + import("/lua/ui/game/chat/ChatInterface.lua").Close() import("/lua/ui/game/unitviewdetail.lua").OnNIS() HideGameUI(state) ShowNISBars() @@ -1175,7 +1173,15 @@ SendChat = function() if newChat then chat.oldTime = GetGameTimeSeconds() table.insert(oldData, chat) - sendChat(chat.sender, chat.msg) + -- Sim-side `ConsoleOutput` messages are log-only — + -- they never open a chat line. The new receive path + -- drops non-`Chat` messages, so handle the print + -- here instead of in the controller. + if chat.msg.ConsoleOutput then + print(LOCF("%s %s", chat.sender or "nil sender", chat.msg.ConsoleOutput)) + else + sendChat(chat.sender, chat.msg) + end end end UnitData.Chat = {} diff --git a/lua/ui/game/layouts/chat_layout.lua b/lua/ui/game/layouts/chat_layout.lua deleted file mode 100644 index 7662cde4af4..00000000000 --- a/lua/ui/game/layouts/chat_layout.lua +++ /dev/null @@ -1,44 +0,0 @@ - -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local Prefs = import("/lua/user/prefs.lua") - -function SetLayout() - local GUI = import("/lua/ui/game/chat.lua").GUI - - local windowTextures = { - tl = UIUtil.UIFile('/game/chat_brd/chat_brd_ul.dds'), - tr = UIUtil.UIFile('/game/chat_brd/chat_brd_ur.dds'), - tm = UIUtil.UIFile('/game/chat_brd/chat_brd_horz_um.dds'), - ml = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_l.dds'), - m = UIUtil.UIFile('/game/chat_brd/chat_brd_m.dds'), - mr = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_r.dds'), - bl = UIUtil.UIFile('/game/chat_brd/chat_brd_ll.dds'), - bm = UIUtil.UIFile('/game/chat_brd/chat_brd_lm.dds'), - br = UIUtil.UIFile('/game/chat_brd/chat_brd_lr.dds'), - borderColor = 'ff415055', - } - - GUI.bg:ApplyWindowTextures(windowTextures) - - GUI.bg.DragTL:SetTexture(UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) - GUI.bg.DragTR:SetTexture(UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) - GUI.bg.DragBL:SetTexture(UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) - GUI.bg.DragBR:SetTexture(UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) - - GUI.bg.DragTL.textures = {up = UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds'), - down = UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_down.dds'), - over = UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_over.dds')} - - GUI.bg.DragTR.textures = {up = UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds'), - down = UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_down.dds'), - over = UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_over.dds')} - - GUI.bg.DragBL.textures = {up = UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds'), - down = UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_down.dds'), - over = UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_over.dds')} - - GUI.bg.DragBR.textures = {up = UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds'), - down = UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_down.dds'), - over = UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_over.dds')} -end \ No newline at end of file diff --git a/lua/ui/game/multifunction.lua b/lua/ui/game/multifunction.lua index 83de50d04b8..f5d54830b4d 100644 --- a/lua/ui/game/multifunction.lua +++ b/lua/ui/game/multifunction.lua @@ -371,7 +371,7 @@ function OnDropoutChecked(self, checked) end function CreateMapDropout(parent) - import("/lua/ui/game/chat.lua").CloseChatConfig() + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() local bg = CreateDropoutBG(false) local function CreateMapOptions(inMapControl) diff --git a/lua/ui/game/tabs.lua b/lua/ui/game/tabs.lua index 1bd5ee5ec3f..c3ac7d7de21 100644 --- a/lua/ui/game/tabs.lua +++ b/lua/ui/game/tabs.lua @@ -585,7 +585,7 @@ function BuildContent(contentID) return end import("/lua/ui/game/multifunction.lua").CloseMapDialog() - import("/lua/ui/game/chat.lua").CloseChatConfig() + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() activeTab = contentID for _, tab in controls.tabs do if tab.Data.content == contentID then diff --git a/lua/ui/game/taunt.lua b/lua/ui/game/taunt.lua index f337e2ecf11..2ff9526b37d 100644 --- a/lua/ui/game/taunt.lua +++ b/lua/ui/game/taunt.lua @@ -133,7 +133,7 @@ local function RecieveTaunt(sender, msg) end -- at this point we do show the message - import("/lua/ui/game/chat.lua").ReceiveChat(sender, {Chat = true, text = LOC(taunt.text), to = "all"}) + import("/lua/ui/game/chat/ChatController.lua").OnReceive(sender, {Chat = true, text = LOC(taunt.text), to = "all"}) -- check if we also play a sound local systemTimeSeconds = GetSystemTimeSeconds() @@ -156,11 +156,11 @@ function RecieveAITaunt(sender, msg) if taunt and msg.aisender then StopSound(prevHandle) prevHandle = PlayVoice(Sound({Cue = taunt.cue, Bank = taunt.bank})) - import("/lua/ui/game/chat.lua").ReceiveChat(sender, {Chat = true, text = LOC(taunt.text), to = "all", aisender = msg.aisender}) + import("/lua/ui/game/chat/ChatController.lua").OnReceive(sender, {Chat = true, text = LOC(taunt.text), to = "all", aisender = msg.aisender}) elseif taunt then StopSound(prevHandle) prevHandle = PlayVoice(Sound({Cue = taunt.cue, Bank = taunt.bank})) - import("/lua/ui/game/chat.lua").ReceiveChat(sender, {Chat = true, text = LOC(taunt.text), to = "all"}) + import("/lua/ui/game/chat/ChatController.lua").OnReceive(sender, {Chat = true, text = LOC(taunt.text), to = "all"}) end end end From 244fccddd4ac530062c084af0b58dbd0db857cd0 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 19:25:50 +0200 Subject: [PATCH 036/130] Add analysis about missing features and changes --- lua/ui/game/chat/CHANGES.md | 82 +++++++++++++++++++++++++++++ lua/ui/game/chat/GAPS.md | 102 ++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 lua/ui/game/chat/CHANGES.md create mode 100644 lua/ui/game/chat/GAPS.md diff --git a/lua/ui/game/chat/CHANGES.md b/lua/ui/game/chat/CHANGES.md new file mode 100644 index 00000000000..4d4ff6c5a74 --- /dev/null +++ b/lua/ui/game/chat/CHANGES.md @@ -0,0 +1,82 @@ +# Chat MVC refactor — behavioural differences + +Catalogue of every intentional change vs. the legacy [`/lua/ui/game/chat.lua`](../chat.lua) after it was replaced by the MVC tree under [`/lua/ui/game/chat/`](.). Additive — new features belong in the individual module docs, not here. + +--- + +## Sim-side `ConsoleOutput` messages + +Sim-originated chat messages carrying a `ConsoleOutput` field are log-only — they never open a chat line. The legacy path inspected this field inside `ReceiveChatFromSim`; the new [`ChatController.OnReceive`](ChatController.lua) drops any message whose `Chat` flag is false, so the `ConsoleOutput` branch moved up the stack. + +**New home**: [`gamemain.lua` `SendChat`](../gamemain.lua) — the sim-chat replay loop prints `ConsoleOutput` messages directly instead of forwarding them to the controller. Every other message still goes through `sendChat(chat.sender, chat.msg)`. + +This keeps the controller focused on displayable messages and preserves the old log behaviour for sim diagnostics. + +--- + +## `PgDn` at scroll-bottom no longer closes the window + +Legacy `ChatPageDown(mod)` had a quirk: pressing it when the feed was already scrolled to the bottom (or the window was hidden) would toggle the window. The new [`ChatInterface.OpenAndScrollLines`](ChatInterface.lua) keybind entry-point opens-then-scrolls; `PgDn` on an already-open, already-bottom window now does nothing. + +Closing is still reachable via the close button, the `Escape` key on an empty edit box, and the `chat_window` keybind. + +--- + +## Skin-specific chat-window theming is gone + +The old `layouts/chat_layout.lua` file (loaded via `UIUtil.GetLayoutFilename('chat')`) applied skin-specific window textures and drag-handle art to the chat window. That file has been deleted along with its `bottom` / `left` / `right` entries in [`/lua/skins/layouts.lua`](../../../skins/layouts.lua). + +The new [`ChatInterface`](ChatInterface.lua) uses a single set of textures for every skin. Skin switching no longer re-themes the chat window. + +--- + +## Empty-text Enter always closes the window + +Legacy behaviour: pressing `Enter` on an empty edit box called `ToggleChat()` — i.e. close if open, open if hidden. Since Enter only fires when the edit box has focus (and focus implies the window is already visible), the new [`ChatEditInterface.OnEnterPressed`](ChatEditInterface.lua) unconditionally calls `ChatController.CloseWindow()` on empty text. Net effect: identical. + +--- + +## Engine registration moved out of module-load + +`/lua/ui/game/chat.lua` registered its receive function and built-in commands as a side-effect of being imported. The new controller exposes an explicit [`ChatController.Init()`](ChatController.lua) which [`gamemain.lua`](../gamemain.lua) calls during UI setup, next to `taunt.Init()` and `build_templates.Init()`. + +**Why**: mods can hook `ChatController` (replacing `Init`, `OnReceive`, or `RegisterBuiltinCommands`) before any wiring happens. A module-load-time register would run before mods get a chance to override. + +--- + +## `ReceiveChat` → `OnReceive` at every injection point + +The legacy `chat.lua.ReceiveChat(sender, msg)` is now [`ChatController.OnReceive(sender, msg)`](ChatController.lua). All existing injection points have been repointed: + +| Caller | Purpose | +|---|---| +| [`AIChatSorian.AISendChatMessage`](../../../AIChatSorian.lua) | AI-to-player chat | +| [`taunt.lua` taunt display](../taunt.lua) | Taunt text as a chat line | +| [`gamemain.lua` `SendChat`](../gamemain.lua) | Sim-replayed chat | + +Message shape and semantics are unchanged — callers pass the same `{Chat, text, to, ...}` tables. + +--- + +## `FindClients` has a new home + +Moved from `/lua/ui/game/chat.lua` to [`ChatController.FindClients`](ChatController.lua). Behaviour is byte-identical (same observer-mode, same ally-resolution logic). Callers updated: + +- [`notify.lua`](../../notify/notify.lua) +- [`score.lua`](../score.lua) +- [`PaintingCanvasAdapter.lua`](../painting/ShareAdapters/PaintingCanvasAdapter.lua) + +--- + +## `CloseChatConfig` → `ChatConfigInterface.Close` + +The legacy standalone config-close entry point is now the `Close()` method on the new config module. Callers updated: + +- [`tabs.lua`](../tabs.lua) +- [`multifunction.lua`](../multifunction.lua) + +--- + +## `ChatPageUp` / `ChatPageDown` → `OpenAndScrollLines` + +The `chat_page_up`, `chat_page_down`, `chat_line_up`, `chat_line_down` key bindings in [`keyactions.lua`](../../../keymap/keyactions.lua) now call [`ChatInterface.OpenAndScrollLines(±n)`](ChatInterface.lua) with a signed delta (negative = older messages) instead of separate up / down module functions. diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md new file mode 100644 index 00000000000..01d8b27856a --- /dev/null +++ b/lua/ui/game/chat/GAPS.md @@ -0,0 +1,102 @@ +# Chat MVC refactor — remaining gaps + +Inventory of legacy [`/lua/ui/game/chat.lua`](../chat.lua) behaviour that the new MVC tree does **not** yet replicate. Gaps are grouped by concern and cite line numbers in the old file so a future author can jump in. + +Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file only tracks work that hasn't happened yet. + +--- + +## Feed mode / fade / pin + +The most conspicuous missing chunk. None of the "auto-fading feed of recent chat when the window is hidden" behaviour has been ported. + +- **Per-line fade timer** — `line.OnFrame` delta-time countdown driven by `ChatOptions.fade_time` ([chat.lua:471-492](../chat.lua)). +- **Window auto-close timer** — `GUI.bg.OnFrame` hides the whole window after `fade_time` with no new traffic ([chat.lua:1171-1176](../chat.lua)). +- **Translucent feed background** — `line.lineStickybg` when `feed_background` is enabled ([chat.lua:233-238, 465](../chat.lua)). Explicitly dropped from `ChatLineInterface` (see its comment at line 22). +- **Feed persist** — `feed_persist` keeps feed lines until their individual timer runs out ([chat.lua:912-920](../chat.lua)). +- **Window opacity** — `SetAlpha(win_alpha)` on window bg and lines ([chat.lua:501, 1150, 1199](../chat.lua)). +- **Pin toggle** — `GUI.bg.OnPinCheck` suspends fade while pinned ([chat.lua:1177-1187](../chat.lua)). No pin button in the new window. + +## Message filtering + +[`ChatInterface.IsValidEntry`](ChatInterface.lua) is a stub returning `true`. The legacy filter gated on three things ([chat.lua:304-310](../chat.lua)): + +- `ChatOptions.links` — hides camera-link messages. +- `ChatOptions[armyID]` — per-sender on/off checkboxes. +- Self-echo suppression and observer rules on receive. + +Per-army filter UI needs re-adding to [`ChatConfigInterface`](config/ChatConfigInterface.lua) (legacy: [chat.lua:1194-1198, 1437-1442](../chat.lua)). + +## Config options not reaching the view + +[`ChatConfigModel`](config/ChatConfigModel.lua) defines every key correctly, but only `font_size` is subscribed by the view (see [`ChatInterface.ApplyOptions`](ChatInterface.lua)). Not yet wired: + +- `all_color` / `allies_color` / `priv_color` / `link_color` / `notify_color` — the per-recipient text-colour palette ([chat.lua:63, 446-450](../chat.lua)). +- `fade_time`, `win_alpha`, `feed_background`, `feed_persist` — feed-mode options (blocked on the feed-mode port above). +- `links` — blocked on filtering port. +- `[armyID]` per-army filter keys — blocked on filtering port. + +No replacement exists for the legacy public `AddChatOptionSetCallback` ([chat.lua:1071-1102](../chat.lua)) — external subscribers to option changes have no API. + +## Camera / ping links + +`UIChatEntry.Camera` is declared on the model but never populated or rendered. + +- **Outgoing**: `chatEdit.camData` checkbox + `tempCam` recall ([chat.lua:750-754](../chat.lua)) not present on [`ChatEditInterface`](ChatEditInterface.lua). `ChatController.Send` never attaches `msg.camera`. +- **Incoming render**: `camIcon` bitmap on the line ([chat.lua:419-428](../chat.lua)) not on [`ChatLineInterface`](ChatLineInterface.lua). +- **Click to jump**: `line.Text` click → `GetCamera('WorldCamera'):RestoreSettings` ([chat.lua:223-229](../chat.lua)) — [`ChatLineInterface`](ChatLineInterface.lua) disables hit-test on the text control. + +## Private reply by clicking a name + +Legacy click on `line.name` set `ChatTo:Set(line.chatID)` and re-focused the edit ([chat.lua:199-212](../chat.lua)). [`ChatLineInterface`](ChatLineInterface.lua) disables hit-test on both name and text controls. No `last_sender` tracking either. + +## Notify-command bridge + +Slash commands in the old dispatcher fell through to `RunChatCommand` in [`/lua/ui/notify/commands.lua`](../../notify/commands.lua) ([chat.lua:729](../chat.lua)), so `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay` all worked from chat. The new [`ChatCommandRegistry`](commands/ChatCommandRegistry.lua) dispatcher does not call `RunChatCommand`, so those commands are dead. + +## Command history recall (↑ / ↓) + +Legacy kept a `commandHistory` ring and recalled it on `VK_UP` / `VK_DOWN` ([chat.lua:681-701](../chat.lua)). Not ported. The new [`ChatEditInterface.OnNonTextKeyPressed`](ChatEditInterface.lua) only handles `PgUp` / `PgDn`. + +## Shift-Enter → allies hotkey + +Legacy `ActivateChat(modifiers)` ([chat.lua:924-933](../chat.lua)) opened the window with the recipient forced to allies when Shift was held — the primary way to toggle between `all` and `allies` mid-typing. [`keyactions.lua` `chat_window`](../../../keymap/keyactions.lua) only toggles visibility; the modifier argument is gone. + +[`ChatController.ApplyDefaultRecipient`](ChatController.lua) reads the `send_type` preference but ignores any caller-supplied modifier. + +## Drag / resize / window-state + +Smaller things still missing on the window itself: + +- **Pin button** — gated on feed-mode port. +- **`OnMoveSet` / `OnResizeSet` focus grab** — the legacy window re-acquired edit focus after moves ([chat.lua:1124, 1131-1135](../chat.lua)). [`ChatInterface.OnResizeSet`](ChatInterface.lua) does not. +- **Button tooltips** — `chat_pin`, `chat_config`, `chat_close`, `chat_camera`, `chat_reset` ([chat.lua:1200, 1211-1214](../chat.lua)) — none attached in the new tree. +- **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.lua:1202-1209](../chat.lua)). Not ported. + +## Army / observer markers + +Per-line visuals mostly work (team-colour square + faction icon via [`ChatLineInterface.SetHeader`](ChatLineInterface.lua)). Missing: + +- **Own-army name disable** — legacy `line.name:Disable()` greyed the sender name on your own messages ([chat.lua:409-413](../chat.lua)). + +## Legacy public API with no replacement + +If any external mod still calls these (no in-tree callers remain), they will break: + +- `GUI` table (chat window handles) +- `ChatLines` +- `ReceiveChat` / `ReceiveChatFromSim` — migrate to [`ChatController.OnReceive`](ChatController.lua) +- `SetupChatLayout` +- `OnNISBegin` +- `ChatPageUp` / `ChatPageDown` — migrate to [`ChatInterface.OpenAndScrollLines`](ChatInterface.lua) +- `CloseChatConfig` — migrate to [`ChatConfigInterface.Close`](config/ChatConfigInterface.lua) +- `CloseChat` +- `AddChatOptionSetCallback` +- `SetLayout` +- `GetArmyData` (the one defined in chat.lua; several other copies exist elsewhere) + +--- + +## Already closed (do not re-list) + +Send path, receive path, `FindClients`, controller `Init`, external importers, skin-layout orphan, `ConsoleOutput` sim-side logging, empty-text Enter. From a16cb74767c873f976d269b9c5656239dade7810 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 21:17:22 +0200 Subject: [PATCH 037/130] Take over the engine events of the chat --- .claude/settings.local.json | 7 + lua/ui/game/chat.legacy.lua | 1565 ++++++++++++++++++++++++++ lua/ui/game/chat.lua | 1569 +-------------------------- lua/ui/game/chat/CHANGES.md | 13 + lua/ui/game/chat/ChatController.lua | 32 + lua/ui/game/chat/GAPS.md | 8 +- lua/ui/game/gamemain.lua | 1 + 7 files changed, 1625 insertions(+), 1570 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 lua/ui/game/chat.legacy.lua diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..5093c7aa7bb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git mv *)" + ] + } +} diff --git a/lua/ui/game/chat.legacy.lua b/lua/ui/game/chat.legacy.lua new file mode 100644 index 00000000000..2914fa82dfd --- /dev/null +++ b/lua/ui/game/chat.legacy.lua @@ -0,0 +1,1565 @@ +local UiUtilsS = import("/lua/uiutilssorian.lua") +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local EffectHelpers = import("/lua/maui/effecthelpers.lua") +local Group = import("/lua/maui/group.lua").Group +local Checkbox = import("/lua/ui/controls/checkbox.lua").Checkbox +local Button = import("/lua/maui/button.lua").Button +local Text = import("/lua/maui/text.lua").Text +local Edit = import("/lua/maui/edit.lua").Edit +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local ItemList = import("/lua/maui/itemlist.lua").ItemList +local Window = import("/lua/maui/window.lua").Window +local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo +local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider +local Prefs = import("/lua/user/prefs.lua") +local Dragger = import("/lua/maui/dragger.lua").Dragger +local Tooltip = import("/lua/ui/game/tooltip.lua") +local UIMain = import("/lua/ui/uimain.lua") +--[[ LOC Strings +To %s: +Chat (%d - %d of %d lines) +--]] + +local AddUnicodeCharToEditText = import("/lua/utf.lua").AddUnicodeCharToEditText + +local CHAT_INACTIVITY_TIMEOUT = 15 -- in seconds +local savedParent = false +local chatHistory = {} + +local commandHistory = {} + +local ChatTo = import("/lua/lazyvar.lua").Create() + +local defOptions = { all_color = 1, + allies_color = 2, + priv_color = 3, + link_color = 4, + notify_color = 8, + font_size = 14, + fade_time = 15, + win_alpha = 1, + feed_background = false, + feed_persist = true} + +local ChatOptions = Prefs.GetFieldFromCurrentProfile("chatoptions") or {} +for option, value in defOptions do + if ChatOptions[option] == nil then + ChatOptions[option] = value + end +end + +GUI = import("/lua/ui/controls.lua").Get() +GUI.chatLines = GUI.chatLines or {} + +local FactionsIcon = {} +local Factions = import("/lua/factions.lua").Factions +for k, FactionData in Factions do + table.insert(FactionsIcon, FactionData.Icon) +end +table.insert(FactionsIcon, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') + + +local chatColors = {'ffffffff', 'ffff4242', 'ffefff42','ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42'} + +local ToStrings = { + to = {text = 'to', caps = 'To', colorkey = 'all_color'}, + allies = {text = 'to allies:', caps = 'To Allies:', colorkey = 'allies_color'}, + all = {text = 'to all:', caps = 'To All:', colorkey = 'all_color'}, + private = {text = 'to you:', caps = 'To You:', colorkey = 'priv_color'}, + notify = {text = 'to allies:', caps = 'To Allies:', colorkey = 'notify_color'}, +} + +function SetLayout() + import(UIUtil.GetLayoutFilename('chat')).SetLayout() +end + +function CreateChatBackground() + local location = {Top = function() return GetFrame(0).Bottom() - LayoutHelpers.ScaleNumber(393) end, + Left = function() return GetFrame(0).Left() + LayoutHelpers.ScaleNumber(8) end, + Right = function() return GetFrame(0).Left() + LayoutHelpers.ScaleNumber(430) end, + Bottom = function() return GetFrame(0).Bottom() - LayoutHelpers.ScaleNumber(238) end} + local bg = Window(GetFrame(0), '', nil, true, true, nil, nil, 'chat_window', location) + bg.Depth:Set(200) + + bg.DragTL = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) + bg.DragTR = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) + bg.DragBL = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) + bg.DragBR = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) + + local controlMap = { + tl = {bg.DragTL}, + tr = {bg.DragTR}, + bl = {bg.DragBL}, + br = {bg.DragBR}, + mr = {bg.DragBR,bg.DragTR}, + ml = {bg.DragBL,bg.DragTL}, + tm = {bg.DragTL,bg.DragTR}, + bm = {bg.DragBL,bg.DragBR}, + } + + bg.RolloverHandler = function(control, event, xControl, yControl, cursor, controlID) + if bg._lockSize then return end + local styles = import("/lua/maui/window.lua").styles + if not bg._sizeLock then + if event.Type == 'MouseEnter' then + if controlMap[controlID] then + for _, control in controlMap[controlID] do + control:SetTexture(control.textures.over) + end + end + GetCursor():SetTexture(styles.cursorFunc(cursor)) + elseif event.Type == 'MouseExit' then + if controlMap[controlID] then + for _, control in controlMap[controlID] do + control:SetTexture(control.textures.up) + end + end + GetCursor():Reset() + elseif event.Type == 'ButtonPress' then + if controlMap[controlID] then + for _, control in controlMap[controlID] do + control:SetTexture(control.textures.down) + end + end + bg.StartSizing(event, xControl, yControl) + bg._sizeLock = true + end + end + end + + bg.OnResizeSet = function(control) + bg.DragTL:SetTexture(bg.DragTL.textures.up) + bg.DragTR:SetTexture(bg.DragTR.textures.up) + bg.DragBL:SetTexture(bg.DragBL.textures.up) + bg.DragBR:SetTexture(bg.DragBR.textures.up) + end + + LayoutHelpers.AtLeftTopIn(bg.DragTL, bg, -26, -6) + bg.DragTL.Depth:Set(220) + bg.DragTL:DisableHitTest() + + LayoutHelpers.AtRightTopIn(bg.DragTR, bg, -22, -8) + bg.DragTR.Depth:Set(bg.DragTL.Depth) + bg.DragTR:DisableHitTest() + + LayoutHelpers.AtLeftBottomIn(bg.DragBL, bg, -26, -8) + bg.DragBL.Depth:Set(bg.DragTL.Depth) + bg.DragBL:DisableHitTest() + + LayoutHelpers.AtRightBottomIn(bg.DragBR, bg, -22, -8) + bg.DragBR.Depth:Set(bg.DragTL.Depth) + bg.DragBR:DisableHitTest() + + bg.ResetPositionBtn = Button(bg, + UIUtil.SkinnableFile('/game/menu-btns/default_btn_up.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_down.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_over.dds'), + UIUtil.SkinnableFile('/game/menu-btns/default_btn_dis.dds')) + LayoutHelpers.LeftOf(bg.ResetPositionBtn, bg._configBtn) + bg.ResetPositionBtn.Depth:Set(function() return bg.Depth() + 10 end) + bg.ResetPositionBtn.OnClick = function(self, modifiers) + for index, position in location do + local i = index + local pos = position + bg[i]:Set(pos) + end + CreateChatLines() + bg:SaveWindowLocation() + end + + Tooltip.AddButtonTooltip(bg.ResetPositionBtn, 'chat_reset') + + bg:SetMinimumResize(400, 160) + return bg +end + +function CreateChatLines() + local function CreateChatLine() + local line = Group(GUI.chatContainer) + + -- Draw the faction icon with a colour representing the team behind it. + line.teamColor = Bitmap(line) + line.teamColor:SetSolidColor('00000000') + line.teamColor.Height:Set(line.Height) + line.teamColor.Width:Set(line.Height) + LayoutHelpers.AtLeftTopIn(line.teamColor, line) + + line.factionIcon = Bitmap(line.teamColor) + line.factionIcon:SetSolidColor('00000000') + LayoutHelpers.FillParent(line.factionIcon, line.teamColor) + + -- Player name + line.name = UIUtil.CreateText(line, '', ChatOptions.font_size, "Arial Bold") + LayoutHelpers.CenteredRightOf(line.name, line.teamColor, 4) + line.name.Depth:Set(function() return line.Depth() + 10 end) + line.name:SetColor('ffffffff') + line.name:DisableHitTest() + line.name:SetDropShadow(true) + line.name.HandleEvent = function(self, event) + if event.Type == 'ButtonPress' then + if line.chatID then + if GUI.bg:IsHidden() then GUI.bg:Show() end + ChatTo:Set(line.chatID) + if GUI.chatEdit.edit then + GUI.chatEdit.edit:AcquireFocus() + end + if GUI.chatEdit.private then + GUI.chatEdit.private:SetCheck(true) + end + end + end + end + + line.text = UIUtil.CreateText(line, '', ChatOptions.font_size, "Arial") + line.text.Depth:Set(function() return line.Depth() + 10 end) + line.text.Left:Set(function() return line.name.Right() + 2 end) + line.text.Right:Set(line.Right) + line.text:SetClipToWidth(true) + line.text:DisableHitTest() + line.text:SetColor('ffc2f6ff') + line.text:SetDropShadow(true) + LayoutHelpers.AtVerticalCenterIn(line.text, line.teamColor) + line.text.HandleEvent = function(self, event) + if event.Type == 'ButtonPress' then + if line.cameraData then + GetCamera('WorldCamera'):RestoreSettings(line.cameraData) + end + end + end + + -- A background for the line that persists after the chat panel is closed (to help with + -- readability against the simulation) + line.lineStickybg = Bitmap(line) + line.lineStickybg:DisableHitTest() + line.lineStickybg:SetSolidColor('aa000000') + LayoutHelpers.FillParent(line.lineStickybg, line) + LayoutHelpers.DepthUnderParent(line.lineStickybg, line) + line.lineStickybg:Hide() + + return line + end + if GUI.chatContainer then + local curEntries = table.getsize(GUI.chatLines) + local neededEntries = math.floor(GUI.chatContainer.Height() / (GUI.chatLines[1].Height() + 0)) + if curEntries - neededEntries == 0 then + return + elseif curEntries - neededEntries < 0 then + for i = curEntries + 1, neededEntries do + local index = i + GUI.chatLines[index] = CreateChatLine() + LayoutHelpers.Below(GUI.chatLines[index], GUI.chatLines[index-1], 0) + GUI.chatLines[index].Height:Set(function() return GUI.chatLines[index].name.Height() + 2 end) + GUI.chatLines[index].Right:Set(GUI.chatContainer.Right) + end + elseif curEntries - neededEntries > 0 then + for i = neededEntries + 1, curEntries do + if GUI.chatLines[i] then + GUI.chatLines[i]:Destroy() + GUI.chatLines[i] = nil + end + end + end + else + local clientArea = GUI.bg:GetClientGroup() + GUI.chatContainer = Group(clientArea) + LayoutHelpers.AtLeftIn(GUI.chatContainer, clientArea, 10) + LayoutHelpers.AtTopIn(GUI.chatContainer, clientArea, 2) + LayoutHelpers.AtRightIn(GUI.chatContainer, clientArea, 38) + LayoutHelpers.AnchorToTop(GUI.chatContainer, GUI.chatEdit, 10) + + SetupChatScroll() + + if not GUI.chatLines[1] then + GUI.chatLines[1] = CreateChatLine() + LayoutHelpers.AtLeftTopIn(GUI.chatLines[1], GUI.chatContainer, 0, 0) + GUI.chatLines[1].Height:Set(function() return GUI.chatLines[1].name.Height() + 2 end) + GUI.chatLines[1].Right:Set(GUI.chatContainer.Right) + end + local index = 1 + while GUI.chatLines[index].Bottom() + GUI.chatLines[1].Height() < GUI.chatContainer.Bottom() do + index = index + 1 + if not GUI.chatLines[index] then + GUI.chatLines[index] = CreateChatLine() + LayoutHelpers.Below(GUI.chatLines[index], GUI.chatLines[index-1], 0) + GUI.chatLines[index].Height:Set(function() return GUI.chatLines[index].name.Height() + 2 end) + GUI.chatLines[index].Right:Set(GUI.chatContainer.Right) + end + end + end +end + +function OnNISBegin() + CloseChat() +end + +function SetupChatScroll() + GUI.chatContainer.top = 1 + GUI.chatContainer.scroll = UIUtil.CreateVertScrollbarFor(GUI.chatContainer) + + local numLines = function() return table.getsize(GUI.chatLines) end + GUI.chatContainer.prevtabsize = 0 + GUI.chatContainer.prevsize = 0 + + local function IsValidEntry(entryData) + if entryData.camera then + return ChatOptions.links and ChatOptions[entryData.armyID] + end + + return ChatOptions[entryData.armyID] + end + + local function DataSize() + if GUI.chatContainer.prevtabsize ~= table.getn(chatHistory) then + local size = 0 + for i, v in chatHistory do + if IsValidEntry(v) then + size = size + table.getn(v.wrappedtext) + end + end + GUI.chatContainer.prevtabsize = table.getn(chatHistory) + GUI.chatContainer.prevsize = size + end + return GUI.chatContainer.prevsize + end + + -- called when the scrollbar for the control requires data to size itself + -- GetScrollValues must return 4 values in this order: + -- rangeMin, rangeMax, visibleMin, visibleMax + -- aixs can be "Vert" or "Horz" + GUI.chatContainer.GetScrollValues = function(self, axis) + local size = DataSize() + --LOG(size, ":", self.top, ":", math.min(self.top + numLines(), size)) + return 1, size, self.top, math.min(self.top + numLines(), size) + end + + -- called when the scrollbar wants to scroll a specific number of lines (negative indicates scroll up) + GUI.chatContainer.ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta)) + end + + -- called when the scrollbar wants to scroll a specific number of pages (negative indicates scroll up) + GUI.chatContainer.ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta) * numLines()) + end + + -- called when the scrollbar wants to set a new visible top line + GUI.chatContainer.ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.top then return end + local size = DataSize() + self.top = math.max(math.min(size - numLines()+1, top), 1) + self:CalcVisible() + end + + -- called to determine if the control is scrollable on a particular access. Must return true or false. + GUI.chatContainer.IsScrollable = function(self, axis) + return true + end + + GUI.chatContainer.ScrollToBottom = function(self) + --LOG(DataSize()) + GUI.chatContainer:ScrollSetTop(nil, DataSize()) + end + + -- determines what controls should be visible or not + GUI.chatContainer.CalcVisible = function(self) + GUI.bg.curTime = 0 + local index = 1 + local tempTop = self.top + local curEntry = 1 + local curTop = 1 + local tempsize = 0 + + if GUI.bg:IsHidden() then + tempTop = math.max(DataSize() - numLines()+1, 1) + end + + for i, v in chatHistory do + if IsValidEntry(v) then + if tempsize + table.getsize(v.wrappedtext) < tempTop then + tempsize = tempsize + table.getsize(v.wrappedtext) + else + curEntry = i + for h, x in v.wrappedtext do + if h + tempsize == tempTop then + curTop = h + break + end + end + break + end + end + end + while GUI.chatLines[index] do + local line = GUI.chatLines[index] + + if not chatHistory[curEntry].wrappedtext[curTop] then + if chatHistory[curEntry].new then chatHistory[curEntry].new = nil end + curTop = 1 + curEntry = curEntry + 1 + while chatHistory[curEntry] and not IsValidEntry(chatHistory[curEntry]) do + curEntry = curEntry + 1 + end + end + if chatHistory[curEntry] then + local Index = index + if curTop == 1 then + line.name:SetText(chatHistory[curEntry].name) + if chatHistory[curEntry].armyID == GetFocusArmy() then + line.name:Disable() + else + line.name:Enable() + end + line.text:SetText(chatHistory[curEntry].wrappedtext[curTop] or "") + line.teamColor:SetSolidColor(chatHistory[curEntry].color) + line.factionIcon:SetTexture(UIUtil.UIFile(FactionsIcon[chatHistory[curEntry].faction])) + line.IsTop = true + line.chatID = chatHistory[curEntry].armyID + if chatHistory[curEntry].camera and not line.camIcon then + line.camIcon = Bitmap(line.text, UIUtil.UIFile('/game/camera-btn/pinned_btn_up.dds')) + LayoutHelpers.SetDimensions(line.camIcon, 20, 16) + LayoutHelpers.AtVerticalCenterIn(line.camIcon, line.teamColor) + LayoutHelpers.RightOf(line.camIcon, line.name, 4) + LayoutHelpers.RightOf(line.text, line.camIcon, 4) + elseif not chatHistory[curEntry].camera and line.camIcon then + line.camIcon:Destroy() + line.camIcon = false + LayoutHelpers.RightOf(line.text, line.name, 2) + end + else + line.name:Disable() + line.name:SetText('') + line.text:SetText(chatHistory[curEntry].wrappedtext[curTop] or "") + line.teamColor:SetSolidColor('00000000') + line.factionIcon:SetSolidColor('00000000') + line.IsTop = false + if line.camIcon then + line.camIcon:Destroy() + line.camIcon = false + LayoutHelpers.RightOf(line.text, line.name, 2) + end + end + if chatHistory[curEntry].camera then + line.cameraData = chatHistory[curEntry].camera + line.text:Enable() + line.text:SetColor(chatColors[ChatOptions.link_color]) + else + line.text:Disable() + line.text:SetColor('ffc2f6ff') + line.text:SetColor(chatColors[ChatOptions[chatHistory[curEntry].tokey]]) + end + + line.EntryID = curEntry + + if GUI.bg:IsHidden() then + + line.curHistory = chatHistory[curEntry] + if line.curHistory.new or line.curHistory.time == nil then + line.curHistory.time = 0 + end + + if line.curHistory.time < ChatOptions.fade_time then + line:Show() + + UIUtil.setVisible(line.lineStickybg, ChatOptions.feed_background) + + if line.name:GetText() == '' then + line.teamColor:Hide() + end + if line.curHistory.wrappedtext[curTop+1] == nil then + line.OnFrame = function(self, delta) + self.curHistory.time = self.curHistory.time + delta + if self.curHistory.time > ChatOptions.fade_time then + if GUI.bg:IsHidden() then + self:Hide() + end + self:SetNeedsFrameUpdate(false) + end + end + -- Don't increment time on lines with wrapped text + else + line.OnFrame = function(self, delta) + if self.curHistory.time > ChatOptions.fade_time then + if GUI.bg:IsHidden() then + self:Hide() + end + self:SetNeedsFrameUpdate(false) + end + end + end + line:SetNeedsFrameUpdate(true) + end + + end + else + line.name:Disable() + line.name:SetText('') + line.text:SetText('') + line.teamColor:SetSolidColor('00000000') + end + line:SetAlpha(ChatOptions.win_alpha, true) + curTop = curTop + 1 + index = index + 1 + end + if chatHistory[curEntry].new then chatHistory[curEntry].new = nil end + end +end + +function FindClients(id) + local t = GetArmiesTable() + local focus = t.focusArmy + local result = {} + if focus == -1 then + for index,client in GetSessionClients() do + if not client.connected then + continue + end + local playerIsObserver = true + for id, player in GetArmiesTable().armiesTable do + if player.outOfGame and player.human and player.nickname == client.name then + table.insert(result, index) + playerIsObserver = false + break + elseif player.nickname == client.name then + playerIsObserver = false + break + end + end + if playerIsObserver then + table.insert(result, index) + end + end + else + local srcs = {} + for army,info in t.armiesTable do + if id then + if army == id then + for k,cmdsrc in info.authorizedCommandSources do + srcs[cmdsrc] = true + end + break + end + else + if IsAlly(focus, army) then + for k,cmdsrc in info.authorizedCommandSources do + srcs[cmdsrc] = true + end + end + end + end + for index,client in GetSessionClients() do + for k,cmdsrc in client.authorizedCommandSources do + if srcs[cmdsrc] then + table.insert(result, index) + break + end + end + end + end + return result +end + +local RunChatCommand = import("/lua/ui/notify/commands.lua").RunChatCommand +function CreateChatEdit() + local parent = GUI.bg:GetClientGroup() + local group = Group(parent) + + group.Bottom:Set(parent.Bottom) + group.Right:Set(parent.Right) + group.Left:Set(parent.Left) + group.Top:Set(function() return group.Bottom() - group.Height() end) + + local toText = UIUtil.CreateText(group, '', 14, 'Arial') + LayoutHelpers.AtBottomIn(toText, group, 1) + LayoutHelpers.AtLeftIn(toText, group, 35) + + ChatTo.OnDirty = function(self) + if ToStrings[self()] then + toText:SetText(LOC(ToStrings[self()].caps)) + else + toText:SetText(LOCF('%s %s:', ToStrings['to'].caps, GetArmyData(self()).nickname)) + end + end + + group.edit = Edit(group) + LayoutHelpers.AnchorToRight(group.edit, toText, 5) + LayoutHelpers.AtRightIn(group.edit, group, 38) + group.edit.Depth:Set(function() return GUI.bg:GetClientGroup().Depth() + 200 end) + LayoutHelpers.AtBottomIn(group.edit, group, 1) + group.edit.Height:Set(function() return group.edit:GetFontHeight() end) + UIUtil.SetupEditStd(group.edit, "ff00ff00", nil, "ffffffff", UIUtil.highlightColor, UIUtil.bodyFont, 14, 200) + group.edit:SetDropShadow(true) + group.edit:ShowBackground(false) + + group.edit:SetText('') + + group.Height:Set(function() return group.edit.Height() end) + + local function CreateTestBtn(text) + local btn = UIUtil.CreateCheckbox(group, '/dialogs/toggle_btn/toggle') + btn.Depth:Set(function() return group.Depth() + 10 end) + btn.OnClick = function(self, modifiers) + if self._checkState == "unchecked" then + self:ToggleCheck() + end + end + btn.txt = UIUtil.CreateText(btn, text, 12, UIUtil.bodyFont) + LayoutHelpers.AtCenterIn(btn.txt, btn) + btn.txt:SetColor('ffffffff') + btn.txt:DisableHitTest() + return btn + end + + group.camData = Checkbox(group, + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_up.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_down.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds')) + + LayoutHelpers.AtRightIn(group.camData, group, 5) + LayoutHelpers.AtVerticalCenterIn(group.camData, group.edit, -1) + + group.chatBubble = Button(group, + UIUtil.UIFile('/game/chat-box_btn/radio_btn_up.dds'), + UIUtil.UIFile('/game/chat-box_btn/radio_btn_down.dds'), + UIUtil.UIFile('/game/chat-box_btn/radio_btn_over.dds'), + UIUtil.UIFile('/game/chat-box_btn/radio_btn_dis.dds')) + group.chatBubble.OnClick = function(self, modifiers) + if not self.list then + self.list = CreateChatList(self) + LayoutHelpers.Above(self.list, self, 15) + LayoutHelpers.AtLeftIn(self.list, self, 15) + else + self.list:Destroy() + self.list = nil + end + end + + toText.HandleEvent = function(self, event) + if event.Type == 'ButtonPress' then + group.chatBubble:OnClick(event.Modifiers) + end + end + + LayoutHelpers.AtLeftIn(group.chatBubble, group, 3) + LayoutHelpers.AtVerticalCenterIn(group.chatBubble, group.edit) + + group.edit.OnNonTextKeyPressed = function(self, charcode, event) + if AddUnicodeCharToEditText(self, charcode) then + return + end + GUI.bg.curTime = 0 + local function RecallCommand(entryNumber) + self:SetText(commandHistory[self.recallEntry].text) + if commandHistory[self.recallEntry].camera then + self.tempCam = commandHistory[self.recallEntry].camera + group.camData:Disable() + group.camData:SetCheck(true) + else + self.tempCam = nil + group.camData:Enable() + group.camData:SetCheck(false) + end + end + if charcode == UIUtil.VK_NEXT then + local mod = 10 + if event.Modifiers.Shift then + mod = 1 + end + ChatPageDown(mod) + return true + elseif charcode == UIUtil.VK_PRIOR then + local mod = 10 + if event.Modifiers.Shift then + mod = 1 + end + ChatPageUp(mod) + return true + elseif charcode == UIUtil.VK_UP then + if not table.empty(commandHistory) then + if self.recallEntry then + self.recallEntry = math.max(self.recallEntry-1, 1) + else + self.recallEntry = table.getsize(commandHistory) + end + RecallCommand(self.recallEntry) + end + elseif charcode == UIUtil.VK_DOWN then + if not table.empty(commandHistory) then + if self.recallEntry then + self.recallEntry = math.min(self.recallEntry+1, table.getsize(commandHistory)) + RecallCommand(self.recallEntry) + if self.recallEntry == table.getsize(commandHistory) then + self.recallEntry = nil + end + else + self:SetText('') + end + end + else + return true + end + end + + group.edit.OnCharPressed = function(self, charcode) + local charLim = self:GetMaxChars() + if charcode == 9 then + return true + end + GUI.bg.curTime = 0 + if STR_Utf8Len(self:GetText()) >= charLim then + local sound = Sound({Cue = 'UI_Menu_Error_01', Bank = 'Interface',}) + PlaySound(sound) + end + end + + group.edit.OnEnterPressed = function(self, text) + -- Analyse for any commands entered for Notify toggling + if string.len(text) > 1 and string.sub(text, 1, 1) == "/" then + local args = {} + + for word in string.gfind(string.sub(text, 2), "%S+") do + table.insert(args, string.lower(word)) + end + + -- We've done the command, exit without sending the message to other players + if RunChatCommand(args) then + return + end + end + + GUI.bg.curTime = 0 + if group.camData:IsDisabled() then + group.camData:Enable() + end + if text == "" then + ToggleChat() + else + local gnBegin, gnEnd = string.find(text, "%s+") + if gnBegin and (gnBegin == 1 and gnEnd == string.len(text)) then + return + end + if import("/lua/ui/game/taunt.lua").CheckForAndHandleTaunt(text) then + return + end + + msg = { to = ChatTo(), Chat = true } + if self.tempCam then + msg.camera = self.tempCam + elseif group.camData:IsChecked() then + msg.camera = GetCamera('WorldCamera'):SaveSettings() + end + msg.text = text + if ChatTo() == 'allies' then + if GetFocusArmy() ~= -1 then + SessionSendChatMessage(FindClients(), msg) + else + msg.Observer = true + SessionSendChatMessage(FindClients(), msg) + end + elseif type(ChatTo()) == 'number' then + if GetFocusArmy() ~= -1 then + SessionSendChatMessage(FindClients(ChatTo()), msg) + msg.echo = true + msg.from = GetArmyData(GetFocusArmy()).nickname + ReceiveChat(GetArmyData(ChatTo()).nickname, msg) + end + else + if GetFocusArmy() == -1 then + msg.Observer = true + SessionSendChatMessage(FindClients(), msg) + else + SessionSendChatMessage(msg) + end + end + table.insert(commandHistory, msg) + self.recallEntry = nil + self.tempCam = nil + end + end + + ChatTo:Set('all') + group.edit:AcquireFocus() + + return group +end + +function ChatPageUp(mod) + if GUI.bg:IsHidden() then + ForkThread(ToggleChat) + else + local newTop = GUI.chatContainer.top - mod + GUI.chatContainer:ScrollSetTop(nil, newTop) + end +end + +function ChatPageDown(mod) + local oldTop = GUI.chatContainer.top + local newTop = GUI.chatContainer.top + mod + GUI.chatContainer:ScrollSetTop(nil, newTop) + if GUI.bg:IsHidden() or oldTop == GUI.chatContainer.top then + ForkThread(ToggleChat) + end +end + +function ReceiveChat(sender, msg) + if not msg.ConsoleOutput then + SimCallback({Func="GiveResourcesToPlayer", Args={ From=GetFocusArmy(), To=GetFocusArmy(), Mass=0, Energy=0, Sender=sender, Msg=msg},} , true) + end + if not SessionIsReplay() then + ReceiveChatFromSim(sender, msg) + end + +end + +function ReceiveChatFromSim(sender, msg) + sender = sender or "nil sender" + if msg.ConsoleOutput then + print(LOCF("%s %s", sender, msg.ConsoleOutput)) + return + end + + if not msg.Chat then + return + end + + if msg.to == 'notify' and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then + return + end + + if type(msg) == 'string' then + msg = { text = msg } + elseif type(msg) ~= 'table' then + msg = { text = repr(msg) } + end + + local armyData = GetArmyData(sender) + if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then + return + end + + local towho = LOC(ToStrings[msg.to].text) or LOC(ToStrings['private'].text) + local tokey = ToStrings[msg.to].colorkey or ToStrings['private'].colorkey + if msg.Observer then + towho = LOC("to observers:") + tokey = "link_color" + if armyData.faction then + armyData.faction = table.getn(FactionsIcon) - 1 + end + end + + if type(msg.to) == 'number' and SessionIsReplay() then + towho = string.format("%s %s:", LOC(ToStrings.to.text), GetArmyData(msg.to).nickname) + end + local name = sender .. ' ' .. towho + + if msg.echo then + if msg.from and SessionIsReplay() then + name = string.format("%s %s %s:", msg.from, LOC(ToStrings.to.text), GetArmyData(msg.to).nickname) + else + name = string.format("%s %s:", LOC(ToStrings.to.caps), sender) + end + end + local tempText = WrapText({text = msg.text, name = name}) + -- if text wrap produces no lines (ie text is all white space) then add a blank line + if table.empty(tempText) then + tempText = {""} + end + local entry = { + name = name, + tokey = tokey, + color = (armyData.color or "ffffffff"), + armyID = (armyData.ArmyID or 1), + faction = (armyData.faction or (table.getn(FactionsIcon)-1))+1, + text = msg.text, + wrappedtext = tempText, + new = true, + camera = msg.camera + } + + table.insert(chatHistory, entry) + if ChatOptions[entry.armyID] then + if table.getsize(chatHistory) == 1 then + GUI.chatContainer:CalcVisible() + else + GUI.chatContainer:ScrollToBottom() + end + end +end + +function ToggleChat() + if GUI.bg:IsHidden() then + GUI.bg:Show() + GUI.chatEdit.edit:AcquireFocus() + if not GUI.bg.pinned then + GUI.bg:SetNeedsFrameUpdate(true) + GUI.bg.curTime = 0 + end + for i, v in GUI.chatLines do + v:SetNeedsFrameUpdate(false) + v:Show() + v.lineStickybg:Hide() + end + GUI.chatContainer:CalcVisible() + else + GUI.bg:Hide() + GUI.chatEdit.edit:AbandonFocus() + GUI.bg:SetNeedsFrameUpdate(false) + + if ChatOptions.feed_persist then + GUI.chatContainer:CalcVisible() + else + for i, v in GUI.chatLines do + if v.curHistory and v.curHistory.time ~= nil then + v.curHistory.time = ChatOptions.fade_time + 1 + end + end + end + end +end + +function ActivateChat(modifiers) + if type(ChatTo()) ~= 'number' then + if (not modifiers.Shift) == (ChatOptions['send_type'] or false) then + ChatTo:Set('allies') + else + ChatTo:Set('all') + end + end + ToggleChat() +end + +--------------------- +--Added for sorian ai +--------------------- + +function CreateChatList(parent) + local armies = GetArmiesTable() + local container = Group(GUI.chatEdit) + container:DisableHitTest() + container.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) + container.entries = {} + local function CreatePlayerEntry(data) + if not data.human and not data.civilian then + data.nickname = UiUtilsS.trim(string.gsub(data.nickname,'%b()', '')) + end + local text = UIUtil.CreateText(container, data.nickname, 12, "Arial") + text:SetColor('ffffffff') + text:DisableHitTest() + + text.BG = Bitmap(text) + text.BG:SetSolidColor('ff000000') + text.BG.Depth:Set(function() return text.Depth() - 1 end) + text.BG.Left:Set(function() return text.Left() - 6 end) + text.BG.Top:Set(function() return text.Top() - 1 end) + text.BG.Width:Set(function() return container.Width() + 8 end) + text.BG.Bottom:Set(function() return text.Bottom() + 1 end) + + text.BG.HandleEvent = function(self, event) + if event.Type == 'MouseEnter' then + self:SetSolidColor('ff666666') + elseif event.Type == 'MouseExit' then + self:SetSolidColor('ff000000') + elseif event.Type == 'ButtonPress' then + ChatTo:Set(data.armyID) + container:Destroy() + parent.list = nil + GUI.chatEdit.edit:Enable() + GUI.chatEdit.edit:AcquireFocus() + end + GUI.bg.curTime = 0 + end + return text + end + + local entries = { + {nickname = ToStrings.all.caps, armyID = 'all'}, + {nickname = ToStrings.allies.caps, armyID = 'allies'}, + } + + for armyID, armyData in armies.armiesTable do + if armyID ~= armies.focusArmy and not armyData.civilian then + table.insert(entries, {nickname = armyData.nickname, armyID = armyID}) + end + end + + local maxWidth = 0 + local height = 0 + for index, data in entries do + local i = index + table.insert(container.entries, CreatePlayerEntry(data)) + if container.entries[i].Width() > maxWidth then + maxWidth = container.entries[i].Width() + 8 + end + height = height + container.entries[i].Height() + if i > 1 then + LayoutHelpers.Above(container.entries[i], container.entries[i-1]) + else + LayoutHelpers.AtLeftIn(container.entries[i], container) + LayoutHelpers.AtBottomIn(container.entries[i], container) + end + end + container.Width:Set(maxWidth + 40) + container.Height:Set(height) + + container.LTBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) + container.LTBG:DisableHitTest() + container.LTBG.Right:Set(container.Left) + container.LTBG.Bottom:Set(container.Top) + + container.RTBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ur.dds')) + container.RTBG:DisableHitTest() + container.RTBG.Left:Set(container.Right) + container.RTBG.Bottom:Set(container.Top) + + container.RBBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lr.dds')) + container.RBBG:DisableHitTest() + container.RBBG.Left:Set(container.Right) + container.RBBG.Top:Set(container.Bottom) + + container.RLBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ll.dds')) + container.RLBG:DisableHitTest() + container.RLBG.Right:Set(container.Left) + container.RLBG.Top:Set(container.Bottom) + + container.LBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_l.dds')) + container.LBG:DisableHitTest() + container.LBG.Right:Set(container.Left) + container.LBG.Top:Set(container.Top) + container.LBG.Bottom:Set(container.Bottom) + + container.RBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_r.dds')) + container.RBG:DisableHitTest() + container.RBG.Left:Set(container.Right) + container.RBG.Top:Set(container.Top) + container.RBG.Bottom:Set(container.Bottom) + + container.TBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_horz_um.dds')) + container.TBG:DisableHitTest() + container.TBG.Left:Set(container.Left) + container.TBG.Right:Set(container.Right) + container.TBG.Bottom:Set(container.Top) + + container.BBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) + container.BBG:DisableHitTest() + container.BBG.Left:Set(container.Left) + container.BBG.Right:Set(container.Right) + container.BBG.Top:Set(container.Bottom) + + function DestroySelf() + parent:OnClick() + end + + UIMain.AddOnMouseClickedFunc(DestroySelf) + + container.OnDestroy = function(self) + UIMain.RemoveOnMouseClickedFunc(DestroySelf) + end + + return container +end + +function SetupChatLayout(mapGroup) + savedParent = mapGroup + CreateChat() + import("/lua/ui/game/gamemain.lua").RegisterChatFunc(ReceiveChat, 'Chat') +end + +---@type table +local OnChatOptionsChangedCallbacks = {} + +--- Adds a callback and calls it with the current chat options. +---@param callback fun(chatOptions: table) +---@param id? string +function AddChatOptionSetCallback(callback, id) + local ok, msg = pcall(callback, ChatOptions) + if not ok then + WARN(string.format('Error with initial run of `ChatOptionSet` callback%s: %s' + , id and string.format(' (id "%s")', tostring(id)) or '' + , msg) + ) + return + end + if id then + OnChatOptionsChangedCallbacks[id] = callback + else + table.insert(OnChatOptionsChangedCallbacks, callback) + end +end + +--- Runs all callbacks using the current chat options. +local function DoChatOptionSetCallbacks() + for id, callback in OnChatOptionsChangedCallbacks do + local ok, msg = pcall(callback, ChatOptions) + if not ok then + WARN(string.format('Error running `ChatOptionSet` callback (id "%s"): %s', tostring(id), msg)) + OnChatOptionsChangedCallbacks[id] = nil + end + end +end + +function CreateChat() + if GUI.bg then + GUI.bg.OnClose() + end + GUI.bg = CreateChatBackground() + GUI.chatEdit = CreateChatEdit() + GUI.bg.OnResize = function(self, x, y, firstFrame) + if firstFrame then + self:SetNeedsFrameUpdate(false) + end + CreateChatLines() + GUI.chatContainer:CalcVisible() + end + GUI.bg.OnResizeSet = function(self) + if not self:IsPinned() then + self:SetNeedsFrameUpdate(true) + end + RewrapLog() + CreateChatLines() + GUI.chatContainer:CalcVisible() + GUI.chatEdit.edit:AcquireFocus() + end + GUI.bg.OnMove = function(self, x, y, firstFrame) + if firstFrame then + self:SetNeedsFrameUpdate(false) + end + end + GUI.bg.OnMoveSet = function(self) + GUI.chatEdit.edit:AcquireFocus() + if not self:IsPinned() then + self:SetNeedsFrameUpdate(true) + end + end + GUI.bg.OnMouseWheel = function(self, rotation) + local newTop = GUI.chatContainer.top - math.floor(rotation / 100) + GUI.chatContainer:ScrollSetTop(nil, newTop) + end + GUI.bg.OnClose = function(self) + ToggleChat() + end + GUI.bg.OnOptionsSet = function(self) + GUI.chatContainer:Destroy() + GUI.chatContainer = false + for i, v in GUI.chatLines do + v:Destroy() + end + GUI.bg:SetAlpha(ChatOptions.win_alpha, true) + GUI.chatLines = {} + CreateChatLines() + RewrapLog() + GUI.chatContainer:CalcVisible() + GUI.chatEdit.edit:AcquireFocus() + if not GUI.bg.pinned then + GUI.bg.curTime = 0 + GUI.bg:SetNeedsFrameUpdate(true) + end + DoChatOptionSetCallbacks() + end + GUI.bg.OnHideWindow = function(self, hidden) + if not hidden then + for i, v in GUI.chatLines do + v:SetNeedsFrameUpdate(false) + end + end + end + GUI.bg.curTime = 0 + GUI.bg.pinned = false + GUI.bg.OnFrame = function(self, delta) + self.curTime = self.curTime + delta + if self.curTime > ChatOptions.fade_time then + ToggleChat() + end + end + GUI.bg.OnPinCheck = function(self, checked) + GUI.bg.pinned = checked + GUI.bg:SetNeedsFrameUpdate(not checked) + GUI.bg.curTime = 0 + GUI.chatEdit.edit:AcquireFocus() + if checked then + Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pinned') + else + Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pin') + end + end + GUI.bg.OnConfigClick = function(self, checked) + if GUI.config then GUI.config:Destroy() GUI.config = false return end + CreateConfigWindow() + GUI.bg:SetNeedsFrameUpdate(false) + + end + for i, v in GetArmiesTable().armiesTable do + if not v.civilian then + ChatOptions[i] = true + end + end + GUI.bg:SetAlpha(ChatOptions.win_alpha, true) + Tooltip.AddButtonTooltip(GUI.bg._closeBtn, 'chat_close') + GUI.bg.OldHandleEvent = GUI.bg.HandleEvent + GUI.bg.HandleEvent = function(self, event) + if event.Type == "WheelRotation" and self:IsHidden() then + import("/lua/ui/game/worldview.lua").ForwardMouseWheelInput(event) + return true + else + return GUI.bg.OldHandleEvent(self, event) + end + end + + Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pin') + Tooltip.AddControlTooltip(GUI.bg._configBtn, 'chat_config') + Tooltip.AddControlTooltip(GUI.bg._closeBtn, 'chat_close') + Tooltip.AddCheckboxTooltip(GUI.chatEdit.camData, 'chat_camera') + + ChatOptions['links'] = ChatOptions.links or true + CreateChatLines() + RewrapLog() + GUI.chatContainer:CalcVisible() + ToggleChat() +end + +function RewrapLog() + local tempSize = 0 + for i, v in chatHistory do + v.wrappedtext = WrapText(v) + tempSize = tempSize + table.getsize(v.wrappedtext) + end + GUI.chatContainer.prevtabsize = 0 + GUI.chatContainer.prevsize = 0 + GUI.chatContainer:ScrollSetTop(nil, tempSize) +end + +function WrapText(data) + return import("/lua/maui/text.lua").WrapText(data.text, + function(line) + local firstLine = GUI.chatLines[1] + if line == 1 then + return firstLine.Right() - (firstLine.name.Left() + firstLine.name:GetStringAdvance(data.name) + 4) + else + return firstLine.Right() - (firstLine.name.Left() + 4) + end + end, + function(text) + return GUI.chatLines[1].text:GetStringAdvance(text) + end) +end + +function GetArmyData(army) + local armies = GetArmiesTable() + local result = nil + if type(army) == 'number' then + if armies.armiesTable[army] then + result = armies.armiesTable[army] + end + elseif type(army) == 'string' then + for i, v in armies.armiesTable do + if v.nickname == army then + result = v + result.ArmyID = i + break + end + end + end + return result +end + +function CloseChat() + if not GUI.bg:IsHidden() then + ToggleChat() + end + if GUI.config then + GUI.config:Destroy() + GUI.config = nil + end +end + +function CreateConfigWindow() + import("/lua/ui/game/multifunction.lua").CloseMapDialog() + local windowTextures = { + tl = UIUtil.SkinnableFile('/game/panel/panel_brd_ul.dds'), + tr = UIUtil.SkinnableFile('/game/panel/panel_brd_ur.dds'), + tm = UIUtil.SkinnableFile('/game/panel/panel_brd_horz_um.dds'), + ml = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_l.dds'), + m = UIUtil.SkinnableFile('/game/panel/panel_brd_m.dds'), + mr = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_r.dds'), + bl = UIUtil.SkinnableFile('/game/panel/panel_brd_ll.dds'), + bm = UIUtil.SkinnableFile('/game/panel/panel_brd_lm.dds'), + br = UIUtil.SkinnableFile('/game/panel/panel_brd_lr.dds'), + borderColor = 'ff415055', + } + + local defPosition = Prefs.GetFieldFromCurrentProfile('chat_config') or nil + GUI.config = Window(GetFrame(0), 'Chat Options', nil, nil, nil, true, true, 'chat_config', defPosition, windowTextures) + GUI.config.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) + Tooltip.AddButtonTooltip(GUI.config._closeBtn, 'chat_close') + LayoutHelpers.AnchorToBottom(GUI.config, GetFrame(0), -700) + LayoutHelpers.SetWidth(GUI.config, 300) + LayoutHelpers.AtHorizontalCenterIn(GUI.config, GetFrame(0)) + LayoutHelpers.ResetRight(GUI.config) + + GUI.config.DragTL = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) + GUI.config.DragTR = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) + GUI.config.DragBL = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) + GUI.config.DragBR = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) + + LayoutHelpers.AtLeftTopIn(GUI.config.DragTL, GUI.config, -24, -8) + + LayoutHelpers.AtRightTopIn(GUI.config.DragTR, GUI.config, -22, -8) + + LayoutHelpers.AtLeftIn(GUI.config.DragBL, GUI.config, -24) + LayoutHelpers.AtBottomIn(GUI.config.DragBL, GUI.config, -8) + + LayoutHelpers.AtRightIn(GUI.config.DragBR, GUI.config, -22) + LayoutHelpers.AtBottomIn(GUI.config.DragBR, GUI.config, -8) + + GUI.config.DragTL.Depth:Set(function() return GUI.config.Depth() + 10 end) + GUI.config.DragTR.Depth:Set(GUI.config.DragTL.Depth) + GUI.config.DragBL.Depth:Set(GUI.config.DragTL.Depth) + GUI.config.DragBR.Depth:Set(GUI.config.DragTL.Depth) + + GUI.config.DragTL:DisableHitTest() + GUI.config.DragTR:DisableHitTest() + GUI.config.DragBL:DisableHitTest() + GUI.config.DragBR:DisableHitTest() + + GUI.config.OnClose = function(self) + GUI.config:Destroy() + GUI.config = false + end + + local options = { + filters = {{type = 'filter', name = 'Links', key = 'links', tooltip = 'chat_filter'}}, + winOptions = { + {type = 'color', name = '', key = 'all_color', tooltip = 'chat_color'}, + {type = 'color', name = '', key = 'allies_color', tooltip = 'chat_color'}, + {type = 'color', name = '', key = 'priv_color', tooltip = 'chat_color'}, + {type = 'color', name = '', key = 'link_color', tooltip = 'chat_color'}, + {type = 'color', name = '', key = 'notify_color', tooltip = 'chat_color'}, + {type = 'splitter'}, + {type = 'slider', name = 'Chat Font Size', key = 'font_size', tooltip = 'chat_fontsize', min = 12, max = 18, inc = 1}, + {type = 'slider', name = 'Window Fade Time', key = 'fade_time', tooltip = 'chat_fadetime', min = 5, max = 30, inc = 1}, + {type = 'slider', name = 'Window Alpha', key = 'win_alpha', tooltip = 'chat_alpha', min = 20, max = 100, inc = 1}, + {type = 'splitter'}, + {type = 'filter', name = 'Default recipient: allies', key = 'send_type', tooltip = 'chat_send_type'}, + {type = 'filter', name = 'Show Feed Background', key = 'feed_background', tooltip = 'chat_feed_background'}, + {type = 'filter', name = 'Persist Feed Timeout', key = 'feed_persist', tooltip = 'chat_feed_persist'}, + }, + } + + local optionGroup = Group(GUI.config:GetClientGroup()) + LayoutHelpers.FillParent(optionGroup, GUI.config:GetClientGroup()) + optionGroup.options = {} + local tempOptions = {} + + local function UpdateOption(key, value) + if key == 'win_alpha' then + value = value / 100 + end + tempOptions[key] = value + end + + local function CreateSplitter() + local splitter = Bitmap(optionGroup) + splitter:SetSolidColor('ff000000') + splitter.Left:Set(optionGroup.Left) + splitter.Right:Set(optionGroup.Right) + splitter.Height:Set(2) + return splitter + end + + local function CreateEntry(data) + local group = Group(optionGroup) + if data.type == 'filter' then + group.check = UIUtil.CreateCheckbox(group, '/dialogs/check-box_btn/', data.name, true) + LayoutHelpers.AtLeftTopIn(group.check, group) + group.check.key = data.key + group.Height:Set(group.check.Height) + group.Width:Set(function() return group.check.Width() end) + group.check.OnCheck = function(self, checked) + UpdateOption(self.key, checked) + end + if ChatOptions[data.key] then + group.check:SetCheck(ChatOptions[data.key], true) + end + elseif data.type == 'color' then + group.name = UIUtil.CreateText(group, data.name, 14, "Arial") + local defValue = ChatOptions[data.key] or 1 + group.color = BitmapCombo(group, chatColors, defValue, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + LayoutHelpers.AtLeftTopIn(group.color, group) + LayoutHelpers.RightOf(group.name, group.color, 5) + LayoutHelpers.AtVerticalCenterIn(group.name, group.color) + LayoutHelpers.SetWidth(group.color, 55) + group.color.key = data.key + group.Height:Set(group.color.Height) + group.Width:Set(group.color.Width) + group.color.OnClick = function(self, index) + UpdateOption(self.key, index) + end + elseif data.type == 'slider' then + group.name = UIUtil.CreateText(group, data.name, 14, "Arial") + LayoutHelpers.AtLeftTopIn(group.name, group) + group.slider = IntegerSlider(group, false, + data.min, data.max, + data.inc, UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), + UIUtil.SkinnableFile('/dialogs/options-02/slider-back_bmp.dds')) + LayoutHelpers.Below(group.slider, group.name) + group.slider.key = data.key + group.Height:Set(function() return group.name.Height() + group.slider.Height() end) + group.slider.OnValueSet = function(self, newValue) + UpdateOption(self.key, newValue) + end + group.value = UIUtil.CreateText(group, '', 14, "Arial") + LayoutHelpers.RightOf(group.value, group.slider) + group.slider.OnValueChanged = function(self, newValue) + group.value:SetText(string.format('%3d', newValue)) + end + local defValue = ChatOptions[data.key] or 1 + if data.key == 'win_alpha' then + defValue = defValue * 100 + end + group.slider:SetValue(defValue) + LayoutHelpers.SetWidth(group, 200) + elseif data.type == 'splitter' then + group.split = CreateSplitter() + LayoutHelpers.AtTopIn(group.split, group) + group.Width:Set(group.split.Width) + group.Height:Set(group.split.Height) + end + if data.type ~= 'splitter' then + Tooltip.AddControlTooltip(group, data.tooltip or 'chat_filter') + end + return group + end + + local armyData = GetArmiesTable() + for i, v in armyData.armiesTable do + if not v.civilian then + table.insert(options.filters, {type = 'filter', name = v.nickname, key = i}) + end + end + + local filterTitle = UIUtil.CreateText(optionGroup, 'Message Filters', 14, "Arial Bold") + LayoutHelpers.AtLeftTopIn(filterTitle, optionGroup, 5, 5) + Tooltip.AddControlTooltip(filterTitle, 'chat_filter') + local index = 1 + for i, v in options.filters do + optionGroup.options[index] = CreateEntry(v) + optionGroup.options[index].Left:Set(filterTitle.Left) + optionGroup.options[index].Right:Set(optionGroup.Right) + if index == 1 then + LayoutHelpers.Below(optionGroup.options[index], filterTitle, 5) + else + LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], -2) + end + index = index + 1 + end + local splitIndex = index + local splitter = CreateSplitter() + splitter.Top:Set(function() return optionGroup.options[splitIndex-1].Bottom() + 5 end) + + local WindowTitle = UIUtil.CreateText(optionGroup, 'Message Colors', 14, "Arial Bold") + LayoutHelpers.Below(WindowTitle, splitter, 5) + WindowTitle.Left:Set(filterTitle.Left) + Tooltip.AddControlTooltip(WindowTitle, 'chat_color') + + local firstOption = true + local optionIndex = 1 + for i, v in options.winOptions do + optionGroup.options[index] = CreateEntry(v) + optionGroup.options[index].Data = v + if firstOption then + LayoutHelpers.Below(optionGroup.options[index], WindowTitle, 5) + optionGroup.options[index].Right:Set(function() return filterTitle.Left() + (optionGroup.Width() / 2) end) + firstOption = false + elseif v.type == 'color' then + optionGroup.options[index].Right:Set(function() return filterTitle.Left() + (optionGroup.Width() / 2) end) + if math.mod(optionIndex, 2) == 1 then + LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-2], 2) + else + LayoutHelpers.RightOf(optionGroup.options[index], optionGroup.options[index-1]) + end + elseif v.type == 'filter' then + LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], 4) + LayoutHelpers.AtLeftIn(optionGroup.options[index], WindowTitle) + else + LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], 4) + LayoutHelpers.AtHorizontalCenterIn(optionGroup.options[index], optionGroup) + end + optionIndex = optionIndex + 1 + index = index + 1 + end + + local applyBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) + LayoutHelpers.Below(applyBtn, optionGroup.options[index-1], 4) + LayoutHelpers.AtLeftIn(applyBtn, optionGroup) + applyBtn.OnClick = function(self) + ChatOptions = table.merged(ChatOptions, tempOptions) + Prefs.SetToCurrentProfile("chatoptions", ChatOptions) + GUI.bg:OnOptionsSet() + end + + local resetBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) + LayoutHelpers.Below(resetBtn, optionGroup.options[index-1], 4) + LayoutHelpers.AtRightIn(resetBtn, optionGroup) + LayoutHelpers.ResetLeft(resetBtn) + resetBtn.OnClick = function(self) + for option, value in defOptions do + for i, control in optionGroup.options do + if control.Data.key == option then + if control.Data.type == 'slider' then + if control.Data.key == 'win_alpha' then + value = value * 100 + end + control.slider:SetValue(value) + elseif control.Data.type == 'color' then + control.color:SetItem(value) + elseif control.Data.type == 'filter' then + control.check:SetCheck(value, true) + end + UpdateOption(option, value) + break + end + end + end + end + + local okBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) + LayoutHelpers.Below(okBtn, resetBtn, 4) + LayoutHelpers.AtLeftIn(okBtn, optionGroup) + okBtn.OnClick = function(self) + ChatOptions = table.merged(ChatOptions, tempOptions) + Prefs.SetToCurrentProfile("chatoptions", ChatOptions) + GUI.bg:OnOptionsSet() + GUI.config:Destroy() + GUI.config = false + end + + local cancelBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) + LayoutHelpers.Below(cancelBtn, resetBtn, 4) + LayoutHelpers.AtRightIn(cancelBtn, optionGroup) + LayoutHelpers.ResetLeft(cancelBtn) + cancelBtn.OnClick = function(self) + GUI.config:Destroy() + GUI.config = false + end + + + GUI.config.Bottom:Set(function() return okBtn.Bottom() + 5 end) + if defPosition ~= nil then + GUI.config.Top:Set(defPosition.top) + GUI.config.Left:Set(defPosition.left) + else + GUI.config.Top:Set(function() return LayoutHelpers.ScaleNumber(90) end) + end + GUI.config:SetPositionLock(false) -- allow window to be draggable, didn't worked in Window() call +end + +function CloseChatConfig() + if GUI.config then + GUI.config:Destroy() + GUI.config = nil + end +end diff --git a/lua/ui/game/chat.lua b/lua/ui/game/chat.lua index 2914fa82dfd..c1c214dbf08 100644 --- a/lua/ui/game/chat.lua +++ b/lua/ui/game/chat.lua @@ -1,1565 +1,8 @@ -local UiUtilsS = import("/lua/uiutilssorian.lua") -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local EffectHelpers = import("/lua/maui/effecthelpers.lua") -local Group = import("/lua/maui/group.lua").Group -local Checkbox = import("/lua/ui/controls/checkbox.lua").Checkbox -local Button = import("/lua/maui/button.lua").Button -local Text = import("/lua/maui/text.lua").Text -local Edit = import("/lua/maui/edit.lua").Edit -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local ItemList = import("/lua/maui/itemlist.lua").ItemList -local Window = import("/lua/maui/window.lua").Window -local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo -local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider -local Prefs = import("/lua/user/prefs.lua") -local Dragger = import("/lua/maui/dragger.lua").Dragger -local Tooltip = import("/lua/ui/game/tooltip.lua") -local UIMain = import("/lua/ui/uimain.lua") ---[[ LOC Strings -To %s: -Chat (%d - %d of %d lines) ---]] - -local AddUnicodeCharToEditText = import("/lua/utf.lua").AddUnicodeCharToEditText - -local CHAT_INACTIVITY_TIMEOUT = 15 -- in seconds -local savedParent = false -local chatHistory = {} - -local commandHistory = {} - -local ChatTo = import("/lua/lazyvar.lua").Create() - -local defOptions = { all_color = 1, - allies_color = 2, - priv_color = 3, - link_color = 4, - notify_color = 8, - font_size = 14, - fade_time = 15, - win_alpha = 1, - feed_background = false, - feed_persist = true} - -local ChatOptions = Prefs.GetFieldFromCurrentProfile("chatoptions") or {} -for option, value in defOptions do - if ChatOptions[option] == nil then - ChatOptions[option] = value - end -end - -GUI = import("/lua/ui/controls.lua").Get() -GUI.chatLines = GUI.chatLines or {} - -local FactionsIcon = {} -local Factions = import("/lua/factions.lua").Factions -for k, FactionData in Factions do - table.insert(FactionsIcon, FactionData.Icon) -end -table.insert(FactionsIcon, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') - - -local chatColors = {'ffffffff', 'ffff4242', 'ffefff42','ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42'} - -local ToStrings = { - to = {text = 'to', caps = 'To', colorkey = 'all_color'}, - allies = {text = 'to allies:', caps = 'To Allies:', colorkey = 'allies_color'}, - all = {text = 'to all:', caps = 'To All:', colorkey = 'all_color'}, - private = {text = 'to you:', caps = 'To You:', colorkey = 'priv_color'}, - notify = {text = 'to allies:', caps = 'To Allies:', colorkey = 'notify_color'}, -} - -function SetLayout() - import(UIUtil.GetLayoutFilename('chat')).SetLayout() -end - -function CreateChatBackground() - local location = {Top = function() return GetFrame(0).Bottom() - LayoutHelpers.ScaleNumber(393) end, - Left = function() return GetFrame(0).Left() + LayoutHelpers.ScaleNumber(8) end, - Right = function() return GetFrame(0).Left() + LayoutHelpers.ScaleNumber(430) end, - Bottom = function() return GetFrame(0).Bottom() - LayoutHelpers.ScaleNumber(238) end} - local bg = Window(GetFrame(0), '', nil, true, true, nil, nil, 'chat_window', location) - bg.Depth:Set(200) - - bg.DragTL = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) - bg.DragTR = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) - bg.DragBL = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) - bg.DragBR = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) - - local controlMap = { - tl = {bg.DragTL}, - tr = {bg.DragTR}, - bl = {bg.DragBL}, - br = {bg.DragBR}, - mr = {bg.DragBR,bg.DragTR}, - ml = {bg.DragBL,bg.DragTL}, - tm = {bg.DragTL,bg.DragTR}, - bm = {bg.DragBL,bg.DragBR}, - } - - bg.RolloverHandler = function(control, event, xControl, yControl, cursor, controlID) - if bg._lockSize then return end - local styles = import("/lua/maui/window.lua").styles - if not bg._sizeLock then - if event.Type == 'MouseEnter' then - if controlMap[controlID] then - for _, control in controlMap[controlID] do - control:SetTexture(control.textures.over) - end - end - GetCursor():SetTexture(styles.cursorFunc(cursor)) - elseif event.Type == 'MouseExit' then - if controlMap[controlID] then - for _, control in controlMap[controlID] do - control:SetTexture(control.textures.up) - end - end - GetCursor():Reset() - elseif event.Type == 'ButtonPress' then - if controlMap[controlID] then - for _, control in controlMap[controlID] do - control:SetTexture(control.textures.down) - end - end - bg.StartSizing(event, xControl, yControl) - bg._sizeLock = true - end - end - end - - bg.OnResizeSet = function(control) - bg.DragTL:SetTexture(bg.DragTL.textures.up) - bg.DragTR:SetTexture(bg.DragTR.textures.up) - bg.DragBL:SetTexture(bg.DragBL.textures.up) - bg.DragBR:SetTexture(bg.DragBR.textures.up) - end - - LayoutHelpers.AtLeftTopIn(bg.DragTL, bg, -26, -6) - bg.DragTL.Depth:Set(220) - bg.DragTL:DisableHitTest() - - LayoutHelpers.AtRightTopIn(bg.DragTR, bg, -22, -8) - bg.DragTR.Depth:Set(bg.DragTL.Depth) - bg.DragTR:DisableHitTest() - - LayoutHelpers.AtLeftBottomIn(bg.DragBL, bg, -26, -8) - bg.DragBL.Depth:Set(bg.DragTL.Depth) - bg.DragBL:DisableHitTest() - - LayoutHelpers.AtRightBottomIn(bg.DragBR, bg, -22, -8) - bg.DragBR.Depth:Set(bg.DragTL.Depth) - bg.DragBR:DisableHitTest() - - bg.ResetPositionBtn = Button(bg, - UIUtil.SkinnableFile('/game/menu-btns/default_btn_up.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_down.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_over.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_dis.dds')) - LayoutHelpers.LeftOf(bg.ResetPositionBtn, bg._configBtn) - bg.ResetPositionBtn.Depth:Set(function() return bg.Depth() + 10 end) - bg.ResetPositionBtn.OnClick = function(self, modifiers) - for index, position in location do - local i = index - local pos = position - bg[i]:Set(pos) - end - CreateChatLines() - bg:SaveWindowLocation() - end - - Tooltip.AddButtonTooltip(bg.ResetPositionBtn, 'chat_reset') - - bg:SetMinimumResize(400, 160) - return bg -end - -function CreateChatLines() - local function CreateChatLine() - local line = Group(GUI.chatContainer) - - -- Draw the faction icon with a colour representing the team behind it. - line.teamColor = Bitmap(line) - line.teamColor:SetSolidColor('00000000') - line.teamColor.Height:Set(line.Height) - line.teamColor.Width:Set(line.Height) - LayoutHelpers.AtLeftTopIn(line.teamColor, line) - - line.factionIcon = Bitmap(line.teamColor) - line.factionIcon:SetSolidColor('00000000') - LayoutHelpers.FillParent(line.factionIcon, line.teamColor) - - -- Player name - line.name = UIUtil.CreateText(line, '', ChatOptions.font_size, "Arial Bold") - LayoutHelpers.CenteredRightOf(line.name, line.teamColor, 4) - line.name.Depth:Set(function() return line.Depth() + 10 end) - line.name:SetColor('ffffffff') - line.name:DisableHitTest() - line.name:SetDropShadow(true) - line.name.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - if line.chatID then - if GUI.bg:IsHidden() then GUI.bg:Show() end - ChatTo:Set(line.chatID) - if GUI.chatEdit.edit then - GUI.chatEdit.edit:AcquireFocus() - end - if GUI.chatEdit.private then - GUI.chatEdit.private:SetCheck(true) - end - end - end - end - - line.text = UIUtil.CreateText(line, '', ChatOptions.font_size, "Arial") - line.text.Depth:Set(function() return line.Depth() + 10 end) - line.text.Left:Set(function() return line.name.Right() + 2 end) - line.text.Right:Set(line.Right) - line.text:SetClipToWidth(true) - line.text:DisableHitTest() - line.text:SetColor('ffc2f6ff') - line.text:SetDropShadow(true) - LayoutHelpers.AtVerticalCenterIn(line.text, line.teamColor) - line.text.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - if line.cameraData then - GetCamera('WorldCamera'):RestoreSettings(line.cameraData) - end - end - end - - -- A background for the line that persists after the chat panel is closed (to help with - -- readability against the simulation) - line.lineStickybg = Bitmap(line) - line.lineStickybg:DisableHitTest() - line.lineStickybg:SetSolidColor('aa000000') - LayoutHelpers.FillParent(line.lineStickybg, line) - LayoutHelpers.DepthUnderParent(line.lineStickybg, line) - line.lineStickybg:Hide() - - return line - end - if GUI.chatContainer then - local curEntries = table.getsize(GUI.chatLines) - local neededEntries = math.floor(GUI.chatContainer.Height() / (GUI.chatLines[1].Height() + 0)) - if curEntries - neededEntries == 0 then - return - elseif curEntries - neededEntries < 0 then - for i = curEntries + 1, neededEntries do - local index = i - GUI.chatLines[index] = CreateChatLine() - LayoutHelpers.Below(GUI.chatLines[index], GUI.chatLines[index-1], 0) - GUI.chatLines[index].Height:Set(function() return GUI.chatLines[index].name.Height() + 2 end) - GUI.chatLines[index].Right:Set(GUI.chatContainer.Right) - end - elseif curEntries - neededEntries > 0 then - for i = neededEntries + 1, curEntries do - if GUI.chatLines[i] then - GUI.chatLines[i]:Destroy() - GUI.chatLines[i] = nil - end - end - end - else - local clientArea = GUI.bg:GetClientGroup() - GUI.chatContainer = Group(clientArea) - LayoutHelpers.AtLeftIn(GUI.chatContainer, clientArea, 10) - LayoutHelpers.AtTopIn(GUI.chatContainer, clientArea, 2) - LayoutHelpers.AtRightIn(GUI.chatContainer, clientArea, 38) - LayoutHelpers.AnchorToTop(GUI.chatContainer, GUI.chatEdit, 10) - - SetupChatScroll() - - if not GUI.chatLines[1] then - GUI.chatLines[1] = CreateChatLine() - LayoutHelpers.AtLeftTopIn(GUI.chatLines[1], GUI.chatContainer, 0, 0) - GUI.chatLines[1].Height:Set(function() return GUI.chatLines[1].name.Height() + 2 end) - GUI.chatLines[1].Right:Set(GUI.chatContainer.Right) - end - local index = 1 - while GUI.chatLines[index].Bottom() + GUI.chatLines[1].Height() < GUI.chatContainer.Bottom() do - index = index + 1 - if not GUI.chatLines[index] then - GUI.chatLines[index] = CreateChatLine() - LayoutHelpers.Below(GUI.chatLines[index], GUI.chatLines[index-1], 0) - GUI.chatLines[index].Height:Set(function() return GUI.chatLines[index].name.Height() + 2 end) - GUI.chatLines[index].Right:Set(GUI.chatContainer.Right) - end - end - end -end - -function OnNISBegin() - CloseChat() -end - -function SetupChatScroll() - GUI.chatContainer.top = 1 - GUI.chatContainer.scroll = UIUtil.CreateVertScrollbarFor(GUI.chatContainer) - - local numLines = function() return table.getsize(GUI.chatLines) end - GUI.chatContainer.prevtabsize = 0 - GUI.chatContainer.prevsize = 0 - - local function IsValidEntry(entryData) - if entryData.camera then - return ChatOptions.links and ChatOptions[entryData.armyID] - end - - return ChatOptions[entryData.armyID] - end - - local function DataSize() - if GUI.chatContainer.prevtabsize ~= table.getn(chatHistory) then - local size = 0 - for i, v in chatHistory do - if IsValidEntry(v) then - size = size + table.getn(v.wrappedtext) - end - end - GUI.chatContainer.prevtabsize = table.getn(chatHistory) - GUI.chatContainer.prevsize = size - end - return GUI.chatContainer.prevsize - end - - -- called when the scrollbar for the control requires data to size itself - -- GetScrollValues must return 4 values in this order: - -- rangeMin, rangeMax, visibleMin, visibleMax - -- aixs can be "Vert" or "Horz" - GUI.chatContainer.GetScrollValues = function(self, axis) - local size = DataSize() - --LOG(size, ":", self.top, ":", math.min(self.top + numLines(), size)) - return 1, size, self.top, math.min(self.top + numLines(), size) - end - - -- called when the scrollbar wants to scroll a specific number of lines (negative indicates scroll up) - GUI.chatContainer.ScrollLines = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta)) - end - - -- called when the scrollbar wants to scroll a specific number of pages (negative indicates scroll up) - GUI.chatContainer.ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta) * numLines()) - end - - -- called when the scrollbar wants to set a new visible top line - GUI.chatContainer.ScrollSetTop = function(self, axis, top) - top = math.floor(top) - if top == self.top then return end - local size = DataSize() - self.top = math.max(math.min(size - numLines()+1, top), 1) - self:CalcVisible() - end - - -- called to determine if the control is scrollable on a particular access. Must return true or false. - GUI.chatContainer.IsScrollable = function(self, axis) - return true - end - - GUI.chatContainer.ScrollToBottom = function(self) - --LOG(DataSize()) - GUI.chatContainer:ScrollSetTop(nil, DataSize()) - end - - -- determines what controls should be visible or not - GUI.chatContainer.CalcVisible = function(self) - GUI.bg.curTime = 0 - local index = 1 - local tempTop = self.top - local curEntry = 1 - local curTop = 1 - local tempsize = 0 - - if GUI.bg:IsHidden() then - tempTop = math.max(DataSize() - numLines()+1, 1) - end - - for i, v in chatHistory do - if IsValidEntry(v) then - if tempsize + table.getsize(v.wrappedtext) < tempTop then - tempsize = tempsize + table.getsize(v.wrappedtext) - else - curEntry = i - for h, x in v.wrappedtext do - if h + tempsize == tempTop then - curTop = h - break - end - end - break - end - end - end - while GUI.chatLines[index] do - local line = GUI.chatLines[index] - - if not chatHistory[curEntry].wrappedtext[curTop] then - if chatHistory[curEntry].new then chatHistory[curEntry].new = nil end - curTop = 1 - curEntry = curEntry + 1 - while chatHistory[curEntry] and not IsValidEntry(chatHistory[curEntry]) do - curEntry = curEntry + 1 - end - end - if chatHistory[curEntry] then - local Index = index - if curTop == 1 then - line.name:SetText(chatHistory[curEntry].name) - if chatHistory[curEntry].armyID == GetFocusArmy() then - line.name:Disable() - else - line.name:Enable() - end - line.text:SetText(chatHistory[curEntry].wrappedtext[curTop] or "") - line.teamColor:SetSolidColor(chatHistory[curEntry].color) - line.factionIcon:SetTexture(UIUtil.UIFile(FactionsIcon[chatHistory[curEntry].faction])) - line.IsTop = true - line.chatID = chatHistory[curEntry].armyID - if chatHistory[curEntry].camera and not line.camIcon then - line.camIcon = Bitmap(line.text, UIUtil.UIFile('/game/camera-btn/pinned_btn_up.dds')) - LayoutHelpers.SetDimensions(line.camIcon, 20, 16) - LayoutHelpers.AtVerticalCenterIn(line.camIcon, line.teamColor) - LayoutHelpers.RightOf(line.camIcon, line.name, 4) - LayoutHelpers.RightOf(line.text, line.camIcon, 4) - elseif not chatHistory[curEntry].camera and line.camIcon then - line.camIcon:Destroy() - line.camIcon = false - LayoutHelpers.RightOf(line.text, line.name, 2) - end - else - line.name:Disable() - line.name:SetText('') - line.text:SetText(chatHistory[curEntry].wrappedtext[curTop] or "") - line.teamColor:SetSolidColor('00000000') - line.factionIcon:SetSolidColor('00000000') - line.IsTop = false - if line.camIcon then - line.camIcon:Destroy() - line.camIcon = false - LayoutHelpers.RightOf(line.text, line.name, 2) - end - end - if chatHistory[curEntry].camera then - line.cameraData = chatHistory[curEntry].camera - line.text:Enable() - line.text:SetColor(chatColors[ChatOptions.link_color]) - else - line.text:Disable() - line.text:SetColor('ffc2f6ff') - line.text:SetColor(chatColors[ChatOptions[chatHistory[curEntry].tokey]]) - end - - line.EntryID = curEntry - - if GUI.bg:IsHidden() then - - line.curHistory = chatHistory[curEntry] - if line.curHistory.new or line.curHistory.time == nil then - line.curHistory.time = 0 - end - - if line.curHistory.time < ChatOptions.fade_time then - line:Show() - - UIUtil.setVisible(line.lineStickybg, ChatOptions.feed_background) - - if line.name:GetText() == '' then - line.teamColor:Hide() - end - if line.curHistory.wrappedtext[curTop+1] == nil then - line.OnFrame = function(self, delta) - self.curHistory.time = self.curHistory.time + delta - if self.curHistory.time > ChatOptions.fade_time then - if GUI.bg:IsHidden() then - self:Hide() - end - self:SetNeedsFrameUpdate(false) - end - end - -- Don't increment time on lines with wrapped text - else - line.OnFrame = function(self, delta) - if self.curHistory.time > ChatOptions.fade_time then - if GUI.bg:IsHidden() then - self:Hide() - end - self:SetNeedsFrameUpdate(false) - end - end - end - line:SetNeedsFrameUpdate(true) - end - - end - else - line.name:Disable() - line.name:SetText('') - line.text:SetText('') - line.teamColor:SetSolidColor('00000000') - end - line:SetAlpha(ChatOptions.win_alpha, true) - curTop = curTop + 1 - index = index + 1 - end - if chatHistory[curEntry].new then chatHistory[curEntry].new = nil end - end -end - -function FindClients(id) - local t = GetArmiesTable() - local focus = t.focusArmy - local result = {} - if focus == -1 then - for index,client in GetSessionClients() do - if not client.connected then - continue - end - local playerIsObserver = true - for id, player in GetArmiesTable().armiesTable do - if player.outOfGame and player.human and player.nickname == client.name then - table.insert(result, index) - playerIsObserver = false - break - elseif player.nickname == client.name then - playerIsObserver = false - break - end - end - if playerIsObserver then - table.insert(result, index) - end - end - else - local srcs = {} - for army,info in t.armiesTable do - if id then - if army == id then - for k,cmdsrc in info.authorizedCommandSources do - srcs[cmdsrc] = true - end - break - end - else - if IsAlly(focus, army) then - for k,cmdsrc in info.authorizedCommandSources do - srcs[cmdsrc] = true - end - end - end - end - for index,client in GetSessionClients() do - for k,cmdsrc in client.authorizedCommandSources do - if srcs[cmdsrc] then - table.insert(result, index) - break - end - end - end - end - return result -end - -local RunChatCommand = import("/lua/ui/notify/commands.lua").RunChatCommand -function CreateChatEdit() - local parent = GUI.bg:GetClientGroup() - local group = Group(parent) - - group.Bottom:Set(parent.Bottom) - group.Right:Set(parent.Right) - group.Left:Set(parent.Left) - group.Top:Set(function() return group.Bottom() - group.Height() end) - - local toText = UIUtil.CreateText(group, '', 14, 'Arial') - LayoutHelpers.AtBottomIn(toText, group, 1) - LayoutHelpers.AtLeftIn(toText, group, 35) - - ChatTo.OnDirty = function(self) - if ToStrings[self()] then - toText:SetText(LOC(ToStrings[self()].caps)) - else - toText:SetText(LOCF('%s %s:', ToStrings['to'].caps, GetArmyData(self()).nickname)) - end - end - - group.edit = Edit(group) - LayoutHelpers.AnchorToRight(group.edit, toText, 5) - LayoutHelpers.AtRightIn(group.edit, group, 38) - group.edit.Depth:Set(function() return GUI.bg:GetClientGroup().Depth() + 200 end) - LayoutHelpers.AtBottomIn(group.edit, group, 1) - group.edit.Height:Set(function() return group.edit:GetFontHeight() end) - UIUtil.SetupEditStd(group.edit, "ff00ff00", nil, "ffffffff", UIUtil.highlightColor, UIUtil.bodyFont, 14, 200) - group.edit:SetDropShadow(true) - group.edit:ShowBackground(false) - - group.edit:SetText('') - - group.Height:Set(function() return group.edit.Height() end) - - local function CreateTestBtn(text) - local btn = UIUtil.CreateCheckbox(group, '/dialogs/toggle_btn/toggle') - btn.Depth:Set(function() return group.Depth() + 10 end) - btn.OnClick = function(self, modifiers) - if self._checkState == "unchecked" then - self:ToggleCheck() - end - end - btn.txt = UIUtil.CreateText(btn, text, 12, UIUtil.bodyFont) - LayoutHelpers.AtCenterIn(btn.txt, btn) - btn.txt:SetColor('ffffffff') - btn.txt:DisableHitTest() - return btn - end - - group.camData = Checkbox(group, - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_up.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_down.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds')) - - LayoutHelpers.AtRightIn(group.camData, group, 5) - LayoutHelpers.AtVerticalCenterIn(group.camData, group.edit, -1) - - group.chatBubble = Button(group, - UIUtil.UIFile('/game/chat-box_btn/radio_btn_up.dds'), - UIUtil.UIFile('/game/chat-box_btn/radio_btn_down.dds'), - UIUtil.UIFile('/game/chat-box_btn/radio_btn_over.dds'), - UIUtil.UIFile('/game/chat-box_btn/radio_btn_dis.dds')) - group.chatBubble.OnClick = function(self, modifiers) - if not self.list then - self.list = CreateChatList(self) - LayoutHelpers.Above(self.list, self, 15) - LayoutHelpers.AtLeftIn(self.list, self, 15) - else - self.list:Destroy() - self.list = nil - end - end - - toText.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - group.chatBubble:OnClick(event.Modifiers) - end - end - - LayoutHelpers.AtLeftIn(group.chatBubble, group, 3) - LayoutHelpers.AtVerticalCenterIn(group.chatBubble, group.edit) - - group.edit.OnNonTextKeyPressed = function(self, charcode, event) - if AddUnicodeCharToEditText(self, charcode) then - return - end - GUI.bg.curTime = 0 - local function RecallCommand(entryNumber) - self:SetText(commandHistory[self.recallEntry].text) - if commandHistory[self.recallEntry].camera then - self.tempCam = commandHistory[self.recallEntry].camera - group.camData:Disable() - group.camData:SetCheck(true) - else - self.tempCam = nil - group.camData:Enable() - group.camData:SetCheck(false) - end - end - if charcode == UIUtil.VK_NEXT then - local mod = 10 - if event.Modifiers.Shift then - mod = 1 - end - ChatPageDown(mod) - return true - elseif charcode == UIUtil.VK_PRIOR then - local mod = 10 - if event.Modifiers.Shift then - mod = 1 - end - ChatPageUp(mod) - return true - elseif charcode == UIUtil.VK_UP then - if not table.empty(commandHistory) then - if self.recallEntry then - self.recallEntry = math.max(self.recallEntry-1, 1) - else - self.recallEntry = table.getsize(commandHistory) - end - RecallCommand(self.recallEntry) - end - elseif charcode == UIUtil.VK_DOWN then - if not table.empty(commandHistory) then - if self.recallEntry then - self.recallEntry = math.min(self.recallEntry+1, table.getsize(commandHistory)) - RecallCommand(self.recallEntry) - if self.recallEntry == table.getsize(commandHistory) then - self.recallEntry = nil - end - else - self:SetText('') - end - end - else - return true - end - end - - group.edit.OnCharPressed = function(self, charcode) - local charLim = self:GetMaxChars() - if charcode == 9 then - return true - end - GUI.bg.curTime = 0 - if STR_Utf8Len(self:GetText()) >= charLim then - local sound = Sound({Cue = 'UI_Menu_Error_01', Bank = 'Interface',}) - PlaySound(sound) - end - end - - group.edit.OnEnterPressed = function(self, text) - -- Analyse for any commands entered for Notify toggling - if string.len(text) > 1 and string.sub(text, 1, 1) == "/" then - local args = {} - - for word in string.gfind(string.sub(text, 2), "%S+") do - table.insert(args, string.lower(word)) - end - - -- We've done the command, exit without sending the message to other players - if RunChatCommand(args) then - return - end - end - - GUI.bg.curTime = 0 - if group.camData:IsDisabled() then - group.camData:Enable() - end - if text == "" then - ToggleChat() - else - local gnBegin, gnEnd = string.find(text, "%s+") - if gnBegin and (gnBegin == 1 and gnEnd == string.len(text)) then - return - end - if import("/lua/ui/game/taunt.lua").CheckForAndHandleTaunt(text) then - return - end - - msg = { to = ChatTo(), Chat = true } - if self.tempCam then - msg.camera = self.tempCam - elseif group.camData:IsChecked() then - msg.camera = GetCamera('WorldCamera'):SaveSettings() - end - msg.text = text - if ChatTo() == 'allies' then - if GetFocusArmy() ~= -1 then - SessionSendChatMessage(FindClients(), msg) - else - msg.Observer = true - SessionSendChatMessage(FindClients(), msg) - end - elseif type(ChatTo()) == 'number' then - if GetFocusArmy() ~= -1 then - SessionSendChatMessage(FindClients(ChatTo()), msg) - msg.echo = true - msg.from = GetArmyData(GetFocusArmy()).nickname - ReceiveChat(GetArmyData(ChatTo()).nickname, msg) - end - else - if GetFocusArmy() == -1 then - msg.Observer = true - SessionSendChatMessage(FindClients(), msg) - else - SessionSendChatMessage(msg) - end - end - table.insert(commandHistory, msg) - self.recallEntry = nil - self.tempCam = nil - end - end - - ChatTo:Set('all') - group.edit:AcquireFocus() - - return group -end - -function ChatPageUp(mod) - if GUI.bg:IsHidden() then - ForkThread(ToggleChat) - else - local newTop = GUI.chatContainer.top - mod - GUI.chatContainer:ScrollSetTop(nil, newTop) - end -end - -function ChatPageDown(mod) - local oldTop = GUI.chatContainer.top - local newTop = GUI.chatContainer.top + mod - GUI.chatContainer:ScrollSetTop(nil, newTop) - if GUI.bg:IsHidden() or oldTop == GUI.chatContainer.top then - ForkThread(ToggleChat) - end -end - -function ReceiveChat(sender, msg) - if not msg.ConsoleOutput then - SimCallback({Func="GiveResourcesToPlayer", Args={ From=GetFocusArmy(), To=GetFocusArmy(), Mass=0, Energy=0, Sender=sender, Msg=msg},} , true) - end - if not SessionIsReplay() then - ReceiveChatFromSim(sender, msg) - end - -end - -function ReceiveChatFromSim(sender, msg) - sender = sender or "nil sender" - if msg.ConsoleOutput then - print(LOCF("%s %s", sender, msg.ConsoleOutput)) - return - end - - if not msg.Chat then - return - end - - if msg.to == 'notify' and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then - return - end - - if type(msg) == 'string' then - msg = { text = msg } - elseif type(msg) ~= 'table' then - msg = { text = repr(msg) } - end - - local armyData = GetArmyData(sender) - if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then - return - end - - local towho = LOC(ToStrings[msg.to].text) or LOC(ToStrings['private'].text) - local tokey = ToStrings[msg.to].colorkey or ToStrings['private'].colorkey - if msg.Observer then - towho = LOC("to observers:") - tokey = "link_color" - if armyData.faction then - armyData.faction = table.getn(FactionsIcon) - 1 - end - end - - if type(msg.to) == 'number' and SessionIsReplay() then - towho = string.format("%s %s:", LOC(ToStrings.to.text), GetArmyData(msg.to).nickname) - end - local name = sender .. ' ' .. towho - - if msg.echo then - if msg.from and SessionIsReplay() then - name = string.format("%s %s %s:", msg.from, LOC(ToStrings.to.text), GetArmyData(msg.to).nickname) - else - name = string.format("%s %s:", LOC(ToStrings.to.caps), sender) - end - end - local tempText = WrapText({text = msg.text, name = name}) - -- if text wrap produces no lines (ie text is all white space) then add a blank line - if table.empty(tempText) then - tempText = {""} - end - local entry = { - name = name, - tokey = tokey, - color = (armyData.color or "ffffffff"), - armyID = (armyData.ArmyID or 1), - faction = (armyData.faction or (table.getn(FactionsIcon)-1))+1, - text = msg.text, - wrappedtext = tempText, - new = true, - camera = msg.camera - } - - table.insert(chatHistory, entry) - if ChatOptions[entry.armyID] then - if table.getsize(chatHistory) == 1 then - GUI.chatContainer:CalcVisible() - else - GUI.chatContainer:ScrollToBottom() - end - end -end - -function ToggleChat() - if GUI.bg:IsHidden() then - GUI.bg:Show() - GUI.chatEdit.edit:AcquireFocus() - if not GUI.bg.pinned then - GUI.bg:SetNeedsFrameUpdate(true) - GUI.bg.curTime = 0 - end - for i, v in GUI.chatLines do - v:SetNeedsFrameUpdate(false) - v:Show() - v.lineStickybg:Hide() - end - GUI.chatContainer:CalcVisible() - else - GUI.bg:Hide() - GUI.chatEdit.edit:AbandonFocus() - GUI.bg:SetNeedsFrameUpdate(false) - - if ChatOptions.feed_persist then - GUI.chatContainer:CalcVisible() - else - for i, v in GUI.chatLines do - if v.curHistory and v.curHistory.time ~= nil then - v.curHistory.time = ChatOptions.fade_time + 1 - end - end - end - end -end - +--- Called by the engine when the user presses Enter outside the chat edit +--- box — the default "open chat" shortcut. Thin shim that delegates to the +--- chat controller, which picks the initial recipient from `send_type` and +--- the Shift modifier before toggling the window. +---@param modifiers? table # {Shift, Ctrl, Alt, ...} function ActivateChat(modifiers) - if type(ChatTo()) ~= 'number' then - if (not modifiers.Shift) == (ChatOptions['send_type'] or false) then - ChatTo:Set('allies') - else - ChatTo:Set('all') - end - end - ToggleChat() -end - ---------------------- ---Added for sorian ai ---------------------- - -function CreateChatList(parent) - local armies = GetArmiesTable() - local container = Group(GUI.chatEdit) - container:DisableHitTest() - container.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) - container.entries = {} - local function CreatePlayerEntry(data) - if not data.human and not data.civilian then - data.nickname = UiUtilsS.trim(string.gsub(data.nickname,'%b()', '')) - end - local text = UIUtil.CreateText(container, data.nickname, 12, "Arial") - text:SetColor('ffffffff') - text:DisableHitTest() - - text.BG = Bitmap(text) - text.BG:SetSolidColor('ff000000') - text.BG.Depth:Set(function() return text.Depth() - 1 end) - text.BG.Left:Set(function() return text.Left() - 6 end) - text.BG.Top:Set(function() return text.Top() - 1 end) - text.BG.Width:Set(function() return container.Width() + 8 end) - text.BG.Bottom:Set(function() return text.Bottom() + 1 end) - - text.BG.HandleEvent = function(self, event) - if event.Type == 'MouseEnter' then - self:SetSolidColor('ff666666') - elseif event.Type == 'MouseExit' then - self:SetSolidColor('ff000000') - elseif event.Type == 'ButtonPress' then - ChatTo:Set(data.armyID) - container:Destroy() - parent.list = nil - GUI.chatEdit.edit:Enable() - GUI.chatEdit.edit:AcquireFocus() - end - GUI.bg.curTime = 0 - end - return text - end - - local entries = { - {nickname = ToStrings.all.caps, armyID = 'all'}, - {nickname = ToStrings.allies.caps, armyID = 'allies'}, - } - - for armyID, armyData in armies.armiesTable do - if armyID ~= armies.focusArmy and not armyData.civilian then - table.insert(entries, {nickname = armyData.nickname, armyID = armyID}) - end - end - - local maxWidth = 0 - local height = 0 - for index, data in entries do - local i = index - table.insert(container.entries, CreatePlayerEntry(data)) - if container.entries[i].Width() > maxWidth then - maxWidth = container.entries[i].Width() + 8 - end - height = height + container.entries[i].Height() - if i > 1 then - LayoutHelpers.Above(container.entries[i], container.entries[i-1]) - else - LayoutHelpers.AtLeftIn(container.entries[i], container) - LayoutHelpers.AtBottomIn(container.entries[i], container) - end - end - container.Width:Set(maxWidth + 40) - container.Height:Set(height) - - container.LTBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) - container.LTBG:DisableHitTest() - container.LTBG.Right:Set(container.Left) - container.LTBG.Bottom:Set(container.Top) - - container.RTBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ur.dds')) - container.RTBG:DisableHitTest() - container.RTBG.Left:Set(container.Right) - container.RTBG.Bottom:Set(container.Top) - - container.RBBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lr.dds')) - container.RBBG:DisableHitTest() - container.RBBG.Left:Set(container.Right) - container.RBBG.Top:Set(container.Bottom) - - container.RLBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ll.dds')) - container.RLBG:DisableHitTest() - container.RLBG.Right:Set(container.Left) - container.RLBG.Top:Set(container.Bottom) - - container.LBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_l.dds')) - container.LBG:DisableHitTest() - container.LBG.Right:Set(container.Left) - container.LBG.Top:Set(container.Top) - container.LBG.Bottom:Set(container.Bottom) - - container.RBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_r.dds')) - container.RBG:DisableHitTest() - container.RBG.Left:Set(container.Right) - container.RBG.Top:Set(container.Top) - container.RBG.Bottom:Set(container.Bottom) - - container.TBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_horz_um.dds')) - container.TBG:DisableHitTest() - container.TBG.Left:Set(container.Left) - container.TBG.Right:Set(container.Right) - container.TBG.Bottom:Set(container.Top) - - container.BBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) - container.BBG:DisableHitTest() - container.BBG.Left:Set(container.Left) - container.BBG.Right:Set(container.Right) - container.BBG.Top:Set(container.Bottom) - - function DestroySelf() - parent:OnClick() - end - - UIMain.AddOnMouseClickedFunc(DestroySelf) - - container.OnDestroy = function(self) - UIMain.RemoveOnMouseClickedFunc(DestroySelf) - end - - return container -end - -function SetupChatLayout(mapGroup) - savedParent = mapGroup - CreateChat() - import("/lua/ui/game/gamemain.lua").RegisterChatFunc(ReceiveChat, 'Chat') -end - ----@type table -local OnChatOptionsChangedCallbacks = {} - ---- Adds a callback and calls it with the current chat options. ----@param callback fun(chatOptions: table) ----@param id? string -function AddChatOptionSetCallback(callback, id) - local ok, msg = pcall(callback, ChatOptions) - if not ok then - WARN(string.format('Error with initial run of `ChatOptionSet` callback%s: %s' - , id and string.format(' (id "%s")', tostring(id)) or '' - , msg) - ) - return - end - if id then - OnChatOptionsChangedCallbacks[id] = callback - else - table.insert(OnChatOptionsChangedCallbacks, callback) - end -end - ---- Runs all callbacks using the current chat options. -local function DoChatOptionSetCallbacks() - for id, callback in OnChatOptionsChangedCallbacks do - local ok, msg = pcall(callback, ChatOptions) - if not ok then - WARN(string.format('Error running `ChatOptionSet` callback (id "%s"): %s', tostring(id), msg)) - OnChatOptionsChangedCallbacks[id] = nil - end - end -end - -function CreateChat() - if GUI.bg then - GUI.bg.OnClose() - end - GUI.bg = CreateChatBackground() - GUI.chatEdit = CreateChatEdit() - GUI.bg.OnResize = function(self, x, y, firstFrame) - if firstFrame then - self:SetNeedsFrameUpdate(false) - end - CreateChatLines() - GUI.chatContainer:CalcVisible() - end - GUI.bg.OnResizeSet = function(self) - if not self:IsPinned() then - self:SetNeedsFrameUpdate(true) - end - RewrapLog() - CreateChatLines() - GUI.chatContainer:CalcVisible() - GUI.chatEdit.edit:AcquireFocus() - end - GUI.bg.OnMove = function(self, x, y, firstFrame) - if firstFrame then - self:SetNeedsFrameUpdate(false) - end - end - GUI.bg.OnMoveSet = function(self) - GUI.chatEdit.edit:AcquireFocus() - if not self:IsPinned() then - self:SetNeedsFrameUpdate(true) - end - end - GUI.bg.OnMouseWheel = function(self, rotation) - local newTop = GUI.chatContainer.top - math.floor(rotation / 100) - GUI.chatContainer:ScrollSetTop(nil, newTop) - end - GUI.bg.OnClose = function(self) - ToggleChat() - end - GUI.bg.OnOptionsSet = function(self) - GUI.chatContainer:Destroy() - GUI.chatContainer = false - for i, v in GUI.chatLines do - v:Destroy() - end - GUI.bg:SetAlpha(ChatOptions.win_alpha, true) - GUI.chatLines = {} - CreateChatLines() - RewrapLog() - GUI.chatContainer:CalcVisible() - GUI.chatEdit.edit:AcquireFocus() - if not GUI.bg.pinned then - GUI.bg.curTime = 0 - GUI.bg:SetNeedsFrameUpdate(true) - end - DoChatOptionSetCallbacks() - end - GUI.bg.OnHideWindow = function(self, hidden) - if not hidden then - for i, v in GUI.chatLines do - v:SetNeedsFrameUpdate(false) - end - end - end - GUI.bg.curTime = 0 - GUI.bg.pinned = false - GUI.bg.OnFrame = function(self, delta) - self.curTime = self.curTime + delta - if self.curTime > ChatOptions.fade_time then - ToggleChat() - end - end - GUI.bg.OnPinCheck = function(self, checked) - GUI.bg.pinned = checked - GUI.bg:SetNeedsFrameUpdate(not checked) - GUI.bg.curTime = 0 - GUI.chatEdit.edit:AcquireFocus() - if checked then - Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pinned') - else - Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pin') - end - end - GUI.bg.OnConfigClick = function(self, checked) - if GUI.config then GUI.config:Destroy() GUI.config = false return end - CreateConfigWindow() - GUI.bg:SetNeedsFrameUpdate(false) - - end - for i, v in GetArmiesTable().armiesTable do - if not v.civilian then - ChatOptions[i] = true - end - end - GUI.bg:SetAlpha(ChatOptions.win_alpha, true) - Tooltip.AddButtonTooltip(GUI.bg._closeBtn, 'chat_close') - GUI.bg.OldHandleEvent = GUI.bg.HandleEvent - GUI.bg.HandleEvent = function(self, event) - if event.Type == "WheelRotation" and self:IsHidden() then - import("/lua/ui/game/worldview.lua").ForwardMouseWheelInput(event) - return true - else - return GUI.bg.OldHandleEvent(self, event) - end - end - - Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pin') - Tooltip.AddControlTooltip(GUI.bg._configBtn, 'chat_config') - Tooltip.AddControlTooltip(GUI.bg._closeBtn, 'chat_close') - Tooltip.AddCheckboxTooltip(GUI.chatEdit.camData, 'chat_camera') - - ChatOptions['links'] = ChatOptions.links or true - CreateChatLines() - RewrapLog() - GUI.chatContainer:CalcVisible() - ToggleChat() -end - -function RewrapLog() - local tempSize = 0 - for i, v in chatHistory do - v.wrappedtext = WrapText(v) - tempSize = tempSize + table.getsize(v.wrappedtext) - end - GUI.chatContainer.prevtabsize = 0 - GUI.chatContainer.prevsize = 0 - GUI.chatContainer:ScrollSetTop(nil, tempSize) -end - -function WrapText(data) - return import("/lua/maui/text.lua").WrapText(data.text, - function(line) - local firstLine = GUI.chatLines[1] - if line == 1 then - return firstLine.Right() - (firstLine.name.Left() + firstLine.name:GetStringAdvance(data.name) + 4) - else - return firstLine.Right() - (firstLine.name.Left() + 4) - end - end, - function(text) - return GUI.chatLines[1].text:GetStringAdvance(text) - end) -end - -function GetArmyData(army) - local armies = GetArmiesTable() - local result = nil - if type(army) == 'number' then - if armies.armiesTable[army] then - result = armies.armiesTable[army] - end - elseif type(army) == 'string' then - for i, v in armies.armiesTable do - if v.nickname == army then - result = v - result.ArmyID = i - break - end - end - end - return result -end - -function CloseChat() - if not GUI.bg:IsHidden() then - ToggleChat() - end - if GUI.config then - GUI.config:Destroy() - GUI.config = nil - end -end - -function CreateConfigWindow() - import("/lua/ui/game/multifunction.lua").CloseMapDialog() - local windowTextures = { - tl = UIUtil.SkinnableFile('/game/panel/panel_brd_ul.dds'), - tr = UIUtil.SkinnableFile('/game/panel/panel_brd_ur.dds'), - tm = UIUtil.SkinnableFile('/game/panel/panel_brd_horz_um.dds'), - ml = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_l.dds'), - m = UIUtil.SkinnableFile('/game/panel/panel_brd_m.dds'), - mr = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_r.dds'), - bl = UIUtil.SkinnableFile('/game/panel/panel_brd_ll.dds'), - bm = UIUtil.SkinnableFile('/game/panel/panel_brd_lm.dds'), - br = UIUtil.SkinnableFile('/game/panel/panel_brd_lr.dds'), - borderColor = 'ff415055', - } - - local defPosition = Prefs.GetFieldFromCurrentProfile('chat_config') or nil - GUI.config = Window(GetFrame(0), 'Chat Options', nil, nil, nil, true, true, 'chat_config', defPosition, windowTextures) - GUI.config.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) - Tooltip.AddButtonTooltip(GUI.config._closeBtn, 'chat_close') - LayoutHelpers.AnchorToBottom(GUI.config, GetFrame(0), -700) - LayoutHelpers.SetWidth(GUI.config, 300) - LayoutHelpers.AtHorizontalCenterIn(GUI.config, GetFrame(0)) - LayoutHelpers.ResetRight(GUI.config) - - GUI.config.DragTL = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) - GUI.config.DragTR = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) - GUI.config.DragBL = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) - GUI.config.DragBR = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) - - LayoutHelpers.AtLeftTopIn(GUI.config.DragTL, GUI.config, -24, -8) - - LayoutHelpers.AtRightTopIn(GUI.config.DragTR, GUI.config, -22, -8) - - LayoutHelpers.AtLeftIn(GUI.config.DragBL, GUI.config, -24) - LayoutHelpers.AtBottomIn(GUI.config.DragBL, GUI.config, -8) - - LayoutHelpers.AtRightIn(GUI.config.DragBR, GUI.config, -22) - LayoutHelpers.AtBottomIn(GUI.config.DragBR, GUI.config, -8) - - GUI.config.DragTL.Depth:Set(function() return GUI.config.Depth() + 10 end) - GUI.config.DragTR.Depth:Set(GUI.config.DragTL.Depth) - GUI.config.DragBL.Depth:Set(GUI.config.DragTL.Depth) - GUI.config.DragBR.Depth:Set(GUI.config.DragTL.Depth) - - GUI.config.DragTL:DisableHitTest() - GUI.config.DragTR:DisableHitTest() - GUI.config.DragBL:DisableHitTest() - GUI.config.DragBR:DisableHitTest() - - GUI.config.OnClose = function(self) - GUI.config:Destroy() - GUI.config = false - end - - local options = { - filters = {{type = 'filter', name = 'Links', key = 'links', tooltip = 'chat_filter'}}, - winOptions = { - {type = 'color', name = '', key = 'all_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'allies_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'priv_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'link_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'notify_color', tooltip = 'chat_color'}, - {type = 'splitter'}, - {type = 'slider', name = 'Chat Font Size', key = 'font_size', tooltip = 'chat_fontsize', min = 12, max = 18, inc = 1}, - {type = 'slider', name = 'Window Fade Time', key = 'fade_time', tooltip = 'chat_fadetime', min = 5, max = 30, inc = 1}, - {type = 'slider', name = 'Window Alpha', key = 'win_alpha', tooltip = 'chat_alpha', min = 20, max = 100, inc = 1}, - {type = 'splitter'}, - {type = 'filter', name = 'Default recipient: allies', key = 'send_type', tooltip = 'chat_send_type'}, - {type = 'filter', name = 'Show Feed Background', key = 'feed_background', tooltip = 'chat_feed_background'}, - {type = 'filter', name = 'Persist Feed Timeout', key = 'feed_persist', tooltip = 'chat_feed_persist'}, - }, - } - - local optionGroup = Group(GUI.config:GetClientGroup()) - LayoutHelpers.FillParent(optionGroup, GUI.config:GetClientGroup()) - optionGroup.options = {} - local tempOptions = {} - - local function UpdateOption(key, value) - if key == 'win_alpha' then - value = value / 100 - end - tempOptions[key] = value - end - - local function CreateSplitter() - local splitter = Bitmap(optionGroup) - splitter:SetSolidColor('ff000000') - splitter.Left:Set(optionGroup.Left) - splitter.Right:Set(optionGroup.Right) - splitter.Height:Set(2) - return splitter - end - - local function CreateEntry(data) - local group = Group(optionGroup) - if data.type == 'filter' then - group.check = UIUtil.CreateCheckbox(group, '/dialogs/check-box_btn/', data.name, true) - LayoutHelpers.AtLeftTopIn(group.check, group) - group.check.key = data.key - group.Height:Set(group.check.Height) - group.Width:Set(function() return group.check.Width() end) - group.check.OnCheck = function(self, checked) - UpdateOption(self.key, checked) - end - if ChatOptions[data.key] then - group.check:SetCheck(ChatOptions[data.key], true) - end - elseif data.type == 'color' then - group.name = UIUtil.CreateText(group, data.name, 14, "Arial") - local defValue = ChatOptions[data.key] or 1 - group.color = BitmapCombo(group, chatColors, defValue, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - LayoutHelpers.AtLeftTopIn(group.color, group) - LayoutHelpers.RightOf(group.name, group.color, 5) - LayoutHelpers.AtVerticalCenterIn(group.name, group.color) - LayoutHelpers.SetWidth(group.color, 55) - group.color.key = data.key - group.Height:Set(group.color.Height) - group.Width:Set(group.color.Width) - group.color.OnClick = function(self, index) - UpdateOption(self.key, index) - end - elseif data.type == 'slider' then - group.name = UIUtil.CreateText(group, data.name, 14, "Arial") - LayoutHelpers.AtLeftTopIn(group.name, group) - group.slider = IntegerSlider(group, false, - data.min, data.max, - data.inc, UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), - UIUtil.SkinnableFile('/dialogs/options-02/slider-back_bmp.dds')) - LayoutHelpers.Below(group.slider, group.name) - group.slider.key = data.key - group.Height:Set(function() return group.name.Height() + group.slider.Height() end) - group.slider.OnValueSet = function(self, newValue) - UpdateOption(self.key, newValue) - end - group.value = UIUtil.CreateText(group, '', 14, "Arial") - LayoutHelpers.RightOf(group.value, group.slider) - group.slider.OnValueChanged = function(self, newValue) - group.value:SetText(string.format('%3d', newValue)) - end - local defValue = ChatOptions[data.key] or 1 - if data.key == 'win_alpha' then - defValue = defValue * 100 - end - group.slider:SetValue(defValue) - LayoutHelpers.SetWidth(group, 200) - elseif data.type == 'splitter' then - group.split = CreateSplitter() - LayoutHelpers.AtTopIn(group.split, group) - group.Width:Set(group.split.Width) - group.Height:Set(group.split.Height) - end - if data.type ~= 'splitter' then - Tooltip.AddControlTooltip(group, data.tooltip or 'chat_filter') - end - return group - end - - local armyData = GetArmiesTable() - for i, v in armyData.armiesTable do - if not v.civilian then - table.insert(options.filters, {type = 'filter', name = v.nickname, key = i}) - end - end - - local filterTitle = UIUtil.CreateText(optionGroup, 'Message Filters', 14, "Arial Bold") - LayoutHelpers.AtLeftTopIn(filterTitle, optionGroup, 5, 5) - Tooltip.AddControlTooltip(filterTitle, 'chat_filter') - local index = 1 - for i, v in options.filters do - optionGroup.options[index] = CreateEntry(v) - optionGroup.options[index].Left:Set(filterTitle.Left) - optionGroup.options[index].Right:Set(optionGroup.Right) - if index == 1 then - LayoutHelpers.Below(optionGroup.options[index], filterTitle, 5) - else - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], -2) - end - index = index + 1 - end - local splitIndex = index - local splitter = CreateSplitter() - splitter.Top:Set(function() return optionGroup.options[splitIndex-1].Bottom() + 5 end) - - local WindowTitle = UIUtil.CreateText(optionGroup, 'Message Colors', 14, "Arial Bold") - LayoutHelpers.Below(WindowTitle, splitter, 5) - WindowTitle.Left:Set(filterTitle.Left) - Tooltip.AddControlTooltip(WindowTitle, 'chat_color') - - local firstOption = true - local optionIndex = 1 - for i, v in options.winOptions do - optionGroup.options[index] = CreateEntry(v) - optionGroup.options[index].Data = v - if firstOption then - LayoutHelpers.Below(optionGroup.options[index], WindowTitle, 5) - optionGroup.options[index].Right:Set(function() return filterTitle.Left() + (optionGroup.Width() / 2) end) - firstOption = false - elseif v.type == 'color' then - optionGroup.options[index].Right:Set(function() return filterTitle.Left() + (optionGroup.Width() / 2) end) - if math.mod(optionIndex, 2) == 1 then - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-2], 2) - else - LayoutHelpers.RightOf(optionGroup.options[index], optionGroup.options[index-1]) - end - elseif v.type == 'filter' then - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], 4) - LayoutHelpers.AtLeftIn(optionGroup.options[index], WindowTitle) - else - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], 4) - LayoutHelpers.AtHorizontalCenterIn(optionGroup.options[index], optionGroup) - end - optionIndex = optionIndex + 1 - index = index + 1 - end - - local applyBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(applyBtn, optionGroup.options[index-1], 4) - LayoutHelpers.AtLeftIn(applyBtn, optionGroup) - applyBtn.OnClick = function(self) - ChatOptions = table.merged(ChatOptions, tempOptions) - Prefs.SetToCurrentProfile("chatoptions", ChatOptions) - GUI.bg:OnOptionsSet() - end - - local resetBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(resetBtn, optionGroup.options[index-1], 4) - LayoutHelpers.AtRightIn(resetBtn, optionGroup) - LayoutHelpers.ResetLeft(resetBtn) - resetBtn.OnClick = function(self) - for option, value in defOptions do - for i, control in optionGroup.options do - if control.Data.key == option then - if control.Data.type == 'slider' then - if control.Data.key == 'win_alpha' then - value = value * 100 - end - control.slider:SetValue(value) - elseif control.Data.type == 'color' then - control.color:SetItem(value) - elseif control.Data.type == 'filter' then - control.check:SetCheck(value, true) - end - UpdateOption(option, value) - break - end - end - end - end - - local okBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(okBtn, resetBtn, 4) - LayoutHelpers.AtLeftIn(okBtn, optionGroup) - okBtn.OnClick = function(self) - ChatOptions = table.merged(ChatOptions, tempOptions) - Prefs.SetToCurrentProfile("chatoptions", ChatOptions) - GUI.bg:OnOptionsSet() - GUI.config:Destroy() - GUI.config = false - end - - local cancelBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(cancelBtn, resetBtn, 4) - LayoutHelpers.AtRightIn(cancelBtn, optionGroup) - LayoutHelpers.ResetLeft(cancelBtn) - cancelBtn.OnClick = function(self) - GUI.config:Destroy() - GUI.config = false - end - - - GUI.config.Bottom:Set(function() return okBtn.Bottom() + 5 end) - if defPosition ~= nil then - GUI.config.Top:Set(defPosition.top) - GUI.config.Left:Set(defPosition.left) - else - GUI.config.Top:Set(function() return LayoutHelpers.ScaleNumber(90) end) - end - GUI.config:SetPositionLock(false) -- allow window to be draggable, didn't worked in Window() call -end - -function CloseChatConfig() - if GUI.config then - GUI.config:Destroy() - GUI.config = nil - end + import("/lua/ui/game/chat/ChatController.lua").ActivateChat(modifiers) end diff --git a/lua/ui/game/chat/CHANGES.md b/lua/ui/game/chat/CHANGES.md index 4d4ff6c5a74..45b53719931 100644 --- a/lua/ui/game/chat/CHANGES.md +++ b/lua/ui/game/chat/CHANGES.md @@ -36,6 +36,19 @@ Legacy behaviour: pressing `Enter` on an empty edit box called `ToggleChat()` --- +## `ActivateChat` — engine Enter-key hook + +The Enter key outside the chat edit box is hard-bound by the engine to a global `ActivateChat(modifiers)` function. The legacy implementation lived at the top of `chat.lua`; the new wiring is: + +- [`ChatController.ActivateChat`](ChatController.lua) — holds the logic (resolve recipient from `send_type` + Shift, then toggle the window). Lives in the controller so it can read from the model and config model without going through globals. +- [`gamemain.ActivateChat`](../gamemain.lua) — a two-line global shim that delegates to the controller. Top-level functions in `gamemain.lua` are discoverable by the engine; modules loaded via `import()` are not, which is why the shim is necessary. + +Behaviour matches the old truth table: without Shift, use the `send_type` default; with Shift, flip the default. If a private recipient is already selected, Shift is ignored (the broadcast-channel switch doesn't clobber an in-progress whisper). + +## Legacy `chat.lua` renamed to `chat.legacy.lua` + +The original monolithic file is preserved on disk as [`chat.legacy.lua`](../chat.legacy.lua) so its source is still available as a reference while porting the remaining gaps. **No live importer remains** — every caller was repointed before the rename. The file can be deleted outright once [GAPS.md](GAPS.md) is empty. + ## Engine registration moved out of module-load `/lua/ui/game/chat.lua` registered its receive function and built-in commands as a side-effect of being imported. The new controller exposes an explicit [`ChatController.Init()`](ChatController.lua) which [`gamemain.lua`](../gamemain.lua) calls during UI setup, next to `taunt.Init()` and `build_templates.Init()`. diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index e09c330b3bf..0442e565b16 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -396,6 +396,38 @@ function Send(text) end end +------------------------------------------------------------------------------- +-- Engine hotkey entry point + +--- Opens the chat window with the recipient forced to `allies` or `all` +--- based on the `send_type` preference and the Shift modifier. The engine +--- calls this via a top-level `ActivateChat` shim in `gamemain.lua` when +--- the user presses Enter outside the edit box. +--- +--- Truth table (`send_type` reads as "default to allies"): +--- * `send_type=false`, no Shift → `all` +--- * `send_type=false`, Shift → `allies` +--- * `send_type=true`, no Shift → `allies` +--- * `send_type=true`, Shift → `all` +--- +--- If the current recipient is already a specific army ID (mid-private +--- message), it is left alone — Shift only switches between the two +--- broadcast channels. +---@param modifiers? table # engine-supplied modifier state ({Shift, Ctrl, ...}) +function ActivateChat(modifiers) + local model = ChatModel.GetSingleton() + if type(model.Recipient()) ~= 'number' then + local sendType = ChatConfigModel.GetSingleton().Committed().send_type or false + local shift = modifiers and modifiers.Shift or false + if (not shift) == sendType then + model.Recipient:Set(ChatModel.RecipientAllies) + else + model.Recipient:Set(ChatModel.RecipientAll) + end + end + import("/lua/ui/game/chat/ChatInterface.lua").Toggle() +end + ------------------------------------------------------------------------------- -- Lifecycle diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 01d8b27856a..a08441abc70 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -58,12 +58,6 @@ Slash commands in the old dispatcher fell through to `RunChatCommand` in [`/lua/ Legacy kept a `commandHistory` ring and recalled it on `VK_UP` / `VK_DOWN` ([chat.lua:681-701](../chat.lua)). Not ported. The new [`ChatEditInterface.OnNonTextKeyPressed`](ChatEditInterface.lua) only handles `PgUp` / `PgDn`. -## Shift-Enter → allies hotkey - -Legacy `ActivateChat(modifiers)` ([chat.lua:924-933](../chat.lua)) opened the window with the recipient forced to allies when Shift was held — the primary way to toggle between `all` and `allies` mid-typing. [`keyactions.lua` `chat_window`](../../../keymap/keyactions.lua) only toggles visibility; the modifier argument is gone. - -[`ChatController.ApplyDefaultRecipient`](ChatController.lua) reads the `send_type` preference but ignores any caller-supplied modifier. - ## Drag / resize / window-state Smaller things still missing on the window itself: @@ -99,4 +93,4 @@ If any external mod still calls these (no in-tree callers remain), they will bre ## Already closed (do not re-list) -Send path, receive path, `FindClients`, controller `Init`, external importers, skin-layout orphan, `ConsoleOutput` sim-side logging, empty-text Enter. +Send path, receive path, `FindClients`, controller `Init`, external importers, skin-layout orphan, `ConsoleOutput` sim-side logging, empty-text Enter, `ActivateChat` (Enter-key hook with Shift → allies), `chat.lua` renamed to `chat.legacy.lua`. diff --git a/lua/ui/game/gamemain.lua b/lua/ui/game/gamemain.lua index 320c3b1e3d2..68310342df1 100644 --- a/lua/ui/game/gamemain.lua +++ b/lua/ui/game/gamemain.lua @@ -734,6 +734,7 @@ function OnQueueChanged(newQueue) end end + --- Called by the engine after the sim confirmed that the game is indeed paused. This is run on all instances that are connected to the lobby. ---@param pausedBy integer # The index of the client in the clients list (that you get via `GetSessionClients`) ---@param timeoutsRemaining number From 693324d5414f7fd61b194f8baeded5c51426eed3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 21:18:37 +0200 Subject: [PATCH 038/130] Remove reference to rework of ActiveChat --- lua/ui/game/chat/CHANGES.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lua/ui/game/chat/CHANGES.md b/lua/ui/game/chat/CHANGES.md index 45b53719931..1314f710414 100644 --- a/lua/ui/game/chat/CHANGES.md +++ b/lua/ui/game/chat/CHANGES.md @@ -36,15 +36,6 @@ Legacy behaviour: pressing `Enter` on an empty edit box called `ToggleChat()` --- -## `ActivateChat` — engine Enter-key hook - -The Enter key outside the chat edit box is hard-bound by the engine to a global `ActivateChat(modifiers)` function. The legacy implementation lived at the top of `chat.lua`; the new wiring is: - -- [`ChatController.ActivateChat`](ChatController.lua) — holds the logic (resolve recipient from `send_type` + Shift, then toggle the window). Lives in the controller so it can read from the model and config model without going through globals. -- [`gamemain.ActivateChat`](../gamemain.lua) — a two-line global shim that delegates to the controller. Top-level functions in `gamemain.lua` are discoverable by the engine; modules loaded via `import()` are not, which is why the shim is necessary. - -Behaviour matches the old truth table: without Shift, use the `send_type` default; with Shift, flip the default. If a private recipient is already selected, Shift is ignored (the broadcast-channel switch doesn't clobber an in-progress whisper). - ## Legacy `chat.lua` renamed to `chat.legacy.lua` The original monolithic file is preserved on disk as [`chat.legacy.lua`](../chat.legacy.lua) so its source is still available as a reference while porting the remaining gaps. **No live importer remains** — every caller was repointed before the rename. The file can be deleted outright once [GAPS.md](GAPS.md) is empty. From f2c8e6a82dc2977b0d279e45e3e56d4daa65efdb Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 21:53:08 +0200 Subject: [PATCH 039/130] Add feature to click on player name to whisper --- lua/ui/game/chat/ChatInterface.lua | 19 +++++++++++- lua/ui/game/chat/ChatLineInterface.lua | 40 ++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 451e33df000..fd9c7472208 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -89,6 +89,7 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@field HistoryObserver LazyVar # derived from ChatModel.History ---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible ---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed +---@field OnLineNameClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared row-name click handler; captures `self` so pool lines don't allocate per-row closures local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface @@ -128,6 +129,20 @@ local ChatInterface = ClassUI(Window) { -- The edit area sits at the bottom of the client region. self.Edit = ChatEditInterface(client) + -- Shared row-name click handler. Built once per window so every + -- pool line can point `OnNameClicked` at the same reference — + -- pool growth never allocates a per-row closure. Captures `self` + -- so we can re-focus the edit box after retargeting. + self.OnLineNameClicked = function(_, entry) + -- Ignore clicks on your own name — whispering yourself is + -- pointless and the picker would still route it as a private + -- message. + if entry.ArmyID and entry.ArmyID ~= GetFocusArmy() then + ChatController.SetRecipient(entry.ArmyID) + self.Edit:AcquireFocus() + end + end + -- Reactive subscriptions use `LazyVarDerive` so each observer is a -- fresh LazyVar that reads from an upstream model field — setting -- our handler can never stomp another subscriber's (see the chat @@ -338,6 +353,7 @@ local ChatInterface = ClassUI(Window) { if not self.Lines[1] then self.Lines[1] = ChatLineInterface(container) self.Lines[1]:SetFontSize(self.FontSize) + self.Lines[1].OnNameClicked = self.OnLineNameClicked Layouter(self.Lines[1]) :AtLeftTopIn(container) :Right(container.Right) @@ -354,6 +370,7 @@ local ChatInterface = ClassUI(Window) { for i = currentCount + 1, neededLines do self.Lines[i] = ChatLineInterface(container) self.Lines[i]:SetFontSize(self.FontSize) + self.Lines[i].OnNameClicked = self.OnLineNameClicked Layouter(self.Lines[i]) :Below(self.Lines[i - 1]) :AtLeftIn(container) @@ -586,7 +603,7 @@ local ChatInterface = ClassUI(Window) { if wrappedIdx == 1 then line:SetHeader(entry, wrappedText) else - line:SetContinuation(wrappedText) + line:SetContinuation(entry, wrappedText) end line:Show() diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index aa0ba721328..c5f2d1e5105 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -30,6 +30,7 @@ table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') ---@field FactionIcon Bitmap ---@field Name Text ---@field Text Text +---@field Entry UIChatEntry | nil ChatLineInterface = ClassUI(Group) { ---@param self UIChatLineInterface @@ -46,13 +47,23 @@ ChatLineInterface = ClassUI(Group) { self.Name = UIUtil.CreateText(self, '', 14, 'Arial Bold') self.Name:SetColor('ffffffff') self.Name:SetDropShadow(true) - self.Name:DisableHitTest() + -- Empty-name continuation lines have zero width here, so the hit + -- rect collapses with them — no need to gate dispatch on row role. + self.Name.HandleEvent = function(_, event) + if event.Type == 'ButtonPress' and self.Entry then + self:OnNameClicked(self.Entry) + end + end self.Text = UIUtil.CreateText(self, '', 14, 'Arial') self.Text:SetColor('ffc2f6ff') self.Text:SetDropShadow(true) self.Text:SetClipToWidth(true) - self.Text:DisableHitTest() + self.Text.HandleEvent = function(_, event) + if event.Type == 'ButtonPress' and self.Entry then + self:OnBodyClicked(self.Entry) + end + end end, ---@param self UIChatLineInterface @@ -94,6 +105,7 @@ ChatLineInterface = ClassUI(Group) { ---@param entry UIChatEntry ---@param wrappedText string # the first wrapped chunk of `entry.Text` SetHeader = function(self, entry, wrappedText) + self.Entry = entry self.Name:SetText(entry.Name or '') self.Text:SetText(wrappedText or entry.Text or '') self.TeamColor:SetSolidColor(entry.Color or '00000000') @@ -107,9 +119,14 @@ ChatLineInterface = ClassUI(Group) { --- The text control remains anchored to `Name.Right + 2`; with an empty --- name that resolves to the left of the row, so continuation lines --- naturally line up under the first wrapped chunk. + --- + --- The entry is still tracked so body clicks on wrapped lines dispatch + --- against the same message the header belongs to. ---@param self UIChatLineInterface + ---@param entry UIChatEntry ---@param wrappedText string - SetContinuation = function(self, wrappedText) + SetContinuation = function(self, entry, wrappedText) + self.Entry = entry self.Name:SetText('') self.Text:SetText(wrappedText or '') self.TeamColor:SetSolidColor('00000000') @@ -119,12 +136,29 @@ ChatLineInterface = ClassUI(Group) { --- Clears all content so the row can stand empty. ---@param self UIChatLineInterface Clear = function(self) + self.Entry = nil self.Name:SetText('') self.Text:SetText('') self.TeamColor:SetSolidColor('00000000') self.FactionIcon:SetSolidColor('00000000') end, + --- Overridable: fires on a click on the sender name. Continuation + --- lines have an empty name control so the hit rect collapses — this + --- only runs on header rows in practice. Default is a no-op; replace + --- the field on an instance to subscribe. + ---@param self UIChatLineInterface + ---@param entry UIChatEntry + OnNameClicked = function(self, entry) end, + + --- Overridable: fires on a click on the message body. Runs for both + --- header and continuation rows — they share the same entry — so a + --- click anywhere on a wrapped message resolves to the right sender. + --- Default is a no-op; replace the field on an instance to subscribe. + ---@param self UIChatLineInterface + ---@param entry UIChatEntry + OnBodyClicked = function(self, entry) end, + --- Updates the font size for both name and body text. The row's `Height` --- LazyVar is derived from `Name.Height`, so the row resizes automatically. ---@param self UIChatLineInterface From b1c7281f584d6de44495d05ebef969c8216be41e Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 21:59:24 +0200 Subject: [PATCH 040/130] Add support for autocomplete --- lua/ui/game/chat/ChatCompletion.lua | 156 +++++++++++++++++++++++++ lua/ui/game/chat/ChatEditInterface.lua | 81 +++++++++++-- 2 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 lua/ui/game/chat/ChatCompletion.lua diff --git a/lua/ui/game/chat/ChatCompletion.lua b/lua/ui/game/chat/ChatCompletion.lua new file mode 100644 index 00000000000..c1123a560ce --- /dev/null +++ b/lua/ui/game/chat/ChatCompletion.lua @@ -0,0 +1,156 @@ + +local CommandRegistry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + +------------------------------------------------------------------------------- +-- Pure tab-completion helper for the chat edit box. +-- +-- Compute(text, caret) → UIChatCompletion | nil +-- +-- The caller (ChatEditInterface) owns the cycle state; this module only +-- produces a fresh record. Positions are codepoint offsets (0-indexed) so +-- they line up with Edit:GetCaretPosition / SetCaretPosition and remain +-- stable across UTF-8 input. +-- +-- Branches: +-- 1. Command — text starts with '/' AND the caret sits inside the first +-- token. Candidates come from the registry. +-- 2. Name — otherwise, complete the current word against non-civilian +-- army nicknames. + +---@class UIChatCompletion +---@field Anchor number # codepoint offset (0-indexed) where the replaced word begins +---@field Consume number # codepoint count of the original word consumed from `Anchor` +---@field Prefix string # original text between Anchor and the caret (for diagnostics) +---@field Candidates string[] # replacement strings in cycle order +---@field Index number # 1-based index of the currently applied candidate +---@field Suffix string # text appended after the candidate (' ' when unambiguous) + +--- Returns the codepoint of the last space at or before `caret`, or 0 if none. +--- Space is ASCII so a codepoint-by-codepoint walk is safe. +---@param text string +---@param caret number +---@return number +local function LastSpaceBefore(text, caret) + local i = caret + while i > 0 do + if STR_Utf8SubString(text, i, 1) == ' ' then + return i + end + i = i - 1 + end + return 0 +end + +--- Returns the codepoint position of the next space at or after `caret + 1`, +--- or the total codepoint length when the word runs to end-of-text. +---@param text string +---@param caret number +---@param textLen number +---@return number +local function NextSpaceAfter(text, caret, textLen) + local i = caret + while i < textLen do + if STR_Utf8SubString(text, i + 1, 1) == ' ' then + return i + end + i = i + 1 + end + return textLen +end + +--- Non-civilian nicknames from the armies table, minus the local player. +--- `focusArmy` is the local player's army ID; it's 0 for observers (so the +--- comparison below is a no-op in observer mode, which is the behaviour we +--- want — observers have no nickname to complete anyway). +---@return string[] +local function CollectNicknames() + local out = {} + local armies = GetArmiesTable() + if not armies or not armies.armiesTable then return out end + local selfArmy = armies.focusArmy + for id, army in armies.armiesTable do + if id ~= selfArmy and not army.civilian and army.nickname then + table.insert(out, army.nickname) + end + end + return out +end + +---@param s string +---@param prefix string +---@return boolean +local function StartsWithCI(s, prefix) + return string.lower(string.sub(s, 1, string.len(prefix))) == string.lower(prefix) +end + +--- Computes a completion record for the caret position in `text`, or nil if +--- nothing matches. The returned record's `Consume` covers the full word +--- under the caret (so completing mid-word overwrites the tail too). +---@param text string +---@param caret number +---@return UIChatCompletion? +function Compute(text, caret) + if not text or text == '' then return nil end + + local textLen = STR_Utf8Len(text) + if caret > textLen then caret = textLen end + + local wordStart = LastSpaceBefore(text, caret) + local wordEnd = NextSpaceAfter(text, caret, textLen) + local isCommand = (wordStart == 0) and (STR_Utf8SubString(text, 1, 1) == '/') + + -- Only append a trailing space when the completion is unambiguous AND + -- the word runs to end-of-text; adding one before an existing space would + -- double up. + local atEnd = wordEnd == textLen + + if isCommand then + local prefix = STR_Utf8SubString(text, 2, caret - 1) + local matches = CommandRegistry.FindMatching(prefix) + local n = table.getn(matches) + if n == 0 then return nil end + local candidates = {} + for _, cmd in ipairs(matches) do + table.insert(candidates, '/' .. cmd.Name) + end + return { + Anchor = 0, + Consume = wordEnd, + Prefix = '/' .. prefix, + Candidates = candidates, + Index = 1, + Suffix = (n == 1 and atEnd) and ' ' or '', + } + end + + local prefix = STR_Utf8SubString(text, wordStart + 1, caret - wordStart) + if prefix == '' then return nil end + + local candidates = {} + for _, name in ipairs(CollectNicknames()) do + if StartsWithCI(name, prefix) then + table.insert(candidates, name) + end + end + local n = table.getn(candidates) + if n == 0 then return nil end + table.sort(candidates) + + return { + Anchor = wordStart, + Consume = wordEnd - wordStart, + Prefix = prefix, + Candidates = candidates, + Index = 1, + Suffix = (n == 1 and atEnd) and ' ' or '', + } +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 26043589ab8..0395076f90e 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -8,6 +8,7 @@ local Button = import("/lua/maui/button.lua").Button local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") +local ChatCompletion = import("/lua/ui/game/chat/ChatCompletion.lua") local ChatListInterface = import("/lua/ui/game/chat/ChatListInterface.lua").ChatListInterface local ChatCommandHintInterface = import("/lua/ui/game/chat/ChatCommandHintInterface.lua").ChatCommandHintInterface @@ -29,7 +30,9 @@ local MaxChars = 200 ---@field EditBox Edit ---@field ChatList UIChatListInterface | nil ---@field CommandHint UIChatCommandHintInterface | nil ----@field RecipientObserver LazyVar # derived from ChatModel.Recipient +---@field RecipientObserver LazyVar # derived from ChatModel.Recipient +---@field Completion UIChatCompletion | nil # active Tab-cycle record, reset on text change +---@field SuppressCompletionReset boolean # true while our own SetText is running ChatEditInterface = ClassUI(Group) { ---@param self UIChatEditInterface @@ -42,6 +45,9 @@ ChatEditInterface = ClassUI(Group) { -- Emptied in `OnDestroy`. self.Trash = TrashBag() + self.Completion = nil + self.SuppressCompletionReset = false + self.ChatBubble = Button(self, UIUtil.UIFile('/game/chat-box_btn/radio_btn_up.dds'), UIUtil.UIFile('/game/chat-box_btn/radio_btn_down.dds'), @@ -95,19 +101,24 @@ ChatEditInterface = ClassUI(Group) { self:CloseCommandHint() end - -- Drive the command-hint popup from the edit-box contents. - -- `OnTextChanged` fires after every insertion, deletion, or `SetText`, - -- so we don't need to poll each frame. + -- Drive the command-hint popup from the edit-box contents, and drop + -- any in-flight Tab-completion cycle whenever the text changes from + -- something other than our own `ApplyCompletion` call. self.EditBox.OnTextChanged = function(_, newText, _) self:RefreshCommandHint(newText or '') + if not self.SuppressCompletionReset then + self.Completion = nil + end end - -- Swallow Tab so focus can't leave the edit box, and ring the error - -- cue when the user types against the character cap. `OnCharPressed` - -- fires before insertion, so `>=` here matches the already-committed - -- length *before* this keystroke — i.e. we beep on the rejected char. + -- Tab runs completion (commands when text starts with '/' and the + -- caret is in the first token, player nicknames otherwise). Repeat + -- presses cycle through candidates; any other keystroke resets the + -- cycle via `OnTextChanged`. `OnCharPressed` fires before insertion, + -- so the `>=` beep catches the keystroke the cap is about to reject. self.EditBox.OnCharPressed = function(edit, charcode) if charcode == UIUtil.VK_TAB then + self:HandleTabCompletion() return true end if STR_Utf8Len(edit:GetText()) >= edit:GetMaxChars() then @@ -151,6 +162,60 @@ ChatEditInterface = ClassUI(Group) { end)) end, + --- Entry point for the Tab key. On the first press, computes a fresh + --- completion record; on subsequent presses, cycles to the next + --- candidate. Plays the error cue when there is nothing to complete so + --- the user isn't left wondering whether the key was handled. + ---@param self UIChatEditInterface + HandleTabCompletion = function(self) + if self.Completion then + local c = self.Completion + c.Index = math.mod(c.Index, table.getn(c.Candidates)) + 1 + self:ApplyCompletion() + return + end + + local text = self.EditBox:GetText() or '' + local caret = self.EditBox:GetCaretPosition() + local completion = ChatCompletion.Compute(text, caret) + if not completion then + PlaySound(Sound({ Cue = 'UI_Menu_Error_01', Bank = 'Interface' })) + return + end + self.Completion = completion + self:ApplyCompletion() + end, + + --- Writes the current candidate into the edit box at the recorded anchor, + --- overwriting the consumed word. `SuppressCompletionReset` guards the + --- `OnTextChanged` branch that would otherwise clear the cycle state as + --- a side-effect of our own edit. + ---@param self UIChatEditInterface + ApplyCompletion = function(self) + if not self.Completion then return end + local c = self.Completion --[[@as UIChatCompletion]] + + local text = self.EditBox:GetText() or '' + local totalLen = STR_Utf8Len(text) + local tailStart = c.Anchor + c.Consume + local before = c.Anchor > 0 and STR_Utf8SubString(text, 1, c.Anchor) or '' + local after = tailStart < totalLen + and STR_Utf8SubString(text, tailStart + 1, totalLen - tailStart) + or '' + local replacement = c.Candidates[c.Index] .. c.Suffix + local replacementLen = STR_Utf8Len(replacement) + local newText = before .. replacement .. after + + self.SuppressCompletionReset = true + self.EditBox:SetText(newText) + self.EditBox:SetCaretPosition(c.Anchor + replacementLen) + self.SuppressCompletionReset = false + + -- Advance the consumed span to match what we just wrote so the next + -- cycle overwrites exactly this candidate, not the original word. + c.Consume = replacementLen + end, + --- Shows or hides the command hint based on the current edit-box text. --- Only opens when the text transitions to exactly `/` — so closing the --- hint via Escape leaves it closed while the user keeps typing past the From 8e70c3be331c82f95b76ffc5e22ae0fb05c34572 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 22:03:32 +0200 Subject: [PATCH 041/130] Fix bug where shift + enter did not work --- lua/ui/game/chat/ChatController.lua | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 0442e565b16..3e3bb082666 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -84,12 +84,11 @@ end --- resets its internal tables) doesn't leave us with an empty registry. function RegisterBuiltinCommands() local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") - local Builtins = import("/lua/ui/game/chat/commands/BuiltinCommands.lua") - Registry.Register(Builtins.All) - Registry.Register(Builtins.Allies) - Registry.Register(Builtins.Whisper) - Registry.Register(Builtins.Help) + Registry.Register(import("/lua/ui/game/chat/commands/All.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/Allies.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/Whisper.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/Help.lua").Command) end ------------------------------------------------------------------------------- @@ -416,7 +415,17 @@ end ---@param modifiers? table # engine-supplied modifier state ({Shift, Ctrl, ...}) function ActivateChat(modifiers) local model = ChatModel.GetSingleton() - if type(model.Recipient()) ~= 'number' then + local wasVisible = model.WindowVisible() + + -- Toggle first. On open this runs `ApplyDefaultRecipient`, which picks + -- a recipient from `send_type` alone — it doesn't see modifiers. On + -- close it just flips visibility and we leave the recipient alone. + import("/lua/ui/game/chat/ChatInterface.lua").Toggle() + + -- Layer the Shift modifier on top of the default. Must happen AFTER + -- the toggle above — writing to `Recipient` before `ToggleWindow` runs + -- gets clobbered by its own `ApplyDefaultRecipient` call. + if not wasVisible and type(model.Recipient()) ~= 'number' then local sendType = ChatConfigModel.GetSingleton().Committed().send_type or false local shift = modifiers and modifiers.Shift or false if (not shift) == sendType then @@ -425,7 +434,6 @@ function ActivateChat(modifiers) model.Recipient:Set(ChatModel.RecipientAll) end end - import("/lua/ui/game/chat/ChatInterface.lua").Toggle() end ------------------------------------------------------------------------------- From ed30748d74b5f57fc976e4622331b64b58b269de Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 22:10:22 +0200 Subject: [PATCH 042/130] Refactor commands to be in separate files --- lua/ui/game/chat/ChatController.lua | 1 + lua/ui/game/chat/commands/All.lua | 23 +++++ lua/ui/game/chat/commands/Allies.lua | 24 +++++ lua/ui/game/chat/commands/BuiltinCommands.lua | 95 ------------------- lua/ui/game/chat/commands/Gift.lua | 66 +++++++++++++ lua/ui/game/chat/commands/Help.lua | 44 +++++++++ lua/ui/game/chat/commands/Whisper.lua | 33 +++++++ lua/ui/game/chat/commands/design.md | 4 +- 8 files changed, 193 insertions(+), 97 deletions(-) create mode 100644 lua/ui/game/chat/commands/All.lua create mode 100644 lua/ui/game/chat/commands/Allies.lua delete mode 100644 lua/ui/game/chat/commands/BuiltinCommands.lua create mode 100644 lua/ui/game/chat/commands/Gift.lua create mode 100644 lua/ui/game/chat/commands/Help.lua create mode 100644 lua/ui/game/chat/commands/Whisper.lua diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 3e3bb082666..da0cf76635e 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -88,6 +88,7 @@ function RegisterBuiltinCommands() Registry.Register(import("/lua/ui/game/chat/commands/All.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/Allies.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/Whisper.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/Gift.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/Help.lua").Command) end diff --git a/lua/ui/game/chat/commands/All.lua b/lua/ui/game/chat/commands/All.lua new file mode 100644 index 00000000000..3a89e628353 --- /dev/null +++ b/lua/ui/game/chat/commands/All.lua @@ -0,0 +1,23 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +------------------------------------------------------------------------------- +-- /all — switch the send target to every player and observer. + +---@type UIChatCommand +Command = { + Name = 'all', + Description = 'Send to all players and observers.', + Execute = function(_, ctx) + ctx.Controller.SetRecipient(ChatModel.RecipientAll) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/Allies.lua b/lua/ui/game/chat/commands/Allies.lua new file mode 100644 index 00000000000..88175757324 --- /dev/null +++ b/lua/ui/game/chat/commands/Allies.lua @@ -0,0 +1,24 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +------------------------------------------------------------------------------- +-- /allies (aka /team) — switch the send target to allies only. + +---@type UIChatCommand +Command = { + Name = 'allies', + Aliases = { 'team' }, + Description = 'Send to allies only.', + Execute = function(_, ctx) + ctx.Controller.SetRecipient(ChatModel.RecipientAllies) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/BuiltinCommands.lua b/lua/ui/game/chat/commands/BuiltinCommands.lua deleted file mode 100644 index cc47659bf1a..00000000000 --- a/lua/ui/game/chat/commands/BuiltinCommands.lua +++ /dev/null @@ -1,95 +0,0 @@ - -local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") - -------------------------------------------------------------------------------- --- Built-in chat commands. --- --- Each command is exported as a top-level `UIChatCommand` table. Importing --- this module has no side effects; `ChatController.RegisterBuiltinCommands` --- is responsible for handing these off to the registry. - -------------------------------------------------------------------------------- --- Recipient switching - ----@type UIChatCommand -All = { - Name = 'all', - Description = 'Send to all players and observers.', - Execute = function(_, ctx) - ctx.Controller.SetRecipient(ChatModel.RecipientAll) - end, -} - ----@type UIChatCommand -Allies = { - Name = 'allies', - Aliases = { 'team' }, - Description = 'Send to allies only.', - Execute = function(_, ctx) - ctx.Controller.SetRecipient(ChatModel.RecipientAllies) - end, -} - ----@type UIChatCommand -Whisper = { - Name = 'whisper', - Aliases = { 'w', 'pm' }, - Description = 'Whisper to a specific player (by nickname or army ID).', - Params = { - { Name = 'target', Type = 'Player' }, - }, - Accept = function(args) - local armies = GetArmiesTable() - if armies and args.target == armies.focusArmy then - return false, "/whisper: can't whisper yourself." - end - return true - end, - Execute = function(args, ctx) - ctx.Controller.SetRecipient(args.target) - end, -} - -------------------------------------------------------------------------------- --- Introspection - ----@type UIChatCommand -Help = { - Name = 'help', - Aliases = { '?' }, - Description = 'Lists available chat commands.', - Execute = function(_, ctx) - local controller = ctx.Controller - local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") - - controller.AppendLocalSystemMessage("Available chat commands:") - - for _, cmd in ipairs(Registry.GetAll()) do - local params = '' - if cmd.Params then - for _, p in ipairs(cmd.Params) do - local fmt = p.Optional and ' [%s]' or ' <%s>' - params = params .. string.format(fmt, p.Name) - end - end - - local aliases = '' - if cmd.Aliases and table.getn(cmd.Aliases) > 0 then - aliases = ' (aka /' .. table.concat(cmd.Aliases, ', /') .. ')' - end - - controller.AppendLocalSystemMessage( - string.format(" /%s%s%s — %s", cmd.Name, params, aliases, cmd.Description or '') - ) - end - end, -} - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/Gift.lua b/lua/ui/game/chat/commands/Gift.lua new file mode 100644 index 00000000000..dfd9974c373 --- /dev/null +++ b/lua/ui/game/chat/commands/Gift.lua @@ -0,0 +1,66 @@ + +------------------------------------------------------------------------------- +-- /gift — transfer the current selection to an allied player. +-- Mirrors the Shift-click gift on the score panel: observers can't gift, +-- a lone ACU can't be gifted, and the sim side still re-checks alliance +-- and `ManualUnitShare` before transferring ownership. + +---@type UIChatCommand +Command = { + Name = 'gift', + Description = 'Gift the current selection to an ally. If no target is given, the unit under the cursor is used.', + Params = { + { Name = 'target', Type = 'Player', Optional = true }, + }, + Accept = function(args) + local focusArmy = GetFocusArmy() + if focusArmy == -1 then + return false, "/gift: observers can't gift units." + end + + -- Fall back to the unit currently under the cursor. `armyIndex` on + -- the rollover is zero-based, so bump it to match the armies-table + -- convention the rest of the command uses. + if args.target == nil then + local info = GetRolloverInfo() + if not info or not info.armyIndex then + return false, "/gift: no target given and no unit under the cursor." + end + args.target = math.floor(info.armyIndex) + 1 + end + + if args.target == focusArmy then + return false, "/gift: can't gift to yourself." + end + if not IsAlly(focusArmy, args.target) then + return false, "/gift: target must be an ally." + end + + local selection = GetSelectedUnits() + if not selection or table.getn(selection) == 0 then + return false, "/gift: no units selected." + end + if table.getn(selection) == 1 and EntityCategoryContains(categories.COMMAND, selection[1]) then + return false, "/gift: can't gift your ACU." + end + + return true + end, + Execute = function(args) + -- `true` as the second arg tells the engine to pass the current + -- selection through to the sim handler as `units`. + SimCallback({ + Func = "GiveUnitsToPlayer", + Args = { From = GetFocusArmy(), To = args.target }, + }, true) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/Help.lua b/lua/ui/game/chat/commands/Help.lua new file mode 100644 index 00000000000..a2bfb372146 --- /dev/null +++ b/lua/ui/game/chat/commands/Help.lua @@ -0,0 +1,44 @@ + +local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + +------------------------------------------------------------------------------- +-- /help (aka /?) — prints every registered command as a local system line. + +---@type UIChatCommand +Command = { + Name = 'help', + Aliases = { '?' }, + Description = 'Lists available chat commands.', + Execute = function(_, ctx) + local controller = ctx.Controller + controller.AppendLocalSystemMessage("Available chat commands:") + + for _, cmd in ipairs(Registry.GetAll()) do + local params = '' + if cmd.Params then + for _, p in ipairs(cmd.Params) do + local fmt = p.Optional and ' [%s]' or ' <%s>' + params = params .. string.format(fmt, p.Name) + end + end + + local aliases = '' + if cmd.Aliases and table.getn(cmd.Aliases) > 0 then + aliases = ' (aka /' .. table.concat(cmd.Aliases, ', /') .. ')' + end + + controller.AppendLocalSystemMessage( + string.format(" /%s%s%s — %s", cmd.Name, params, aliases, cmd.Description or '') + ) + end + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/Whisper.lua b/lua/ui/game/chat/commands/Whisper.lua new file mode 100644 index 00000000000..9881112a04e --- /dev/null +++ b/lua/ui/game/chat/commands/Whisper.lua @@ -0,0 +1,33 @@ + +------------------------------------------------------------------------------- +-- /whisper (aka /w, /pm) — private-message a specific player. +-- The `target` parameter type is resolved by `ChatCommandTypes.lua`. + +---@type UIChatCommand +Command = { + Name = 'whisper', + Aliases = { 'w', 'pm' }, + Description = 'Whisper to a specific player (by nickname or army ID).', + Params = { + { Name = 'target', Type = 'Player' }, + }, + Accept = function(args) + local armies = GetArmiesTable() + if armies and args.target == armies.focusArmy then + return false, "/whisper: can't whisper yourself." + end + return true + end, + Execute = function(args, ctx) + ctx.Controller.SetRecipient(args.target) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/design.md b/lua/ui/game/chat/commands/design.md index d9e785638c1..442523dfc1b 100644 --- a/lua/ui/game/chat/commands/design.md +++ b/lua/ui/game/chat/commands/design.md @@ -134,9 +134,9 @@ Splitting `Accept` out keeps the failure path uniform (always surfaces as a syst ## 8. Bootstrap -`BuiltinCommands.lua` has no side effects on import — it just exports each command as a named `UIChatCommand` table (`All`, `Allies`, `Whisper`, `Help`). Importing the module does not register anything. +Each built-in command lives in its own file under `commands/` (e.g. `All.lua`, `Allies.lua`, `Whisper.lua`, `Help.lua`) and exports a single top-level `Command` table. Importing a command file has no side effects — a command is inert until the controller registers it. One command per file keeps the diff footprint of adding, removing, or overriding a command local to that file, which is the whole reason builtins were split out of the old monolithic module. -`ChatController.RegisterBuiltinCommands()` is the single registration site: it pulls the named exports from `BuiltinCommands` and hands them to `ChatCommandRegistry.Register`. It is idempotent, so it can be called from multiple init paths without harm. `ChatController.Send` invokes it lazily on the first slash-prefixed message so the feature works without an explicit init hook; once a proper `ChatController:Init` exists (see `CLAUDE.md §Init`), the call should move there. +`ChatController.RegisterBuiltinCommands()` is the single registration site: it imports each command file and hands its `Command` export to `ChatCommandRegistry.Register`. It is idempotent, so it can be called from multiple init paths without harm. `ChatController.Send` invokes it lazily on the first slash-prefixed message so the feature works without an explicit init hook; `ChatController:Init` also calls it at startup. External modules (notify, mods, future subsystems) register their own commands by calling `Registry.Register` directly, independent of the builtins. From c1b55fb0224d0cc354033efeb9cf58766ed23312 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Thu, 23 Apr 2026 22:26:17 +0200 Subject: [PATCH 043/130] Add other commands that may be interesting and put them into a builtin folder --- .claude/settings.local.json | 3 +- lua/ui/game/chat/ChatController.lua | 13 +-- .../game/chat/commands/{ => builtin}/All.lua | 0 .../chat/commands/{ => builtin}/Allies.lua | 0 .../chat/commands/builtin/GiftResources.lua | 86 +++++++++++++++++++ .../{Gift.lua => builtin/GiftUnits.lua} | 16 ++-- .../game/chat/commands/{ => builtin}/Help.lua | 0 lua/ui/game/chat/commands/builtin/Recall.lua | 34 ++++++++ .../chat/commands/builtin/ToEngineers.lua | 35 ++++++++ .../chat/commands/{ => builtin}/Whisper.lua | 0 lua/ui/game/chat/commands/design.md | 2 +- 11 files changed, 174 insertions(+), 15 deletions(-) rename lua/ui/game/chat/commands/{ => builtin}/All.lua (100%) rename lua/ui/game/chat/commands/{ => builtin}/Allies.lua (100%) create mode 100644 lua/ui/game/chat/commands/builtin/GiftResources.lua rename lua/ui/game/chat/commands/{Gift.lua => builtin/GiftUnits.lua} (78%) rename lua/ui/game/chat/commands/{ => builtin}/Help.lua (100%) create mode 100644 lua/ui/game/chat/commands/builtin/Recall.lua create mode 100644 lua/ui/game/chat/commands/builtin/ToEngineers.lua rename lua/ui/game/chat/commands/{ => builtin}/Whisper.lua (100%) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5093c7aa7bb..dd64210a8c1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(git mv *)" + "Bash(git mv *)", + "Bash(git -C d:/faf-development/fa mv lua/ui/game/chat/commands/Gift.lua lua/ui/game/chat/commands/GiftUnits.lua)" ] } } diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index da0cf76635e..090ddb2728b 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -85,11 +85,14 @@ end function RegisterBuiltinCommands() local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") - Registry.Register(import("/lua/ui/game/chat/commands/All.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/Allies.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/Whisper.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/Gift.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/Help.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/All.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Allies.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Whisper.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/GiftUnits.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/GiftResources.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Recall.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/ToEngineers.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Help.lua").Command) end ------------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/commands/All.lua b/lua/ui/game/chat/commands/builtin/All.lua similarity index 100% rename from lua/ui/game/chat/commands/All.lua rename to lua/ui/game/chat/commands/builtin/All.lua diff --git a/lua/ui/game/chat/commands/Allies.lua b/lua/ui/game/chat/commands/builtin/Allies.lua similarity index 100% rename from lua/ui/game/chat/commands/Allies.lua rename to lua/ui/game/chat/commands/builtin/Allies.lua diff --git a/lua/ui/game/chat/commands/builtin/GiftResources.lua b/lua/ui/game/chat/commands/builtin/GiftResources.lua new file mode 100644 index 00000000000..1503ec8548f --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/GiftResources.lua @@ -0,0 +1,86 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +------------------------------------------------------------------------------- +-- /gift-resources [target] — gift a fraction of your mass +-- or energy to an ally. Percent is an integer 1-100; type is "mass" or +-- "energy". When no target is given, the current chat recipient is used, +-- but only if it's a specific player. +-- +-- The sim-side handler (`GiveResourcesToPlayer`) takes fractions (0-1), so +-- a user-friendly 1-100 is divided here before the callback. + +local function NormalizeType(token) + local lower = string.lower(token or '') + if lower == 'mass' or lower == 'm' then + return 'mass' + elseif lower == 'energy' or lower == 'e' then + return 'energy' + end + return nil +end + +---@type UIChatCommand +Command = { + Name = 'gift-resources', + Description = 'Gift a percentage (1-100) of your mass or energy to an ally.', + Params = { + { Name = 'percent', Type = 'Int' }, + { Name = 'type', Type = 'String' }, + { Name = 'target', Type = 'Player', Optional = true }, + }, + Accept = function(args, ctx) + local focusArmy = GetFocusArmy() + if focusArmy == -1 then + return false, "/gift-resources: observers can't gift resources." + end + + if args.percent < 1 or args.percent > 100 then + return false, "/gift-resources: percent must be between 1 and 100." + end + + local kind = NormalizeType(args.type) + if not kind then + return false, "/gift-resources: type must be 'mass' or 'energy'." + end + args.type = kind + + if args.target == nil then + local recipient = ctx.Model.Recipient() + if type(recipient) ~= 'number' then + return false, "/gift-resources: no target given and no player selected as chat recipient." + end + args.target = recipient + end + + if args.target == focusArmy then + return false, "/gift-resources: can't gift to yourself." + end + if not IsAlly(focusArmy, args.target) then + return false, "/gift-resources: target must be an ally." + end + + return true + end, + Execute = function(args) + local fraction = args.percent / 100 + SimCallback({ + Func = "GiveResourcesToPlayer", + Args = { + From = GetFocusArmy(), + To = args.target, + Mass = args.type == 'mass' and fraction or 0, + Energy = args.type == 'energy' and fraction or 0, + }, + }) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/Gift.lua b/lua/ui/game/chat/commands/builtin/GiftUnits.lua similarity index 78% rename from lua/ui/game/chat/commands/Gift.lua rename to lua/ui/game/chat/commands/builtin/GiftUnits.lua index dfd9974c373..14da06ab939 100644 --- a/lua/ui/game/chat/commands/Gift.lua +++ b/lua/ui/game/chat/commands/builtin/GiftUnits.lua @@ -1,13 +1,13 @@ ------------------------------------------------------------------------------- --- /gift — transfer the current selection to an allied player. +-- /gift-units — transfer the current selection to an allied player. -- Mirrors the Shift-click gift on the score panel: observers can't gift, -- a lone ACU can't be gifted, and the sim side still re-checks alliance -- and `ManualUnitShare` before transferring ownership. ---@type UIChatCommand Command = { - Name = 'gift', + Name = 'gift-units', Description = 'Gift the current selection to an ally. If no target is given, the unit under the cursor is used.', Params = { { Name = 'target', Type = 'Player', Optional = true }, @@ -15,7 +15,7 @@ Command = { Accept = function(args) local focusArmy = GetFocusArmy() if focusArmy == -1 then - return false, "/gift: observers can't gift units." + return false, "/gift-units: observers can't gift units." end -- Fall back to the unit currently under the cursor. `armyIndex` on @@ -24,24 +24,24 @@ Command = { if args.target == nil then local info = GetRolloverInfo() if not info or not info.armyIndex then - return false, "/gift: no target given and no unit under the cursor." + return false, "/gift-units: no target given and no unit under the cursor." end args.target = math.floor(info.armyIndex) + 1 end if args.target == focusArmy then - return false, "/gift: can't gift to yourself." + return false, "/gift-units: can't gift to yourself." end if not IsAlly(focusArmy, args.target) then - return false, "/gift: target must be an ally." + return false, "/gift-units: target must be an ally." end local selection = GetSelectedUnits() if not selection or table.getn(selection) == 0 then - return false, "/gift: no units selected." + return false, "/gift-units: no units selected." end if table.getn(selection) == 1 and EntityCategoryContains(categories.COMMAND, selection[1]) then - return false, "/gift: can't gift your ACU." + return false, "/gift-units: can't gift your ACU." end return true diff --git a/lua/ui/game/chat/commands/Help.lua b/lua/ui/game/chat/commands/builtin/Help.lua similarity index 100% rename from lua/ui/game/chat/commands/Help.lua rename to lua/ui/game/chat/commands/builtin/Help.lua diff --git a/lua/ui/game/chat/commands/builtin/Recall.lua b/lua/ui/game/chat/commands/builtin/Recall.lua new file mode 100644 index 00000000000..82fef541e03 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Recall.lua @@ -0,0 +1,34 @@ + +------------------------------------------------------------------------------- +-- /recall — cast a "yes" vote on the team recall. Mirrors clicking the +-- `Recall` button in the diplomacy panel. Observers can't vote. +-- +-- Only the "yes" case is exposed for now; voting no is rare enough that the +-- diplomacy UI suffices. + +---@type UIChatCommand +Command = { + Name = 'recall', + Description = 'Vote yes on the team recall.', + Accept = function() + if GetFocusArmy() == -1 then + return false, "/recall: observers can't vote." + end + return true + end, + Execute = function() + SimCallback({ + Func = "SetRecallVote", + Args = { From = GetFocusArmy(), Vote = true }, + }) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/ToEngineers.lua b/lua/ui/game/chat/commands/builtin/ToEngineers.lua new file mode 100644 index 00000000000..c235dad4e90 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/ToEngineers.lua @@ -0,0 +1,35 @@ + +------------------------------------------------------------------------------- +-- /to-engineers — narrow the current selection to just the engineers. If +-- nothing is selected, or none of the selected units are engineers, the +-- command reports an error rather than silently clearing the selection. + +---@type UIChatCommand +Command = { + Name = 'to-engineers', + Description = 'Narrow the current selection to engineers only.', + Accept = function(args) + local selection = GetSelectedUnits() + if not selection or table.getn(selection) == 0 then + return false, "/to-engineers: nothing selected." + end + local engineers = EntityCategoryFilterDown(categories.ENGINEER, selection) + if table.getn(engineers) == 0 then + return false, "/to-engineers: no engineers in selection." + end + args.engineers = engineers + return true + end, + Execute = function(args) + SelectUnits(args.engineers) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/Whisper.lua b/lua/ui/game/chat/commands/builtin/Whisper.lua similarity index 100% rename from lua/ui/game/chat/commands/Whisper.lua rename to lua/ui/game/chat/commands/builtin/Whisper.lua diff --git a/lua/ui/game/chat/commands/design.md b/lua/ui/game/chat/commands/design.md index 442523dfc1b..042cb47f2fa 100644 --- a/lua/ui/game/chat/commands/design.md +++ b/lua/ui/game/chat/commands/design.md @@ -134,7 +134,7 @@ Splitting `Accept` out keeps the failure path uniform (always surfaces as a syst ## 8. Bootstrap -Each built-in command lives in its own file under `commands/` (e.g. `All.lua`, `Allies.lua`, `Whisper.lua`, `Help.lua`) and exports a single top-level `Command` table. Importing a command file has no side effects — a command is inert until the controller registers it. One command per file keeps the diff footprint of adding, removing, or overriding a command local to that file, which is the whole reason builtins were split out of the old monolithic module. +Each built-in command lives in its own file under `commands/builtin/` (e.g. `All.lua`, `Allies.lua`, `Whisper.lua`, `Help.lua`) and exports a single top-level `Command` table. Importing a command file has no side effects — a command is inert until the controller registers it. One command per file keeps the diff footprint of adding, removing, or overriding a command local to that file, which is the whole reason builtins were split out of the old monolithic module. The registry and parameter-type resolvers stay at `commands/` so the infrastructure sits above the commands that use it. `ChatController.RegisterBuiltinCommands()` is the single registration site: it imports each command file and hands its `Command` export to `ChatCommandRegistry.Register`. It is idempotent, so it can be called from multiple init paths without harm. `ChatController.Send` invokes it lazily on the first slash-prefixed message so the feature works without an explicit init hook; `ChatController:Init` also calls it at startup. From 2d7b1b824ec8860b91ddcaf28202724ba549c520 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 06:49:16 +0200 Subject: [PATCH 044/130] Add ability to mute players --- lua/ui/game/chat/ChatCommandHintInterface.lua | 134 +++++++++++++----- lua/ui/game/chat/ChatController.lua | 1 + lua/ui/game/chat/ChatEditInterface.lua | 26 +++- lua/ui/game/chat/ChatInterface.lua | 18 ++- lua/ui/game/chat/commands/builtin/Clear.lua | 24 ++++ .../game/chat/config/ChatConfigController.lua | 47 +++++- .../game/chat/config/ChatConfigInterface.lua | 48 +++++++ lua/ui/game/chat/config/ChatConfigModel.lua | 6 + 8 files changed, 257 insertions(+), 47 deletions(-) create mode 100644 lua/ui/game/chat/commands/builtin/Clear.lua diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index e002d0cb008..1193e823844 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -14,7 +14,6 @@ local RowFontSize = 12 local RowFontName = 'Arial' local HorizontalPadding = 12 local VerticalPadding = 2 -local DividerHeight = 2 --- Renders a command the same way `/help` does: name, params, aliases, description. ---@param cmd UIChatCommand @@ -41,23 +40,23 @@ end -- user's input. Reuses a pool of row controls across refreshes — entries are -- shown/hidden and re-positioned via a per-row `ordinal` LazyVar rather than -- rebuilt from scratch. --- --- A pinned `/help` footer is always visible at the bottom. ---@class UIChatHintRow ---@field Text Text ---@field BG Bitmap ----@field Ordinal LazyVar # 0 = hidden, 1 = row closest to footer, etc. +---@field Ordinal LazyVar # 0 = hidden, 1 = bottom row, growing upward ---@field Target UIChatCommand | nil +---@field Hovered boolean +---@field Paint fun() # re-applies BG solid-colour from Hovered + owner.Selected ---@class UIChatCommandHintInterface : Group ---@field Edit Edit ---@field OnSelect? fun(cmd: UIChatCommand) ---@field Rows UIChatHintRow[] # reusable pool, indexed by ordinal ----@field Footer UIChatHintRow # always-visible /help row ----@field Divider Bitmap +---@field Background Bitmap # solid backdrop covering the whole popup ---@field RowHeight LazyVar ---@field VisibleCount LazyVar +---@field Selected LazyVar # 0 = no selection, 1..VisibleCount = row ordinal ---@field LastText string ---@field LTBG Bitmap ---@field RTBG Bitmap @@ -81,6 +80,15 @@ ChatCommandHintInterface = ClassUI(Group) { self.Rows = {} self.LastText = '' self.VisibleCount = Create(0) + self.Selected = Create(0) + + -- Solid backdrop so the tiny per-row highlight bitmaps (which only + -- span Text.Top-1..Text.Bottom+1) don't leave visible gaps between + -- rows. The row BGs now sit transparent on top of this for hover / + -- selection highlighting. + self.Background = Bitmap(self) + self.Background:SetSolidColor('ff000000') + self.Background:DisableHitTest() -- Sample the row height from a throwaway Text. ---@diagnostic disable-next-line: param-type-mismatch @@ -89,17 +97,6 @@ ChatCommandHintInterface = ClassUI(Group) { self.RowHeight = Create(probe.Height() + VerticalPadding) probe:Destroy() - -- Footer (always-visible /help row). - self.Footer = self:BuildRow() - local help = Registry.Lookup('help') - self.Footer.Target = help - self.Footer.Text:SetText(help and FormatCommand(help) or '/help') - self.Footer.Text:SetColor('ffbbbbbb') - - self.Divider = Bitmap(self) - self.Divider:SetSolidColor('ff444444') - self.Divider:DisableHitTest() - -- Decorative borders (same skin as ChatListInterface). self.LTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) self.LTBG:DisableHitTest() @@ -117,6 +114,10 @@ ChatCommandHintInterface = ClassUI(Group) { self.TBG:DisableHitTest() self.BBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) self.BBG:DisableHitTest() + + -- Repaint highlighted rows when the selection moves. We own `Selected` + -- so binding its OnDirty directly is safe (see CLAUDE.md §LazyVar). + self.Selected.OnDirty = function() self:RepaintRows() end end, ---@param self UIChatCommandHintInterface @@ -143,19 +144,17 @@ ChatCommandHintInterface = ClassUI(Group) { ---@diagnostic disable: undefined-field self.Height:SetFunction(function() - return (self.VisibleCount() + 1) * self.RowHeight() + DividerHeight + return self.VisibleCount() * self.RowHeight() end) - -- Footer pinned to the bottom of self. - self.Footer.Text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) - self.Footer.Text.Bottom:SetFunction(function() return self.Bottom() end) - self:LayoutRowBackground(self.Footer) - - -- Divider sits directly above the footer. - self.Divider.Left:SetFunction(function() return self.Left() end) - self.Divider.Right:SetFunction(function() return self.Right() end) - self.Divider.Bottom:SetFunction(function() return self.Footer.Text.Top() end) - self.Divider.Height:SetFunction(function() return DividerHeight end) + -- Unified backdrop covers the entire popup. Rows are transparent by + -- default and only paint when hovered or selected, so the backdrop + -- fills the slivers between row highlight strips. + self.Background.Left:SetFunction(function() return self.Left() end) + self.Background.Right:SetFunction(function() return self.Right() end) + self.Background.Top:SetFunction(function() return self.Top() end) + self.Background.Bottom:SetFunction(function() return self.Bottom() end) + self.Background.Depth:SetFunction(function() return self.Depth() end) -- Borders hug the outside of self on all eight sides. Layouter(self.LTBG):Right(self.Left):Bottom(self.Top):End() @@ -186,14 +185,26 @@ ChatCommandHintInterface = ClassUI(Group) { row.Text:DisableHitTest() row.BG = Bitmap(row.Text) - row.BG:SetSolidColor('ff000000') + row.BG:SetSolidColor('00000000') + row.Hovered = false local owner = self - row.BG.HandleEvent = function(bg, event) + local function paint() + if row.Hovered or (row.Ordinal() > 0 and owner.Selected() == row.Ordinal()) then + row.BG:SetSolidColor('ff666666') + else + row.BG:SetSolidColor('00000000') + end + end + row.Paint = paint + + row.BG.HandleEvent = function(_, event) if event.Type == 'MouseEnter' then - bg:SetSolidColor('ff666666') + row.Hovered = true + paint() elseif event.Type == 'MouseExit' then - bg:SetSolidColor('ff000000') + row.Hovered = false + paint() elseif event.Type == 'ButtonPress' then if row.Target and owner.OnSelect then owner.OnSelect(row.Target) @@ -204,6 +215,45 @@ ChatCommandHintInterface = ClassUI(Group) { return row end, + --- Repaints every row to reflect the current `Selected` ordinal. Called + --- from the Selected observer and whenever Refresh rebuilds the list. + ---@param self UIChatCommandHintInterface + RepaintRows = function(self) + for _, row in pairs(self.Rows) do + if row.Paint then row.Paint() end + end + end, + + --- Wraps `Selected` to the next visible dynamic row. No-op when there + --- are no matches. + ---@param self UIChatCommandHintInterface + SelectNext = function(self) + local n = self.VisibleCount() + if n <= 0 then return end + local cur = self.Selected() + self.Selected:Set(cur >= n and 1 or cur + 1) + end, + + --- Wraps `Selected` to the previous visible dynamic row. No-op when + --- there are no matches. + ---@param self UIChatCommandHintInterface + SelectPrev = function(self) + local n = self.VisibleCount() + if n <= 0 then return end + local cur = self.Selected() + self.Selected:Set(cur <= 1 and n or cur - 1) + end, + + --- Returns the currently-selected command, or nil when nothing matches. + ---@param self UIChatCommandHintInterface + ---@return UIChatCommand? + GetSelected = function(self) + local ord = self.Selected() + if ord <= 0 then return nil end + local row = self.Rows[ord] + return row and row.Target or nil + end, + --- Lazily pulls a dynamic row out of the pool, creating it if needed and --- wiring its position binding to its own ordinal LazyVar. ---@param self UIChatCommandHintInterface @@ -221,7 +271,7 @@ ChatCommandHintInterface = ClassUI(Group) { row.Text.Bottom:SetFunction(function() local ord = row.Ordinal() if ord <= 0 then return self.Top() end - return self.Divider.Top() - (ord - 1) * self.RowHeight() + return self.Bottom() - (ord - 1) * self.RowHeight() end) ---@diagnostic enable: undefined-field @@ -257,9 +307,7 @@ ChatCommandHintInterface = ClassUI(Group) { if space then prefix = string.sub(prefix, 1, space - 1) end for _, cmd in ipairs(Registry.FindMatching(prefix)) do - if cmd.Name ~= 'help' then -- help lives in the footer - table.insert(matches, cmd) - end + table.insert(matches, cmd) end end @@ -281,6 +329,20 @@ ChatCommandHintInterface = ClassUI(Group) { end self.VisibleCount:Set(table.getn(matches)) + + -- Keep the previously-selected ordinal when possible; otherwise land + -- on the first match (or clear the selection when nothing matches). + local n = table.getn(matches) + local cur = self.Selected() + if n == 0 then + self.Selected:Set(0) + elseif cur < 1 or cur > n then + self.Selected:Set(1) + else + -- Ordinal is unchanged but the target underneath probably isn't — + -- force a repaint so colors match the new row assignments. + self:RepaintRows() + end ---@diagnostic enable: undefined-field end, diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 090ddb2728b..4c38f837000 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -92,6 +92,7 @@ function RegisterBuiltinCommands() Registry.Register(import("/lua/ui/game/chat/commands/builtin/GiftResources.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Recall.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/ToEngineers.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Clear.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Help.lua").Command) end diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 0395076f90e..13c30ffc05a 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -142,6 +142,7 @@ ChatEditInterface = ClassUI(Group) { -- Page Up / Page Down scroll the chat feed. Shift narrows to one row. -- Matches the legacy chat.lua binding so muscle memory carries over. + -- Up / Down cycle the command-hint selection while the hint is open. -- Lazy import of ChatInterface avoids the import cycle: ChatInterface -- imports this module at load time, so the reverse edge has to defer. self.EditBox.OnNonTextKeyPressed = function(_, keycode, modifiers) @@ -150,6 +151,10 @@ ChatEditInterface = ClassUI(Group) { import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(-step) elseif keycode == UIUtil.VK_NEXT then import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(step) + elseif keycode == UIUtil.VK_UP and self.CommandHint then + self.CommandHint:SelectNext() + elseif keycode == UIUtil.VK_DOWN and self.CommandHint then + self.CommandHint:SelectPrev() end end @@ -162,12 +167,23 @@ ChatEditInterface = ClassUI(Group) { end)) end, - --- Entry point for the Tab key. On the first press, computes a fresh - --- completion record; on subsequent presses, cycles to the next - --- candidate. Plays the error cue when there is nothing to complete so - --- the user isn't left wondering whether the key was handled. + --- Entry point for the Tab key. When the command hint is open, Tab + --- commits the currently-selected command into the edit box (mirroring + --- a click on the hint row). Otherwise it runs the in-box completion + --- cycle for nicknames. Plays the error cue when there is nothing to + --- complete so the user isn't left wondering whether the key was handled. ---@param self UIChatEditInterface HandleTabCompletion = function(self) + if self.CommandHint then + local hint = self.CommandHint --[[@as UIChatCommandHintInterface]] + local cmd = hint:GetSelected() + if cmd then + self.EditBox:SetText('/' .. cmd.Name .. ' ') + self:AcquireFocus() + return + end + end + if self.Completion then local c = self.Completion c.Index = math.mod(c.Index, table.getn(c.Candidates)) + 1 @@ -247,7 +263,7 @@ ChatEditInterface = ClassUI(Group) { local hint = ChatCommandHintInterface(self, self.EditBox) self.CommandHint = hint - LayoutHelpers.Above(hint, self.EditBox, 4) + LayoutHelpers.Above(hint, self.EditBox, 14) LayoutHelpers.AtLeftIn(hint, self.EditBox) hint:SetOnSelect(function(cmd) self.EditBox:SetText('/' .. cmd.Name .. ' ') diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index fd9c7472208..95ee78fedc6 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -405,8 +405,13 @@ local ChatInterface = ClassUI(Window) { -- wrap widths depend on font metrics, so rewrap all entries. self:RebuildPool() self:RewrapAll() - self:CalcVisible() end + + -- Filter-affecting options (muted, links) may have changed without + -- touching the font. Recompute what's visible so entries newly + -- excluded by `IsValidEntry` drop out of the feed immediately. + self:RefreshVirtualSize() + self:CalcVisible() end, --------------------------------------------------------------------------- @@ -461,13 +466,18 @@ local ChatInterface = ClassUI(Window) { --------------------------------------------------------------------------- --- Whether an entry counts toward the virtual scroll size and should - --- appear in `CalcVisible`. Stubbed: wiring the per-army filter and - --- the camera-link filter to `ChatConfigModel.Committed` is a later step. + --- appear in `CalcVisible`. Currently gates on the per-army mute map + --- from `ChatConfigModel.Committed`; camera-link filtering is still TODO. ---@param self UIChatInterface ---@param entry UIChatEntry ---@return boolean IsValidEntry = function(self, entry) - return entry ~= nil + if entry == nil then return false end + local muted = ChatConfigModel.GetSingleton().Committed().muted + if muted and entry.ArmyID and muted[entry.ArmyID] then + return false + end + return true end, --------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/commands/builtin/Clear.lua b/lua/ui/game/chat/commands/builtin/Clear.lua new file mode 100644 index 00000000000..6d8e5de2d86 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Clear.lua @@ -0,0 +1,24 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +------------------------------------------------------------------------------- +-- /clear — wipes the local chat history. Sets a fresh empty table ref on the +-- model's `History` so observers (the line view) go dirty and redraw. + +---@type UIChatCommand +Command = { + Name = 'clear', + Description = 'Clear the local chat history.', + Execute = function() + ChatModel.GetSingleton().History:Set({}) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/config/ChatConfigController.lua b/lua/ui/game/chat/config/ChatConfigController.lua index 75d43882dcd..7457cf8892d 100644 --- a/lua/ui/game/chat/config/ChatConfigController.lua +++ b/lua/ui/game/chat/config/ChatConfigController.lua @@ -5,14 +5,19 @@ local function Model() return import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetSingleton() end ---- Commits the pending options: saves them to the profile and marks them active. +--- Commits the pending options: marks them active for this session and +--- persists everything except `muted`. Mutes are intentionally per-game so +--- they don't follow the player into the next match. function Apply() local model = Model() local options = table.copy(model.Pending()) model.Committed:Set(options) + + local persisted = table.copy(options) + persisted.muted = nil Prefs.SetToCurrentProfile( import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetProfileKey(), - options + persisted ) end @@ -40,6 +45,44 @@ function SetOption(key, value) model.Pending:Set(draft) end +--- Toggles the muted flag for a specific army on the pending options. +--- Absent entries are treated as "not muted"; setting `muted = false` clears +--- the key so the table stays compact. +---@param armyID number +---@param muted boolean +function SetMuted(armyID, muted) + local model = Model() + local draft = table.copy(model.Pending()) + local map = table.copy(draft.muted or {}) + if muted then + map[armyID] = true + else + map[armyID] = nil + end + draft.muted = map + model.Pending:Set(draft) +end + +--- Applies a mute state directly to `Committed` so slash-command usage +--- (`/mute`, `/unmute`) takes effect immediately without going through the +--- full Apply/Cancel dance. Pending is left alone — if the config dialog is +--- open it keeps its draft, and the next open re-syncs Pending from +--- Committed via `SetupSingleton`/`Cancel`. +---@param armyID number +---@param muted boolean +function SetMutedLive(armyID, muted) + local model = Model() + local options = table.copy(model.Committed()) + local map = table.copy(options.muted or {}) + if muted then + map[armyID] = true + else + map[armyID] = nil + end + options.muted = map + model.Committed:Set(options) +end + ------------------------------------------------------------------------------- --#region Debugging diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index a1ecd8f2f75..61970078fc5 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -38,6 +38,10 @@ local CheckboxDefs = { ---@field Combo BitmapCombo ---@field Key string +---@class UIChatConfigMuteRow +---@field Checkbox Checkbox +---@field ArmyID number + ---@class UIChatConfigInterface : Window ---@field Trash TrashBag # owns every derived subscription-LazyVar ---@field LabelColors Text @@ -50,6 +54,8 @@ local CheckboxDefs = { ---@field SliderWinAlpha IntegerSlider ---@field LabelBehavior Text ---@field Checkboxes Checkbox[] +---@field LabelMuted Text +---@field MuteRows UIChatConfigMuteRow[] ---@field BtnApply Button ---@field BtnReset Button ---@field BtnOk Button @@ -148,6 +154,28 @@ local ChatConfigInterface = ClassUI(Window) { self.Checkboxes[i] = cb end + -- ---- Muted players ---- + -- One checkbox per non-civilian army other than the local player. + -- The list is captured at dialog-open time; closing and reopening the + -- dialog rebuilds against fresh session state. + self.LabelMuted = UIUtil.CreateText(client, "Muted players", 12, UIUtil.titleFont) + + self.MuteRows = {} + local armies = GetArmiesTable() + local focusArmy = armies and armies.focusArmy or -1 + if armies and armies.armiesTable then + for armyID, army in armies.armiesTable do + if not army.civilian and armyID ~= focusArmy and army.nickname then + local id = armyID + local cb = UIUtil.CreateCheckbox(client, '/dialogs/check-box_btn/', army.nickname, true) + cb.OnCheck = function(_, checked) + ChatConfigController.SetMuted(id, checked) + end + table.insert(self.MuteRows, { Checkbox = cb, ArmyID = id }) + end + end + end + -- ---- Buttons ---- self.BtnApply = UIUtil.CreateButtonStd(client, '/widgets02/small', "Apply", 14) self.BtnApply.OnClick = function() ChatConfigController.Apply() end @@ -261,6 +289,21 @@ local ChatConfigInterface = ClassUI(Window) { prev = cb end + -- Muted players section + Layouter(self.LabelMuted) + :Below(prev, 12) + :AtLeftIn(client, pad) + :End() + + prev = self.LabelMuted + for _, row in ipairs(self.MuteRows) do + Layouter(row.Checkbox) + :Below(prev, 6) + :AtLeftIn(client, pad) + :End() + prev = row.Checkbox + end + -- Buttons: Apply | Reset on one row, OK | Cancel on the next Layouter(self.BtnApply) :Below(prev, 12) @@ -311,6 +354,11 @@ local ChatConfigInterface = ClassUI(Window) { end self.Checkboxes[i]:SetCheck(value, true) end + + local muted = options.muted or {} + for _, row in ipairs(self.MuteRows) do + row.Checkbox:SetCheck(muted[row.ArmyID] == true, true) + end end, OnClose = function(self) diff --git a/lua/ui/game/chat/config/ChatConfigModel.lua b/lua/ui/game/chat/config/ChatConfigModel.lua index 4e0ea349578..f8afbbc0697 100644 --- a/lua/ui/game/chat/config/ChatConfigModel.lua +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -17,6 +17,7 @@ local ProfileKey = "chatoptions" ---@field feed_persist boolean ---@field send_type boolean # false = all, true = allies ---@field links boolean # show camera-link messages +---@field muted table # armyID -> true when muted; absent = not muted ---@type UIChatOptions local DefaultOptions = { @@ -32,6 +33,7 @@ local DefaultOptions = { feed_persist = true, send_type = false, links = true, + muted = {}, } @@ -52,6 +54,7 @@ KeyFeedBackground = 'feed_background' KeyFeedPersist = 'feed_persist' KeySendType = 'send_type' KeyLinks = 'links' +KeyMuted = 'muted' ------------------------------------------------------------------------------- -- Value ranges for numeric options. Exported as module globals so the view @@ -89,10 +92,13 @@ function GetSingleton() end --- Creates and initializes the model singleton from the player profile. +--- Mutes are deliberately per-game: any `muted` payload read from prefs is +--- discarded, and `Apply` strips it before saving. ---@return UIChatConfigModel function SetupSingleton() local saved = Prefs.GetFieldFromCurrentProfile(ProfileKey) or {} local committed = table.merged(DefaultOptions, saved) + committed.muted = {} ModelInstance = { Committed = Create(committed), From 37a64fd09143478de99c7b38527128e06a0898f5 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 06:49:28 +0200 Subject: [PATCH 045/130] Add commands to quickly mute players --- lua/ui/game/chat/ChatController.lua | 2 ++ lua/ui/game/chat/commands/builtin/Mute.lua | 36 ++++++++++++++++++++ lua/ui/game/chat/commands/builtin/Unmute.lua | 28 +++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 lua/ui/game/chat/commands/builtin/Mute.lua create mode 100644 lua/ui/game/chat/commands/builtin/Unmute.lua diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 4c38f837000..46ea169dd00 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -92,6 +92,8 @@ function RegisterBuiltinCommands() Registry.Register(import("/lua/ui/game/chat/commands/builtin/GiftResources.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Recall.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/ToEngineers.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Mute.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Unmute.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Clear.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Help.lua").Command) end diff --git a/lua/ui/game/chat/commands/builtin/Mute.lua b/lua/ui/game/chat/commands/builtin/Mute.lua new file mode 100644 index 00000000000..edad9a78b2b --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Mute.lua @@ -0,0 +1,36 @@ + +local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") + +------------------------------------------------------------------------------- +-- /mute — hide future messages from a specific player for this +-- game. Goes straight to `Committed` via `SetMutedLive` so the filter picks +-- up the change immediately; `Pending` is untouched so an open config dialog +-- keeps its draft. + +---@type UIChatCommand +Command = { + Name = 'mute', + Description = 'Hide messages from a specific player for the rest of the game.', + Params = { + { Name = 'target', Type = 'Player' }, + }, + Accept = function(args) + local armies = GetArmiesTable() + if armies and args.target == armies.focusArmy then + return false, "/mute: can't mute yourself." + end + return true + end, + Execute = function(args) + ChatConfigController.SetMutedLive(args.target, true) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Unmute.lua b/lua/ui/game/chat/commands/builtin/Unmute.lua new file mode 100644 index 00000000000..e2b262f49ea --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Unmute.lua @@ -0,0 +1,28 @@ + +local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") + +------------------------------------------------------------------------------- +-- /unmute — reverse of `/mute`. Clears the mute flag for the given +-- player so their messages start showing again (both new arrivals and any +-- that landed in the history while they were muted). + +---@type UIChatCommand +Command = { + Name = 'unmute', + Description = 'Re-show messages from a previously muted player.', + Params = { + { Name = 'target', Type = 'Player' }, + }, + Execute = function(args) + ChatConfigController.SetMutedLive(args.target, false) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From a23f85328dfcca8a0b23879bec8cc0f6003eabf2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 07:15:20 +0200 Subject: [PATCH 046/130] Abandon focus upon losing visibility of chat window --- lua/ui/game/chat/ChatEditInterface.lua | 6 ++++++ lua/ui/game/chat/ChatInterface.lua | 1 + 2 files changed, 7 insertions(+) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 13c30ffc05a..66ba2f3eed5 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -353,6 +353,12 @@ ChatEditInterface = ClassUI(Group) { self.EditBox:AcquireFocus() end, + --- Moves keyboard focus out of the edit box. + ---@param self UIChatEditInterface + AbandonFocus = function(self) + self.EditBox:AbandonFocus() + end, + --- Empties our trash bag so every derived observer we allocated is --- destroyed — no `OnDirty` can fire into a torn-down `self`. ---@param self UIChatEditInterface diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 95ee78fedc6..9c9e839a467 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -186,6 +186,7 @@ local ChatInterface = ClassUI(Window) { self:Show() self.Edit:AcquireFocus() else + self.Edit:AbandonFocus() self:Hide() end end From 3a8b8bb84a8ba4d0ca1763f7b2bbc55450e99542 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 07:15:34 +0200 Subject: [PATCH 047/130] Do not infer opening with all or allies twice --- lua/ui/game/chat/ChatController.lua | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 46ea169dd00..985a60787c1 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -5,22 +5,8 @@ local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") ------------------------------------------------------------------------------- -- Window visibility ---- Applies the `send_type` default-recipient option when the chat window ---- opens. If the user has already selected a specific player for a private ---- message, their choice is left alone. -local function ApplyDefaultRecipient() - local model = ChatModel.GetSingleton() - if type(model.Recipient()) == 'number' then - return - end - local options = ChatConfigModel.GetSingleton().Committed() - local target = options.send_type and ChatModel.RecipientAllies or ChatModel.RecipientAll - model.Recipient:Set(target) -end - --- Shows the chat window. function OpenWindow() - ApplyDefaultRecipient() ChatModel.GetSingleton().WindowVisible:Set(true) end @@ -32,11 +18,7 @@ end --- Toggles the chat window open or closed. function ToggleWindow() local lv = ChatModel.GetSingleton().WindowVisible - local willOpen = not lv() - if willOpen then - ApplyDefaultRecipient() - end - lv:Set(willOpen) + lv:Set(not lv()) end ------------------------------------------------------------------------------- From 6f256ccdbb1f20b33e8ca998c13132b68ef43182 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 07:16:04 +0200 Subject: [PATCH 048/130] Add support for auto complete player names --- lua/ui/game/chat/ChatCompletion.lua | 16 ++++++++++++++-- lua/ui/game/chat/commands/ChatCommandTypes.lua | 10 ++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lua/ui/game/chat/ChatCompletion.lua b/lua/ui/game/chat/ChatCompletion.lua index c1123a560ce..4f00332a411 100644 --- a/lua/ui/game/chat/ChatCompletion.lua +++ b/lua/ui/game/chat/ChatCompletion.lua @@ -126,10 +126,22 @@ function Compute(text, caret) local prefix = STR_Utf8SubString(text, wordStart + 1, caret - wordStart) if prefix == '' then return nil end + -- Allow `@nick` shorthand: strip the leading `@` for matching but keep + -- it in every candidate so the inserted text is still `@Jip`. Command + -- param resolvers strip a leading `@` symmetrically (see + -- ChatCommandTypes), so `/whisper @Jip` works the same as `/whisper Jip`. + local atSign = '' + local matchPrefix = prefix + if string.sub(prefix, 1, 1) == '@' then + atSign = '@' + matchPrefix = string.sub(prefix, 2) + if matchPrefix == '' then return nil end + end + local candidates = {} for _, name in ipairs(CollectNicknames()) do - if StartsWithCI(name, prefix) then - table.insert(candidates, name) + if StartsWithCI(name, matchPrefix) then + table.insert(candidates, atSign .. name) end end local n = table.getn(candidates) diff --git a/lua/ui/game/chat/commands/ChatCommandTypes.lua b/lua/ui/game/chat/commands/ChatCommandTypes.lua index 73bfe5fe51a..2e99685c995 100644 --- a/lua/ui/game/chat/commands/ChatCommandTypes.lua +++ b/lua/ui/game/chat/commands/ChatCommandTypes.lua @@ -11,6 +11,11 @@ --- Looks up an army by nickname or by numeric army ID. Civilian armies are --- excluded to match the behaviour of the recipient picker. +--- +--- A leading `@` is stripped before matching so `@Jip` and `Jip` are +--- equivalent — this lets the chat-edit `@nick` autocomplete (see +--- ChatCompletion) feed straight into commands like `/whisper @Jip` without +--- the user having to delete the `@` first. ---@param token string ---@return boolean ok ---@return number | string armyIDOrError @@ -20,6 +25,11 @@ local function ResolveArmy(token) return false, "no army table available." end + -- do not include '@' of '@nick' when matching against army nicknames or IDs; this allows the user to quickly find usernames + if string.sub(token, 1, 1) == '@' then + token = string.sub(token, 2) + end + local asNum = tonumber(token) if asNum then local army = armies.armiesTable[asNum] From a62790c5557c39d7d2110ebcc1a8e9a57da99103 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 07:42:05 +0200 Subject: [PATCH 049/130] Fix issues with hot reload --- lua/ui/game/chat/ChatCommandHintInterface.lua | 9 --- lua/ui/game/chat/ChatCompletion.lua | 9 --- lua/ui/game/chat/ChatController.lua | 36 ++++++++++- lua/ui/game/chat/ChatEditInterface.lua | 33 ++++++---- lua/ui/game/chat/ChatFactionBadge.lua | 9 --- lua/ui/game/chat/ChatInterface.lua | 24 +++++-- lua/ui/game/chat/ChatLineInterface.lua | 64 ++++++++++++++++--- lua/ui/game/chat/ChatListInterface.lua | 9 --- lua/ui/game/chat/ChatModel.lua | 7 +- .../chat/commands/ChatCommandRegistry.lua | 9 --- .../game/chat/commands/ChatCommandTypes.lua | 9 --- lua/ui/game/chat/commands/builtin/All.lua | 9 --- lua/ui/game/chat/commands/builtin/Allies.lua | 9 --- lua/ui/game/chat/commands/builtin/Clear.lua | 9 --- .../chat/commands/builtin/GiftResources.lua | 9 --- .../game/chat/commands/builtin/GiftUnits.lua | 9 --- lua/ui/game/chat/commands/builtin/Help.lua | 9 --- lua/ui/game/chat/commands/builtin/Mute.lua | 9 --- lua/ui/game/chat/commands/builtin/Recall.lua | 9 --- .../chat/commands/builtin/ToEngineers.lua | 9 --- lua/ui/game/chat/commands/builtin/Unmute.lua | 9 --- lua/ui/game/chat/commands/builtin/Whisper.lua | 9 --- .../game/chat/config/ChatConfigInterface.lua | 3 +- 23 files changed, 137 insertions(+), 183 deletions(-) diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index 1193e823844..d7a90cbab71 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -353,12 +353,3 @@ ChatCommandHintInterface = ClassUI(Group) { self.OnSelect = callback end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/ChatCompletion.lua b/lua/ui/game/chat/ChatCompletion.lua index 4f00332a411..95186442b5d 100644 --- a/lua/ui/game/chat/ChatCompletion.lua +++ b/lua/ui/game/chat/ChatCompletion.lua @@ -157,12 +157,3 @@ function Compute(text, caret) Suffix = (n == 1 and atEnd) and ' ' or '', } end - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 985a60787c1..a533568521c 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -322,9 +322,12 @@ end --- Sends a chat message to the current recipient. Dispatches slash commands, --- drops all-whitespace bodies, short-circuits taunts, then routes the --- payload to the engine based on the recipient and whether the local player ---- is observing. +--- is observing. When `attachCamera` is true, snapshots the current world +--- camera and ships it on the message so recipients can jump to the view +--- by clicking the line. ---@param text string -function Send(text) +---@param attachCamera? boolean +function Send(text, attachCamera) if not text or text == '' then return end if string.sub(text, 1, 1) == '/' then @@ -358,6 +361,10 @@ function Send(text) text = text, } + if attachCamera then + msg.camera = GetCamera('WorldCamera'):SaveSettings() + end + if recipient == ChatModel.RecipientAllies then if focusArmy == -1 then msg.Observer = true end SessionSendChatMessage(FindClients(), msg) @@ -445,8 +452,31 @@ end ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-runs `Init()` on the freshly imported module so +--- `gamemain.chatFuncs['Chat']` rebinds to the NEW `OnReceive` closure and +--- `RegisterBuiltinCommands` repopulates the registry. Without this, edits +--- to this file leave the old function registered and the new command set +--- empty — sending continues to "work" but receives keep flowing through +--- stale code, and slash commands stop dispatching. +--- +--- The short delay + re-import gives any cascading reloads (command files, +--- ChatModel, etc.) time to settle before we wire things up — calling +--- `newModule.Init()` synchronously can capture stale references partway +--- through the reload pipeline. +function __moduleinfo.OnReload(newModule) + ForkThread(function() + WaitFrames(1) + newModule.Init() + end) +end + function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) end --#endregion diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 66ba2f3eed5..842df3d264b 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -5,6 +5,7 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Edit = import("/lua/maui/edit.lua").Edit local Button = import("/lua/maui/button.lua").Button +local Checkbox = import("/lua/maui/checkbox.lua").Checkbox local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") @@ -28,6 +29,7 @@ local MaxChars = 200 ---@field ChatBubble Button ---@field RecipientLabel Text ---@field EditBox Edit +---@field CamCheckbox Checkbox # toggle: attach world-camera state to the next message ---@field ChatList UIChatListInterface | nil ---@field CommandHint UIChatCommandHintInterface | nil ---@field RecipientObserver LazyVar # derived from ChatModel.Recipient @@ -67,6 +69,17 @@ ChatEditInterface = ClassUI(Group) { end end + -- Camera-attach toggle. When checked, the next Send call snapshots + -- the world camera and ships it on the message; recipients can click + -- the resulting cam-icon on their chat line to jump to the view. + self.CamCheckbox = Checkbox(self, + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_up.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_down.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds'), + UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds')) + self.EditBox = Edit(self) -- Placeholder bounds so that `SetupEditStd` below, which internally @@ -93,7 +106,7 @@ ChatEditInterface = ClassUI(Group) { -- and "dismiss" depending on whether there's anything to send. self.EditBox.OnEnterPressed = function(edit, text) if text and text ~= '' then - ChatController.Send(text) + ChatController.Send(text, self.CamCheckbox:IsChecked()) edit:SetText('') else ChatController.CloseWindow() @@ -294,9 +307,16 @@ ChatEditInterface = ClassUI(Group) { :AtVerticalCenterIn(self) :End() + -- Camera-attach toggle pinned to the right edge so the edit box can + -- claim the remaining width. + Layouter(self.CamCheckbox) + :AtRightIn(self, 4) + :AtVerticalCenterIn(self) + :End() + Layouter(self.EditBox) :AnchorToRight(self.RecipientLabel, 4) - :AtRightIn(self, 2) + :AnchorToLeft(self.CamCheckbox, 4) :AtVerticalCenterIn(self) :Height(function() return self.EditBox:GetFontHeight() end) :End() @@ -366,12 +386,3 @@ ChatEditInterface = ClassUI(Group) { self.Trash:Destroy() end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/ChatFactionBadge.lua b/lua/ui/game/chat/ChatFactionBadge.lua index 1c0a7de9a72..9cbbbf48b4c 100644 --- a/lua/ui/game/chat/ChatFactionBadge.lua +++ b/lua/ui/game/chat/ChatFactionBadge.lua @@ -69,12 +69,3 @@ ChatFactionBadge = ClassUI(Group) { self.Color:SetSolidColor(color or 'ffffffff') end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 9c9e839a467..0c701b59352 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -90,6 +90,7 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible ---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed ---@field OnLineNameClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared row-name click handler; captures `self` so pool lines don't allocate per-row closures +---@field OnLineCameraClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared cam-icon click handler; captures `self` to restore the saved camera local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface @@ -143,6 +144,15 @@ local ChatInterface = ClassUI(Window) { end end + -- Shared cam-icon click handler. Same one-closure-per-window pattern + -- as `OnLineNameClicked`. Restores the world camera to the state + -- the sender saved when they shipped the message. + self.OnLineCameraClicked = function(_, entry) + if entry.Camera then + GetCamera('WorldCamera'):RestoreSettings(entry.Camera) + end + end + -- Reactive subscriptions use `LazyVarDerive` so each observer is a -- fresh LazyVar that reads from an upstream model field — setting -- our handler can never stomp another subscriber's (see the chat @@ -355,6 +365,7 @@ local ChatInterface = ClassUI(Window) { self.Lines[1] = ChatLineInterface(container) self.Lines[1]:SetFontSize(self.FontSize) self.Lines[1].OnNameClicked = self.OnLineNameClicked + self.Lines[1].OnCameraClicked = self.OnLineCameraClicked Layouter(self.Lines[1]) :AtLeftTopIn(container) :Right(container.Right) @@ -372,6 +383,7 @@ local ChatInterface = ClassUI(Window) { self.Lines[i] = ChatLineInterface(container) self.Lines[i]:SetFontSize(self.FontSize) self.Lines[i].OnNameClicked = self.OnLineNameClicked + self.Lines[i].OnCameraClicked = self.OnLineCameraClicked Layouter(self.Lines[i]) :Below(self.Lines[i - 1]) :AtLeftIn(container) @@ -763,9 +775,7 @@ end --- Called by the module manager when this module is reloaded. ---@param newModule any function __moduleinfo.OnReload(newModule) - if Instance then - newModule.Open() - end + newModule.Open() end --- Called by the module manager when this module becomes dirty. @@ -776,7 +786,13 @@ function __moduleinfo.OnDirty() Instance:Destroy() Instance = nil end - import(__moduleinfo.name) + + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) end --#endregion diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index c5f2d1e5105..909b6563024 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -17,6 +17,8 @@ for _, data in Factions do end table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') +local CamIconTexture = '/game/camera-btn/pinned_btn_up.dds' + ------------------------------------------------------------------------------- -- A single chat row: team-coloured faction icon, sender name and message text. -- @@ -29,6 +31,7 @@ table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') ---@field TeamColor Bitmap ---@field FactionIcon Bitmap ---@field Name Text +---@field CamIcon Bitmap # camera-link affordance, hidden unless entry.Camera is set ---@field Text Text ---@field Entry UIChatEntry | nil ChatLineInterface = ClassUI(Group) { @@ -55,6 +58,19 @@ ChatLineInterface = ClassUI(Group) { end end + -- Camera-link icon. Kept "invisible" when unused by clearing to a + -- transparent solid colour and disabling hit-test — calling `Hide()` + -- here would be undone when the window's `Show()` cascades to + -- descendants (same reason `FactionIcon` cycles via SolidColor). + self.CamIcon = Bitmap(self) + self.CamIcon:SetSolidColor('00000000') + self.CamIcon:DisableHitTest() + self.CamIcon.HandleEvent = function(_, event) + if event.Type == 'ButtonPress' and self.Entry then + self:OnCameraClicked(self.Entry) + end + end + self.Text = UIUtil.CreateText(self, '', 14, 'Arial') self.Text:SetColor('ffc2f6ff') self.Text:SetDropShadow(true) @@ -90,6 +106,18 @@ ChatLineInterface = ClassUI(Group) { :Over(self, 10) :End() + -- Cam icon sits between the name and text on header rows. Fixed + -- 20x16 footprint matching the legacy `pinned_btn_up.dds` art. + Layouter(self.CamIcon) + :RightOf(self.Name, 4) + :AtVerticalCenterIn(self.TeamColor) + :Width(20) + :Height(16) + :Over(self, 10) + :End() + + -- Text Left jumps over the icon when present; SetHeader rebinds this + -- when the entry's camera state changes. Layouter(self.Text) :Left(function() return self.Name.Right() + 2 end) :Right(self.Right) @@ -112,6 +140,20 @@ ChatLineInterface = ClassUI(Group) { local iconIndex = entry.Faction or table.getn(FactionIcons) self.FactionIcon:SetTexture(UIUtil.UIFile(FactionIcons[iconIndex])) + + -- Camera affordance: switch between textured (hit-testable) and + -- transparent SolidColor (inert) rather than Show/Hide, so the + -- window-wide `Show()` cascade can't reveal stale icons. Re-applying + -- `RightOf` replaces the previous Left binding (no leak). + if entry.Camera then + self.CamIcon:SetTexture(UIUtil.UIFile(CamIconTexture)) + self.CamIcon:EnableHitTest() + LayoutHelpers.RightOf(self.Text, self.CamIcon, 4) + else + self.CamIcon:SetSolidColor('00000000') + self.CamIcon:DisableHitTest() + LayoutHelpers.RightOf(self.Text, self.Name, 2) + end end, --- Populates the row as a CONTINUATION of a wrapped entry: the name slot @@ -131,6 +173,9 @@ ChatLineInterface = ClassUI(Group) { self.Text:SetText(wrappedText or '') self.TeamColor:SetSolidColor('00000000') self.FactionIcon:SetSolidColor('00000000') + self.CamIcon:SetSolidColor('00000000') + self.CamIcon:DisableHitTest() + LayoutHelpers.RightOf(self.Text, self.Name, 2) end, --- Clears all content so the row can stand empty. @@ -141,6 +186,9 @@ ChatLineInterface = ClassUI(Group) { self.Text:SetText('') self.TeamColor:SetSolidColor('00000000') self.FactionIcon:SetSolidColor('00000000') + self.CamIcon:SetSolidColor('00000000') + self.CamIcon:DisableHitTest() + LayoutHelpers.RightOf(self.Text, self.Name, 2) end, --- Overridable: fires on a click on the sender name. Continuation @@ -159,6 +207,13 @@ ChatLineInterface = ClassUI(Group) { ---@param entry UIChatEntry OnBodyClicked = function(self, entry) end, + --- Overridable: fires on a click on the camera icon. Only header rows + --- show the icon (continuation rows hide it), so this only runs there. + --- Default is a no-op; replace the field on an instance to subscribe. + ---@param self UIChatLineInterface + ---@param entry UIChatEntry + OnCameraClicked = function(self, entry) end, + --- Updates the font size for both name and body text. The row's `Height` --- LazyVar is derived from `Name.Height`, so the row resizes automatically. ---@param self UIChatLineInterface @@ -168,12 +223,3 @@ ChatLineInterface = ClassUI(Group) { self.Text:SetFont('Arial', size) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index 8cdca494e76..e3e8ac2a914 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -262,12 +262,3 @@ ChatListInterface = ClassUI(Group) { self._OnClosed = callback end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index ea995b4efc9..c1952817663 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -70,7 +70,12 @@ function __moduleinfo.OnReload(newModule) end function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) end --#endregion diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index bb02711b904..35259c57a0b 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -247,12 +247,3 @@ function Dispatch(text) cmd.Execute(args, ctx) return true, nil end - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/ChatCommandTypes.lua b/lua/ui/game/chat/commands/ChatCommandTypes.lua index 2e99685c995..414be65992d 100644 --- a/lua/ui/game/chat/commands/ChatCommandTypes.lua +++ b/lua/ui/game/chat/commands/ChatCommandTypes.lua @@ -83,12 +83,3 @@ end Resolvers.String = function(token) return true, token end - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/All.lua b/lua/ui/game/chat/commands/builtin/All.lua index 3a89e628353..7b639ee0927 100644 --- a/lua/ui/game/chat/commands/builtin/All.lua +++ b/lua/ui/game/chat/commands/builtin/All.lua @@ -12,12 +12,3 @@ Command = { ctx.Controller.SetRecipient(ChatModel.RecipientAll) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/Allies.lua b/lua/ui/game/chat/commands/builtin/Allies.lua index 88175757324..2e8410a3463 100644 --- a/lua/ui/game/chat/commands/builtin/Allies.lua +++ b/lua/ui/game/chat/commands/builtin/Allies.lua @@ -13,12 +13,3 @@ Command = { ctx.Controller.SetRecipient(ChatModel.RecipientAllies) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/Clear.lua b/lua/ui/game/chat/commands/builtin/Clear.lua index 6d8e5de2d86..fc30d568d5f 100644 --- a/lua/ui/game/chat/commands/builtin/Clear.lua +++ b/lua/ui/game/chat/commands/builtin/Clear.lua @@ -13,12 +13,3 @@ Command = { ChatModel.GetSingleton().History:Set({}) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/GiftResources.lua b/lua/ui/game/chat/commands/builtin/GiftResources.lua index 1503ec8548f..40a7d188705 100644 --- a/lua/ui/game/chat/commands/builtin/GiftResources.lua +++ b/lua/ui/game/chat/commands/builtin/GiftResources.lua @@ -75,12 +75,3 @@ Command = { }) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/GiftUnits.lua b/lua/ui/game/chat/commands/builtin/GiftUnits.lua index 14da06ab939..e58a66a5529 100644 --- a/lua/ui/game/chat/commands/builtin/GiftUnits.lua +++ b/lua/ui/game/chat/commands/builtin/GiftUnits.lua @@ -55,12 +55,3 @@ Command = { }, true) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/Help.lua b/lua/ui/game/chat/commands/builtin/Help.lua index a2bfb372146..ddd126cb4c1 100644 --- a/lua/ui/game/chat/commands/builtin/Help.lua +++ b/lua/ui/game/chat/commands/builtin/Help.lua @@ -33,12 +33,3 @@ Command = { end end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/Mute.lua b/lua/ui/game/chat/commands/builtin/Mute.lua index edad9a78b2b..69482e3f96c 100644 --- a/lua/ui/game/chat/commands/builtin/Mute.lua +++ b/lua/ui/game/chat/commands/builtin/Mute.lua @@ -25,12 +25,3 @@ Command = { ChatConfigController.SetMutedLive(args.target, true) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/Recall.lua b/lua/ui/game/chat/commands/builtin/Recall.lua index 82fef541e03..14e238297b2 100644 --- a/lua/ui/game/chat/commands/builtin/Recall.lua +++ b/lua/ui/game/chat/commands/builtin/Recall.lua @@ -23,12 +23,3 @@ Command = { }) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/ToEngineers.lua b/lua/ui/game/chat/commands/builtin/ToEngineers.lua index c235dad4e90..591190e0374 100644 --- a/lua/ui/game/chat/commands/builtin/ToEngineers.lua +++ b/lua/ui/game/chat/commands/builtin/ToEngineers.lua @@ -24,12 +24,3 @@ Command = { SelectUnits(args.engineers) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/Unmute.lua b/lua/ui/game/chat/commands/builtin/Unmute.lua index e2b262f49ea..8a8bab21027 100644 --- a/lua/ui/game/chat/commands/builtin/Unmute.lua +++ b/lua/ui/game/chat/commands/builtin/Unmute.lua @@ -17,12 +17,3 @@ Command = { ChatConfigController.SetMutedLive(args.target, false) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/commands/builtin/Whisper.lua b/lua/ui/game/chat/commands/builtin/Whisper.lua index 9881112a04e..50b0f68ad05 100644 --- a/lua/ui/game/chat/commands/builtin/Whisper.lua +++ b/lua/ui/game/chat/commands/builtin/Whisper.lua @@ -22,12 +22,3 @@ Command = { ctx.Controller.SetRecipient(args.target) end, } - -------------------------------------------------------------------------------- ---#region Debugging - -function __moduleinfo.OnDirty() - import(__moduleinfo.name) -end - ---#endregion diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 61970078fc5..8b8ac780652 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -426,10 +426,9 @@ function __moduleinfo.OnDirty() Instance = nil end - LOG(__moduleinfo.name .. " is dirty, re-importing...") ForkThread( function() - WaitSeconds(0.1) + WaitFrames(2) local module = import(__moduleinfo.name) module.Open() end From 84043f0c0cf9849109a2ee0d6b143c74ded02006 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 08:01:22 +0200 Subject: [PATCH 050/130] Implement focus grab on moving --- lua/ui/game/chat/ChatInterface.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 0c701b59352..2edc5518aeb 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -690,6 +690,13 @@ local ChatInterface = ClassUI(Window) { self.DragBR:SetTexture(self.DragBR.textures.up) end, + --- Engine-invoked when the user finishes dragging the window. The drag + --- handler steals focus mid-move, so re-acquire it so the user can keep + --- typing without a second click on the edit box. + OnMoveSet = function(self) + self.Edit:AcquireFocus() + end, + --- Mouse wheel over the window scrolls the chat. `rotation` is in wheel --- units (usually ±120 per notch); one notch ≈ one line. OnMouseWheel = function(self, rotation) From 1c56d7c762f04833052f33a5faee00665fcd9e3b Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 08:49:17 +0200 Subject: [PATCH 051/130] Send chat messages as callback to store it in the replay --- lua/ChatUtils.lua | 72 +++++++++++++++++++++++ lua/SimCallbacks.lua | 3 + lua/SimUtils.lua | 21 ++++--- lua/ui/game/chat/ChatController.lua | 88 +++++++++++++++++++++++++++-- lua/ui/game/chat/ChatModel.lua | 1 + 5 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 lua/ChatUtils.lua diff --git a/lua/ChatUtils.lua b/lua/ChatUtils.lua new file mode 100644 index 00000000000..6f55ace30a0 --- /dev/null +++ b/lua/ChatUtils.lua @@ -0,0 +1,72 @@ + +-- Sim-side helpers for the refactored in-game chat. Lives outside `SimUtils` +-- so chat can grow without bloating the general utility file, and so UI and +-- other sim systems have one obvious place to look for chat-relay logic. +-- +-- The UI-side counterparts live under `/lua/ui/game/chat/`. Anything that +-- has to run sim-side (command-source lookups, trusted sender stamping, +-- `Sync` writes) belongs here; pure formatting or routing of an already- +-- trusted message should stay on the UI side. + +local SimUtils = import("/lua/simutils.lua") + +--- Legacy replay hook kept for external callers that may still reference it. +--- The refactored chat path no longer uses this — chat is now relayed through +--- `Sync.ChatMessages` (see `SendChatMessage`) and external replay parsers +--- read the `Sender`/`Msg` fields off the recorded `GiveResourcesToPlayer` +--- callback args, which the UI sender emits once per outgoing message. +---@param data {Sender: integer, Msg: string} +function SendChatToReplay(data) + if data.Sender and data.Msg then + if not Sync.UnitData.Chat then + Sync.UnitData.Chat = {} + end + table.insert(Sync.UnitData.Chat, { sender = data.Sender, msg = data.Msg }) + end +end + +--- Relays a chat message from a UI client back to every UI client via +--- `Sync.ChatMessages`. The sender field is taken from the command source +--- and written into `Msg.From` so clients can't spoof the originating army. +--- UI-side listeners dedupe against messages already in history (by `Id`), +--- so firing this alongside the legacy `SessionSendChatMessage` path is safe. +--- +--- Ally checks: private messages (numeric `msg.to`) require `IsAlly(from, to)` +--- — we refuse to relay a whisper between non-allies the way the legacy +--- `FindClients` path refused to route one. The `all` and `allies` channels +--- are permitted from any player; `Sync.ChatMessages` broadcasts to every UI, +--- so the UI is responsible for hiding `allies` messages from non-allies on +--- display. +--- +--- Observers have no entry in the command-source-to-army map, so this path +--- drops their messages. Observer chat continues to work over the legacy +--- `SessionSendChatMessage` path; a future iteration can extend the sim +--- relay to carry an observer-identity field if we decide replays need to +--- show observer lines. +--- +--- This is also the hook for sim-originated chat: a sim system that wants a +--- line to appear in every UI's chat feed can call `SendChatMessage` with a +--- synthesised `Msg` table (remember to set `Chat = true` and a non-empty +--- `text`, and leave `From` alone — we overwrite it). +---@param data {Msg: table} +function SendChatMessage(data) + if type(data) ~= 'table' or type(data.Msg) ~= 'table' then return end + local msg = data.Msg + if msg.Chat ~= true then return end + if type(msg.text) ~= 'string' or msg.text == '' then return end + + -- Trusted sender stamp; ignore whatever the client put in `msg.From`. + local from = SimUtils.GetCurrentCommandSourceArmy() + if not from then return end + + -- Private-message guard: a numeric `to` is an army ID the sender is + -- whispering to. Cross-alliance whispers are rejected. + if type(msg.to) == 'number' and not IsAlly(from, msg.to) then + return + end + + msg.From = from + + Sync.ChatMessages = Sync.ChatMessages or {} + table.insert(Sync.ChatMessages, msg) +end diff --git a/lua/SimCallbacks.lua b/lua/SimCallbacks.lua index 63d984c647c..b2d96865036 100644 --- a/lua/SimCallbacks.lua +++ b/lua/SimCallbacks.lua @@ -13,6 +13,7 @@ ---@field Args table local SimUtils = import("/lua/simutils.lua") +local ChatUtils = import("/lua/chatutils.lua") local SimPing = import("/lua/simping.lua") local SimTriggers = import("/lua/scenariotriggers.lua") local SUtils = import("/lua/ai/sorianutilities.lua") @@ -162,6 +163,8 @@ Callbacks.GiveUnitsToPlayer = SimUtils.GiveUnitsToPlayer Callbacks.GiveResourcesToPlayer = SimUtils.GiveResourcesToPlayer +Callbacks.SendChatMessage = ChatUtils.SendChatMessage + Callbacks.SetResourceSharing = SimUtils.SetResourceSharing Callbacks.RequestAlliedVictory = SimUtils.RequestAlliedVictory diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua index a6af784323a..1922e274f25 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -1449,19 +1449,18 @@ function SetOfferDraw(data) brain.OfferingDraw = data.Value end ----@param data {Sender: integer, Msg: string} -function SendChatToReplay(data) - if data.Sender and data.Msg then - if not Sync.UnitData.Chat then - Sync.UnitData.Chat = {} - end - table.insert(Sync.UnitData.Chat, { sender = data.Sender, msg = data.Msg }) - end -end +-- Chat-relay helpers moved to `/lua/ChatUtils.lua`: +-- * `SendChatToReplay` — legacy `Sync.UnitData.Chat` writer, kept for mods. +-- * `SendChatMessage` — trusted sim relay that feeds `Sync.ChatMessages`. ----@param data {From: Army, To: Army, Mass: number, Energy: number} +---@param data {From: Army, To: Army, Mass: number, Energy: number, Sender?: string, Msg?: table} function GiveResourcesToPlayer(data) - SendChatToReplay(data) + -- The refactored chat path (see `ChatUtils.SendChatMessage`) still fires + -- this callback once per outgoing chat message with `Sender`/`Msg` set, + -- because external replay parsers scrape those fields out of the recorded + -- args. The legacy per-receive `SendChatToReplay` write into + -- `Sync.UnitData.Chat` is gone — chat now syncs through + -- `Sync.ChatMessages`. -- Ignore observers and players trying to send resources to themselves or to enemies if data.From == -1 or data.From == data.To or not IsAlly(data.From, data.To) then diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index a533568521c..6efca8eb8fc 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -224,7 +224,7 @@ local ToStrings = { --- appends it to the model history. Fields with natural defaults (colour, --- army ID, faction icon) fall back when the army data is missing or the --- sender is an observer. ----@param args { Name: string, Text?: string, ArmyData?: table, IsObserver?: boolean, Recipient: UIChatRecipient, Camera?: table } +---@param args { Name: string, Text?: string, ArmyData?: table, IsObserver?: boolean, Recipient: UIChatRecipient, Camera?: table, Id?: string } local function AppendChatLine(args) local armyData = args.ArmyData or {} -- Observers have no `faction`; fall through to the tail icon in @@ -239,6 +239,7 @@ local function AppendChatLine(args) Faction = (faction or 4) + 1, Recipient = args.Recipient, Camera = args.Camera, + Id = args.Id, } end @@ -288,9 +289,42 @@ function OnReceive(sender, msg) IsObserver = msg.Observer, Recipient = to, Camera = msg.camera, + Id = msg.Id, } end +--- Handler for the `Sync.ChatMessages` category, populated by the sim-side +--- `SendChatMessage` callback. Each entry in `msgs` is a message table the +--- sim has stamped with a trusted `From` army index. +--- +--- In live play the same message also arrives via `SessionSendChatMessage` +--- (handled by `OnReceive`) — whichever path lands first seeds the entry's +--- `Id`, and this handler skips any message whose id is already there. +--- Sim-originated messages have no `SessionSendChatMessage` counterpart, so +--- they flow straight through. +--- +--- Replays are the case where `SessionSendChatMessage` never fires: this +--- handler is the *only* source of chat in a replay. +---@param msgs table[] +function OnSyncChatMessages(msgs) + if type(msgs) ~= 'table' then return end + + local history = ChatModel.GetSingleton().History() + local seen = {} + for _, entry in history do + if entry.Id then seen[entry.Id] = true end + end + + for _, msg in msgs do + if not (msg.Id and seen[msg.Id]) then + local armyData = GetArmyData(msg.From) + local nickname = armyData and armyData.nickname or tostring(msg.From or 'Unknown') + OnReceive(nickname, msg) + if msg.Id then seen[msg.Id] = true end + end + end +end + ------------------------------------------------------------------------------- -- Echoing (local synthesis for outgoing privates) -- @@ -313,6 +347,7 @@ local function OnEcho(senderData, recipientData, msg) ArmyData = senderData, Recipient = msg.to, Camera = msg.camera, + Id = msg.Id, } end @@ -361,17 +396,58 @@ function Send(text, attachCamera) text = text, } + -- Observers can't target a private recipient; silently drop (old + -- chat.lua did the same — the command simply had no effect). Bail + -- before stamping an id or firing sim callbacks so we don't leak + -- a message the engine would have refused to deliver anyway. + if focusArmy == -1 and type(recipient) == 'number' then return end + + -- Flag observer broadcasts so receivers render "to observers:". Both + -- delivery paths (engine-routed and sim-routed) need to see this bit, + -- so set it before either fires. + if focusArmy == -1 then msg.Observer = true end + if attachCamera then msg.camera = GetCamera('WorldCamera'):SaveSettings() end + -- Stamp a near-unique id on the message *before* it leaves this function. + -- The same `msg` table travels through two parallel delivery paths — the + -- live `SessionSendChatMessage` broadcast and the sim-routed + -- `SendChatMessage`→`Sync.ChatMessages` path — and the receiver-side + -- dedupe uses this id to tell the two apart. `tostring(msg)` yields the + -- table's address, which collides only if the same address is reused for + -- another chat message within the dedupe window — vanishingly rare. + msg.Id = tostring(msg) + + -- Replay-parser backwards compat: external replay tools scrape chat out + -- of recorded `GiveResourcesToPlayer` callback args. We fire one zero- + -- resource callback per outgoing message so they keep working, regardless + -- of who the real recipient is. `From`/`To` are both the focus army so + -- the sim-side ally/self-transfer guard short-circuits without doing + -- anything. Observers skip it — no army to ship. + if focusArmy ~= -1 then + local senderData = GetArmyData(focusArmy) + SimCallback({ + Func = 'GiveResourcesToPlayer', + Args = { + From = focusArmy, To = focusArmy, Mass = 0, Energy = 0, + Sender = senderData and senderData.nickname or tostring(focusArmy), + Msg = msg, + }, + }, false) + end + + -- Sim-routed path: hand the message to the sim, which validates and + -- re-broadcasts it via `Sync.ChatMessages` to every connected UI. In live + -- play this runs alongside `SessionSendChatMessage` and our id-based + -- dedupe keeps it from double-posting; in replays it is the *only* path + -- the viewer sees, which is exactly what we want. + SimCallback({ Func = 'SendChatMessage', Args = { Msg = msg } }, false) + if recipient == ChatModel.RecipientAllies then - if focusArmy == -1 then msg.Observer = true end SessionSendChatMessage(FindClients(), msg) elseif type(recipient) == 'number' then - -- Observers can't target a private recipient; silently drop (old - -- chat.lua did the same — the command simply had no effect). - if focusArmy == -1 then return end SessionSendChatMessage(FindClients(recipient), msg) -- The engine does not bounce private messages back to the sender; @@ -383,7 +459,6 @@ function Send(text, attachCamera) end else if focusArmy == -1 then - msg.Observer = true SessionSendChatMessage(FindClients(), msg) else SessionSendChatMessage(msg) @@ -446,6 +521,7 @@ end --- dispatches, safe under hot reload. function Init() import("/lua/ui/game/gamemain.lua").RegisterChatFunc(OnReceive, 'Chat') + AddOnSyncHashedCallback(OnSyncChatMessages, 'ChatMessages', 'Chat') RegisterBuiltinCommands() end diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index c1952817663..657381b379d 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -23,6 +23,7 @@ RecipientAllies = 'allies' ---@field Faction number # faction icon index (1-based) ---@field Recipient UIChatRecipient # the target this message was directed to ---@field Camera? table # camera state when the message is a ping link +---@field Id? string # near-unique sender-stamped id (`tostring(msg)`); used to dedupe the `Sync.ChatMessages` replay/sim path against the live `SessionSendChatMessage` path ---@field WrappedText? string[] # view-side cache: text wrapped to the current row width (populated by ChatInterface) ------------------------------------------------------------------------------- From f0ce46c6579530f43023f39290e0af69f1ac5e6f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 08:54:53 +0200 Subject: [PATCH 052/130] Re-introduce the taunt ability --- lua/ui/game/chat/ChatController.lua | 1 + .../chat/commands/ChatCommandRegistry.lua | 40 ++++++++++++------- lua/ui/game/chat/commands/builtin/Taunt.lua | 38 ++++++++++++++++++ 3 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 lua/ui/game/chat/commands/builtin/Taunt.lua diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 6efca8eb8fc..b9e8adf3952 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -74,6 +74,7 @@ function RegisterBuiltinCommands() Registry.Register(import("/lua/ui/game/chat/commands/builtin/GiftResources.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Recall.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/ToEngineers.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Taunt.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Mute.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Unmute.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Clear.lua").Command) diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 35259c57a0b..52d72c5eec8 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -23,6 +23,7 @@ local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") ---@field Aliases? string[] ---@field Description string ---@field Params? UIChatCommandParam[] +---@field ShouldRegister? fun(): boolean # optional gate evaluated at `Register` time; false drops the command from the registry (and the hint / `/help` listing) for this session ---@field Accept? fun(args: table, ctx: UIChatCommandContext): boolean, string? ---@field Execute fun(args: table, ctx: UIChatCommandContext) @@ -35,13 +36,38 @@ local Aliases = {} ------------------------------------------------------------------------------- -- Registration +--- Removes a command and its aliases. +---@param name string +function Unregister(name) + local key = string.lower(name) + local cmd = Commands[key] + if not cmd then return end + if cmd.Aliases then + for _, alias in ipairs(cmd.Aliases) do + Aliases[string.lower(alias)] = nil + end + end + Commands[key] = nil +end + --- Registers a command. Overwrites any previous registration with the same --- canonical name; aliases from the previous registration are cleared first. +--- +--- A command can opt out of registration entirely by returning `false` from +--- its optional `ShouldRegister` hook — used for session-conditional commands +--- (observer-only, replay-only, single-player-only, etc.). We still call +--- `Unregister` first so a reload that newly disqualifies a command can't +--- leave its previous entry in the registry. ---@param cmd UIChatCommand function Register(cmd) assert(cmd and cmd.Name, "Chat command requires a name.") assert(cmd.Execute, "Chat command requires an execute function.") + -- some commands are game state specific + if cmd.ShouldRegister and not cmd.ShouldRegister() then + return + end + local key = string.lower(cmd.Name) local previous = Commands[key] @@ -59,20 +85,6 @@ function Register(cmd) end end ---- Removes a command and its aliases. ----@param name string -function Unregister(name) - local key = string.lower(name) - local cmd = Commands[key] - if not cmd then return end - if cmd.Aliases then - for _, alias in ipairs(cmd.Aliases) do - Aliases[string.lower(alias)] = nil - end - end - Commands[key] = nil -end - --- Returns a flat list of every registered command (canonical entries only). ---@return UIChatCommand[] function GetAll() diff --git a/lua/ui/game/chat/commands/builtin/Taunt.lua b/lua/ui/game/chat/commands/builtin/Taunt.lua new file mode 100644 index 00000000000..92fedb556b2 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Taunt.lua @@ -0,0 +1,38 @@ + +------------------------------------------------------------------------------- +-- /taunt — broadcast a numbered taunt from the `taunts` table in +-- `/lua/ui/game/taunt.lua`. Same entry point as the legacy `/N` shortcut, +-- exposed here under a discoverable name so the command-hint popup lists it. +-- +-- Out-of-range indices are still sent on the wire; receivers silently ignore +-- unknown entries in `taunt.RecieveTaunt`, matching the legacy behaviour. + +---@type UIChatCommand +Command = { + Name = 'taunt', + Description = 'Play a numbered taunt for every player to hear.', + Params = { + { Name = 'index', Type = 'Int' }, + }, + Accept = function(args) + if GetFocusArmy() == -1 then + return false, "/taunt: observers can't taunt." + end + if args.index < 1 then + return false, "/taunt: index must be at least 1." + end + return true + end, + Execute = function(args) + import("/lua/ui/game/taunt.lua").SendTaunt(args.index) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From e7b76d3d6368e5e2b470d86944d550d0df72e446 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 09:04:05 +0200 Subject: [PATCH 053/130] Add single player commands --- lua/ui/game/chat/ChatController.lua | 3 ++ lua/ui/game/chat/commands/builtin/Load.lua | 47 +++++++++++++++++++ lua/ui/game/chat/commands/builtin/Restart.lua | 28 +++++++++++ lua/ui/game/chat/commands/builtin/Save.lua | 35 ++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 lua/ui/game/chat/commands/builtin/Load.lua create mode 100644 lua/ui/game/chat/commands/builtin/Restart.lua create mode 100644 lua/ui/game/chat/commands/builtin/Save.lua diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index b9e8adf3952..3dd91d6f287 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -78,6 +78,9 @@ function RegisterBuiltinCommands() Registry.Register(import("/lua/ui/game/chat/commands/builtin/Mute.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Unmute.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Clear.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Restart.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Save.lua").Command) + Registry.Register(import("/lua/ui/game/chat/commands/builtin/Load.lua").Command) Registry.Register(import("/lua/ui/game/chat/commands/builtin/Help.lua").Command) end diff --git a/lua/ui/game/chat/commands/builtin/Load.lua b/lua/ui/game/chat/commands/builtin/Load.lua new file mode 100644 index 00000000000..795de03225f --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Load.lua @@ -0,0 +1,47 @@ + +local Prefs = import("/lua/user/prefs.lua") + +------------------------------------------------------------------------------- +-- /load [name] — load a save by name (default: the quick-save slot). The +-- path is built the same way `QuickSave` does in `gamemain.lua`, so an +-- omitted name lines up exactly with the slot `/save` writes to. +-- +-- Load errors surface as the engine's standard failure dialog via +-- `LoadSavedGame`'s return values; the command stays silent on success +-- because the game is already transitioning out. + +local function IsSingleplayer() + return not SessionIsMultiplayer() and not SessionIsReplay() +end + +---@type UIChatCommand +Command = { + Name = 'load', + Description = 'Load a saved game by name (defaults to the quick-save slot).', + ShouldRegister = IsSingleplayer, + Params = { + { Name = 'name', Type = 'Rest', Optional = true }, + }, + Execute = function(args, ctx) + local name = args.name or LOC("QuickSave") + local saveType = import("/lua/ui/campaign/campaignmanager.lua").campaignMode + and "CampaignSave" or "SaveGame" + local path = GetSpecialFilePath(Prefs.GetCurrentProfile().Name, name, saveType) + + local ok, err = LoadSavedGame(path) + if not ok and err then + ctx.Controller.AppendLocalSystemMessage( + string.format("/load: could not load '%s' (%s).", name, tostring(err)) + ) + end + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Restart.lua b/lua/ui/game/chat/commands/builtin/Restart.lua new file mode 100644 index 00000000000..dadf70304aa --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Restart.lua @@ -0,0 +1,28 @@ + +------------------------------------------------------------------------------- +-- /restart — immediately restart the current session. Single-player only. +-- Skips the confirmation dialog that the escape-menu's Restart button shows; +-- the command itself is deliberate enough. + +local function IsSingleplayer() + return not SessionIsMultiplayer() and not SessionIsReplay() +end + +---@type UIChatCommand +Command = { + Name = 'restart', + Description = 'Restart the current mission (single-player only).', + ShouldRegister = IsSingleplayer, + Execute = function() + RestartSession() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Save.lua b/lua/ui/game/chat/commands/builtin/Save.lua new file mode 100644 index 00000000000..88c13b1cc0e --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Save.lua @@ -0,0 +1,35 @@ + +------------------------------------------------------------------------------- +-- /save [name] — quick-save the current session. Without a name, uses the +-- localised default ("QuickSave" in English) so repeated saves overwrite the +-- same slot — matching the quick-save hotkey in `keyactions.lua`. +-- +-- Accepts `Rest` so a multi-word name goes through as-is: `/save before boss` +-- saves to "before boss". + +local function IsSingleplayer() + return not SessionIsMultiplayer() and not SessionIsReplay() +end + +---@type UIChatCommand +Command = { + Name = 'save', + Description = 'Quick-save the current session (optional name).', + ShouldRegister = IsSingleplayer, + Params = { + { Name = 'name', Type = 'Rest', Optional = true }, + }, + Execute = function(args) + local name = args.name or LOC("QuickSave") + import("/lua/ui/game/gamemain.lua").QuickSave(name) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From baeca85c672c1eeb22aa1f11c3dafd81d47565a9 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 17:25:04 +0200 Subject: [PATCH 054/130] Robust loading of commands to prevent game failing to start when drunk --- lua/ui/game/chat/ChatController.lua | 43 +++++++----- .../chat/commands/ChatCommandRegistry.lua | 69 ++++++++++++++++++- .../game/chat/commands/builtin/DebugLog.lua | 25 +++++++ .../chat/commands/builtin/DebugStatistics.lua | 26 +++++++ .../game/chat/commands/builtin/Debugger.lua | 25 +++++++ .../game/chat/commands/builtin/EndMission.lua | 27 ++++++++ lua/ui/game/chat/commands/builtin/Load.lua | 8 +-- lua/ui/game/chat/commands/builtin/Pause.lua | 26 +++++++ lua/ui/game/chat/commands/builtin/Restart.lua | 15 ++-- lua/ui/game/chat/commands/builtin/Resume.lua | 25 +++++++ lua/ui/game/chat/commands/builtin/Save.lua | 8 +-- lua/ui/game/chat/commands/builtin/Speed.lua | 33 +++++++++ lua/ui/game/chat/commands/builtin/ToTick.lua | 53 ++++++++++++++ 13 files changed, 347 insertions(+), 36 deletions(-) create mode 100644 lua/ui/game/chat/commands/builtin/DebugLog.lua create mode 100644 lua/ui/game/chat/commands/builtin/DebugStatistics.lua create mode 100644 lua/ui/game/chat/commands/builtin/Debugger.lua create mode 100644 lua/ui/game/chat/commands/builtin/EndMission.lua create mode 100644 lua/ui/game/chat/commands/builtin/Pause.lua create mode 100644 lua/ui/game/chat/commands/builtin/Resume.lua create mode 100644 lua/ui/game/chat/commands/builtin/Speed.lua create mode 100644 lua/ui/game/chat/commands/builtin/ToTick.lua diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 3dd91d6f287..972406912e0 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -67,21 +67,29 @@ end function RegisterBuiltinCommands() local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") - Registry.Register(import("/lua/ui/game/chat/commands/builtin/All.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Allies.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Whisper.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/GiftUnits.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/GiftResources.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Recall.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/ToEngineers.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Taunt.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Mute.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Unmute.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Clear.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Restart.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Save.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Load.lua").Command) - Registry.Register(import("/lua/ui/game/chat/commands/builtin/Help.lua").Command) + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/All.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Allies.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Whisper.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/GiftUnits.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/GiftResources.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Recall.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/ToEngineers.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Taunt.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Mute.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Unmute.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Clear.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Restart.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Save.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Load.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Pause.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Resume.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Speed.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/EndMission.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/DebugLog.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/DebugStatistics.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Debugger.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/ToTick.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Help.lua") end ------------------------------------------------------------------------------- @@ -228,7 +236,7 @@ local ToStrings = { --- appends it to the model history. Fields with natural defaults (colour, --- army ID, faction icon) fall back when the army data is missing or the --- sender is an observer. ----@param args { Name: string, Text?: string, ArmyData?: table, IsObserver?: boolean, Recipient: UIChatRecipient, Camera?: table, Id?: string } +---@param args { Name: string, Text?: string, ArmyData?: table, IsObserver?: boolean, Recipient: UIChatRecipient, Camera?: table, Location?: UIChatEntryLocation, Id?: string } local function AppendChatLine(args) local armyData = args.ArmyData or {} -- Observers have no `faction`; fall through to the tail icon in @@ -243,6 +251,7 @@ local function AppendChatLine(args) Faction = (faction or 4) + 1, Recipient = args.Recipient, Camera = args.Camera, + Location = args.Location, Id = args.Id, } end @@ -293,6 +302,7 @@ function OnReceive(sender, msg) IsObserver = msg.Observer, Recipient = to, Camera = msg.camera, + Location = msg.location, Id = msg.Id, } end @@ -351,6 +361,7 @@ local function OnEcho(senderData, recipientData, msg) ArmyData = senderData, Recipient = msg.to, Camera = msg.camera, + Location = msg.location, Id = msg.Id, } end diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 52d72c5eec8..2730ee402b6 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -85,6 +85,53 @@ function Register(cmd) end end +--- Defensive wrapper around `Register`: takes a module path, loads it, and +--- registers its `Command` export — all inside pcalls so one broken file +--- can't take down the entire registration pass (and with it the chat +--- system + anything that depends on `ChatController.Init`). +--- +--- Every failure — missing file, import error, missing or malformed +--- `Command` export, Register throwing — is logged and swallowed. +---@param path string +function RegisterFromPath(path) + if not DiskGetFileInfo(path) then + WARN(string.format("Chat command skipped: file not found '%s'.", tostring(path))) + return + end + + local ok, module = pcall(import, path) + if not ok then + WARN(string.format("Chat command skipped: failed to import '%s' (%s).", + tostring(path), tostring(module))) + return + end + + local cmd = module and module.Command + if type(cmd) ~= 'table' then + WARN(string.format("Chat command skipped: '%s' does not export a `Command` table.", + tostring(path))) + return + end + + if type(cmd.Name) ~= 'string' or cmd.Name == '' then + WARN(string.format("Chat command skipped: '%s' has an invalid `Command.Name`.", + tostring(path))) + return + end + + if type(cmd.Execute) ~= 'function' then + WARN(string.format("Chat command skipped: '%s' has no `Command.Execute` function.", + tostring(path))) + return + end + + local registered, err = pcall(Register, cmd) + if not registered then + WARN(string.format("Chat command skipped: Register('%s') threw (%s).", + tostring(path), tostring(err))) + end +end + --- Returns a flat list of every registered command (canonical entries only). ---@return UIChatCommand[] function GetAll() @@ -250,12 +297,30 @@ function Dispatch(text) } if cmd.Accept then - local ok, reason = cmd.Accept(args, ctx) + -- Accept is user code — a crash here is a bug, not a rejection. Treat + -- it as a soft failure so the chat send path doesn't propagate the + -- throw up through the edit box's event handler. The full stack goes + -- to the log; chat only gets the "check the log" hint. + local pcallOk, ok, reason = pcall(cmd.Accept, args, ctx) + if not pcallOk then + WARN(string.format("/%s: Accept threw (%s).", cmd.Name, tostring(ok))) + return false, string.format( + "/%s: command errored while validating — see the log for details.", + cmd.Name) + end if not ok then return false, reason or string.format("/%s: command rejected.", cmd.Name) end end - cmd.Execute(args, ctx) + -- Same pcall treatment for Execute. Side effects that ran before the + -- throw aren't rolled back — this just keeps the chat input usable. + local executeOk, err = pcall(cmd.Execute, args, ctx) + if not executeOk then + WARN(string.format("/%s: Execute threw (%s).", cmd.Name, tostring(err))) + return false, string.format( + "/%s: command errored while running — see the log for details.", + cmd.Name) + end return true, nil end diff --git a/lua/ui/game/chat/commands/builtin/DebugLog.lua b/lua/ui/game/chat/commands/builtin/DebugLog.lua new file mode 100644 index 00000000000..7b5ff34ecc0 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/DebugLog.lua @@ -0,0 +1,25 @@ + +------------------------------------------------------------------------------- +-- /debug-log — toggle the log window (same entry point the debug hotkey +-- uses). Only registered when the game was launched with `/debug`. + +---@type UIChatCommand +Command = { + Name = 'debug-log', + Description = 'Toggle the log window.', + ShouldRegister = function() + return HasCommandLineArg('/debug') + end, + Execute = function() + ConExecute('WIN_ToggleLogDialog') + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/DebugStatistics.lua b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua new file mode 100644 index 00000000000..9a77ad41f75 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua @@ -0,0 +1,26 @@ + +------------------------------------------------------------------------------- +-- /debug-statistics — runs the engine's `ShowStats` console command, which +-- cycles the overlay that reports frame time, memory, etc. Only registered +-- when the game was launched with `/debug`. + +---@type UIChatCommand +Command = { + Name = 'debug-statistics', + Description = 'Cycle the engine ShowStats overlay.', + ShouldRegister = function() + return HasCommandLineArg('/debug') + end, + Execute = function() + ConExecute('ShowStats') + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Debugger.lua b/lua/ui/game/chat/commands/builtin/Debugger.lua new file mode 100644 index 00000000000..ffa5455d46b --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Debugger.lua @@ -0,0 +1,25 @@ + +------------------------------------------------------------------------------- +-- /debugger — opens the Lua debugger attached to the running session. +-- Only registered when the game was launched with `/debug`. + +---@type UIChatCommand +Command = { + Name = 'debugger', + Description = 'Open the in-game Lua debugger.', + ShouldRegister = function() + return HasCommandLineArg('/debug') + end, + Execute = function() + ConExecute('SC_LuaDebugger') + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/EndMission.lua b/lua/ui/game/chat/commands/builtin/EndMission.lua new file mode 100644 index 00000000000..dfa7fa063f2 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/EndMission.lua @@ -0,0 +1,27 @@ + +------------------------------------------------------------------------------- +-- /end-mission — forfeits the current session and opens the score screen. +-- Delegates to the same `EndGame` function the escape-menu button uses, so +-- campaign vs. skirmish branching stays consistent. Available in +-- single-player and replay. + +---@type UIChatCommand +Command = { + Name = 'end-mission', + Description = 'Forfeit the current skirmish or mission and show the score screen.', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + Execute = function() + import("/lua/ui/game/tabs.lua").EndGame() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Load.lua b/lua/ui/game/chat/commands/builtin/Load.lua index 795de03225f..352462af71e 100644 --- a/lua/ui/game/chat/commands/builtin/Load.lua +++ b/lua/ui/game/chat/commands/builtin/Load.lua @@ -10,15 +10,13 @@ local Prefs = import("/lua/user/prefs.lua") -- `LoadSavedGame`'s return values; the command stays silent on success -- because the game is already transitioning out. -local function IsSingleplayer() - return not SessionIsMultiplayer() and not SessionIsReplay() -end - ---@type UIChatCommand Command = { Name = 'load', Description = 'Load a saved game by name (defaults to the quick-save slot).', - ShouldRegister = IsSingleplayer, + ShouldRegister = function() + return not SessionIsMultiplayer() + end, Params = { { Name = 'name', Type = 'Rest', Optional = true }, }, diff --git a/lua/ui/game/chat/commands/builtin/Pause.lua b/lua/ui/game/chat/commands/builtin/Pause.lua new file mode 100644 index 00000000000..47f0017319b --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Pause.lua @@ -0,0 +1,26 @@ + +------------------------------------------------------------------------------- +-- /pause — pause the local simulation. Available in single-player and +-- replay; multiplayer pausing goes through a vote/request flow handled by +-- the existing hotkey, so this command stays out of the way there. + +---@type UIChatCommand +Command = { + Name = 'pause', + Description = 'Pause the simulation.', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + Execute = function() + SessionRequestPause() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Restart.lua b/lua/ui/game/chat/commands/builtin/Restart.lua index dadf70304aa..3ff9d7e4cbd 100644 --- a/lua/ui/game/chat/commands/builtin/Restart.lua +++ b/lua/ui/game/chat/commands/builtin/Restart.lua @@ -1,18 +1,17 @@ ------------------------------------------------------------------------------- --- /restart — immediately restart the current session. Single-player only. --- Skips the confirmation dialog that the escape-menu's Restart button shows; --- the command itself is deliberate enough. - -local function IsSingleplayer() - return not SessionIsMultiplayer() and not SessionIsReplay() -end +-- /restart — immediately restart the current session. Available in +-- single-player and replay (skipped in multiplayer). Skips the confirmation +-- dialog that the escape-menu's Restart button shows — the command itself +-- is deliberate enough. ---@type UIChatCommand Command = { Name = 'restart', Description = 'Restart the current mission (single-player only).', - ShouldRegister = IsSingleplayer, + ShouldRegister = function() + return not SessionIsMultiplayer() + end, Execute = function() RestartSession() end, diff --git a/lua/ui/game/chat/commands/builtin/Resume.lua b/lua/ui/game/chat/commands/builtin/Resume.lua new file mode 100644 index 00000000000..dc7480c6936 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Resume.lua @@ -0,0 +1,25 @@ + +------------------------------------------------------------------------------- +-- /resume — un-pause the local simulation. Symmetric with `/pause`; +-- available in single-player and replay. + +---@type UIChatCommand +Command = { + Name = 'resume', + Description = 'Resume the simulation.', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + Execute = function() + SessionResume() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Save.lua b/lua/ui/game/chat/commands/builtin/Save.lua index 88c13b1cc0e..69c5bf60dd3 100644 --- a/lua/ui/game/chat/commands/builtin/Save.lua +++ b/lua/ui/game/chat/commands/builtin/Save.lua @@ -7,15 +7,13 @@ -- Accepts `Rest` so a multi-word name goes through as-is: `/save before boss` -- saves to "before boss". -local function IsSingleplayer() - return not SessionIsMultiplayer() and not SessionIsReplay() -end - ---@type UIChatCommand Command = { Name = 'save', Description = 'Quick-save the current session (optional name).', - ShouldRegister = IsSingleplayer, + ShouldRegister = function() + return not SessionIsMultiplayer() + end, Params = { { Name = 'name', Type = 'Rest', Optional = true }, }, diff --git a/lua/ui/game/chat/commands/builtin/Speed.lua b/lua/ui/game/chat/commands/builtin/Speed.lua new file mode 100644 index 00000000000..0d222fd533a --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Speed.lua @@ -0,0 +1,33 @@ + +------------------------------------------------------------------------------- +-- /speed — set the simulation speed multiplier via the `WLD_GameSpeed` +-- console var. Range is engine-side (typically -10..+10); invalid values +-- are ignored by the engine rather than throwing. +-- +-- Available in single-player and replay. Multiplayer speed changes go +-- through a vote/request flow on the host, not a direct console write, so +-- the command is unregistered there. + +---@type UIChatCommand +Command = { + Name = 'speed', + Description = 'Set the simulation speed.', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + Params = { + { Name = 'value', Type = 'Int' }, + }, + Execute = function(args) + ConExecute("WLD_GameSpeed " .. args.value) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/ToTick.lua b/lua/ui/game/chat/commands/builtin/ToTick.lua new file mode 100644 index 00000000000..769c8bff190 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/ToTick.lua @@ -0,0 +1,53 @@ + +------------------------------------------------------------------------------- +-- /debug-wind — fast-forward the simulation to `tick` at maximum +-- speed, then pause. Maxes out `WLD_GameSpeed` first so the sim runs as +-- fast as the engine allows, then hands off to `wld_RunWithTheWind` which +-- halts on its own when the target tick is reached. + +---@type UIChatCommand +Command = { + Name = 'to-tick', + Description = 'Fast-forward the sim to and pause there.', + ShouldRegister = function() + return HasCommandLineArg('/debug') + end, + Params = { + { Name = 'tick', Type = 'Int' }, + }, + Accept = function(args) + if args.tick < 0 then + return false, "/to-tick: tick must be non-negative." + end + local current = GetGameTick() + if args.tick <= current then + return false, string.format( + "/to-tick: target tick %d has already passed (now at %d).", + args.tick, current) + end + return true + end, + Execute = function(args) + ConExecute("wld_RunWithTheWind 1") + + -- wait till we get there and pause + ForkThread( + function() + while GetGameTick() < args.tick - 5 do + WaitFrames(1) + end + ConExecute("wld_RunWithTheWind 0") + SessionRequestPause() + end + ) + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From b83dab8d5250eb71a5fda65736f147947e9b9841 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Fri, 24 Apr 2026 17:25:25 +0200 Subject: [PATCH 055/130] Add ability for AIs and/or other source to make chat messages --- lua/ChatUtils.lua | 49 +++++++++++- lua/aibrain.lua | 5 +- lua/aibrains/components/chat.lua | 103 +++++++++++++++++++++++++ lua/ui/game/chat/ChatInterface.lua | 24 +++++- lua/ui/game/chat/ChatLineInterface.lua | 6 +- lua/ui/game/chat/ChatModel.lua | 13 +++- 6 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 lua/aibrains/components/chat.lua diff --git a/lua/ChatUtils.lua b/lua/ChatUtils.lua index 6f55ace30a0..ba84c3ab1f5 100644 --- a/lua/ChatUtils.lua +++ b/lua/ChatUtils.lua @@ -10,6 +10,52 @@ local SimUtils = import("/lua/simutils.lua") +--- Per-client recipient filter. The sim runs deterministically on every +--- client, but `Sync` is per-client and `GetFocusArmy()` reads the local +--- viewer's focus — so every client can independently decide whether to +--- relay a given message to its own UI, without diverging the shared sim +--- state. Mirrors the legacy `FindClients` routing policy: +--- +--- * Observers (`focus == -1`) see everything, matching the legacy replay +--- and live-spectator behaviour where privates become visible so the +--- conversation can be attributed. +--- * `all` broadcasts always pass. +--- * `allies` broadcasts pass to the sender and to anyone `IsAlly` with +--- the sender. +--- * Numeric `to` is a private whisper — only the sender and the named +--- recipient pass. +---@param msg {From: integer, to: 'all' | 'allies' | integer} +---@return boolean +function IsLocalRecipient(msg) + local focus = GetFocusArmy() + if focus == -1 then return true end + + local to = msg.to + if to == 'all' then return true end + if to == 'allies' then + return focus == msg.From or IsAlly(focus, msg.From) + end + + if type(to) == 'number' then + return focus == msg.From or focus == to + end + return false +end + +--- Writes `msg` onto `Sync.ChatMessages` only if the local client is a +--- legitimate recipient. Shared entry point for every sim-originated +--- chat emitter (the `SendChatMessage` callback for UI-sent messages, the +--- `AIBrainChatComponent` for AI-emitted lines, and any future sim system +--- that wants to drop a line into the chat feed) so the recipient policy +--- is enforced sim-side in exactly one place. +---@param msg table +function RelayChatMessage(msg) + if IsLocalRecipient(msg) then + Sync.ChatMessages = Sync.ChatMessages or {} + table.insert(Sync.ChatMessages, msg) + end +end + --- Legacy replay hook kept for external callers that may still reference it. --- The refactored chat path no longer uses this — chat is now relayed through --- `Sync.ChatMessages` (see `SendChatMessage`) and external replay parsers @@ -67,6 +113,5 @@ function SendChatMessage(data) msg.From = from - Sync.ChatMessages = Sync.ChatMessages or {} - table.insert(Sync.ChatMessages, msg) + RelayChatMessage(msg) end diff --git a/lua/aibrain.lua b/lua/aibrain.lua index c580e5f7752..cf82c69743d 100644 --- a/lua/aibrain.lua +++ b/lua/aibrain.lua @@ -23,6 +23,7 @@ local FactoryManagerBrainComponent = import("/lua/aibrains/components/factoryman local JammerManagerBrainComponent = import("/lua/aibrains/components/jammermanagerbraincomponent.lua").JammerManagerBrainComponent local StatManagerBrainComponent = import("/lua/aibrains/components/statmanagerbraincomponent.lua").StatManagerBrainComponent local EnergyManagerBrainComponent = import("/lua/aibrains/components/energymanagerbraincomponent.lua").EnergyManagerBrainComponent +local AIBrainChatComponent = import("/lua/aibrains/components/chat.lua").AIBrainChatComponent ---@class TriggerSpec ---@field Callback function @@ -49,7 +50,7 @@ local BrainGetUnitsAroundPoint = moho.aibrain_methods.GetUnitsAroundPoint local BrainGetListOfUnits = moho.aibrain_methods.GetListOfUnits local CategoriesDummyUnit = categories.DUMMYUNIT ----@class AIBrain: FactoryManagerBrainComponent, StatManagerBrainComponent, JammerManagerBrainComponent, EnergyManagerBrainComponent, StorageManagerBrainComponent, moho.aibrain_methods +---@class AIBrain: FactoryManagerBrainComponent, StatManagerBrainComponent, JammerManagerBrainComponent, EnergyManagerBrainComponent, StorageManagerBrainComponent, AIBrainChatComponent, moho.aibrain_methods ---@field AI boolean ---@field Name string # Army name ---@field Nickname string # Player / AI / character name @@ -66,7 +67,7 @@ local CategoriesDummyUnit = categories.DUMMYUNIT ---@field LastUnitKilledBy Army # Which army last killed one of our units. Used for transfering to killer in other victory conditions. ---@field Army integer # Cached `GetArmyIndex` engine call AIBrain = Class(FactoryManagerBrainComponent, StatManagerBrainComponent, JammerManagerBrainComponent, - EnergyManagerBrainComponent, StorageManagerBrainComponent, moho.aibrain_methods) { + EnergyManagerBrainComponent, StorageManagerBrainComponent, AIBrainChatComponent, moho.aibrain_methods) { Status = 'InProgress', diff --git a/lua/aibrains/components/chat.lua b/lua/aibrains/components/chat.lua new file mode 100644 index 00000000000..9a0844dfe6a --- /dev/null +++ b/lua/aibrains/components/chat.lua @@ -0,0 +1,103 @@ + +--*************************************************************************** +--** Summary: Chat component for the AI brain. Lets any AI drop a line into +--** every UI's chat feed with a single call — no knowledge of the sim-to-UI +--** sync plumbing required. +--** +--** The legacy `AIChatSorian` path pushed messages through a dedicated +--** `Sync.AIChat` → UI `AIChat()` → `ChatController.OnReceive` pipeline that +--** bypassed the rest of the chat system. Since the UI now listens on +--** `Sync.ChatMessages` (dedup'd by a sender-stamped `Id`), a sim-side +--** emitter — AI brain, campaign script, whatever — can write straight to +--** that stream and have its message surface through the same code path as +--** player chat, including the replay-playback path. +--**************************************************************************** + +local ChatUtils = import("/lua/chatutils.lua") + +---@alias AIBrainChatRecipient 'all' | 'allies' | integer + +--- Optional location hint attached to a chat message. The UI renders the +--- cam-icon affordance when either `Position` or `Area` is set and, on +--- click, points the viewer's camera at the matching spot — `MoveTo` for a +--- point (viewer's pitch/heading/zoom preserved) or `MoveToRegion` for an +--- area (framing computed automatically). Only one of the two is used; if +--- both are present `Area` wins. +---@class AIBrainChatLocation +---@field Position? Vector # world-space focus point +---@field Area? Rectangle # world-space rectangle to frame + +---@class AIBrainChatComponent +AIBrainChatComponent = ClassSimple { + + --- Broadcasts a message to every connected UI as an "all" chat line. + ---@param self AIBrainChatComponent + ---@param text string + ---@param location? AIBrainChatLocation + SendChatToAll = function(self, text, location) + self:SendChatTo('all', text, location) + end, + + --- Broadcasts a message to the AI's allies. `Sync.ChatMessages` reaches + --- every UI, so the non-ally filter is applied client-side on display. + ---@param self AIBrainChatComponent + ---@param text string + ---@param location? AIBrainChatLocation + SendChatToAllies = function(self, text, location) + self:SendChatTo('allies', text, location) + end, + + --- Whispers a message to a specific army. No ally constraint — the AI is + --- trusted sim code and may legitimately taunt an enemy or message a + --- neutral party. + ---@param self AIBrainChatComponent + ---@param army integer + ---@param text string + ---@param location? AIBrainChatLocation + SendChatToPlayer = function(self, army, text, location) + self:SendChatTo(army, text, location) + end, + + --- Addresses a message back at this brain's own army. Useful for + --- debug-style output and campaign hints that should only reach whoever + --- is watching this AI's perspective (typically just observers with + --- full vision, or a human controller in campaign setups). + ---@param self AIBrainChatComponent | AIBrain + ---@param text string + ---@param location? AIBrainChatLocation + SendChatToSelf = function(self, text, location) + self:SendChatTo(self:GetArmyIndex(), text, location) + end, + + --- Shared implementation: builds the message, stamps it with the + --- brain's army index and a dedupe id, and hands it to + --- `ChatUtils.RelayChatMessage` for sim-side recipient filtering. + --- The id is the message table's address — near-unique, survives + --- serialisation as a plain string, and keeps the UI dedupe from + --- double-posting if the same message arrives more than once (see + --- `ChatController.OnSyncChatMessages`). + --- + --- `location`, if provided, rides on the message as `msg.location` and + --- is surfaced to the UI as `entry.Location` — the click handler in + --- `ChatInterface` translates it to a `MoveTo`/`MoveToRegion` call at + --- click time, so there is no need to synthesise a camera snapshot + --- sim-side. + ---@param self AIBrainChatComponent | AIBrain + ---@param to AIBrainChatRecipient + ---@param text string + ---@param location? AIBrainChatLocation + SendChatTo = function(self, to, text, location) + if type(text) ~= 'string' or text == '' then return end + + local msg = { + Chat = true, + to = to, + text = text, + From = self:GetArmyIndex(), + location = location, + } + msg.Id = tostring(msg) + + ChatUtils.RelayChatMessage(msg) + end, +} diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 2edc5518aeb..fef60f8c6a2 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -145,11 +145,27 @@ local ChatInterface = ClassUI(Window) { end -- Shared cam-icon click handler. Same one-closure-per-window pattern - -- as `OnLineNameClicked`. Restores the world camera to the state - -- the sender saved when they shipped the message. + -- as `OnLineNameClicked`. Two wire formats: + -- * `entry.Camera` — a full `SaveSettings` snapshot the sender + -- wanted us to adopt verbatim (player attached their view). + -- * `entry.Location` — a point or region from a sim-originated + -- sender (AI brain, system message). We translate on click so + -- the viewer's pitch/heading/zoom are preserved for a point + -- move, or the framing is computed by `MoveToRegion` for an + -- area. Checked first so a future message carrying both + -- prefers the lighter-weight hint. self.OnLineCameraClicked = function(_, entry) - if entry.Camera then - GetCamera('WorldCamera'):RestoreSettings(entry.Camera) + local cam = GetCamera('WorldCamera') + if entry.Location then + if entry.Location.Area then + cam:MoveToRegion(entry.Location.Area, 0.5) + elseif entry.Location.Position then + local settings = cam:SaveSettings() + settings.Focus = entry.Location.Position + cam:RestoreSettings(settings) + end + elseif entry.Camera then + cam:RestoreSettings(entry.Camera) end end diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index 909b6563024..b33245e8c3c 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -144,8 +144,10 @@ ChatLineInterface = ClassUI(Group) { -- Camera affordance: switch between textured (hit-testable) and -- transparent SolidColor (inert) rather than Show/Hide, so the -- window-wide `Show()` cascade can't reveal stale icons. Re-applying - -- `RightOf` replaces the previous Left binding (no leak). - if entry.Camera then + -- `RightOf` replaces the previous Left binding (no leak). Shown for + -- both full `Camera` snapshots (player attached their view) and + -- `Location` hints (AI tagged a point or region). + if entry.Camera or entry.Location then self.CamIcon:SetTexture(UIUtil.UIFile(CamIconTexture)) self.CamIcon:EnableHitTest() LayoutHelpers.RightOf(self.Text, self.CamIcon, 4) diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index 657381b379d..0717ce07bbf 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -15,6 +15,16 @@ RecipientAllies = 'allies' ------------------------------------------------------------------------------- -- History entry. +--- Location hint carried by a message: either a single point or a bounding +--- rectangle in world space. Sim-originated senders (AI brains, future +--- system messages) populate this instead of `Camera` — the UI translates +--- it to an appropriate camera move on click (`Camera:MoveTo` for a point, +--- `Camera:MoveToRegion` for an area) so the viewer's pitch/heading is not +--- forced to match the sender's. +---@class UIChatEntryLocation +---@field Position? Vector # world-space focus point +---@field Area? Rectangle # world-space rectangle to frame + ---@class UIChatEntry ---@field Name string # formatted prefix, e.g. "Sender to allies:" ---@field Text string # raw message body @@ -22,7 +32,8 @@ RecipientAllies = 'allies' ---@field ArmyID number # sender's army index ---@field Faction number # faction icon index (1-based) ---@field Recipient UIChatRecipient # the target this message was directed to ----@field Camera? table # camera state when the message is a ping link +---@field Camera? table # camera state (`SaveSettings` snapshot) when the sender attached their exact view +---@field Location? UIChatEntryLocation # lightweight location hint from a sim-originated sender (AI brain, system message) ---@field Id? string # near-unique sender-stamped id (`tostring(msg)`); used to dedupe the `Sync.ChatMessages` replay/sim path against the live `SessionSendChatMessage` path ---@field WrappedText? string[] # view-side cache: text wrapped to the current row width (populated by ChatInterface) From 0b62bf055cce8164d97efbbd7b394764bf437a4f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 10:28:40 +0200 Subject: [PATCH 056/130] Add a scrollbar for command hints --- lua/ui/game/chat/ChatCommandHintInterface.lua | 181 ++++++++++++++++-- 1 file changed, 169 insertions(+), 12 deletions(-) diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index d7a90cbab71..25d8d728cf3 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -13,7 +13,17 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local RowFontSize = 12 local RowFontName = 'Arial' local HorizontalPadding = 12 -local VerticalPadding = 2 +local VerticalPadding = 2 + +--- Cap the popup height: at most this many command rows are visible at +--- once; anything beyond scrolls. Chosen to match the point where the +--- popup becomes visually overwhelming on a 1080p screen. +local MaxVisibleRows = 6 + +--- Width reserved on the right of the popup for the scrollbar. Reserved +--- unconditionally so the layout doesn't reflow when the scrollbar shows +--- / hides with the match count. +local ScrollbarWidth = 24 --- Renders a command the same way `/help` does: name, params, aliases, description. ---@param cmd UIChatCommand @@ -54,9 +64,11 @@ end ---@field OnSelect? fun(cmd: UIChatCommand) ---@field Rows UIChatHintRow[] # reusable pool, indexed by ordinal ---@field Background Bitmap # solid backdrop covering the whole popup +---@field Scrollbar Scrollbar # vertical scrollbar shown when VisibleCount > MaxVisibleRows ---@field RowHeight LazyVar ---@field VisibleCount LazyVar ---@field Selected LazyVar # 0 = no selection, 1..VisibleCount = row ordinal +---@field ScrollBottom LazyVar # 1-based ordinal at the bottom visible slot ---@field LastText string ---@field LTBG Bitmap ---@field RTBG Bitmap @@ -81,6 +93,7 @@ ChatCommandHintInterface = ClassUI(Group) { self.LastText = '' self.VisibleCount = Create(0) self.Selected = Create(0) + self.ScrollBottom = Create(1) -- Solid backdrop so the tiny per-row highlight bitmaps (which only -- span Text.Top-1..Text.Bottom+1) don't leave visible gaps between @@ -118,6 +131,10 @@ ChatCommandHintInterface = ClassUI(Group) { -- Repaint highlighted rows when the selection moves. We own `Selected` -- so binding its OnDirty directly is safe (see CLAUDE.md §LazyVar). self.Selected.OnDirty = function() self:RepaintRows() end + + -- Scroll changes: re-hide/show rows so only the current window is + -- visible. We own `ScrollBottom`, so direct OnDirty is safe. + self.ScrollBottom.OnDirty = function() self:UpdateRowVisibility() end end, ---@param self UIChatCommandHintInterface @@ -138,13 +155,16 @@ ChatCommandHintInterface = ClassUI(Group) { local textWidth = probe.Width() probe:Destroy() + -- Reserve scrollbar width unconditionally so the popup doesn't + -- reflow when the scrollbar appears / disappears with match count. Layouter(self) - :Width(textWidth + HorizontalPadding * 2) + :Width(textWidth + HorizontalPadding * 2 + ScrollbarWidth) :End() ---@diagnostic disable: undefined-field self.Height:SetFunction(function() - return self.VisibleCount() * self.RowHeight() + local rows = math.min(self.VisibleCount(), MaxVisibleRows) + return rows * self.RowHeight() end) -- Unified backdrop covers the entire popup. Rows are transparent by @@ -166,6 +186,27 @@ ChatCommandHintInterface = ClassUI(Group) { Layouter(self.TBG):Left(self.Left):Right(self.Right):Bottom(self.Top):End() Layouter(self.BBG):Left(self.Left):Right(self.Right):Top(self.Bottom):End() ---@diagnostic enable: undefined-field + + -- Vertical scrollbar along the right edge. `CreateVertScrollbarFor` + -- calls `scrollbar:SetScrollable(self)`, which drives dispatch into + -- `self:GetScrollValues` / `ScrollLines` / `ScrollPages` / + -- `ScrollSetTop` / `IsScrollable` (see below). A negative + -- `offset_right` pulls the bar inside the popup bounds instead of + -- overlapping the right border art. + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self, -ScrollbarWidth) + + -- Toggle the scrollbar visibility with match count. `Hide()` here is + -- safe — the hint popup is created fresh each time the user types + -- `/`, so the Show() cascade on the outer chat window can't undo it. + local function syncScrollbarVisibility() + if self.VisibleCount() > MaxVisibleRows then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end + self.VisibleCount.OnDirty = function() syncScrollbarVisibility() end + syncScrollbarVisibility() end, --- Builds a reusable row (text + highlight bitmap + hover handler). @@ -224,24 +265,132 @@ ChatCommandHintInterface = ClassUI(Group) { end end, + --- Shows rows whose ordinal falls inside the current scroll window and + --- hides the rest. Rebound on `ScrollBottom` changes and called from + --- Refresh after the row set has been updated. + ---@param self UIChatCommandHintInterface + UpdateRowVisibility = function(self) + local scrollBottom = self.ScrollBottom() + for ord, row in pairs(self.Rows) do + local inWindow = row.Ordinal() > 0 + and ord >= scrollBottom + and ord < scrollBottom + MaxVisibleRows + if inWindow then + row.Text:Show() + row.BG:Show() + else + row.Text:Hide() + row.BG:Hide() + end + end + end, + + --- Scrolls so `ordinal` falls inside the visible window. Called from + --- `SelectNext` / `SelectPrev` so keyboard navigation drags the scroll + --- along with the highlight. + ---@param self UIChatCommandHintInterface + ---@param ordinal number + EnsureOrdinalVisible = function(self, ordinal) + if ordinal <= 0 then return end + local scrollBottom = self.ScrollBottom() + if ordinal < scrollBottom then + self.ScrollBottom:Set(ordinal) + elseif ordinal >= scrollBottom + MaxVisibleRows then + self.ScrollBottom:Set(ordinal - MaxVisibleRows + 1) + end + end, + + ------------------------------------------------------------------------- + -- Scrollable interface — wired to by the MAUI `Scrollbar` control. + -- + -- The scrollbar thinks top-down (thumb at top = top of content), but our + -- ordinals grow bottom-up (ord 1 at the bottom of the popup, ord N at + -- the top). We convert at the boundary so the thumb tracks visually: + -- thumb at top → highest ordinals visible at top of popup. + -- + -- topdown_top = n - ScrollBottom - MaxVisibleRows + 2 + -- ScrollBottom = n - topdown_top - MaxVisibleRows + 2 (inverse) + ------------------------------------------------------------------------- + + ---@param self UIChatCommandHintInterface + ---@param axis string + GetScrollValues = function(self, axis) + local n = self.VisibleCount() + if n <= 0 then return 1, 1, 1, 1 end + local top = n - self.ScrollBottom() - MaxVisibleRows + 2 + if top < 1 then top = 1 end + return 1, n, top, math.min(top + MaxVisibleRows - 1, n) + end, + + ---@param self UIChatCommandHintInterface + ---@param axis string + ---@param delta number + ScrollLines = function(self, axis, delta) + local _, _, top, _ = self:GetScrollValues(axis) + self:ScrollSetTop(axis, top + math.floor(delta)) + end, + + ---@param self UIChatCommandHintInterface + ---@param axis string + ---@param delta number + ScrollPages = function(self, axis, delta) + local _, _, top, _ = self:GetScrollValues(axis) + self:ScrollSetTop(axis, top + math.floor(delta) * MaxVisibleRows) + end, + + ---@param self UIChatCommandHintInterface + ---@param axis string + ---@param top number # in scrollbar (top-down) coordinates + ScrollSetTop = function(self, axis, top) + local n = self.VisibleCount() + if n <= 0 then return end + local maxTop = math.max(1, n - MaxVisibleRows + 1) + top = math.max(1, math.min(maxTop, math.floor(top or 1))) + local newScrollBottom = n - top - MaxVisibleRows + 2 + newScrollBottom = math.max(1, math.min(maxTop, newScrollBottom)) + if newScrollBottom ~= self.ScrollBottom() then + self.ScrollBottom:Set(newScrollBottom) + end + end, + + ---@param self UIChatCommandHintInterface + ---@param axis string + IsScrollable = function(self, axis) + return self.VisibleCount() > MaxVisibleRows + end, + + --- Mouse wheel over the popup scrolls the visible window. One notch + --- (~120 wheel units) moves one row. + ---@param self UIChatCommandHintInterface + ---@param rotation number + OnMouseWheel = function(self, rotation) + self:ScrollLines(nil, -math.floor(rotation / 100)) + end, + --- Wraps `Selected` to the next visible dynamic row. No-op when there - --- are no matches. + --- are no matches. Scrolls the view so the new selection stays on + --- screen, matching keyboard-nav expectations. ---@param self UIChatCommandHintInterface SelectNext = function(self) local n = self.VisibleCount() if n <= 0 then return end local cur = self.Selected() - self.Selected:Set(cur >= n and 1 or cur + 1) + local next = cur >= n and 1 or cur + 1 + self.Selected:Set(next) + self:EnsureOrdinalVisible(next) end, --- Wraps `Selected` to the previous visible dynamic row. No-op when - --- there are no matches. + --- there are no matches. Scrolls the view so the new selection stays + --- on screen. ---@param self UIChatCommandHintInterface SelectPrev = function(self) local n = self.VisibleCount() if n <= 0 then return end local cur = self.Selected() - self.Selected:Set(cur <= 1 and n or cur - 1) + local prev = cur <= 1 and n or cur - 1 + self.Selected:Set(prev) + self:EnsureOrdinalVisible(prev) end, --- Returns the currently-selected command, or nil when nothing matches. @@ -271,7 +420,12 @@ ChatCommandHintInterface = ClassUI(Group) { row.Text.Bottom:SetFunction(function() local ord = row.Ordinal() if ord <= 0 then return self.Top() end - return self.Bottom() - (ord - 1) * self.RowHeight() + -- `slot` = 1 at the bottom visible row, MaxVisibleRows at the + -- top. Rows outside the window get positioned at `self.Top()` + -- and hidden by `UpdateRowVisibility`. + local slot = ord - self.ScrollBottom() + 1 + if slot < 1 or slot > MaxVisibleRows then return self.Top() end + return self.Bottom() - (slot - 1) * self.RowHeight() end) ---@diagnostic enable: undefined-field @@ -316,20 +470,21 @@ ChatCommandHintInterface = ClassUI(Group) { local row = self:GetOrCreateRow(i) row.Target = cmd row.Text:SetText(FormatCommand(cmd)) - row.Text:Show() - row.BG:Show() row.Ordinal:Set(i) end for i = table.getn(matches) + 1, table.getn(self.Rows) do local row = self.Rows[i] row.Target = nil - row.Text:Hide() - row.BG:Hide() row.Ordinal:Set(0) end self.VisibleCount:Set(table.getn(matches)) + -- Any time the match set changes, start the scroll window at the + -- bottom. `UpdateRowVisibility` below applies Show/Hide using the + -- fresh window. + self.ScrollBottom:Set(1) + -- Keep the previously-selected ordinal when possible; otherwise land -- on the first match (or clear the selection when nothing matches). local n = table.getn(matches) @@ -343,6 +498,8 @@ ChatCommandHintInterface = ClassUI(Group) { -- force a repaint so colors match the new row assignments. self:RepaintRows() end + + self:UpdateRowVisibility() ---@diagnostic enable: undefined-field end, From b9ab8a476d5d3750ac9fe37a138a3e2db6dd9ef2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 10:43:35 +0200 Subject: [PATCH 057/130] Always read from the configuration source --- lua/ui/game/chat/ChatInterface.lua | 75 ++++++++++++++++++------------ 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index fef60f8c6a2..4e17aae0b86 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -79,7 +79,6 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@field Scrollbar Scrollbar ---@field ScrollTop number # 1-based virtual position of the top visible row ---@field VirtualSize number # total wrapped lines across valid entries ----@field FontSize number # current font size (from ChatOptions.font_size) ---@field DragTL Bitmap # top-left corner resize grip ---@field DragTR Bitmap # top-right corner resize grip ---@field DragBL Bitmap # bottom-left corner resize grip @@ -116,7 +115,6 @@ local ChatInterface = ClassUI(Window) { self.Lines = {} self.ScrollTop = 1 self.VirtualSize = 0 - self.FontSize = ChatConfigModel.GetSingleton().Committed().font_size or 14 -- Expose the scrollable interface on the container so -- `UIUtil.CreateVertScrollbarFor(LinesContainer)` binds correctly. @@ -174,7 +172,6 @@ local ChatInterface = ClassUI(Window) { -- our handler can never stomp another subscriber's (see the chat -- CLAUDE.md for the pattern). local model = ChatModel.GetSingleton() - local configModel = ChatConfigModel.GetSingleton() -- History → wrap new entries, refresh size, stick to bottom. The -- initial firing happens before __post_init so the wrap call has @@ -190,18 +187,10 @@ local ChatInterface = ClassUI(Window) { ) ) - -- Committed chat options → apply font size, rebuild the pool (line - -- height tracks the font), rewrap all entries (wrap widths depend - -- on font metrics), and re-render. - self.OptionsObserver = self.Trash:Add( - LazyVarDerive( - configModel.Committed, - function(lv) - self:ApplyOptions(lv() - ) - end - ) - ) + -- `OptionsObserver` is wired up in `__post_init`, not here — its + -- initial fire triggers `ApplyOptions → RebuildPool`, which reads + -- `self.Lines[1].Height()` and so requires the container layout to + -- already be in place. -- Window visibility → show / hide the frame. self.WindowVisibleObserver = self.Trash:Add( @@ -361,6 +350,21 @@ local ChatInterface = ClassUI(Window) { self:RebuildPool() self:RewrapAll() self:ScrollToBottom() + + -- Committed chat options → apply font size, rebuild the pool (line + -- height tracks the font), rewrap all entries (wrap widths depend + -- on font metrics), and re-render. Wired here rather than in + -- `__init` so the initial fire of `LazyVarDerive` runs against a + -- fully laid-out window — `RebuildPool` reads `self.Lines[1].Height` + -- and gets a circular dependency error if the layout isn't ready. + self.OptionsObserver = self.Trash:Add( + LazyVarDerive( + ChatConfigModel.GetSingleton().Committed, + function(lv) + self:ApplyOptions(lv()) + end + ) + ) end, --------------------------------------------------------------------------- @@ -374,12 +378,15 @@ local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface RebuildPool = function(self) local container = self.LinesContainer + -- Read the live size straight from the config model so the pool + -- always tracks the current option without a cached copy on `self`. + local fontSize = ChatConfigModel.GetSingleton().Committed().font_size or 14 -- Need one line to establish the row height. The row's Height is a -- lazy function of the name-text font (see ChatLineInterface). if not self.Lines[1] then self.Lines[1] = ChatLineInterface(container) - self.Lines[1]:SetFontSize(self.FontSize) + self.Lines[1]:SetFontSize(fontSize) self.Lines[1].OnNameClicked = self.OnLineNameClicked self.Lines[1].OnCameraClicked = self.OnLineCameraClicked Layouter(self.Lines[1]) @@ -397,7 +404,7 @@ local ChatInterface = ClassUI(Window) { -- Grow: append rows below the previous one. for i = currentCount + 1, neededLines do self.Lines[i] = ChatLineInterface(container) - self.Lines[i]:SetFontSize(self.FontSize) + self.Lines[i]:SetFontSize(fontSize) self.Lines[i].OnNameClicked = self.OnLineNameClicked self.Lines[i].OnCameraClicked = self.OnLineCameraClicked Layouter(self.Lines[i]) @@ -418,27 +425,33 @@ local ChatInterface = ClassUI(Window) { -- Options application --------------------------------------------------------------------------- - --- Applies a `UIChatOptions` snapshot to the window. Currently handles - --- `font_size`; future options (colours, alpha, feed-mode flags) will + --- Applies a `UIChatOptions` snapshot to the window. Handles `font_size` + --- and `win_alpha` today; future options (colours, feed-mode flags) will --- extend this method. + --- + --- The model is the source of truth — we don't cache any of these values + --- on `self`. `ApplyOptions` only fires when the user commits a config + --- change (or once on startup when the observer first reads), so doing + --- the work unconditionally is fine even if some values didn't move. ---@param self UIChatInterface ---@param options UIChatOptions ApplyOptions = function(self, options) local size = options.font_size or 14 - if size ~= self.FontSize then - self.FontSize = size - for _, line in ipairs(self.Lines) do - line:SetFontSize(size) - end - -- Row height tracks the font, so the pool may need resizing; - -- wrap widths depend on font metrics, so rewrap all entries. - self:RebuildPool() - self:RewrapAll() + for _, line in ipairs(self.Lines) do + line:SetFontSize(size) end + -- Row height tracks the font, so the pool may need resizing; + -- wrap widths depend on font metrics, so rewrap all entries. + self:RebuildPool() + self:RewrapAll() + + -- Window opacity. `SetAlpha(_, true)` cascades to every descendant + -- so chrome, lines, edit, and scrollbar all dim uniformly. + self:SetAlpha(options.win_alpha or 1.0, true) - -- Filter-affecting options (muted, links) may have changed without - -- touching the font. Recompute what's visible so entries newly - -- excluded by `IsValidEntry` drop out of the feed immediately. + -- Filter-affecting options (muted, links) may have changed too. + -- Recompute what's visible so entries newly excluded by + -- `IsValidEntry` drop out of the feed immediately. self:RefreshVirtualSize() self:CalcVisible() end, From 6a975ad6e53175149b4861b098a68290626fa1f4 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 10:46:40 +0200 Subject: [PATCH 058/130] Introduce shorthand to quickly get options --- lua/ui/game/chat/ChatController.lua | 2 +- lua/ui/game/chat/ChatInterface.lua | 4 ++-- lua/ui/game/chat/config/ChatConfigModel.lua | 24 +++++++++++++-------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 972406912e0..04d8128941e 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -512,7 +512,7 @@ function ActivateChat(modifiers) -- the toggle above — writing to `Recipient` before `ToggleWindow` runs -- gets clobbered by its own `ApplyDefaultRecipient` call. if not wasVisible and type(model.Recipient()) ~= 'number' then - local sendType = ChatConfigModel.GetSingleton().Committed().send_type or false + local sendType = ChatConfigModel.GetOptions().send_type or false local shift = modifiers and modifiers.Shift or false if (not shift) == sendType then model.Recipient:Set(ChatModel.RecipientAllies) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 4e17aae0b86..0c2336e4364 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -380,7 +380,7 @@ local ChatInterface = ClassUI(Window) { local container = self.LinesContainer -- Read the live size straight from the config model so the pool -- always tracks the current option without a cached copy on `self`. - local fontSize = ChatConfigModel.GetSingleton().Committed().font_size or 14 + local fontSize = ChatConfigModel.GetOptions().font_size or 14 -- Need one line to establish the row height. The row's Height is a -- lazy function of the name-text font (see ChatLineInterface). @@ -515,7 +515,7 @@ local ChatInterface = ClassUI(Window) { ---@return boolean IsValidEntry = function(self, entry) if entry == nil then return false end - local muted = ChatConfigModel.GetSingleton().Committed().muted + local muted = ChatConfigModel.GetOptions().muted if muted and entry.ArmyID and muted[entry.ArmyID] then return false end diff --git a/lua/ui/game/chat/config/ChatConfigModel.lua b/lua/ui/game/chat/config/ChatConfigModel.lua index f8afbbc0697..31b42db9c8d 100644 --- a/lua/ui/game/chat/config/ChatConfigModel.lua +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -82,15 +82,6 @@ WinAlphaSliderRange = { Min = 20, Max = 100, Inc = 1 } ---@type UIChatConfigModel | nil local ModelInstance = nil ---- Returns the model singleton, creating it if it does not exist yet. ----@return UIChatConfigModel -function GetSingleton() - if not ModelInstance then - SetupSingleton() - end - return ModelInstance -end - --- Creates and initializes the model singleton from the player profile. --- Mutes are deliberately per-game: any `muted` payload read from prefs is --- discarded, and `Apply` strips it before saving. @@ -108,6 +99,21 @@ function SetupSingleton() return ModelInstance end +--- Returns the model singleton, creating it if it does not exist yet. +---@return UIChatConfigModel +function GetSingleton() + return ModelInstance or SetupSingleton() +end + +--- Shorthand for `GetSingleton().Committed()` — the current, applied +--- options snapshot. Use it for one-shot reads at the point of use; views +--- that need to react to changes should still subscribe via +--- `LazyVarDerive(GetSingleton().Committed, ...)`. +---@return UIChatOptions +function GetOptions() + return GetSingleton().Committed() +end + --- Returns a fresh copy of the built-in defaults. ---@return UIChatOptions function GetDefaults() From 3e023bd99f055942afcef0f9569a57b0db56c566 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:18:37 +0200 Subject: [PATCH 059/130] Attempt to improve ui scaling --- lua/ui/game/chat/ChatCommandHintInterface.lua | 21 +- lua/ui/game/chat/ChatController.lua | 1 + lua/ui/game/chat/ChatEditInterface.lua | 11 +- lua/ui/game/chat/ChatInterface.lua | 1 + lua/ui/game/chat/ChatLineInterface.lua | 10 +- lua/ui/game/chat/ChatListInterface.lua | 14 +- lua/ui/game/chat/LAYOUT.md | 257 ++++++++++++++++++ .../commands/builtin/DebugDumpControls.lua | 26 ++ .../game/chat/config/ChatConfigInterface.lua | 3 +- 9 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 lua/ui/game/chat/LAYOUT.md create mode 100644 lua/ui/game/chat/commands/builtin/DebugDumpControls.lua diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index 25d8d728cf3..f449b91bc10 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -107,7 +107,10 @@ ChatCommandHintInterface = ClassUI(Group) { ---@diagnostic disable-next-line: param-type-mismatch local probe = UIUtil.CreateText(self, '/sample', RowFontSize, RowFontName) ---@diagnostic disable-next-line: undefined-field - self.RowHeight = Create(probe.Height() + VerticalPadding) + -- `probe.Height()` is already in scaled pixels (Layouter stores + -- everything scaled); the padding constant has to be scaled by hand + -- so it tracks the user's UI scale setting. + self.RowHeight = Create(probe.Height() + LayoutHelpers.ScaleNumber(VerticalPadding)) probe:Destroy() -- Decorative borders (same skin as ChatListInterface). @@ -157,8 +160,14 @@ ChatCommandHintInterface = ClassUI(Group) { -- Reserve scrollbar width unconditionally so the popup doesn't -- reflow when the scrollbar appears / disappears with match count. + -- + -- `textWidth` is already in scaled pixels (read off a laid-out probe), + -- so we can't pass the sum to `:Width(number)` — Layouter would call + -- `ScaleNumber` on the whole expression and double-scale the text + -- portion. Use a function form and scale only the raw constants. + local extraScaled = LayoutHelpers.ScaleNumber(HorizontalPadding * 2 + ScrollbarWidth) Layouter(self) - :Width(textWidth + HorizontalPadding * 2 + ScrollbarWidth) + :Width(function() return textWidth + extraScaled end) :End() ---@diagnostic disable: undefined-field @@ -416,7 +425,8 @@ ChatCommandHintInterface = ClassUI(Group) { self.Rows[idx] = row ---@diagnostic disable: undefined-field - row.Text.Left:SetFunction(function() return self.Left() + HorizontalPadding end) + local horizontalPaddingScaled = LayoutHelpers.ScaleNumber(HorizontalPadding) + row.Text.Left:SetFunction(function() return self.Left() + horizontalPaddingScaled end) row.Text.Bottom:SetFunction(function() local ord = row.Ordinal() if ord <= 0 then return self.Top() end @@ -439,10 +449,11 @@ ChatCommandHintInterface = ClassUI(Group) { ---@param row UIChatHintRow LayoutRowBackground = function(self, row) ---@diagnostic disable: undefined-field + local onePixelScaled = LayoutHelpers.ScaleNumber(1) row.BG.Left:SetFunction(function() return self.Left() end) row.BG.Right:SetFunction(function() return self.Right() end) - row.BG.Top:SetFunction(function() return row.Text.Top() - 1 end) - row.BG.Bottom:SetFunction(function() return row.Text.Bottom() + 1 end) + row.BG.Top:SetFunction(function() return row.Text.Top() - onePixelScaled end) + row.BG.Bottom:SetFunction(function() return row.Text.Bottom() + onePixelScaled end) row.BG.Depth:SetFunction(function() return row.Text.Depth() - 1 end) ---@diagnostic enable: undefined-field end, diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 04d8128941e..19560000c3b 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -86,6 +86,7 @@ function RegisterBuiltinCommands() Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Speed.lua") Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/EndMission.lua") Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/DebugLog.lua") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua") Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/DebugStatistics.lua") Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Debugger.lua") Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/ToTick.lua") diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 842df3d264b..fcac6b0324e 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -321,11 +321,14 @@ ChatEditInterface = ClassUI(Group) { :Height(function() return self.EditBox:GetFontHeight() end) :End() - -- The group sizes itself to the edit's font height; the parent - -- positions it (Left/Right/Bottom) and leaves Height alone. This - -- mirrors the original `group.Height:Set(function() return group.edit.Height() end)`. + -- The group sizes itself to the edit's font height plus a scaled + -- padding so the bitmap-sized children (chat bubble, camera button) + -- have visual breathing room around the text. Without the pad, at + -- higher UI scales `self.Height == EditBox.Height` and the buttons + -- end up flush against the edit baseline, looking shifted down. + local heightPadScaled = LayoutHelpers.ScaleNumber(6) Layouter(self) - :Height(function() return self.EditBox.Height() end) + :Height(function() return self.EditBox.Height() + heightPadScaled end) :End() end, diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 0c2336e4364..7d536424480 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -327,6 +327,7 @@ local ChatInterface = ClassUI(Window) { :AtLeftIn(client) :AtRightIn(client) :AtBottomIn(client) + :Height(22) :Over(client) :End() diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index b33245e8c3c..a056751a76f 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -85,10 +85,16 @@ ChatLineInterface = ClassUI(Group) { ---@param self UIChatLineInterface ---@param parent Control __post_init = function(self, parent) + -- `Layouter:Height(number)` would auto-scale a literal, but the + -- closures below need to track upstream LazyVars reactively, and + -- raw constants inside a SetFunction body don't get scaled. Pre-scale + -- the 2px row padding once so it follows the user's UI scale. + local twoPxScaled = LayoutHelpers.ScaleNumber(2) + -- Derive the row's height from the name font so pool sizing and -- scroll positions scale automatically with `ChatOptions.font_size`. Layouter(self) - :Height(function() return self.Name.Height() + 2 end) + :Height(function() return self.Name.Height() + twoPxScaled end) :End() Layouter(self.TeamColor) @@ -119,7 +125,7 @@ ChatLineInterface = ClassUI(Group) { -- Text Left jumps over the icon when present; SetHeader rebinds this -- when the entry's camera state changes. Layouter(self.Text) - :Left(function() return self.Name.Right() + 2 end) + :Left(function() return self.Name.Right() + twoPxScaled end) :Right(self.Right) :AtVerticalCenterIn(self.TeamColor) :Over(self, 10) diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index e3e8ac2a914..e129e4fcb07 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -231,14 +231,18 @@ ChatListInterface = ClassUI(Group) { -- The highlight bar spans the full row (including the badge area) -- and sits behind everything in the depth order. Direct LazyVar -- `:SetFunction` calls match the original `chat.lua` pattern and - -- avoid Layouter's reused-state quirks. + -- avoid Layouter's reused-state quirks. The pixel offsets need + -- explicit scaling — the closures bypass Layouter's auto-scale. local text = entry.Text + local bgInsetLeft = LayoutHelpers.ScaleNumber(6) + local bgInsetWidth = LayoutHelpers.ScaleNumber(8) + local onePxScaled = LayoutHelpers.ScaleNumber(1) ---@diagnostic disable: undefined-field entry.BG.Depth:SetFunction(function() return text.Depth() - 1 end) - entry.BG.Left:SetFunction(function() return self.Left() - 6 end) - entry.BG.Top:SetFunction(function() return text.Top() - 1 end) - entry.BG.Width:SetFunction(function() return self.Width() + 8 end) - entry.BG.Bottom:SetFunction(function() return text.Bottom() + 1 end) + entry.BG.Left:SetFunction(function() return self.Left() - bgInsetLeft end) + entry.BG.Top:SetFunction(function() return text.Top() - onePxScaled end) + entry.BG.Width:SetFunction(function() return self.Width() + bgInsetWidth end) + entry.BG.Bottom:SetFunction(function() return text.Bottom() + onePxScaled end) ---@diagnostic enable: undefined-field end, diff --git a/lua/ui/game/chat/LAYOUT.md b/lua/ui/game/chat/LAYOUT.md new file mode 100644 index 00000000000..2abd9cfb867 --- /dev/null +++ b/lua/ui/game/chat/LAYOUT.md @@ -0,0 +1,257 @@ +# 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 MVC contract. + +Notation throughout: + +- **(S)** — value scales with the UI factor (`pixelScaleFactor`). Includes + values passed through layout helpers, `ScaleNumber(N)`, and font-derived + metrics like `GetFontHeight()`. +- **(F)** — fixed actual pixels. Bitmap-intrinsic dimensions on `Bitmap` / + `Button` / `Checkbox` controls don't auto-scale; they render at their texture + size regardless of UI factor. +- **(D)** — derived from other LazyVars (e.g. `Bottom = Top + Height`). + +--- + +## Component hierarchy + +``` +ChatInterface (Window) +│ Left/Top/Right/Bottom = DefaultRect (S, drag/resize moves these) +│ client = inside skin-border insets +│ +├── DragTL/TR/BL/BR (F bitmap) AtLeftTopIn(self, -26, -8) etc. +├── ResetPositionBtn (F bitmap) AnchorToLeft(_configBtn, 4) +│ +└── client (Window's inner group) + │ + ├── LinesContainer (Group) + │ │ Left = client.Left + pad(S) + │ │ Right = client.Right - 36(S) + │ │ Top = client.Top + pad(S) + │ │ Bottom = Edit.Top - 12(S) ← anchored to Edit's top + │ │ + │ ├── Lines[1..N] ChatLineInterface pool + │ │ Height = Name.Height(S) + 2(S) + │ │ pool size = floor(container.Height / row.Height) + │ │ + │ └── Scrollbar CreateVertScrollbarFor(LinesContainer) + │ + └── Edit (ChatEditInterface) + │ Left = client.Left + │ Right = client.Right + │ Bottom = client.Bottom + │ Height = EditBox.Height(S) + ScaleNumber(6)(S) ← the padding + │ Top = Bottom - Height (D) + │ + ├── ChatBubble (F ≈ 24×24 bitmap) + │ Left = self.Left + 3(S) + │ Top = AtVerticalCenterIn(self) + │ = self.Top + (self.Height(S) - 24(F)) / 2 + │ + ├── RecipientLabel (S font, ≈ font_h) + │ Left = ChatBubble.Right + 2(S) + │ Top = AtVerticalCenterIn(self) + │ = self.Top + (self.Height(S) - font_h(S)) / 2 + │ + ├── EditBox (S font, height = GetFontHeight) + │ Left = RecipientLabel.Right + 4(S) + │ Right = CamCheckbox.Left - 4(S) + │ Top = AtVerticalCenterIn(self) = self.Top + pad/2 + │ Height = GetFontHeight()(S) + │ + ├── CamCheckbox (F ≈ 24×24 bitmap) + │ Right = self.Right - 4(S) + │ Top = AtVerticalCenterIn(self) + │ + ├── ChatList (popup, child of self, on demand) + │ Above(ChatBubble, 15(S)) + │ AtLeftIn(ChatBubble, 15(S)) + │ + └── CommandHint (popup, child of self, on demand) + Above(EditBox, 14(S)) + AtLeftIn(EditBox) +``` + +--- + +## ChatLineInterface (one row in the line pool) + +``` +self +│ Height = Name.Height(S) + 2(S) ← row tracks the font +│ +├── TeamColor AtLeftTopIn(self) +│ │ Width = self.Height +│ │ Height = self.Height ← square +│ │ +│ └── FactionIcon Fill(TeamColor) +│ +├── Name (S Text) CenteredRightOf(TeamColor, 4) +│ Over(self, 10) +│ +├── CamIcon RightOf(Name, 4(S)) +│ (F ≈ 20×16) AtVerticalCenterIn(TeamColor) +│ :Width(20)(S) :Height(16)(S) +│ ← hidden when entry.Camera is nil +│ +└── Text (S) Left = Name.Right + 2(S) + (or CamIcon.Right + 4 if camera attached) + Right = self.Right + Top = AtVerticalCenterIn(TeamColor) +``` + +--- + +## ChatCommandHintInterface (slash-command popup) + +``` +self +│ Width = textWidth(S) + ScaleNumber(HorizontalPadding*2 + ScrollbarWidth) +│ Height = min(VisibleCount, MaxVisibleRows) * RowHeight(S) +│ Position = LayoutHelpers.Above(EditBox, 14)(S) by parent +│ +├── Background Left/Right/Top/Bottom = self edges (Fill) +│ Depth = self.Depth (lowest layer) +│ +├── Rows[i] text.Left = self.Left + horizontalPadding(S) +│ text.Bottom = self.Bottom - (slot - 1) * RowHeight(S) +│ BG.Top = text.Top - 1(S), BG.Bottom = text.Bottom + 1(S) +│ +├── Scrollbar CreateVertScrollbarFor(self, -ScrollbarWidth(S)) +│ +└── Borders LTBG/RTBG/.../BBG hug outside of self +``` + +The scrollbar's "top" is inverted: ordinals grow upward (1 at the bottom), so +`GetScrollValues` reports `top = N - ScrollBottom - MaxVisibleRows + 2`. Drag +the thumb up → highest ordinals visible at the top of the popup. + +--- + +## ChatListInterface (recipient picker popup) + +``` +self +│ Width = sized to entry content +│ Height = sum(Entries[i].Height) +│ +├── Entries[i] Stacked Below(prev) +│ ├── Text +│ ├── BG Left = self.Left - 6(S) +│ │ Width = self.Width + 8(S) ← BG bleeds outside self +│ │ Top/Bottom = text ± 1(S) +│ └── Badge? AtLeftIn(self, 3(S)) +│ AtVerticalCenterIn(Text) +│ +└── Borders LTBG/RTBG/.../BBG +``` + +--- + +## What scales, what doesn't + +| Control | Width × Height | Notes | +|--------------------|--------------------------|--------------------------------------------------| +| `ChatBubble` | (F) ≈ 24 × 24 | Bitmap intrinsic, no auto-scale. | +| `CamCheckbox` | (F) ≈ 24 × 24 | Bitmap intrinsic, no auto-scale. | +| `CamIcon` | (S) 20 × 16 | `Layouter:Width`/`:Height` literal — auto-scaled. | +| `TeamColor` | (S) N × N | `Width = Height = Name.Height + 2`. | +| `FactionIcon` | (S) fills TeamColor | | +| `ResetPositionBtn` | (F) bitmap intrinsic | | +| Drag handles | (F) bitmap intrinsic | | +| Text controls | (S) font-derived | `Name`, `RecipientLabel`, `EditBox`, message `Text`. | +| Borders | (F) bitmap intrinsic | `LTBG`/`RTBG`/etc. on every popup. | +| Hint `Background` | (S) Fill of self | self is sized in scaled units, so this is too. | + +--- + +## Chat-edit vertical layout at 100% / 150% / 200% + +`font_h ≈ 17 / 25 / 33` (S) · `bitmap_h ≈ 24 / 24 / 24` (F) + +### `pad = ScaleNumber(6)` (current) + +| | 100% | 150% | 200% | +|-------------------------|-------------------------|-------------------------|-------------------------| +| `self.Height` | `17 + 6 = 23` | `25 + 9 = 34` | `33 + 12 = 45` | +| `EditBox.Top` | `self.Top + 3` | `self.Top + 4.5` | `self.Top + 6` | +| `ChatBubble.Top` center | `(23-24)/2 = -0.5` | `(34-24)/2 = 5` | `(45-24)/2 = 10.5` | +| `ChatBubble.Top` value | `self.Top − 1` | `self.Top + 5` | `self.Top + 10` | +| `CamCheckbox.Top` | `self.Top − 1` | `self.Top + 5` | `self.Top + 10` | +| `RecipientLabel.Top` | `self.Top + 3` | `self.Top + 4.5` | `self.Top + 6` | +| **net** | buttons 4 px above text | buttons 0.5 px above | buttons 4 px above | +| | (over-correct) | (looks OK) | (looks OK-ish, but 4 px | +| | | | empty above text) | + +### `pad = 0` (legacy, no padding) + +| | 100% | 150% | 200% | +|-------------------------|-------------------------|-------------------------|-------------------------| +| `self.Height` | `17` | `25` | `33` | +| `EditBox.Top` | `self.Top + 0` | `self.Top + 0` | `self.Top + 0` | +| `ChatBubble.Top` | `self.Top − 4` | `self.Top + 0` | `self.Top + 5` | +| **net** | buttons 4 px above text | buttons AT text top | buttons 5 px BELOW text | +| | (legacy "frame" look) | (looks low — empty | top (looks low — | +| | | space below button) | growing gap below) | + +--- + +## The structural issue + +Buttons are **fixed pixels (F)**. Text is **scaled pixels (S)**. As the UI +factor grows, `font_h` overtakes `bitmap_h`. `AtVerticalCenterIn` aligns +geometric centres — but the eye reads alignment between the bitmap centre +and the **text's optical centre** (about `font_h / 3` from the top, because +of ascender/descender asymmetry). + +| pad value | Behaviour | +|-----------|-----------| +| `pad = 0` | button geometric-centre == text geometric-centre. Works only when `font_h ≈ bitmap_h` (i.e. ~100% UI scale). Drifts visibly at higher scales. | +| `pad = 6(S)` | everything centred in a slightly bigger box; text sits higher within `self`, which "fixes" higher scales but over-corrects at 100% (text leaves its natural baseline). | + +Neither single constant works at every scale because the offset we want +between button and text scales **with `font_h`**, not with the UI factor +alone. + +### Two paths forward + +Both anchor the bitmap buttons to the text optical line instead of geometric +centre: + +**(A) Per-button `SetFunction`** + +```lua +ChatBubble.Top:SetFunction(function() + return self.EditBox.Top() + + self.EditBox.Height() / 3 + - self.ChatBubble.Height() / 2 +end) +-- and self.Height = EditBox.Height (drop the pad) +``` + +**(B) Helper `OpticalCenterIn(child, edit)`** that does (A); apply to each +bitmap-sized child (`ChatBubble`, `CamCheckbox`). `RecipientLabel` and the +edit text stay on `AtVerticalCenterIn` since they're font-sized and already +align with each other at every scale. + +The critical observation: **only the bitmap-sized children (`ChatBubble`, +`CamCheckbox`) misbehave across scales.** The font-sized children +(`RecipientLabel`, `EditBox`) align fine with each other at every scale +because they share the same intrinsic height. The fix only needs to touch the +bitmap children. + +--- + +## Where each value lives in the code + +| Concern | File | +|------------------------------------|---------------------------------------------------------------------------| +| `DefaultRect`, drag handles, scrollbar, line pool | [`ChatInterface.lua`](ChatInterface.lua) | +| `LinesContainer` ↔ `Edit` anchoring | [`ChatInterface.lua` `__post_init`](ChatInterface.lua) | +| `ChatBubble` / `RecipientLabel` / `EditBox` / `CamCheckbox` layout | [`ChatEditInterface.lua` `__post_init`](ChatEditInterface.lua) | +| Row geometry (`TeamColor`, `Name`, `CamIcon`, `Text`) | [`ChatLineInterface.lua` `__post_init`](ChatLineInterface.lua) | +| Hint popup width / height / row positioning | [`ChatCommandHintInterface.lua`](ChatCommandHintInterface.lua) | +| Recipient picker entries + BG bleed | [`ChatListInterface.lua` `CreateEntry`](ChatListInterface.lua) | diff --git a/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua new file mode 100644 index 00000000000..4a71a7e52de --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua @@ -0,0 +1,26 @@ + +------------------------------------------------------------------------------- +-- /debug-dump-controls — toggles `ui_DebugAltClick`, the console flag that +-- swaps Alt+left-click into a "switch focus army to whichever army owns this +-- unit" shortcut. Only registered when the game was launched with `/debug`. + +---@type UIChatCommand +Command = { + Name = 'debug-dump-controls', + Description = 'Toggle the Alt+click army-switch debug shortcut.', + ShouldRegister = function() + return HasCommandLineArg('/debug') + end, + Execute = function() + ConExecute('UI_DumpControlsUnderCursor') + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 8b8ac780652..f112b968c52 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -330,7 +330,8 @@ local ChatConfigInterface = ClassUI(Window) { -- drag handler's Right:Set(Left + Width) will snap the window to -- whatever Width was pinned to (the textures render against Right, -- so a Width/Right mismatch is invisible until the first drag). - self.Bottom:Set(function() return self.BtnCancel.Bottom() + 16 end) + local bottomPadScaled = LayoutHelpers.ScaleNumber(16) + self.Bottom:Set(function() return self.BtnCancel.Bottom() + bottomPadScaled end) end, --- Syncs every control to reflect the given options table. From db23ec651f182af5263eeb9e0c94fe1ec2938571 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:19:07 +0200 Subject: [PATCH 060/130] __post_init belongs close to __init --- lua/ui/game/chat/ChatEditInterface.lua | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index fcac6b0324e..1bc548e8d47 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -180,6 +180,44 @@ ChatEditInterface = ClassUI(Group) { end)) end, + ---@param self UIChatEditInterface + ---@param parent Control + __post_init = function(self, parent) + Layouter(self.ChatBubble) + :AtLeftIn(self, 3) + :AtVerticalCenterIn(self) + :End() + + Layouter(self.RecipientLabel) + :AnchorToRight(self.ChatBubble, 2) + :AtVerticalCenterIn(self) + :End() + + -- Camera-attach toggle pinned to the right edge so the edit box can + -- claim the remaining width. + Layouter(self.CamCheckbox) + :AtRightIn(self, 4) + :AtVerticalCenterIn(self) + :End() + + Layouter(self.EditBox) + :AnchorToRight(self.RecipientLabel, 4) + :AnchorToLeft(self.CamCheckbox, 4) + :AtVerticalCenterIn(self) + :Height(function() return self.EditBox:GetFontHeight() end) + :End() + + -- The group sizes itself to the edit's font height plus a scaled + -- padding so the bitmap-sized children (chat bubble, camera button) + -- have visual breathing room around the text. Without the pad, at + -- higher UI scales `self.Height == EditBox.Height` and the buttons + -- end up flush against the edit baseline, looking shifted down. + local heightPadScaled = LayoutHelpers.ScaleNumber(6) + Layouter(self) + :Height(function() return self.EditBox.Height() + heightPadScaled end) + :End() + end, + --- Entry point for the Tab key. When the command hint is open, Tab --- commits the currently-selected command into the edit box (mirroring --- a click on the hint row). Otherwise it runs the in-box completion @@ -294,44 +332,6 @@ ChatEditInterface = ClassUI(Group) { hint:Destroy() end, - ---@param self UIChatEditInterface - ---@param parent Control - __post_init = function(self, parent) - Layouter(self.ChatBubble) - :AtLeftIn(self, 3) - :AtVerticalCenterIn(self) - :End() - - Layouter(self.RecipientLabel) - :AnchorToRight(self.ChatBubble, 2) - :AtVerticalCenterIn(self) - :End() - - -- Camera-attach toggle pinned to the right edge so the edit box can - -- claim the remaining width. - Layouter(self.CamCheckbox) - :AtRightIn(self, 4) - :AtVerticalCenterIn(self) - :End() - - Layouter(self.EditBox) - :AnchorToRight(self.RecipientLabel, 4) - :AnchorToLeft(self.CamCheckbox, 4) - :AtVerticalCenterIn(self) - :Height(function() return self.EditBox:GetFontHeight() end) - :End() - - -- The group sizes itself to the edit's font height plus a scaled - -- padding so the bitmap-sized children (chat bubble, camera button) - -- have visual breathing room around the text. Without the pad, at - -- higher UI scales `self.Height == EditBox.Height` and the buttons - -- end up flush against the edit baseline, looking shifted down. - local heightPadScaled = LayoutHelpers.ScaleNumber(6) - Layouter(self) - :Height(function() return self.EditBox.Height() + heightPadScaled end) - :End() - end, - --- Opens the recipient picker popup, or closes it if it is already open. ---@param self UIChatEditInterface ToggleList = function(self) From f4c1dcd2f873bbff4f2c0b81cc6da90fedfe33a9 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:31:59 +0200 Subject: [PATCH 061/130] Add separate control for as a container for the chat lines --- lua/ui/game/chat/ChatInterface.lua | 506 +++-------------------- lua/ui/game/chat/ChatLinesInterface.lua | 525 ++++++++++++++++++++++++ lua/ui/game/chat/ChatListInterface.lua | 2 +- lua/ui/game/chat/GAPS.md | 4 +- lua/ui/game/chat/LAYOUT.md | 24 +- 5 files changed, 590 insertions(+), 471 deletions(-) create mode 100644 lua/ui/game/chat/ChatLinesInterface.lua diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 7d536424480..03bdf7fe30e 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -2,22 +2,25 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Window = import("/lua/maui/window.lua").Window -local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Button = import("/lua/maui/button.lua").Button -local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface +local ChatLinesInterface = import("/lua/ui/game/chat/ChatLinesInterface.lua").ChatLinesInterface local ChatEditInterface = import("/lua/ui/game/chat/ChatEditInterface.lua").ChatEditInterface 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") -local MauiWrapText = import("/lua/maui/text.lua").WrapText local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = false + --- Skin textures for the chat window frame. Mirrors the layout that --- `/lua/ui/game/layouts/chat_layout.lua` applies to the legacy chat Window --- so the new window matches the original visual style. @@ -50,46 +53,32 @@ end local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ------------------------------------------------------------------------------- --- The main chat window: a draggable, resizable frame hosting a dynamically --- sized pool of chat line rows plus the edit area at the bottom. +-- The main chat window: a draggable, resizable frame hosting a +-- `ChatLinesInterface` (line pool + scrollbar) and a `ChatEditInterface` +-- (input area). -- --- This class owns four related concerns lifted from the legacy chat.lua: +-- The window owns three concerns; the rest is delegated: -- --- 1. Pool sizing (`RebuildPool`) — line count follows --- the container height. --- 2. Text wrapping (`WrapEntry` / `RewrapAll`) — wraps message text to --- the current row width; --- results cached on the --- entry itself. --- 3. Scroll container (`GetScrollValues`, …) — virtual size = total --- wrapped-line count --- across *valid* entries. --- 4. Visibility mapping (`CalcVisible`) — projects the scroll --- position onto the pool. +-- 1. Window chrome — drag handles, reset-position button, close / +-- config buttons, resize bookkeeping. +-- 2. Visibility — observes `model.WindowVisible` to show/hide. +-- 3. Window-level options — `win_alpha` (cascades to descendants). -- --- Filtering (per-army / camera-link / feed mode) is stubbed via --- `IsValidEntry` which always returns true for now — wiring it up to --- `ChatConfigModel` is a follow-up step. +-- Pool sizing, text wrapping, scrolling, filtering, and the per-row click +-- forwarding all live on `ChatLinesInterface` — see that file. ---@class UIChatInterface : Window ----@field Trash TrashBag # owns every subscription-LazyVar we create ----@field LinesContainer Group ----@field Lines UIChatLineInterface[] ----@field Edit UIChatEditInterface ----@field Scrollbar Scrollbar ----@field ScrollTop number # 1-based virtual position of the top visible row ----@field VirtualSize number # total wrapped lines across valid entries +---@field Trash TrashBag # owns every subscription-LazyVar we create +---@field Lines UIChatLinesInterface # the wrapped panel containing line rows + scrollbar +---@field Edit UIChatEditInterface ---@field DragTL Bitmap # top-left corner resize grip ---@field DragTR Bitmap # top-right corner resize grip ---@field DragBL Bitmap # bottom-left corner resize grip ---@field DragBR Bitmap # bottom-right corner resize grip ---@field DragHandleControlMap table # resize-bitmap id → grips to highlight ---@field ResetPositionBtn Button # titlebar button that restores DefaultRect ----@field HistoryObserver LazyVar # derived from ChatModel.History ----@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible ----@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed ----@field OnLineNameClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared row-name click handler; captures `self` so pool lines don't allocate per-row closures ----@field OnLineCameraClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared cam-icon click handler; captures `self` to restore the saved camera +---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible +---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed (window-level options only) local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface @@ -108,90 +97,29 @@ local ChatInterface = ClassUI(Window) { -- Emptied in `OnDestroy`. self.Trash = TrashBag() - -- Container for the line pool. Stays empty until __post_init can - -- measure its laid-out height and build the pool from that. - self.LinesContainer = Group(client, "ChatLinesContainer") - - self.Lines = {} - self.ScrollTop = 1 - self.VirtualSize = 0 - - -- Expose the scrollable interface on the container so - -- `UIUtil.CreateVertScrollbarFor(LinesContainer)` binds correctly. - -- The logic and state live on `self`; the container just delegates. - self.LinesContainer.GetScrollValues = function(_, axis) return self:GetScrollValues(axis) end - self.LinesContainer.ScrollLines = function(_, axis, delta) self:ScrollLines(axis, delta) end - self.LinesContainer.ScrollPages = function(_, axis, delta) self:ScrollPages(axis, delta) end - self.LinesContainer.ScrollSetTop = function(_, axis, top) self:ScrollSetTop(axis, top) end - self.LinesContainer.IsScrollable = function(_, axis) return self:IsScrollable(axis) end - - -- The edit area sits at the bottom of the client region. + -- The lines panel and edit area. Both are laid out in `__post_init` + -- once the client area has a real size to anchor against. + self.Lines = ChatLinesInterface(client) self.Edit = ChatEditInterface(client) - -- Shared row-name click handler. Built once per window so every - -- pool line can point `OnNameClicked` at the same reference — - -- pool growth never allocates a per-row closure. Captures `self` - -- so we can re-focus the edit box after retargeting. - self.OnLineNameClicked = function(_, entry) - -- Ignore clicks on your own name — whispering yourself is - -- pointless and the picker would still route it as a private - -- message. + -- Override the lines panel's name-click hook to set the chat + -- recipient and re-focus the edit box. `OnCameraClicked` keeps the + -- panel's default behaviour (jump the world camera). Ignore clicks + -- on your own name — whispering yourself is pointless and the + -- picker would still route it as a private message. + self.Lines.OnNameClicked = function(entry) if entry.ArmyID and entry.ArmyID ~= GetFocusArmy() then ChatController.SetRecipient(entry.ArmyID) self.Edit:AcquireFocus() end end - -- Shared cam-icon click handler. Same one-closure-per-window pattern - -- as `OnLineNameClicked`. Two wire formats: - -- * `entry.Camera` — a full `SaveSettings` snapshot the sender - -- wanted us to adopt verbatim (player attached their view). - -- * `entry.Location` — a point or region from a sim-originated - -- sender (AI brain, system message). We translate on click so - -- the viewer's pitch/heading/zoom are preserved for a point - -- move, or the framing is computed by `MoveToRegion` for an - -- area. Checked first so a future message carrying both - -- prefers the lighter-weight hint. - self.OnLineCameraClicked = function(_, entry) - local cam = GetCamera('WorldCamera') - if entry.Location then - if entry.Location.Area then - cam:MoveToRegion(entry.Location.Area, 0.5) - elseif entry.Location.Position then - local settings = cam:SaveSettings() - settings.Focus = entry.Location.Position - cam:RestoreSettings(settings) - end - elseif entry.Camera then - cam:RestoreSettings(entry.Camera) - end - end - -- Reactive subscriptions use `LazyVarDerive` so each observer is a -- fresh LazyVar that reads from an upstream model field — setting -- our handler can never stomp another subscriber's (see the chat -- CLAUDE.md for the pattern). local model = ChatModel.GetSingleton() - -- History → wrap new entries, refresh size, stick to bottom. The - -- initial firing happens before __post_init so the wrap call has - -- no pool to measure against; that's fine — RewrapAll runs once - -- the pool exists. - self.HistoryObserver = self.Trash:Add( - LazyVarDerive( - model.History, - function(lv) - self:OnHistoryChanged(lv() - ) - end - ) - ) - - -- `OptionsObserver` is wired up in `__post_init`, not here — its - -- initial fire triggers `ApplyOptions → RebuildPool`, which reads - -- `self.Lines[1].Height()` and so requires the container layout to - -- already be in place. - -- Window visibility → show / hide the frame. self.WindowVisibleObserver = self.Trash:Add( LazyVarDerive( @@ -331,370 +259,31 @@ local ChatInterface = ClassUI(Window) { :Over(client) :End() - -- Leave a ~20px gap on the right for the scrollbar, which sits - -- anchored to the container's right edge (see below). - Layouter(self.LinesContainer) + -- The lines panel fills the rest of the client area above the edit + -- box. The scrollbar is its own concern — `ChatLinesInterface` + -- reserves the space inside its right edge for the scrollbar + -- widget, so the parent only has to allocate a single rect. + Layouter(self.Lines) :AtLeftIn(client, pad) - :AtRightIn(client, 36) + :AtRightIn(client, pad) :AtTopIn(client, pad) :AnchorToTop(self.Edit, 12) :End() - -- Create the vertical scrollbar. `CreateVertScrollbarFor` calls - -- `Scrollbar:SetScrollable(control)` on the passed control, so the - -- scrollable interface has to live on `LinesContainer` (as - -- delegates to self — see __init). - self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.LinesContainer) - - -- Now that the container has a real size, build the pool and do - -- a first wrap + render pass. - self:RebuildPool() - self:RewrapAll() - self:ScrollToBottom() - - -- Committed chat options → apply font size, rebuild the pool (line - -- height tracks the font), rewrap all entries (wrap widths depend - -- on font metrics), and re-render. Wired here rather than in - -- `__init` so the initial fire of `LazyVarDerive` runs against a - -- fully laid-out window — `RebuildPool` reads `self.Lines[1].Height` - -- and gets a circular dependency error if the layout isn't ready. + -- Committed chat options → window-level concerns only. Pool sizing, + -- font, and filter changes are owned by the lines panel; we just + -- handle `win_alpha` here. `SetAlpha(_, true)` cascades so chrome, + -- lines, edit, and scrollbar all dim uniformly. self.OptionsObserver = self.Trash:Add( LazyVarDerive( ChatConfigModel.GetSingleton().Committed, function(lv) - self:ApplyOptions(lv()) + self:SetAlpha(lv().win_alpha or 1.0, true) end ) ) end, - --------------------------------------------------------------------------- - -- Pool sizing - --------------------------------------------------------------------------- - - --- Rebuilds the line pool to fit the current container height. Adds rows - --- at the bottom when the window grows, destroys the tail when it shrinks. - --- Safe to call repeatedly; callers are expected to follow up with - --- `CalcVisible` (and `RewrapAll` on a true resize). - ---@param self UIChatInterface - RebuildPool = function(self) - local container = self.LinesContainer - -- Read the live size straight from the config model so the pool - -- always tracks the current option without a cached copy on `self`. - local fontSize = ChatConfigModel.GetOptions().font_size or 14 - - -- Need one line to establish the row height. The row's Height is a - -- lazy function of the name-text font (see ChatLineInterface). - if not self.Lines[1] then - self.Lines[1] = ChatLineInterface(container) - self.Lines[1]:SetFontSize(fontSize) - self.Lines[1].OnNameClicked = self.OnLineNameClicked - self.Lines[1].OnCameraClicked = self.OnLineCameraClicked - Layouter(self.Lines[1]) - :AtLeftTopIn(container) - :Right(container.Right) - :End() - end - - local rowHeight = self.Lines[1].Height() - if rowHeight < 1 then rowHeight = 18 end -- safety fallback - - local neededLines = math.max(1, math.floor(container.Height() / rowHeight)) - local currentCount = table.getn(self.Lines) - - -- Grow: append rows below the previous one. - for i = currentCount + 1, neededLines do - self.Lines[i] = ChatLineInterface(container) - self.Lines[i]:SetFontSize(fontSize) - self.Lines[i].OnNameClicked = self.OnLineNameClicked - self.Lines[i].OnCameraClicked = self.OnLineCameraClicked - Layouter(self.Lines[i]) - :Below(self.Lines[i - 1]) - :AtLeftIn(container) - :Right(container.Right) - :End() - end - - -- Shrink: destroy the surplus tail. - for i = currentCount, neededLines + 1, -1 do - self.Lines[i]:Destroy() - self.Lines[i] = nil - end - end, - - --------------------------------------------------------------------------- - -- Options application - --------------------------------------------------------------------------- - - --- Applies a `UIChatOptions` snapshot to the window. Handles `font_size` - --- and `win_alpha` today; future options (colours, feed-mode flags) will - --- extend this method. - --- - --- The model is the source of truth — we don't cache any of these values - --- on `self`. `ApplyOptions` only fires when the user commits a config - --- change (or once on startup when the observer first reads), so doing - --- the work unconditionally is fine even if some values didn't move. - ---@param self UIChatInterface - ---@param options UIChatOptions - ApplyOptions = function(self, options) - local size = options.font_size or 14 - for _, line in ipairs(self.Lines) do - line:SetFontSize(size) - end - -- Row height tracks the font, so the pool may need resizing; - -- wrap widths depend on font metrics, so rewrap all entries. - self:RebuildPool() - self:RewrapAll() - - -- Window opacity. `SetAlpha(_, true)` cascades to every descendant - -- so chrome, lines, edit, and scrollbar all dim uniformly. - self:SetAlpha(options.win_alpha or 1.0, true) - - -- Filter-affecting options (muted, links) may have changed too. - -- Recompute what's visible so entries newly excluded by - -- `IsValidEntry` drop out of the feed immediately. - self:RefreshVirtualSize() - self:CalcVisible() - end, - - --------------------------------------------------------------------------- - -- Text wrapping - --------------------------------------------------------------------------- - - --- Wraps a single entry's text to fit the current row width. Results are - --- cached on the entry itself as `entry.WrappedText`. The first wrapped - --- line reserves space for the name prefix; continuation lines span the - --- wider area to the right of the team-colour column. - ---@param self UIChatInterface - ---@param entry UIChatEntry - WrapEntry = function(self, entry) - local measureLine = self.Lines[1] - if not measureLine then - entry.WrappedText = { entry.Text or '' } - return - end - - local name = entry.Name or '' - local lines = MauiWrapText(entry.Text or '', - function(lineIndex) - if lineIndex == 1 then - return measureLine.Right() - - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) - else - return measureLine.Right() - - (measureLine.Name.Left() + 4) - end - end, - function(textChunk) - return measureLine.Text:GetStringAdvance(textChunk) - end) - - if table.empty(lines) then lines = { '' } end - entry.WrappedText = lines - end, - - --- Re-wraps every entry in the history. Used on resize (width change) - --- and on option changes that affect the measuring font. - ---@param self UIChatInterface - RewrapAll = function(self) - local history = ChatModel.GetSingleton().History() - for _, entry in ipairs(history) do - self:WrapEntry(entry) - end - self:RefreshVirtualSize(history) - end, - - --------------------------------------------------------------------------- - -- Filtering - --------------------------------------------------------------------------- - - --- Whether an entry counts toward the virtual scroll size and should - --- appear in `CalcVisible`. Currently gates on the per-army mute map - --- from `ChatConfigModel.Committed`; camera-link filtering is still TODO. - ---@param self UIChatInterface - ---@param entry UIChatEntry - ---@return boolean - IsValidEntry = function(self, entry) - if entry == nil then return false end - local muted = ChatConfigModel.GetOptions().muted - if muted and entry.ArmyID and muted[entry.ArmyID] then - return false - end - return true - end, - - --------------------------------------------------------------------------- - -- Scroll container - --------------------------------------------------------------------------- - - --- Recomputes `VirtualSize` = total wrapped lines across all valid entries. - ---@param self UIChatInterface - ---@param history? UIChatEntry[] - RefreshVirtualSize = function(self, history) - history = history or ChatModel.GetSingleton().History() - local size = 0 - for _, entry in ipairs(history) do - if self:IsValidEntry(entry) then - size = size + ((entry.WrappedText and table.getn(entry.WrappedText)) or 1) - end - end - self.VirtualSize = size - end, - - --- Standard MAUI scrollable interface: returns (rangeMin, rangeMax, visibleMin, visibleMax). - ---@param self UIChatInterface - ---@param axis string # "Vert" or "Horz" - GetScrollValues = function(self, axis) - local poolSize = table.getn(self.Lines) - local top = self.ScrollTop - return 1, self.VirtualSize, top, math.min(top + poolSize, self.VirtualSize) - end - - , - --- Scrolls by a number of rows (negative = toward older messages). - ---@param self UIChatInterface - ---@param axis string - ---@param delta number - ScrollLines = function(self, axis, delta) - self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) - end, - - --- Scrolls by a page (pool-size worth of rows). - ---@param self UIChatInterface - ---@param axis string - ---@param delta number - ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * table.getn(self.Lines)) - end, - - --- Jumps to an absolute virtual position, clamped to the valid range. - --- Name and signature match the engine's `ScrollSetTop(axis, top)` contract - --- so `Scrollbar:SetScrollable` can call it directly. - ---@param self UIChatInterface - ---@param axis string - ---@param top number - ScrollSetTop = function(self, axis, top) - top = math.floor(top or 1) - local poolSize = table.getn(self.Lines) - local maxTop = math.max(1, self.VirtualSize - poolSize + 1) - local clamped = math.max(1, math.min(maxTop, top)) - if clamped == self.ScrollTop then return end - self.ScrollTop = clamped - self:CalcVisible() - end, - - --- Standard MAUI scrollable interface: whether scrolling is possible on - --- the given axis. - ---@param self UIChatInterface - ---@param axis string - ---@return boolean - IsScrollable = function(self, axis) - return true - end, - - --- Snaps to the bottom of the virtual list. - ---@param self UIChatInterface - ScrollToBottom = function(self) - self:ScrollSetTop(nil, self.VirtualSize) - -- ScrollSetTop short-circuits when the position doesn't change, but - -- the pool still needs a render pass after a rebuild / rewrap. - self:CalcVisible() - end, - - --------------------------------------------------------------------------- - -- Visibility mapping - --------------------------------------------------------------------------- - - --- Projects `[ScrollTop, ScrollTop + poolSize)` in virtual space onto the - --- line pool. Skips over filtered-out entries, uses `SetHeader` for the - --- first wrapped line of an entry and `SetContinuation` for the rest. - ---@param self UIChatInterface - CalcVisible = function(self) - if not self.Lines[1] then return end - - local history = ChatModel.GetSingleton().History() - local historyCount = table.getn(history) - local poolSize = table.getn(self.Lines) - local scrollTop = self.ScrollTop - - -- Walk to the entry + wrapped-line that covers virtual position `scrollTop`. - local entryIdx = 1 - local wrappedIdx = 1 - local virtualPos = 0 - - while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do - entryIdx = entryIdx + 1 - end - - while entryIdx <= historyCount do - local entry = history[entryIdx] - local wrapCount = (entry.WrappedText and table.getn(entry.WrappedText)) or 1 - if virtualPos + wrapCount >= scrollTop then - wrappedIdx = scrollTop - virtualPos - if wrappedIdx < 1 then wrappedIdx = 1 end - break - end - virtualPos = virtualPos + wrapCount - entryIdx = entryIdx + 1 - while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do - entryIdx = entryIdx + 1 - end - end - - -- Fill each pool row; advance the cursor through wrapped lines and - -- skip filtered entries as we go. - for poolIdx = 1, poolSize do - local line = self.Lines[poolIdx] - if entryIdx > historyCount then - line:Clear() - line:Hide() - else - local entry = history[entryIdx] - local wrapped = entry.WrappedText - local wrappedText = (wrapped and wrapped[wrappedIdx]) or entry.Text or '' - - if wrappedIdx == 1 then - line:SetHeader(entry, wrappedText) - else - line:SetContinuation(entry, wrappedText) - end - line:Show() - - local wrapCount = (wrapped and table.getn(wrapped)) or 1 - if wrappedIdx < wrapCount then - wrappedIdx = wrappedIdx + 1 - else - wrappedIdx = 1 - entryIdx = entryIdx + 1 - while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do - entryIdx = entryIdx + 1 - end - end - end - end - end, - - --------------------------------------------------------------------------- - -- Model reactions - --------------------------------------------------------------------------- - - --- Called whenever `model.History` fires dirty. Wraps entries we haven't - --- wrapped yet (new arrivals), refreshes the virtual size, and snaps to - --- the bottom so the new line is visible. - ---@param self UIChatInterface - ---@param history UIChatEntry[] - OnHistoryChanged = function(self, history) - for _, entry in ipairs(history) do - if not entry.WrappedText then - self:WrapEntry(entry) - end - end - self:RefreshVirtualSize(history) - if self.Lines[1] then - self:ScrollToBottom() - end - end, - --------------------------------------------------------------------------- -- Window event hooks --------------------------------------------------------------------------- @@ -702,8 +291,7 @@ local ChatInterface = ClassUI(Window) { --- Fired continuously during a resize drag. Keep it cheap: just resize --- the pool and re-render against existing wraps. OnResize = function(self, width, height, firstFrame) - self:RebuildPool() - self:CalcVisible() + self.Lines:OnResizeLive() end, --- Fired when a resize drag ends. Rewrapping is expensive, so it only @@ -711,9 +299,7 @@ local ChatInterface = ClassUI(Window) { --- grips back to their `up` texture — the RolloverHandler leaves them --- on `down` when StartSizing took over. OnResizeSet = function(self) - self:RebuildPool() - self:RewrapAll() - self:CalcVisible() + self.Lines:OnResizeFinished() self.DragTL:SetTexture(self.DragTL.textures.up) self.DragTR:SetTexture(self.DragTR.textures.up) self.DragBL:SetTexture(self.DragBL.textures.up) @@ -730,7 +316,7 @@ local ChatInterface = ClassUI(Window) { --- Mouse wheel over the window scrolls the chat. `rotation` is in wheel --- units (usually ±120 per notch); one notch ≈ one line. OnMouseWheel = function(self, rotation) - self:ScrollLines(nil, -math.floor(rotation / 100)) + self.Lines:ScrollLines(nil, -math.floor(rotation / 100)) end, --- Engine-invoked when the user clicks the close button on the window frame. @@ -783,7 +369,7 @@ end ---@param delta number function ScrollLines(delta) if Instance then - Instance:ScrollLines(nil, delta) + Instance.Lines:ScrollLines(nil, delta) end end @@ -792,7 +378,7 @@ end ---@param delta number function ScrollPages(delta) if Instance then - Instance:ScrollPages(nil, delta) + Instance.Lines:ScrollPages(nil, delta) end end diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua new file mode 100644 index 00000000000..3bc6e352ee0 --- /dev/null +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -0,0 +1,525 @@ + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group + +local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") + +local MauiWrapText = import("/lua/maui/text.lua").WrapText +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- Reserve space on the right of the wrapper for the scrollbar widget. +-- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny +-- breathing margin between the line text and the scrollbar. +local ScrollbarReserve = 20 + +------------------------------------------------------------------------------- +-- A self-contained chat-lines panel: outer wrapper, inner pool of line rows, +-- and the vertical scrollbar — packaged so a parent can drop it in and size +-- it as one unit. Lifted out of `ChatInterface.lua` so the chat window only +-- has to lay this out alongside the edit area. +-- +-- This class owns four related concerns that used to live on the window: +-- +-- 1. Pool sizing (`RebuildPool`) — line count follows +-- the container height. +-- 2. Text wrapping (`WrapEntry` / `RewrapAll`) — wraps message text to +-- the current row width; +-- results cached on the +-- entry itself. +-- 3. Scroll container (`GetScrollValues`, …) — virtual size = total +-- wrapped-line count +-- across *valid* entries. +-- 4. Visibility mapping (`CalcVisible`) — projects the scroll +-- position onto the pool. +-- +-- Filtering currently gates on `ChatConfigModel.GetOptions().muted`; +-- camera-link filtering is still TODO. +-- +-- Click hooks (`OnNameClicked`, `OnCameraClicked`) are overridable instance +-- fields with sensible defaults — name click is a no-op (window-level +-- concern), camera click jumps the world camera to the entry's hint. + +---@class UIChatLinesInterface : Group +---@field Trash TrashBag # owns every subscription-LazyVar we create +---@field Pool Group # inner group hosting the line rows +---@field Scrollbar Scrollbar +---@field Lines UIChatLineInterface[] +---@field ScrollTop number # 1-based virtual position of the top visible row +---@field VirtualSize number # total wrapped lines across valid entries +---@field HistoryObserver LazyVar +---@field OptionsObserver LazyVar +---@field LineNameClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared row-name click handler; captures `self` so pool lines don't allocate per-row closures +---@field LineCameraClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared cam-icon click handler; captures `self` for the same reason +---@field OnNameClicked fun(entry: UIChatEntry) # overridable: replace to react to a sender-name click +---@field OnCameraClicked fun(entry: UIChatEntry) # overridable: replace to override camera-link behaviour +ChatLinesInterface = ClassUI(Group) { + + ---@param self UIChatLinesInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatLinesInterface") + + self.Trash = TrashBag() + self.Lines = {} + self.ScrollTop = 1 + self.VirtualSize = 0 + + -- Pool holds the actual line rows. Sized in `__post_init` to stop + -- short of our right edge so the scrollbar (anchored to Pool's + -- right) sits inside our footprint. + self.Pool = Group(self, "ChatLinesPool") + + -- Forward the scrollable interface from the Pool (which the + -- scrollbar binds to via `Scrollbar:SetScrollable`) up to `self`, + -- where the state lives. + self.Pool.GetScrollValues = function(_, axis) return self:GetScrollValues(axis) end + self.Pool.ScrollLines = function(_, axis, delta) self:ScrollLines(axis, delta) end + self.Pool.ScrollPages = function(_, axis, delta) self:ScrollPages(axis, delta) end + self.Pool.ScrollSetTop = function(_, axis, top) self:ScrollSetTop(axis, top) end + self.Pool.IsScrollable = function(_, axis) return self:IsScrollable(axis) end + + -- Default click hooks. Replaced on the instance by callers that + -- want different behaviour (e.g. the chat window re-points + -- `OnNameClicked` to set the recipient + re-focus the edit box). + self.OnNameClicked = function(entry) end + self.OnCameraClicked = function(entry) + local cam = GetCamera('WorldCamera') + if entry.Location then + if entry.Location.Area then + cam:MoveToRegion(entry.Location.Area, 0.5) + elseif entry.Location.Position then + local settings = cam:SaveSettings() + settings.Focus = entry.Location.Position + cam:RestoreSettings(settings) + end + elseif entry.Camera then + cam:RestoreSettings(entry.Camera) + end + end + + -- Built once per panel so every pool line can point its + -- `OnNameClicked` / `OnCameraClicked` at the same reference — pool + -- growth never allocates a per-row closure. Each forwarder reads + -- `self.OnNameClicked` (etc.) on every invocation, so callers can + -- replace the public hook at any time without re-wiring the rows. + self.LineNameClicked = function(_, entry) self.OnNameClicked(entry) end + self.LineCameraClicked = function(_, entry) self.OnCameraClicked(entry) end + + -- History → wrap new entries, refresh size, stick to bottom. The + -- initial firing happens before `__post_init` so the wrap call has + -- no pool to measure against; that's fine — `RewrapAll` runs once + -- the pool exists. + local model = ChatModel.GetSingleton() + self.HistoryObserver = self.Trash:Add( + LazyVarDerive( + model.History, + function(lv) + self:OnHistoryChanged(lv()) + end + ) + ) + + -- `OptionsObserver` is wired in `__post_init`, not here — its + -- initial fire triggers `ApplyOptions → RebuildPool`, which reads + -- `self.Lines[1].Height()` and so requires the pool layout to + -- already be in place. + end, + + ---@param self UIChatLinesInterface + ---@param parent Control + __post_init = function(self, parent) + -- Pool fills the wrapper but stops `ScrollbarReserve` short of the + -- right edge so the scrollbar (which the engine anchors to Pool's + -- right) lands inside our footprint. + Layouter(self.Pool) + :AtLeftTopIn(self) + :AtRightIn(self, ScrollbarReserve) + :AtBottomIn(self) + :End() + + -- `CreateVertScrollbarFor` calls `Scrollbar:SetScrollable(attachto)` + -- so the scrollable methods have to live on `Pool` — see the + -- forwarding stubs in `__init`. + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Pool) + + self:RebuildPool() + self:RewrapAll() + self:ScrollToBottom() + + -- Committed chat options → font size, muted filter, etc. Wired + -- here rather than in `__init` so the initial fire runs against a + -- fully laid-out pool — `RebuildPool` reads `self.Lines[1].Height` + -- and would hit a circular dependency if the layout isn't ready. + self.OptionsObserver = self.Trash:Add( + LazyVarDerive( + ChatConfigModel.GetSingleton().Committed, + function(lv) self:ApplyOptions(lv()) end + ) + ) + end, + + --------------------------------------------------------------------------- + -- Pool sizing + --------------------------------------------------------------------------- + + --- Rebuilds the line pool to fit the current Pool height. Adds rows at + --- the bottom when we grow, destroys the tail when we shrink. Safe to + --- call repeatedly; callers are expected to follow up with `CalcVisible` + --- (and `RewrapAll` on a true resize). + ---@param self UIChatLinesInterface + RebuildPool = function(self) + local pool = self.Pool + -- Read the live size straight from the config model so the pool + -- always tracks the current option without a cached copy on `self`. + local fontSize = ChatConfigModel.GetOptions().font_size or 14 + + -- Need one line to establish the row height. The row's `Height` is + -- a lazy function of the name-text font (see `ChatLineInterface`). + if not self.Lines[1] then + self.Lines[1] = ChatLineInterface(pool) + self.Lines[1]:SetFontSize(fontSize) + self.Lines[1].OnNameClicked = self.LineNameClicked + self.Lines[1].OnCameraClicked = self.LineCameraClicked + Layouter(self.Lines[1]) + :AtLeftTopIn(pool) + :Right(pool.Right) + :End() + end + + local rowHeight = self.Lines[1].Height() + if rowHeight < 1 then rowHeight = 18 end -- safety fallback + + local neededLines = math.max(1, math.floor(pool.Height() / rowHeight)) + local currentCount = table.getn(self.Lines) + + -- Grow: append rows below the previous one. + for i = currentCount + 1, neededLines do + self.Lines[i] = ChatLineInterface(pool) + self.Lines[i]:SetFontSize(fontSize) + self.Lines[i].OnNameClicked = self.LineNameClicked + self.Lines[i].OnCameraClicked = self.LineCameraClicked + Layouter(self.Lines[i]) + :Below(self.Lines[i - 1]) + :AtLeftIn(pool) + :Right(pool.Right) + :End() + end + + -- Shrink: destroy the surplus tail. + for i = currentCount, neededLines + 1, -1 do + self.Lines[i]:Destroy() + self.Lines[i] = nil + end + end, + + --------------------------------------------------------------------------- + -- Options application + --------------------------------------------------------------------------- + + --- Applies a `UIChatOptions` snapshot. Handles `font_size` and the + --- `muted` filter today; future options that affect line rendering + --- (colours, link visibility) extend this method. + --- + --- Window-level options (`win_alpha`, default recipient, …) are the + --- parent's responsibility — we deliberately don't touch them here. + ---@param self UIChatLinesInterface + ---@param options UIChatOptions + ApplyOptions = function(self, options) + local size = options.font_size or 14 + for _, line in ipairs(self.Lines) do + line:SetFontSize(size) + end + -- Row height tracks the font, so the pool may need resizing; + -- wrap widths depend on font metrics, so rewrap all entries. + self:RebuildPool() + self:RewrapAll() + + -- Filter-affecting options (muted, links) may have changed too. + -- Recompute what's visible so entries newly excluded by + -- `IsValidEntry` drop out of the feed immediately. + self:RefreshVirtualSize() + self:CalcVisible() + end, + + --------------------------------------------------------------------------- + -- Text wrapping + --------------------------------------------------------------------------- + + --- Wraps a single entry's text to fit the current row width. Results are + --- cached on the entry itself as `entry.WrappedText`. The first wrapped + --- line reserves space for the name prefix; continuation lines span the + --- wider area to the right of the team-colour column. + ---@param self UIChatLinesInterface + ---@param entry UIChatEntry + WrapEntry = function(self, entry) + local measureLine = self.Lines[1] + if not measureLine then + entry.WrappedText = { entry.Text or '' } + return + end + + local name = entry.Name or '' + local lines = MauiWrapText(entry.Text or '', + function(lineIndex) + if lineIndex == 1 then + return measureLine.Right() + - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) + else + return measureLine.Right() + - (measureLine.Name.Left() + 4) + end + end, + function(textChunk) + return measureLine.Text:GetStringAdvance(textChunk) + end) + + if table.empty(lines) then lines = { '' } end + entry.WrappedText = lines + end, + + --- Re-wraps every entry in the history. Used on resize (width change) + --- and on option changes that affect the measuring font. + ---@param self UIChatLinesInterface + RewrapAll = function(self) + local history = ChatModel.GetSingleton().History() + for _, entry in ipairs(history) do + self:WrapEntry(entry) + end + self:RefreshVirtualSize(history) + end, + + --------------------------------------------------------------------------- + -- Filtering + --------------------------------------------------------------------------- + + --- Whether an entry counts toward the virtual scroll size and should + --- appear in `CalcVisible`. Currently gates on the per-army mute map + --- from `ChatConfigModel.Committed`; camera-link filtering is still TODO. + ---@param self UIChatLinesInterface + ---@param entry UIChatEntry + ---@return boolean + IsValidEntry = function(self, entry) + if entry == nil then return false end + local muted = ChatConfigModel.GetOptions().muted + if muted and entry.ArmyID and muted[entry.ArmyID] then + return false + end + return true + end, + + --------------------------------------------------------------------------- + -- Scroll container + --------------------------------------------------------------------------- + + --- Recomputes `VirtualSize` = total wrapped lines across all valid entries. + ---@param self UIChatLinesInterface + ---@param history? UIChatEntry[] + RefreshVirtualSize = function(self, history) + history = history or ChatModel.GetSingleton().History() + local size = 0 + for _, entry in ipairs(history) do + if self:IsValidEntry(entry) then + size = size + ((entry.WrappedText and table.getn(entry.WrappedText)) or 1) + end + end + self.VirtualSize = size + end, + + --- Standard MAUI scrollable interface: returns (rangeMin, rangeMax, visibleMin, visibleMax). + ---@param self UIChatLinesInterface + ---@param axis string # "Vert" or "Horz" + GetScrollValues = function(self, axis) + local poolSize = table.getn(self.Lines) + local top = self.ScrollTop + return 1, self.VirtualSize, top, math.min(top + poolSize, self.VirtualSize) + end, + + --- Scrolls by a number of rows (negative = toward older messages). + ---@param self UIChatLinesInterface + ---@param axis string + ---@param delta number + ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) + end, + + --- Scrolls by a page (pool-size worth of rows). + ---@param self UIChatLinesInterface + ---@param axis string + ---@param delta number + ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * table.getn(self.Lines)) + end, + + --- Jumps to an absolute virtual position, clamped to the valid range. + --- Name and signature match the engine's `ScrollSetTop(axis, top)` contract + --- so `Scrollbar:SetScrollable` can call it directly. + ---@param self UIChatLinesInterface + ---@param axis string + ---@param top number + ScrollSetTop = function(self, axis, top) + top = math.floor(top or 1) + local poolSize = table.getn(self.Lines) + local maxTop = math.max(1, self.VirtualSize - poolSize + 1) + local clamped = math.max(1, math.min(maxTop, top)) + if clamped == self.ScrollTop then return end + self.ScrollTop = clamped + self:CalcVisible() + end, + + --- Standard MAUI scrollable interface: whether scrolling is possible on + --- the given axis. + ---@param self UIChatLinesInterface + ---@param axis string + ---@return boolean + IsScrollable = function(self, axis) + return true + end, + + --- Snaps to the bottom of the virtual list. + ---@param self UIChatLinesInterface + ScrollToBottom = function(self) + self:ScrollSetTop(nil, self.VirtualSize) + -- ScrollSetTop short-circuits when the position doesn't change, but + -- the pool still needs a render pass after a rebuild / rewrap. + self:CalcVisible() + end, + + --------------------------------------------------------------------------- + -- Visibility mapping + --------------------------------------------------------------------------- + + --- Projects `[ScrollTop, ScrollTop + poolSize)` in virtual space onto the + --- line pool. Skips over filtered-out entries, uses `SetHeader` for the + --- first wrapped line of an entry and `SetContinuation` for the rest. + ---@param self UIChatLinesInterface + CalcVisible = function(self) + if not self.Lines[1] then return end + + local history = ChatModel.GetSingleton().History() + local historyCount = table.getn(history) + local poolSize = table.getn(self.Lines) + local scrollTop = self.ScrollTop + + -- Walk to the entry + wrapped-line that covers virtual position `scrollTop`. + local entryIdx = 1 + local wrappedIdx = 1 + local virtualPos = 0 + + while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do + entryIdx = entryIdx + 1 + end + + while entryIdx <= historyCount do + local entry = history[entryIdx] + local wrapCount = (entry.WrappedText and table.getn(entry.WrappedText)) or 1 + if virtualPos + wrapCount >= scrollTop then + wrappedIdx = scrollTop - virtualPos + if wrappedIdx < 1 then wrappedIdx = 1 end + break + end + virtualPos = virtualPos + wrapCount + entryIdx = entryIdx + 1 + while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do + entryIdx = entryIdx + 1 + end + end + + -- Fill each pool row; advance the cursor through wrapped lines and + -- skip filtered entries as we go. + for poolIdx = 1, poolSize do + local line = self.Lines[poolIdx] + if entryIdx > historyCount then + line:Clear() + line:Hide() + else + local entry = history[entryIdx] + local wrapped = entry.WrappedText + local wrappedText = (wrapped and wrapped[wrappedIdx]) or entry.Text or '' + + if wrappedIdx == 1 then + line:SetHeader(entry, wrappedText) + else + line:SetContinuation(entry, wrappedText) + end + line:Show() + + local wrapCount = (wrapped and table.getn(wrapped)) or 1 + if wrappedIdx < wrapCount then + wrappedIdx = wrappedIdx + 1 + else + wrappedIdx = 1 + entryIdx = entryIdx + 1 + while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do + entryIdx = entryIdx + 1 + end + end + end + end + end, + + --------------------------------------------------------------------------- + -- Model reactions + --------------------------------------------------------------------------- + + --- Called whenever `model.History` fires dirty. Wraps entries we haven't + --- wrapped yet (new arrivals), refreshes the virtual size, and snaps to + --- the bottom so the new line is visible. + ---@param self UIChatLinesInterface + ---@param history UIChatEntry[] + OnHistoryChanged = function(self, history) + for _, entry in ipairs(history) do + if not entry.WrappedText then + self:WrapEntry(entry) + end + end + self:RefreshVirtualSize(history) + if self.Lines[1] then + self:ScrollToBottom() + end + end, + + --------------------------------------------------------------------------- + -- Resize hooks (driven by the parent window's resize events) + --------------------------------------------------------------------------- + + --- Cheap resize pass: rebuild the pool to the new height and re-render + --- against existing wraps. Wrap widths are width-dependent but rewrapping + --- every drag frame is too expensive — see `OnResizeFinished`. + ---@param self UIChatLinesInterface + OnResizeLive = function(self) + self:RebuildPool() + self:CalcVisible() + end, + + --- Expensive resize pass: rebuild + rewrap + re-render. Call once when + --- the user finishes a resize drag. + ---@param self UIChatLinesInterface + OnResizeFinished = function(self) + self:RebuildPool() + self:RewrapAll() + self:CalcVisible() + end, + + --- Empties our trash bag so every derived observer we allocated is + --- destroyed — no `OnDirty` can fire into a torn-down `self`. + ---@param self UIChatLinesInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index e129e4fcb07..dd2633084c7 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -49,7 +49,7 @@ ChatListInterface = ClassUI(Group) { -- Popups must sit above the chat window's inner content (line rows, -- edit area) to receive hover and click events. A plain +1 offset - -- ties with the line rows, which default to LinesContainer+1 — + -- ties with the line rows, which default to ChatLinesInterface+1 — -- matching the list's default depth. +100 gives unambiguous headroom. LayoutHelpers.DepthOverParent(self, parent, 100) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index a08441abc70..192fbe2c7cc 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -19,7 +19,7 @@ The most conspicuous missing chunk. None of the "auto-fading feed of recent chat ## Message filtering -[`ChatInterface.IsValidEntry`](ChatInterface.lua) is a stub returning `true`. The legacy filter gated on three things ([chat.lua:304-310](../chat.lua)): +[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) is a stub returning `true`. The legacy filter gated on three things ([chat.lua:304-310](../chat.lua)): - `ChatOptions.links` — hides camera-link messages. - `ChatOptions[armyID]` — per-sender on/off checkboxes. @@ -29,7 +29,7 @@ Per-army filter UI needs re-adding to [`ChatConfigInterface`](config/ChatConfigI ## Config options not reaching the view -[`ChatConfigModel`](config/ChatConfigModel.lua) defines every key correctly, but only `font_size` is subscribed by the view (see [`ChatInterface.ApplyOptions`](ChatInterface.lua)). Not yet wired: +[`ChatConfigModel`](config/ChatConfigModel.lua) defines every key correctly, but only `font_size` and `win_alpha` are subscribed by the view (see [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) and [`ChatInterface` `__post_init`](ChatInterface.lua)). Not yet wired: - `all_color` / `allies_color` / `priv_color` / `link_color` / `notify_color` — the per-recipient text-colour palette ([chat.lua:63, 446-450](../chat.lua)). - `fade_time`, `win_alpha`, `feed_background`, `feed_persist` — feed-mode options (blocked on the feed-mode port above). diff --git a/lua/ui/game/chat/LAYOUT.md b/lua/ui/game/chat/LAYOUT.md index 2abd9cfb867..3dd1ba46e1c 100644 --- a/lua/ui/game/chat/LAYOUT.md +++ b/lua/ui/game/chat/LAYOUT.md @@ -27,17 +27,24 @@ ChatInterface (Window) │ └── client (Window's inner group) │ - ├── LinesContainer (Group) + ├── Lines (ChatLinesInterface, Group) │ │ Left = client.Left + pad(S) - │ │ Right = client.Right - 36(S) + │ │ Right = client.Right - pad(S) │ │ Top = client.Top + pad(S) │ │ Bottom = Edit.Top - 12(S) ← anchored to Edit's top │ │ - │ ├── Lines[1..N] ChatLineInterface pool - │ │ Height = Name.Height(S) + 2(S) - │ │ pool size = floor(container.Height / row.Height) + │ ├── Pool (Group) + │ │ │ Left = ChatLinesInterface.Left + │ │ │ Right = ChatLinesInterface.Right - 20(S) ← scrollbar reserve + │ │ │ Top = ChatLinesInterface.Top + │ │ │ Bottom = ChatLinesInterface.Bottom + │ │ │ + │ │ └── Lines[1..N] ChatLineInterface pool + │ │ Height = Name.Height(S) + 2(S) + │ │ pool size = floor(Pool.Height / row.Height) │ │ - │ └── Scrollbar CreateVertScrollbarFor(LinesContainer) + │ └── Scrollbar CreateVertScrollbarFor(Pool) + │ anchored to Pool's right edge │ └── Edit (ChatEditInterface) │ Left = client.Left @@ -249,8 +256,9 @@ bitmap children. | Concern | File | |------------------------------------|---------------------------------------------------------------------------| -| `DefaultRect`, drag handles, scrollbar, line pool | [`ChatInterface.lua`](ChatInterface.lua) | -| `LinesContainer` ↔ `Edit` anchoring | [`ChatInterface.lua` `__post_init`](ChatInterface.lua) | +| `DefaultRect`, drag handles, window chrome | [`ChatInterface.lua`](ChatInterface.lua) | +| `Lines` ↔ `Edit` anchoring (window-level) | [`ChatInterface.lua` `__post_init`](ChatInterface.lua) | +| Pool / Scrollbar layout, scroll state, wrapping, filtering | [`ChatLinesInterface.lua`](ChatLinesInterface.lua) | | `ChatBubble` / `RecipientLabel` / `EditBox` / `CamCheckbox` layout | [`ChatEditInterface.lua` `__post_init`](ChatEditInterface.lua) | | Row geometry (`TeamColor`, `Name`, `CamIcon`, `Text`) | [`ChatLineInterface.lua` `__post_init`](ChatLineInterface.lua) | | Hint popup width / height / row positioning | [`ChatCommandHintInterface.lua`](ChatCommandHintInterface.lua) | From 727848c236618f5639e89fbba86716b9a8a20192 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:32:12 +0200 Subject: [PATCH 062/130] Add debug tooling to make it easier to reason about controls --- lua/ui/game/chat/ChatCommandHintInterface.lua | 13 +++++++ lua/ui/game/chat/ChatEditInterface.lua | 14 ++++++++ lua/ui/game/chat/ChatInterface.lua | 13 +++++++ lua/ui/game/chat/ChatLineInterface.lua | 13 +++++++ lua/ui/game/chat/ChatLinesInterface.lua | 35 +++++++++++++++---- lua/ui/game/chat/ChatListInterface.lua | 13 +++++++ .../game/chat/config/ChatConfigInterface.lua | 14 ++++++++ 7 files changed, 109 insertions(+), 6 deletions(-) diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index f449b91bc10..98bb5bd861d 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -10,6 +10,11 @@ local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") local Layouter = LayoutHelpers.ReusedLayoutFor +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = false + local RowFontSize = 12 local RowFontName = 'Arial' local HorizontalPadding = 12 @@ -78,6 +83,7 @@ end ---@field RBG Bitmap ---@field TBG Bitmap ---@field BBG Bitmap +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true ChatCommandHintInterface = ClassUI(Group) { ---@param self UIChatCommandHintInterface @@ -216,6 +222,13 @@ ChatCommandHintInterface = ClassUI(Group) { end self.VisibleCount.OnDirty = function() syncScrollbarVisibility() end syncScrollbarVisibility() + + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('4040ffff') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end end, --- Builds a reusable row (text + highlight bitmap + hover handler). diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 1bc548e8d47..415ee5d67a1 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -6,6 +6,7 @@ local Group = import("/lua/maui/group.lua").Group local Edit = import("/lua/maui/edit.lua").Edit local Button = import("/lua/maui/button.lua").Button local Checkbox = import("/lua/maui/checkbox.lua").Checkbox +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") @@ -17,6 +18,11 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = false + local MaxChars = 200 ------------------------------------------------------------------------------- @@ -35,6 +41,7 @@ local MaxChars = 200 ---@field RecipientObserver LazyVar # derived from ChatModel.Recipient ---@field Completion UIChatCompletion | nil # active Tab-cycle record, reset on text change ---@field SuppressCompletionReset boolean # true while our own SetText is running +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true ChatEditInterface = ClassUI(Group) { ---@param self UIChatEditInterface @@ -216,6 +223,13 @@ ChatEditInterface = ClassUI(Group) { Layouter(self) :Height(function() return self.EditBox.Height() + heightPadScaled end) :End() + + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('40ff40ff') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end end, --- Entry point for the Tab key. When the command hint is open, Tab diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 03bdf7fe30e..fb803ec3898 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -79,6 +79,7 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@field ResetPositionBtn Button # titlebar button that restores DefaultRect ---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible ---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed (window-level options only) +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface @@ -270,6 +271,11 @@ local ChatInterface = ClassUI(Window) { :AnchorToTop(self.Edit, 12) :End() + -- Now that the lines panel has a real rect, let it build its pool + -- and wire its options observer (the initial fire reads the laid- + -- out `Pool.Height()`). + self.Lines:Initialize() + -- Committed chat options → window-level concerns only. Pool sizing, -- font, and filter changes are owned by the lines panel; we just -- handle `win_alpha` here. `SetAlpha(_, true)` cascades so chrome, @@ -282,6 +288,13 @@ local ChatInterface = ClassUI(Window) { end ) ) + + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('40ff4040') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end end, --------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index a056751a76f..eafbd13f831 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -9,6 +9,11 @@ local Factions = import("/lua/factions.lua").Factions local Layouter = LayoutHelpers.ReusedLayoutFor +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = false + -- Collect faction icons up-front; append an observer icon as the final entry -- so non-player senders can be represented too. local FactionIcons = {} @@ -34,6 +39,7 @@ local CamIconTexture = '/game/camera-btn/pinned_btn_up.dds' ---@field CamIcon Bitmap # camera-link affordance, hidden unless entry.Camera is set ---@field Text Text ---@field Entry UIChatEntry | nil +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true ChatLineInterface = ClassUI(Group) { ---@param self UIChatLineInterface @@ -130,6 +136,13 @@ ChatLineInterface = ClassUI(Group) { :AtVerticalCenterIn(self.TeamColor) :Over(self, 10) :End() + + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('404040ff') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end end, --- Populates the row as the FIRST wrapped line of an entry: shows the diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 3bc6e352ee0..f01499cb970 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -3,6 +3,7 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface @@ -14,6 +15,11 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = false + -- Reserve space on the right of the wrapper for the scrollbar widget. -- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny -- breathing margin between the line text and the scrollbar. @@ -59,6 +65,7 @@ local ScrollbarReserve = 20 ---@field LineCameraClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared cam-icon click handler; captures `self` for the same reason ---@field OnNameClicked fun(entry: UIChatEntry) # overridable: replace to react to a sender-name click ---@field OnCameraClicked fun(entry: UIChatEntry) # overridable: replace to override camera-link behaviour +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true ChatLinesInterface = ClassUI(Group) { ---@param self UIChatLinesInterface @@ -137,7 +144,10 @@ ChatLinesInterface = ClassUI(Group) { __post_init = function(self, parent) -- Pool fills the wrapper but stops `ScrollbarReserve` short of the -- right edge so the scrollbar (which the engine anchors to Pool's - -- right) lands inside our footprint. + -- right) lands inside our footprint. These bindings are reactive — + -- they don't evaluate to concrete pixels until the parent has laid + -- us out, which is why pool / wrap / scroll work happens in + -- `Initialize` below rather than here. Layouter(self.Pool) :AtLeftTopIn(self) :AtRightIn(self, ScrollbarReserve) @@ -146,17 +156,30 @@ ChatLinesInterface = ClassUI(Group) { -- `CreateVertScrollbarFor` calls `Scrollbar:SetScrollable(attachto)` -- so the scrollable methods have to live on `Pool` — see the - -- forwarding stubs in `__init`. + -- forwarding stubs in `__init`. Anchoring the scrollbar is also + -- reactive (it tracks `Pool.Right`), so this is safe pre-layout. self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Pool) + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('4040ff40') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end + end, + + --- Called by the parent once it has laid out the lines panel. The + --- initial `RebuildPool` reads `Pool.Height()` — which evaluates to + --- zero until our outer rect is bound to something concrete — so the + --- pool / rewrap / scroll work has to wait until the parent positions + --- us. Wiring `OptionsObserver` here defers its initial fire (which + --- calls `ApplyOptions → RebuildPool`) for the same reason. + ---@param self UIChatLinesInterface + Initialize = function(self) self:RebuildPool() self:RewrapAll() self:ScrollToBottom() - -- Committed chat options → font size, muted filter, etc. Wired - -- here rather than in `__init` so the initial fire runs against a - -- fully laid-out pool — `RebuildPool` reads `self.Lines[1].Height` - -- and would hit a circular dependency if the layout isn't ready. self.OptionsObserver = self.Trash:Add( LazyVarDerive( ChatConfigModel.GetSingleton().Committed, diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index dd2633084c7..671da2f0d2e 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -14,6 +14,11 @@ local ChatController = import("/lua/ui/game/chat/ChatController.lua") local Layouter = LayoutHelpers.ReusedLayoutFor +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = false + ---@class UIChatListEntry ---@field Text Text ---@field BG Bitmap @@ -39,6 +44,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor ---@field RBG Bitmap ---@field TBG Bitmap ---@field BBG Bitmap +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true ChatListInterface = ClassUI(Group) { ---@param self UIChatListInterface @@ -194,6 +200,13 @@ ChatListInterface = ClassUI(Group) { end self:LayoutBorder() + + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('40ffff40') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end end, --- Lays out one row: the text anchored above `below` (or at the bottom diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index f112b968c52..e5185d4bd29 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -4,6 +4,7 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Window = import("/lua/maui/window.lua").Window local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") @@ -12,6 +13,11 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = false + -- 8 ARGB solid colors selectable as message color swatches. local Colors = { 'ffffffff', 'ffff4242', 'ffefff42', 'ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42' } @@ -61,6 +67,7 @@ local CheckboxDefs = { ---@field BtnOk Button ---@field BtnCancel Button ---@field PendingObserver LazyVar # derived from ChatConfigModel.Pending +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true local ChatConfigInterface = ClassUI(Window) { ---@param self UIChatConfigInterface @@ -332,6 +339,13 @@ local ChatConfigInterface = ClassUI(Window) { -- so a Width/Right mismatch is invisible until the first drag). local bottomPadScaled = LayoutHelpers.ScaleNumber(16) self.Bottom:Set(function() return self.BtnCancel.Bottom() + bottomPadScaled end) + + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('40ff8040') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end end, --- Syncs every control to reflect the given options table. From 1171961036b342e57798672e43fd423e4ecfa739 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:43:27 +0200 Subject: [PATCH 063/130] Fix scaling for edit interface --- lua/ui/game/chat/ChatEditInterface.lua | 8 ++++---- lua/ui/game/chat/ChatInterface.lua | 10 +++++----- lua/ui/game/chat/ChatLinesInterface.lua | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 415ee5d67a1..e55ed26dc44 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -21,7 +21,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. -local Debug = false +local Debug = true local MaxChars = 200 @@ -191,19 +191,19 @@ ChatEditInterface = ClassUI(Group) { ---@param parent Control __post_init = function(self, parent) Layouter(self.ChatBubble) - :AtLeftIn(self, 3) + :AtLeftIn(self, 6) :AtVerticalCenterIn(self) :End() Layouter(self.RecipientLabel) - :AnchorToRight(self.ChatBubble, 2) + :AnchorToRight(self.ChatBubble, 10) :AtVerticalCenterIn(self) :End() -- Camera-attach toggle pinned to the right edge so the edit box can -- claim the remaining width. Layouter(self.CamCheckbox) - :AtRightIn(self, 4) + :AtRightIn(self, 8) :AtVerticalCenterIn(self) :End() diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index fb803ec3898..20b9a848687 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -101,7 +101,7 @@ local ChatInterface = ClassUI(Window) { -- The lines panel and edit area. Both are laid out in `__post_init` -- once the client area has a real size to anchor against. self.Lines = ChatLinesInterface(client) - self.Edit = ChatEditInterface(client) + self.Edit = ChatEditInterface(self) -- Override the lines panel's name-click hook to set the chat -- recipient and re-focus the edit box. `OnCameraClicked` keeps the @@ -253,10 +253,10 @@ local ChatInterface = ClassUI(Window) { -- Full width, flush with the bottom of the client area. The edit -- group derives its own height (see ChatEditInterface.__post_init). Layouter(self.Edit) - :AtLeftIn(client) - :AtRightIn(client) - :AtBottomIn(client) - :Height(22) + :AtLeftIn(self) + :AtRightIn(self) + :AtBottomIn(self, 6) + :Height(19) :Over(client) :End() diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index f01499cb970..6829038f06e 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -18,7 +18,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. -local Debug = false +local Debug = true -- Reserve space on the right of the wrapper for the scrollbar widget. -- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny From 1627eee10f7b44ea57cabe791e3e653aed03ae20 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:49:48 +0200 Subject: [PATCH 064/130] Fix scaling of chat lines container --- lua/ui/game/chat/ChatEditInterface.lua | 2 +- lua/ui/game/chat/ChatInterface.lua | 10 +++++----- lua/ui/game/chat/ChatLinesInterface.lua | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index e55ed26dc44..1282ad3175a 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -21,7 +21,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. -local Debug = true +local Debug = false local MaxChars = 200 diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 20b9a848687..07530184b9d 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -248,7 +248,6 @@ local ChatInterface = ClassUI(Window) { ---@param parent Control __post_init = function(self, parent) local client = self:GetClientGroup() - local pad = 4 -- Full width, flush with the bottom of the client area. The edit -- group derives its own height (see ChatEditInterface.__post_init). @@ -264,11 +263,12 @@ local ChatInterface = ClassUI(Window) { -- box. The scrollbar is its own concern — `ChatLinesInterface` -- reserves the space inside its right edge for the scrollbar -- widget, so the parent only has to allocate a single rect. + local uniformPadding = 4 Layouter(self.Lines) - :AtLeftIn(client, pad) - :AtRightIn(client, pad) - :AtTopIn(client, pad) - :AnchorToTop(self.Edit, 12) + :Below(self.TitleGroup, 9 + uniformPadding) + :AtLeftIn(self, 6 + uniformPadding) + :AtRightIn(self, 8 + uniformPadding) + :AtBottomIn(self, 32 + uniformPadding) :End() -- Now that the lines panel has a real rect, let it build its pool diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 6829038f06e..1ae13344956 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -23,7 +23,7 @@ local Debug = true -- Reserve space on the right of the wrapper for the scrollbar widget. -- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny -- breathing margin between the line text and the scrollbar. -local ScrollbarReserve = 20 +local ScrollbarReserve = 32 ------------------------------------------------------------------------------- -- A self-contained chat-lines panel: outer wrapper, inner pool of line rows, From 789bd1000fc3456583470e3507e6434a01be8bc0 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:54:27 +0200 Subject: [PATCH 065/130] Rename fields to make it easier to understand what they are --- lua/ui/game/chat/ChatEditInterface.lua | 44 +++++++++---------- lua/ui/game/chat/ChatInterface.lua | 34 +++++++-------- lua/ui/game/chat/ChatLinesInterface.lua | 56 ++++++++++++------------- 3 files changed, 67 insertions(+), 67 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 1282ad3175a..7621696fcd7 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -36,8 +36,8 @@ local MaxChars = 200 ---@field RecipientLabel Text ---@field EditBox Edit ---@field CamCheckbox Checkbox # toggle: attach world-camera state to the next message ----@field ChatList UIChatListInterface | nil ----@field CommandHint UIChatCommandHintInterface | nil +---@field ChatListInterface UIChatListInterface | nil +---@field ChatCommandHintInterface UIChatCommandHintInterface | nil ---@field RecipientObserver LazyVar # derived from ChatModel.Recipient ---@field Completion UIChatCompletion | nil # active Tab-cycle record, reset on text change ---@field SuppressCompletionReset boolean # true while our own SetText is running @@ -149,7 +149,7 @@ ChatEditInterface = ClassUI(Group) { -- Escape priorities: (1) close an open command hint, (2) clear any -- text, (3) close the chat window. self.EditBox.OnEscPressed = function(_, text) - if self.CommandHint then + if self.ChatCommandHintInterface then self:CloseCommandHint() return true end @@ -171,10 +171,10 @@ ChatEditInterface = ClassUI(Group) { import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(-step) elseif keycode == UIUtil.VK_NEXT then import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(step) - elseif keycode == UIUtil.VK_UP and self.CommandHint then - self.CommandHint:SelectNext() - elseif keycode == UIUtil.VK_DOWN and self.CommandHint then - self.CommandHint:SelectPrev() + elseif keycode == UIUtil.VK_UP and self.ChatCommandHintInterface then + self.ChatCommandHintInterface:SelectNext() + elseif keycode == UIUtil.VK_DOWN and self.ChatCommandHintInterface then + self.ChatCommandHintInterface:SelectPrev() end end @@ -239,8 +239,8 @@ ChatEditInterface = ClassUI(Group) { --- complete so the user isn't left wondering whether the key was handled. ---@param self UIChatEditInterface HandleTabCompletion = function(self) - if self.CommandHint then - local hint = self.CommandHint --[[@as UIChatCommandHintInterface]] + if self.ChatCommandHintInterface then + local hint = self.ChatCommandHintInterface --[[@as UIChatCommandHintInterface]] local cmd = hint:GetSelected() if cmd then self.EditBox:SetText('/' .. cmd.Name .. ' ') @@ -305,29 +305,29 @@ ChatEditInterface = ClassUI(Group) { ---@param self UIChatEditInterface ---@param text string RefreshCommandHint = function(self, text) - if self.CommandHint then + if self.ChatCommandHintInterface then if string.sub(text, 1, 1) == '/' then - self.CommandHint:Refresh(text) + self.ChatCommandHintInterface:Refresh(text) else self:CloseCommandHint() end elseif text == '/' then self:OpenCommandHint() - self.CommandHint:Refresh(text) + self.ChatCommandHintInterface:Refresh(text) end end, --- Creates the hint popup and anchors it directly above the edit box. ---@param self UIChatEditInterface OpenCommandHint = function(self) - if self.CommandHint then return end + if self.ChatCommandHintInterface then return end -- Ensure the built-ins exist before the hint queries the registry; -- otherwise we'd only see the footer fallback on the first open. ChatController.RegisterBuiltinCommands() local hint = ChatCommandHintInterface(self, self.EditBox) - self.CommandHint = hint + self.ChatCommandHintInterface = hint LayoutHelpers.Above(hint, self.EditBox, 14) LayoutHelpers.AtLeftIn(hint, self.EditBox) hint:SetOnSelect(function(cmd) @@ -340,29 +340,29 @@ ChatEditInterface = ClassUI(Group) { --- message, clears the prefix, or otherwise leaves command-entry mode. ---@param self UIChatEditInterface CloseCommandHint = function(self) - if not self.CommandHint then return end - local hint = self.CommandHint --[[@as UIChatCommandHintInterface]] - self.CommandHint = nil + if not self.ChatCommandHintInterface then return end + local hint = self.ChatCommandHintInterface --[[@as UIChatCommandHintInterface]] + self.ChatCommandHintInterface = nil hint:Destroy() end, --- Opens the recipient picker popup, or closes it if it is already open. ---@param self UIChatEditInterface ToggleList = function(self) - if self.ChatList then - local list = self.ChatList --[[@as UIChatListInterface]] - self.ChatList = nil + if self.ChatListInterface then + local list = self.ChatListInterface --[[@as UIChatListInterface]] + self.ChatListInterface = nil list:Destroy() self:AcquireFocus() else local list = ChatListInterface(self) - self.ChatList = list + self.ChatListInterface = list -- Position the popup above-left of the chat-bubble button. -- Depth is handled by the list itself (see ChatListInterface.__init). LayoutHelpers.Above(list, self.ChatBubble, 15) LayoutHelpers.AtLeftIn(list, self.ChatBubble, 15) list:SetOnClosed(function() - self.ChatList = nil + self.ChatListInterface = nil self:AcquireFocus() end) end diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 07530184b9d..59e15a9e4f8 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -69,8 +69,8 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@class UIChatInterface : Window ---@field Trash TrashBag # owns every subscription-LazyVar we create ----@field Lines UIChatLinesInterface # the wrapped panel containing line rows + scrollbar ----@field Edit UIChatEditInterface +---@field ChatLinesInterface UIChatLinesInterface # the wrapped panel containing line rows + scrollbar +---@field ChatEditInterface UIChatEditInterface ---@field DragTL Bitmap # top-left corner resize grip ---@field DragTR Bitmap # top-right corner resize grip ---@field DragBL Bitmap # bottom-left corner resize grip @@ -100,18 +100,18 @@ local ChatInterface = ClassUI(Window) { -- The lines panel and edit area. Both are laid out in `__post_init` -- once the client area has a real size to anchor against. - self.Lines = ChatLinesInterface(client) - self.Edit = ChatEditInterface(self) + self.ChatLinesInterface = ChatLinesInterface(client) + self.ChatEditInterface = ChatEditInterface(self) -- Override the lines panel's name-click hook to set the chat -- recipient and re-focus the edit box. `OnCameraClicked` keeps the -- panel's default behaviour (jump the world camera). Ignore clicks -- on your own name — whispering yourself is pointless and the -- picker would still route it as a private message. - self.Lines.OnNameClicked = function(entry) + self.ChatLinesInterface.OnNameClicked = function(entry) if entry.ArmyID and entry.ArmyID ~= GetFocusArmy() then ChatController.SetRecipient(entry.ArmyID) - self.Edit:AcquireFocus() + self.ChatEditInterface:AcquireFocus() end end @@ -128,9 +128,9 @@ local ChatInterface = ClassUI(Window) { function(lv) if lv() then self:Show() - self.Edit:AcquireFocus() + self.ChatEditInterface:AcquireFocus() else - self.Edit:AbandonFocus() + self.ChatEditInterface:AbandonFocus() self:Hide() end end @@ -251,7 +251,7 @@ local ChatInterface = ClassUI(Window) { -- Full width, flush with the bottom of the client area. The edit -- group derives its own height (see ChatEditInterface.__post_init). - Layouter(self.Edit) + Layouter(self.ChatEditInterface) :AtLeftIn(self) :AtRightIn(self) :AtBottomIn(self, 6) @@ -264,7 +264,7 @@ local ChatInterface = ClassUI(Window) { -- reserves the space inside its right edge for the scrollbar -- widget, so the parent only has to allocate a single rect. local uniformPadding = 4 - Layouter(self.Lines) + Layouter(self.ChatLinesInterface) :Below(self.TitleGroup, 9 + uniformPadding) :AtLeftIn(self, 6 + uniformPadding) :AtRightIn(self, 8 + uniformPadding) @@ -274,7 +274,7 @@ local ChatInterface = ClassUI(Window) { -- Now that the lines panel has a real rect, let it build its pool -- and wire its options observer (the initial fire reads the laid- -- out `Pool.Height()`). - self.Lines:Initialize() + self.ChatLinesInterface:Initialize() -- Committed chat options → window-level concerns only. Pool sizing, -- font, and filter changes are owned by the lines panel; we just @@ -304,7 +304,7 @@ local ChatInterface = ClassUI(Window) { --- Fired continuously during a resize drag. Keep it cheap: just resize --- the pool and re-render against existing wraps. OnResize = function(self, width, height, firstFrame) - self.Lines:OnResizeLive() + self.ChatLinesInterface:OnResizeLive() end, --- Fired when a resize drag ends. Rewrapping is expensive, so it only @@ -312,7 +312,7 @@ local ChatInterface = ClassUI(Window) { --- grips back to their `up` texture — the RolloverHandler leaves them --- on `down` when StartSizing took over. OnResizeSet = function(self) - self.Lines:OnResizeFinished() + self.ChatLinesInterface:OnResizeFinished() self.DragTL:SetTexture(self.DragTL.textures.up) self.DragTR:SetTexture(self.DragTR.textures.up) self.DragBL:SetTexture(self.DragBL.textures.up) @@ -323,13 +323,13 @@ local ChatInterface = ClassUI(Window) { --- handler steals focus mid-move, so re-acquire it so the user can keep --- typing without a second click on the edit box. OnMoveSet = function(self) - self.Edit:AcquireFocus() + self.ChatEditInterface:AcquireFocus() end, --- Mouse wheel over the window scrolls the chat. `rotation` is in wheel --- units (usually ±120 per notch); one notch ≈ one line. OnMouseWheel = function(self, rotation) - self.Lines:ScrollLines(nil, -math.floor(rotation / 100)) + self.ChatLinesInterface:ScrollLines(nil, -math.floor(rotation / 100)) end, --- Engine-invoked when the user clicks the close button on the window frame. @@ -382,7 +382,7 @@ end ---@param delta number function ScrollLines(delta) if Instance then - Instance.Lines:ScrollLines(nil, delta) + Instance.ChatLinesInterface:ScrollLines(nil, delta) end end @@ -391,7 +391,7 @@ end ---@param delta number function ScrollPages(delta) if Instance then - Instance.Lines:ScrollPages(nil, delta) + Instance.ChatLinesInterface:ScrollPages(nil, delta) end end diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 1ae13344956..81b40c65127 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -56,7 +56,7 @@ local ScrollbarReserve = 32 ---@field Trash TrashBag # owns every subscription-LazyVar we create ---@field Pool Group # inner group hosting the line rows ---@field Scrollbar Scrollbar ----@field Lines UIChatLineInterface[] +---@field ChatLineInterfaces UIChatLineInterface[] ---@field ScrollTop number # 1-based virtual position of the top visible row ---@field VirtualSize number # total wrapped lines across valid entries ---@field HistoryObserver LazyVar @@ -74,7 +74,7 @@ ChatLinesInterface = ClassUI(Group) { Group.__init(self, parent, "ChatLinesInterface") self.Trash = TrashBag() - self.Lines = {} + self.ChatLineInterfaces = {} self.ScrollTop = 1 self.VirtualSize = 0 @@ -135,7 +135,7 @@ ChatLinesInterface = ClassUI(Group) { -- `OptionsObserver` is wired in `__post_init`, not here — its -- initial fire triggers `ApplyOptions → RebuildPool`, which reads - -- `self.Lines[1].Height()` and so requires the pool layout to + -- `self.ChatLineInterfaces[1].Height()` and so requires the pool layout to -- already be in place. end, @@ -205,31 +205,31 @@ ChatLinesInterface = ClassUI(Group) { -- Need one line to establish the row height. The row's `Height` is -- a lazy function of the name-text font (see `ChatLineInterface`). - if not self.Lines[1] then - self.Lines[1] = ChatLineInterface(pool) - self.Lines[1]:SetFontSize(fontSize) - self.Lines[1].OnNameClicked = self.LineNameClicked - self.Lines[1].OnCameraClicked = self.LineCameraClicked - Layouter(self.Lines[1]) + if not self.ChatLineInterfaces[1] then + self.ChatLineInterfaces[1] = ChatLineInterface(pool) + self.ChatLineInterfaces[1]:SetFontSize(fontSize) + self.ChatLineInterfaces[1].OnNameClicked = self.LineNameClicked + self.ChatLineInterfaces[1].OnCameraClicked = self.LineCameraClicked + Layouter(self.ChatLineInterfaces[1]) :AtLeftTopIn(pool) :Right(pool.Right) :End() end - local rowHeight = self.Lines[1].Height() + local rowHeight = self.ChatLineInterfaces[1].Height() if rowHeight < 1 then rowHeight = 18 end -- safety fallback local neededLines = math.max(1, math.floor(pool.Height() / rowHeight)) - local currentCount = table.getn(self.Lines) + local currentCount = table.getn(self.ChatLineInterfaces) -- Grow: append rows below the previous one. for i = currentCount + 1, neededLines do - self.Lines[i] = ChatLineInterface(pool) - self.Lines[i]:SetFontSize(fontSize) - self.Lines[i].OnNameClicked = self.LineNameClicked - self.Lines[i].OnCameraClicked = self.LineCameraClicked - Layouter(self.Lines[i]) - :Below(self.Lines[i - 1]) + self.ChatLineInterfaces[i] = ChatLineInterface(pool) + self.ChatLineInterfaces[i]:SetFontSize(fontSize) + self.ChatLineInterfaces[i].OnNameClicked = self.LineNameClicked + self.ChatLineInterfaces[i].OnCameraClicked = self.LineCameraClicked + Layouter(self.ChatLineInterfaces[i]) + :Below(self.ChatLineInterfaces[i - 1]) :AtLeftIn(pool) :Right(pool.Right) :End() @@ -237,8 +237,8 @@ ChatLinesInterface = ClassUI(Group) { -- Shrink: destroy the surplus tail. for i = currentCount, neededLines + 1, -1 do - self.Lines[i]:Destroy() - self.Lines[i] = nil + self.ChatLineInterfaces[i]:Destroy() + self.ChatLineInterfaces[i] = nil end end, @@ -256,7 +256,7 @@ ChatLinesInterface = ClassUI(Group) { ---@param options UIChatOptions ApplyOptions = function(self, options) local size = options.font_size or 14 - for _, line in ipairs(self.Lines) do + for _, line in ipairs(self.ChatLineInterfaces) do line:SetFontSize(size) end -- Row height tracks the font, so the pool may need resizing; @@ -282,7 +282,7 @@ ChatLinesInterface = ClassUI(Group) { ---@param self UIChatLinesInterface ---@param entry UIChatEntry WrapEntry = function(self, entry) - local measureLine = self.Lines[1] + local measureLine = self.ChatLineInterfaces[1] if not measureLine then entry.WrappedText = { entry.Text or '' } return @@ -359,7 +359,7 @@ ChatLinesInterface = ClassUI(Group) { ---@param self UIChatLinesInterface ---@param axis string # "Vert" or "Horz" GetScrollValues = function(self, axis) - local poolSize = table.getn(self.Lines) + local poolSize = table.getn(self.ChatLineInterfaces) local top = self.ScrollTop return 1, self.VirtualSize, top, math.min(top + poolSize, self.VirtualSize) end, @@ -377,7 +377,7 @@ ChatLinesInterface = ClassUI(Group) { ---@param axis string ---@param delta number ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * table.getn(self.Lines)) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * table.getn(self.ChatLineInterfaces)) end, --- Jumps to an absolute virtual position, clamped to the valid range. @@ -388,7 +388,7 @@ ChatLinesInterface = ClassUI(Group) { ---@param top number ScrollSetTop = function(self, axis, top) top = math.floor(top or 1) - local poolSize = table.getn(self.Lines) + local poolSize = table.getn(self.ChatLineInterfaces) local maxTop = math.max(1, self.VirtualSize - poolSize + 1) local clamped = math.max(1, math.min(maxTop, top)) if clamped == self.ScrollTop then return end @@ -423,11 +423,11 @@ ChatLinesInterface = ClassUI(Group) { --- first wrapped line of an entry and `SetContinuation` for the rest. ---@param self UIChatLinesInterface CalcVisible = function(self) - if not self.Lines[1] then return end + if not self.ChatLineInterfaces[1] then return end local history = ChatModel.GetSingleton().History() local historyCount = table.getn(history) - local poolSize = table.getn(self.Lines) + local poolSize = table.getn(self.ChatLineInterfaces) local scrollTop = self.ScrollTop -- Walk to the entry + wrapped-line that covers virtual position `scrollTop`. @@ -457,7 +457,7 @@ ChatLinesInterface = ClassUI(Group) { -- Fill each pool row; advance the cursor through wrapped lines and -- skip filtered entries as we go. for poolIdx = 1, poolSize do - local line = self.Lines[poolIdx] + local line = self.ChatLineInterfaces[poolIdx] if entryIdx > historyCount then line:Clear() line:Hide() @@ -503,7 +503,7 @@ ChatLinesInterface = ClassUI(Group) { end end self:RefreshVirtualSize(history) - if self.Lines[1] then + if self.ChatLineInterfaces[1] then self:ScrollToBottom() end end, From c7903cb15a5576b8ab74be25c46d11809d5d3b14 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 14:56:03 +0200 Subject: [PATCH 066/130] Fix chat line overflow when chat window is reset --- lua/ui/game/chat/ChatInterface.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 59e15a9e4f8..8cd5a92be6b 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -237,6 +237,7 @@ local ChatInterface = ClassUI(Window) { self.Right:Set(scaled(DefaultRect.Right)) self.Bottom:Set(scaled(DefaultRect.Bottom)) self:SaveWindowLocation() + self:OnResizeSet() end Layouter(self.ResetPositionBtn) From b7e6f3280f632513dc2f58bfe62d54d89e9848a5 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 15:11:13 +0200 Subject: [PATCH 067/130] Fix bug in window title group not scaling with ui scale --- lua/maui/window.lua | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lua/maui/window.lua b/lua/maui/window.lua index 3ae46c59fdb..69e3fec232b 100644 --- a/lua/maui/window.lua +++ b/lua/maui/window.lua @@ -217,11 +217,13 @@ Window = ClassUI(Group) { self.window_m.Bottom:Set(self.window_bm.Top) self.TitleGroup = Group(self, 'window title group') - self.TitleGroup.Top:Set(self.tm.Top) - self.TitleGroup.Left:Set(self.tl.Left) - self.TitleGroup.Right:Set(self.tr.Right) - self.TitleGroup.Height:Set(30) - self.TitleGroup.Depth:Set(function() return self._windowGroup.Depth() + 2 end) + LayoutHelpers.LayoutFor(self.TitleGroup) + :Top(self.tm.Top) + :Left(self.tl.Left) + :Right(self.tr.Right) + :Height(30) + :Over(self._windowGroup, 2) + :End() if icon then self._titleIcon = Bitmap(self.TitleGroup, icon) @@ -285,11 +287,10 @@ Window = ClassUI(Group) { LayoutHelpers.Layouter(self.ClientGroup) :Top(self.TitleGroup.Bottom) :Left(self.ml.Right) - :Height(function() return self.bm.Top() - self.TitleGroup.Bottom() end) - :Width(function() return self.mr.Left() - self.ml.Right() end) :Right(self.mr.Left) :Bottom(self.bm.Top) :Over(self.window_m) + :End() self.StartSizing = function(event, xControl, yControl) local drag = Dragger() From 6d2c4be4feb8f3efc2be15f0e904cde003f74c40 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 15:19:39 +0200 Subject: [PATCH 068/130] Fix scaling issues --- lua/ui/game/chat/ChatEditInterface.lua | 16 +++------------- lua/ui/game/chat/ChatInterface.lua | 17 ++++++++--------- lua/ui/game/chat/ChatLinesInterface.lua | 2 +- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 7621696fcd7..9918755a0ef 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -196,15 +196,15 @@ ChatEditInterface = ClassUI(Group) { :End() Layouter(self.RecipientLabel) - :AnchorToRight(self.ChatBubble, 10) + :AnchorToRight(self.ChatBubble, 6) :AtVerticalCenterIn(self) :End() -- Camera-attach toggle pinned to the right edge so the edit box can -- claim the remaining width. Layouter(self.CamCheckbox) - :AtRightIn(self, 8) - :AtVerticalCenterIn(self) + :AtRightIn(self, 12) + :AtVerticalCenterIn(self, -2) :End() Layouter(self.EditBox) @@ -214,16 +214,6 @@ ChatEditInterface = ClassUI(Group) { :Height(function() return self.EditBox:GetFontHeight() end) :End() - -- The group sizes itself to the edit's font height plus a scaled - -- padding so the bitmap-sized children (chat bubble, camera button) - -- have visual breathing room around the text. Without the pad, at - -- higher UI scales `self.Height == EditBox.Height` and the buttons - -- end up flush against the edit baseline, looking shifted down. - local heightPadScaled = LayoutHelpers.ScaleNumber(6) - Layouter(self) - :Height(function() return self.EditBox.Height() + heightPadScaled end) - :End() - if Debug then self.DebugBG = Bitmap(self) self.DebugBG:SetSolidColor('40ff40ff') diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 8cd5a92be6b..4fab9ad759d 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -85,14 +85,12 @@ local ChatInterface = ClassUI(Window) { ---@param self UIChatInterface ---@param parent Control __init = function(self, parent) - Window.__init(self, parent, "", false, true, true, false, false, "chat_window_v2", DefaultRect, WindowTextures) + Window.__init(self, parent, "Chat dialog", false, true, true, false, false, "chat_window_v2", DefaultRect, WindowTextures) self:SetMinimumResize(400, 160) self:SetupDragHandles() self:SetupResetPositionButton() - local client = self:GetClientGroup() - -- Single trash bag for everything we allocate that needs explicit -- destruction — currently just the derived observer LazyVars. -- Emptied in `OnDestroy`. @@ -100,7 +98,7 @@ local ChatInterface = ClassUI(Window) { -- The lines panel and edit area. Both are laid out in `__post_init` -- once the client area has a real size to anchor against. - self.ChatLinesInterface = ChatLinesInterface(client) + self.ChatLinesInterface = ChatLinesInterface(self) self.ChatEditInterface = ChatEditInterface(self) -- Override the lines panel's name-click hook to set the chat @@ -264,12 +262,13 @@ local ChatInterface = ClassUI(Window) { -- box. The scrollbar is its own concern — `ChatLinesInterface` -- reserves the space inside its right edge for the scrollbar -- widget, so the parent only has to allocate a single rect. - local uniformPadding = 4 + local paddingHorizontal = 8 + local paddingVertical = 2 Layouter(self.ChatLinesInterface) - :Below(self.TitleGroup, 9 + uniformPadding) - :AtLeftIn(self, 6 + uniformPadding) - :AtRightIn(self, 8 + uniformPadding) - :AtBottomIn(self, 32 + uniformPadding) + :AtTopIn(client, paddingVertical) + :AtLeftIn(client, paddingHorizontal) + :AtRightIn(client, paddingHorizontal) + :AnchorToTop(self.ChatEditInterface, 4) :End() -- Now that the lines panel has a real rect, let it build its pool diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 81b40c65127..3237ae9049e 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -18,7 +18,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. -local Debug = true +local Debug = false -- Reserve space on the right of the wrapper for the scrollbar widget. -- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny From 75da955da42b0ed8924b61866f6f5b0fe7c988e3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 16:00:53 +0200 Subject: [PATCH 069/130] Do not fade out chat lines --- lua/ui/game/chat/ChatInterface.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 4fab9ad759d..55453c67cbe 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -279,12 +279,15 @@ local ChatInterface = ClassUI(Window) { -- Committed chat options → window-level concerns only. Pool sizing, -- font, and filter changes are owned by the lines panel; we just -- handle `win_alpha` here. `SetAlpha(_, true)` cascades so chrome, - -- lines, edit, and scrollbar all dim uniformly. + -- edit, and scrollbar all dim uniformly. self.OptionsObserver = self.Trash:Add( LazyVarDerive( ChatConfigModel.GetSingleton().Committed, function(lv) self:SetAlpha(lv().win_alpha or 1.0, true) + + -- do not dim chat lines + self.ChatLinesInterface:SetAlpha(1.0, true) end ) ) From b01432f5e56d2dc1d09911f13a83e331da8c1667 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 16:44:38 +0200 Subject: [PATCH 070/130] Implement window-wide fade on inactivity --- .claude/settings.local.json | 3 ++- lua/ui/game/chat/ChatController.lua | 21 ++++++++++++++- lua/ui/game/chat/ChatEditInterface.lua | 3 +++ lua/ui/game/chat/ChatInterface.lua | 34 ++++++++++++++++++++++++- lua/ui/game/chat/ChatLinesInterface.lua | 2 ++ lua/ui/game/chat/ChatListInterface.lua | 1 + lua/ui/game/chat/ChatModel.lua | 3 +++ 7 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dd64210a8c1..498d2dcd9f8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(git mv *)", - "Bash(git -C d:/faf-development/fa mv lua/ui/game/chat/commands/Gift.lua lua/ui/game/chat/commands/GiftUnits.lua)" + "Bash(git -C d:/faf-development/fa mv lua/ui/game/chat/commands/Gift.lua lua/ui/game/chat/commands/GiftUnits.lua)", + "Bash(grep -n \"feed mode\\\\|Feed mode\\\\|FeedMode\\\\|step 1\\\\|Step 1\\\\|## Feed\\\\|# Feed\\\\|## 1\\\\\\\\.\\\\|### 1\\\\\\\\.\\\\|plan.*feed\\\\|feed.*plan\" \"C:/Users/wbwij/.claude/projects/d--faf-development-fa/110ae14a-5ac0-4c8d-a019-efe340367afb.jsonl\")" ] } } diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 19560000c3b..9419771a175 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -21,6 +21,22 @@ function ToggleWindow() lv:Set(not lv()) end +------------------------------------------------------------------------------- +-- Activity heartbeat +-- +-- Every UI surface that wants to count as "user is engaged with chat" calls +-- this — keystrokes, scrolling, recipient-picker hovers, etc. The chat +-- window observes `model.LastActivity` to drive its idle / fade timeout, but +-- any future subscriber (a feed-mode line fader, an away-status indicator) +-- can read the same field without rewiring the call sites. + +--- Records a user / system activity event by stamping `LastActivity` with the +--- current system time. Cheap and idempotent — call freely from anywhere +--- that detects engagement with the chat UI. +function NotifyActivity() + ChatModel.GetSingleton().LastActivity:Set(GetSystemTimeSeconds()) +end + ------------------------------------------------------------------------------- -- Recipient @@ -34,13 +50,16 @@ end -- Messages --- Appends an entry to the history log. Called by the receive path as well as ---- by locally-echoed outgoing messages. +--- by locally-echoed outgoing messages. Doubles as an activity heartbeat — +--- every new line counts as engagement, so a burst of incoming chat keeps +--- the window from auto-fading mid-conversation. ---@param entry UIChatEntry function AppendEntry(entry) local model = ChatModel.GetSingleton() local history = table.copy(model.History()) table.insert(history, entry) model.History:Set(history) + NotifyActivity() end --- Appends a synthetic, local-only system line to the history. Used by the diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 9918755a0ef..c97c7312cd3 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -112,6 +112,7 @@ ChatEditInterface = ClassUI(Group) { -- the legacy `chat.lua` shortcut where Enter serves as both "send" -- and "dismiss" depending on whether there's anything to send. self.EditBox.OnEnterPressed = function(edit, text) + ChatController.NotifyActivity() if text and text ~= '' then ChatController.Send(text, self.CamCheckbox:IsChecked()) edit:SetText('') @@ -125,6 +126,7 @@ ChatEditInterface = ClassUI(Group) { -- any in-flight Tab-completion cycle whenever the text changes from -- something other than our own `ApplyCompletion` call. self.EditBox.OnTextChanged = function(_, newText, _) + ChatController.NotifyActivity() self:RefreshCommandHint(newText or '') if not self.SuppressCompletionReset then self.Completion = nil @@ -166,6 +168,7 @@ ChatEditInterface = ClassUI(Group) { -- Lazy import of ChatInterface avoids the import cycle: ChatInterface -- imports this module at load time, so the reverse edge has to defer. self.EditBox.OnNonTextKeyPressed = function(_, keycode, modifiers) + ChatController.NotifyActivity() local step = modifiers and modifiers.Shift and 1 or 10 if keycode == UIUtil.VK_PRIOR then import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(-step) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 55453c67cbe..baa7b92e356 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -119,7 +119,11 @@ local ChatInterface = ClassUI(Window) { -- CLAUDE.md for the pattern). local model = ChatModel.GetSingleton() - -- Window visibility → show / hide the frame. + -- Window visibility → show / hide the frame, gate the idle timer. + -- `SetNeedsFrameUpdate(true)` is what makes `OnFrame` actually fire; + -- toggling it with visibility avoids ticking while hidden. Showing + -- the window stamps `LastActivity` so the user gets a fresh full + -- `fade_time` window before auto-close kicks in. self.WindowVisibleObserver = self.Trash:Add( LazyVarDerive( model.WindowVisible, @@ -127,7 +131,10 @@ local ChatInterface = ClassUI(Window) { if lv() then self:Show() self.ChatEditInterface:AcquireFocus() + ChatController.NotifyActivity() + self:SetNeedsFrameUpdate(true) else + self:SetNeedsFrameUpdate(false) self.ChatEditInterface:AbandonFocus() self:Hide() end @@ -300,6 +307,27 @@ local ChatInterface = ClassUI(Window) { end end, + --------------------------------------------------------------------------- + -- Idle / fade timer + --------------------------------------------------------------------------- + + --- Engine-driven frame tick. Only fires while `SetNeedsFrameUpdate(true)` + --- is set; the visibility observer toggles that with the window so we + --- don't tick while hidden. The timer is fully model-driven: any caller + --- that wants to count as activity calls `ChatController.NotifyActivity()` + --- to stamp `model.LastActivity`. Once the elapsed time since that stamp + --- crosses `fade_time`, ask the controller to close — closing flips + --- `model.WindowVisible`, which in turn disables further frame ticks. + ---@param self UIChatInterface + ---@param delta number # seconds since the last frame, unused (we read absolute time) + OnFrame = function(self, delta) + local fadeTime = ChatConfigModel.GetOptions().fade_time or 15 + local elapsed = GetSystemTimeSeconds() - ChatModel.GetSingleton().LastActivity() + if elapsed >= fadeTime then + ChatController.CloseWindow() + end + end, + --------------------------------------------------------------------------- -- Window event hooks --------------------------------------------------------------------------- @@ -307,6 +335,7 @@ local ChatInterface = ClassUI(Window) { --- Fired continuously during a resize drag. Keep it cheap: just resize --- the pool and re-render against existing wraps. OnResize = function(self, width, height, firstFrame) + ChatController.NotifyActivity() self.ChatLinesInterface:OnResizeLive() end, @@ -315,6 +344,7 @@ local ChatInterface = ClassUI(Window) { --- grips back to their `up` texture — the RolloverHandler leaves them --- on `down` when StartSizing took over. OnResizeSet = function(self) + ChatController.NotifyActivity() self.ChatLinesInterface:OnResizeFinished() self.DragTL:SetTexture(self.DragTL.textures.up) self.DragTR:SetTexture(self.DragTR.textures.up) @@ -326,12 +356,14 @@ local ChatInterface = ClassUI(Window) { --- handler steals focus mid-move, so re-acquire it so the user can keep --- typing without a second click on the edit box. OnMoveSet = function(self) + ChatController.NotifyActivity() self.ChatEditInterface:AcquireFocus() end, --- Mouse wheel over the window scrolls the chat. `rotation` is in wheel --- units (usually ±120 per notch); one notch ≈ one line. OnMouseWheel = function(self, rotation) + ChatController.NotifyActivity() self.ChatLinesInterface:ScrollLines(nil, -math.floor(rotation / 100)) end, diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 3237ae9049e..1a2735c4607 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -8,6 +8,7 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface 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") local MauiWrapText = import("/lua/maui/text.lua").WrapText @@ -387,6 +388,7 @@ ChatLinesInterface = ClassUI(Group) { ---@param axis string ---@param top number ScrollSetTop = function(self, axis, top) + ChatController.NotifyActivity() top = math.floor(top or 1) local poolSize = table.getn(self.ChatLineInterfaces) local maxTop = math.max(1, self.VirtualSize - poolSize + 1) diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index 671da2f0d2e..c3b962bde69 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -138,6 +138,7 @@ ChatListInterface = ClassUI(Group) { -- Capture target in a local so each entry closes over its own value. local target = def.Target entry.BG.HandleEvent = function(bg, event) + ChatController.NotifyActivity() if event.Type == 'MouseEnter' then bg:SetSolidColor('ff666666') elseif event.Type == 'MouseExit' then diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index 0717ce07bbf..0812ca8394a 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -44,6 +44,7 @@ RecipientAllies = 'allies' ---@field History LazyVar # append-only message 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 user / receive activity; observed by the chat window's idle / fade timer ---@type UIChatModel | nil local ModelInstance = nil @@ -55,6 +56,7 @@ function SetupSingleton() History = Create({}), Recipient = Create(RecipientAll), WindowVisible = Create(false), + LastActivity = Create(GetSystemTimeSeconds()), } return ModelInstance end @@ -78,6 +80,7 @@ function __moduleinfo.OnReload(newModule) handle.History:Set(ModelInstance.History()) handle.Recipient:Set(ModelInstance.Recipient()) handle.WindowVisible:Set(ModelInstance.WindowVisible()) + handle.LastActivity:Set(ModelInstance.LastActivity()) end end From c2143ff02917a86482db5c9752cf6f7b6423fab3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 16:57:09 +0200 Subject: [PATCH 071/130] Add debug hotkeys to make testing more convenient --- lua/keymap/debugKeyActions.lua | 50 +++++++++++- lua/keymap/keyactions.lua | 8 -- lua/keymap/keydescriptions.lua | 13 ++- lua/ui/game/chat/CHANGES.md | 2 +- lua/ui/game/chat/ChatDebug.lua | 142 +++++++++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 lua/ui/game/chat/ChatDebug.lua diff --git a/lua/keymap/debugKeyActions.lua b/lua/keymap/debugKeyActions.lua index e78302d188a..5941fd6e52e 100644 --- a/lua/keymap/debugKeyActions.lua +++ b/lua/keymap/debugKeyActions.lua @@ -319,8 +319,56 @@ local keyActionsDebugAI = { }, } +--- Bindings that exercise the in-game chat. Filed under the `chat` category +--- (rather than `debug`) so the keybindings dialog groups them with the +--- regular chat actions — they're harmless to bind in normal play. +---@type table +local keyActionsDebugChat = { + ['debug_chat_window'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").Toggle()', + category = 'chat', + }, + ['debug_chat_config'] = { + action = 'UI_Lua import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle()', + category = 'chat', + }, + ['debug_chat_append_system_message'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").AppendSystemMessage()', + category = 'chat', + }, + ['debug_chat_append_short_message'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").AppendShortMessage()', + category = 'chat', + }, + ['debug_chat_append_long_message'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").AppendLongMessage()', + category = 'chat', + }, + ['debug_chat_append_burst'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").AppendBurst()', + category = 'chat', + }, + ['debug_chat_append_camera_message'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").AppendCameraMessage()', + category = 'chat', + }, + ['debug_chat_set_recipient_all'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").SetRecipientAll()', + category = 'chat', + }, + ['debug_chat_set_recipient_allies'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").SetRecipientAllies()', + category = 'chat', + }, + ['debug_chat_clear_history'] = { + action = 'UI_Lua import("/lua/ui/game/chat/ChatDebug.lua").ClearHistory()', + category = 'chat', + }, +} + ---@type table debugKeyActions = table.combine( keyActionsDebug, - keyActionsDebugAI + keyActionsDebugAI, + keyActionsDebugChat ) diff --git a/lua/keymap/keyactions.lua b/lua/keymap/keyactions.lua index bc5fbe699af..a8966005c9f 100755 --- a/lua/keymap/keyactions.lua +++ b/lua/keymap/keyactions.lua @@ -1878,14 +1878,6 @@ local keyActionsChat = { action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").OpenAndScrollLines(1)', category = 'chat', }, - ['chat_config'] = { - action = 'UI_Lua import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle()', - category = 'chat', - }, - ['chat_window'] = { - action = 'UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").Toggle()', - category = 'chat', - }, } ---@type table diff --git a/lua/keymap/keydescriptions.lua b/lua/keymap/keydescriptions.lua index edae3d86c26..4f17b220170 100755 --- a/lua/keymap/keydescriptions.lua +++ b/lua/keymap/keydescriptions.lua @@ -197,8 +197,17 @@ keyDescriptions = { ['chat_page_down'] = 'Chat page down', ['chat_line_up'] = 'Chat line up', ['chat_line_down'] = 'Chat line down', - ['chat_config'] = 'Toggle chat options', - ['chat_window'] = 'Toggle chat window', + + ['debug_chat_window'] = 'Toggle the chat window', + ['debug_chat_config'] = 'Toggle the chat options dialog', + ['debug_chat_append_system_message'] = 'Append a synthetic system message to the chat', + ['debug_chat_append_short_message'] = 'Append a short synthetic chat message', + ['debug_chat_append_long_message'] = 'Append a long synthetic chat message (tests text wrapping)', + ['debug_chat_append_burst'] = 'Append ten synthetic chat messages in a burst (tests scrolling)', + ['debug_chat_append_camera_message'] = 'Append a chat message with a location hint at the current camera focus', + ['debug_chat_set_recipient_all'] = 'Set the chat recipient to all', + ['debug_chat_set_recipient_allies'] = 'Set the chat recipient to allies', + ['debug_chat_clear_history'] = 'Clear the chat history', ['switch_skin_up'] = 'Rotate skins up', ['switch_skin_down'] = 'Rotate skins down', diff --git a/lua/ui/game/chat/CHANGES.md b/lua/ui/game/chat/CHANGES.md index 1314f710414..2b51eeae310 100644 --- a/lua/ui/game/chat/CHANGES.md +++ b/lua/ui/game/chat/CHANGES.md @@ -18,7 +18,7 @@ This keeps the controller focused on displayable messages and preserves the old Legacy `ChatPageDown(mod)` had a quirk: pressing it when the feed was already scrolled to the bottom (or the window was hidden) would toggle the window. The new [`ChatInterface.OpenAndScrollLines`](ChatInterface.lua) keybind entry-point opens-then-scrolls; `PgDn` on an already-open, already-bottom window now does nothing. -Closing is still reachable via the close button, the `Escape` key on an empty edit box, and the `chat_window` keybind. +Closing is still reachable via the close button, the `Escape` key on an empty edit box, and the `debug_chat_window` keybind. --- diff --git a/lua/ui/game/chat/ChatDebug.lua b/lua/ui/game/chat/ChatDebug.lua new file mode 100644 index 00000000000..5eabe15c897 --- /dev/null +++ b/lua/ui/game/chat/ChatDebug.lua @@ -0,0 +1,142 @@ + +------------------------------------------------------------------------------- +-- Helpers wired to the `debug_chat_*` hotkeys in +-- `/lua/keymap/debugKeyActions.lua`. Each function exercises a distinct +-- chat path so the rendering / scrolling / camera / recipient flows can be +-- inspected without needing a second client to actually send messages. + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatController = import("/lua/ui/game/chat/ChatController.lua") + +--- A sample paragraph long enough to force the chat line wrapper to span +--- multiple rows at every supported font size. +local LongText = + "The quick brown fox jumps over the lazy dog and then doubles back, " .. + "dodges a passing T2 mobile artillery shell, ramps off a discarded " .. + "engineer drone, and lands neatly on the scoreboard with a triumphant " .. + "bark — at which point the dog wakes up and demands to know who " .. + "authorised the construction of the ramp in the first place." + +--- Synthesises a chat entry stamped with the local focus army's metadata so +--- the rendering colour and faction icon match a real outgoing message. The +--- entry carries a fresh `Id` so the dedupe in `OnSyncChatMessages` doesn't +--- swallow it later. +---@param overrides table # fields merged on top of the synth defaults +---@return UIChatEntry +local function SynthEntry(overrides) + local focus = GetFocusArmy() + local armies = GetArmiesTable().armiesTable + local data = (focus and focus > 0) and armies[focus] or {} + local entry = { + Name = (data.nickname or 'Debug') .. ' to all:', + Text = '[debug] sample message at ' .. tostring(GetSystemTimeSeconds()), + Color = data.color or 'ffffffff', + ArmyID = focus or 1, + Faction = (data.faction or 4) + 1, + Recipient = ChatModel.RecipientAll, + } + for k, v in overrides or {} do + entry[k] = v + end + entry.Id = entry.Id or tostring(entry) + return entry +end + +------------------------------------------------------------------------------- +-- Window & dialog toggles +------------------------------------------------------------------------------- + +--- Toggles the chat window. Thin wrapper so the hotkey action string can +--- live alongside the rest of the chat-debug helpers. +function ToggleWindow() + import("/lua/ui/game/chat/ChatInterface.lua").Toggle() +end + +--- Toggles the chat config dialog. +function ToggleConfig() + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() +end + +------------------------------------------------------------------------------- +-- Synthetic message injection +------------------------------------------------------------------------------- + +--- Appends a local-only system line. Exercises +--- `ChatController.AppendLocalSystemMessage` and the system-style colour. +function AppendSystemMessage() + ChatController.AppendLocalSystemMessage( + '[debug] system message at ' .. tostring(GetSystemTimeSeconds()) + ) +end + +--- Appends a single short synthetic chat entry. Exercises the basic +--- model→view path and the auto-scroll-to-bottom on history change. +function AppendShortMessage() + ChatController.AppendEntry(SynthEntry({})) +end + +--- Appends a synthetic entry whose body is long enough to wrap onto several +--- rows at every supported font size. Exercises `ChatLinesInterface.WrapEntry` +--- and the continuation-row layout. +function AppendLongMessage() + ChatController.AppendEntry(SynthEntry({ Text = LongText })) +end + +--- Appends ten short entries in a single batch. Exercises pool sizing +--- (the visible window grows past the line cap), virtual-size accounting, +--- and the snap-to-bottom behaviour on rapid arrivals. +function AppendBurst() + for i = 1, 10 do + ChatController.AppendEntry(SynthEntry({ + Text = string.format('[debug] burst %d / 10', i), + })) + end +end + +--- Appends an entry with a `Location` hint pointing at the current world +--- camera focus. Exercises the camera-icon toggle on the row and the +--- `Camera:MoveTo` jump on click. The point is captured at hotkey time, so +--- pressing the key, panning the camera, and clicking the icon should +--- bounce the camera back to the original spot. +function AppendCameraMessage() + local cam = GetCamera('WorldCamera') + local settings = cam:SaveSettings() + ChatController.AppendEntry(SynthEntry({ + Text = '[debug] click the camera icon to jump back here', + Location = { Position = settings.Focus }, + })) +end + +------------------------------------------------------------------------------- +-- Recipient state +------------------------------------------------------------------------------- + +--- Forces the current send target to "all". Exercises the recipient-label +--- LazyVar binding in the edit row. +function SetRecipientAll() + ChatController.SetRecipient(ChatModel.RecipientAll) +end + +--- Forces the current send target to "allies". +function SetRecipientAllies() + ChatController.SetRecipient(ChatModel.RecipientAllies) +end + +------------------------------------------------------------------------------- +-- History reset +------------------------------------------------------------------------------- + +--- Wipes the history log. Exercises the empty-pool branch in +--- `ChatLinesInterface.CalcVisible` and the model-side dirty propagation. +function ClearHistory() + ChatModel.GetSingleton().History:Set({}) +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion From edf003fdc3fb3c8f3a216969aa6620aedec96a1d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 20:13:53 +0200 Subject: [PATCH 072/130] Implement a chat feed when dialog is not open --- lua/ui/game/chat/ChatFeedInterface.lua | 338 ++++++++++++++++++++++++ lua/ui/game/chat/ChatInterface.lua | 18 +- lua/ui/game/chat/ChatLinesInterface.lua | 115 ++++---- lua/ui/game/chat/ChatUtils.lua | 59 +++++ lua/ui/game/chat/GAPS.md | 86 +++--- 5 files changed, 513 insertions(+), 103 deletions(-) create mode 100644 lua/ui/game/chat/ChatFeedInterface.lua create mode 100644 lua/ui/game/chat/ChatUtils.lua diff --git a/lua/ui/game/chat/ChatFeedInterface.lua b/lua/ui/game/chat/ChatFeedInterface.lua new file mode 100644 index 00000000000..f074ecf2f28 --- /dev/null +++ b/lua/ui/game/chat/ChatFeedInterface.lua @@ -0,0 +1,338 @@ + +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +--- Flip to `true` to overlay a semi-transparent coloured bitmap over the +--- control so its bounds are visible at runtime. Each chat interface uses a +--- distinct colour so overlapping controls can be told apart at a glance. +local Debug = true + +--- Cap on how many feed rows are visible at once. Older rows above this +--- are dropped immediately when a new row pushes in — feed mode is for +--- glanceable awareness, not full scrollback. +local MaxFeedRows = 8 + +--- Length of the alpha fade-out near the end of a row's lifetime, in +--- seconds. Capped to half the configured `fade_time` so very short +--- timeouts still get a visible fade rather than a hard pop. +local FadeOutDuration = 2 + +------------------------------------------------------------------------------- +-- A separate "feed" view of the chat history that surfaces messages while +-- the main chat window is hidden. Mounted as a sibling of the chat window +-- (so the window's `Show`/`Hide` cascade can't reach us), but pinned to the +-- window's line area via LazyVar bindings — drag/resize the chat window and +-- the feed tracks for free. +-- +-- The feed is fully model-driven: +-- * `ChatModel.History` — incoming entries are appended as feed rows. +-- * `ChatModel.WindowVisible` — feed visible iff window hidden + we have rows. +-- Each row carries its own age timer ticked by `OnFrame`; rows past +-- `fade_time` destroy themselves. There is no shared timer / pinning yet — +-- those land in later steps along with `feed_persist` and `feed_background`. + +---@class UIChatFeedRow +---@field Line UIChatLineInterface # exactly one wrapped chunk: header on the entry's first row, continuation on the rest +---@field Entry UIChatEntry # the source message this line belongs to +---@field Time number # seconds since this row was added; each row ages and expires independently + +---@class UIChatFeedInterface : Group +---@field Trash TrashBag # owns every subscription-LazyVar we create +---@field Window UIChatInterface | nil # chat window we anchor to; nil for standalone debug +---@field Rows UIChatFeedRow[] # active feed rows, oldest first, newest last +---@field LastHistoryLength number # high-water mark so we only feed in entries we haven't already seen +---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible +---@field HistoryObserver LazyVar # derived from ChatModel.History +---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true +ChatFeedInterface = ClassUI(Group) { + + ---@param self UIChatFeedInterface + ---@param parent Control + ---@param window UIChatInterface | nil + __init = function(self, parent, window) + Group.__init(self, parent, "ChatFeedInterface") + self:DisableHitTest() + + self.Trash = TrashBag() + self.Window = window + self.Rows = {} + + local model = ChatModel.GetSingleton() + + -- Seed the high-water mark to whatever's already in history so the + -- initial fire of `HistoryObserver` doesn't replay every existing + -- entry as a fresh feed line. + self.LastHistoryLength = table.getn(model.History()) + + -- Window visibility flips us in / out of feed mode. Opening the + -- window throws away every active feed row — anything the user + -- wanted to read is now in the main view, and a stale fade + -- countdown lingering across an open/close cycle would just clutter + -- the feed with content the user already saw. `UpdateVisibility` + -- then hides us (rows == 0) and stops the frame ticker. + self.WindowVisibleObserver = self.Trash:Add( + LazyVarDerive(model.WindowVisible, function(lv) + if lv() then + self:ClearAll() + end + self:UpdateVisibility() + end) + ) + + -- New entries → push to the feed only while the window is hidden. + -- Entries received with the window open are the user's to read in + -- the main view; we still bump `LastHistoryLength` either way so + -- they aren't replayed when the window later closes. + self.HistoryObserver = self.Trash:Add( + LazyVarDerive(model.History, function(lv) + self:OnHistoryChanged(lv()) + end) + ) + end, + + ---@param self UIChatFeedInterface + ---@param parent Control + ---@param window UIChatInterface | nil + __post_init = function(self, parent, window) + if self.Window then + -- One-way LazyVar bind to the chat window's line area. Drag / + -- resize the chat window with the feed visible (during a + -- transition, etc.) and the feed tracks for free; no observer + -- glue, no model write — the dependency graph does it. + ---@diagnostic disable-next-line: param-type-mismatch + Layouter(self) + :Left(self.Window.ChatLinesInterface.Left) + :Right(self.Window.ChatLinesInterface.Right) + :Top(self.Window.ChatLinesInterface.Top) + :Bottom(self.Window.ChatLinesInterface.Bottom) + :End() + else + -- Standalone debug fallback: anchor near the bottom-left of the + -- frame so `Toggle()` from a dev hotkey still shows somewhere. + Layouter(self) + :AtLeftBottomIn(parent, 8, 60) + :Width(420) + :Height(160) + :End() + end + + -- Start hidden — `UpdateVisibility` reveals us when both conditions + -- (window hidden + rows > 0) are met. + self:Hide() + self:UpdateVisibility() + + if Debug then + self.DebugBG = Bitmap(self) + self.DebugBG:SetSolidColor('40c040c0') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end + end, + + --------------------------------------------------------------------------- + -- History handling + --------------------------------------------------------------------------- + + --- Called whenever `ChatModel.History` fires dirty. Pushes entries that + --- arrived since the last call onto the feed — but only while the chat + --- window is hidden. Entries received while the window is open are the + --- user's to read in the main view, not surfaced again on next close. + --- We still bump `LastHistoryLength` either way so we never replay + --- already-seen entries when the window later closes. + ---@param self UIChatFeedInterface + ---@param history UIChatEntry[] + OnHistoryChanged = function(self, history) + local newCount = table.getn(history) + if not ChatModel.GetSingleton().WindowVisible() then + for i = self.LastHistoryLength + 1, newCount do + self:AppendRow(history[i]) + end + end + self.LastHistoryLength = newCount + end, + + --- Appends one feed row per wrapped chunk in `entry`. Each row carries + --- its own `Time`, so capping and expiry act on individual lines + --- rather than entry-blocks — when the cap kicks in mid-stream, only + --- the single oldest row drops out instead of the entire block of + --- chunks belonging to one wrapped entry. + --- + --- We force the wrap before reading `entry.WrappedText`. Both views + --- observe the same `model.History` LazyVar, but `used_by` iteration + --- order is unspecified — if we fire before the chat-lines observer + --- the cache is empty and we'd degenerate to a single-line fallback. + --- We borrow the chat panel's measure-line because it shares our row + --- width exactly (LazyVar bind), so the wrap is valid here. + ---@param self UIChatFeedInterface + ---@param entry UIChatEntry + AppendRow = function(self, entry) + if not entry then return end + + if not entry.WrappedText and self.Window then + ChatUtils.WrapEntry(entry, self.Window.ChatLinesInterface.ChatLineInterfaces[1]) + end + + local wrapped = entry.WrappedText + if not wrapped or table.getn(wrapped) == 0 then + wrapped = { entry.Text or '' } + end + + local fontSize = ChatConfigModel.GetOptions().font_size or 14 + + for i, chunk in ipairs(wrapped) do + -- Per-chunk cap: a wrapped message pushes one row in for one + -- row out, keeping the visible total at exactly `MaxFeedRows`. + if table.getn(self.Rows) >= MaxFeedRows then + self:RemoveOldest() + end + + local line = ChatLineInterface(self) + line:SetFontSize(fontSize) + if i == 1 then + line:SetHeader(entry, chunk) + else + line:SetContinuation(entry, chunk) + end + line:SetAlpha(1.0, true) + table.insert(self.Rows, { Line = line, Entry = entry, Time = 0 }) + end + + self:LayoutRows() + self:UpdateVisibility() + end, + + --- Pins each row from the bottom up. The bottom-most row anchors to + --- `AtBottomIn(self)`; every other row stacks `Above` the row that + --- comes after it in `Rows`. Because `AppendRow` inserts an entry's + --- chunks in reading order (header first, continuations after), the + --- header still sits at the top of its block and continuations below. + ---@param self UIChatFeedInterface + LayoutRows = function(self) + local count = table.getn(self.Rows) + for i = count, 1, -1 do + local row = self.Rows[i] + if i == count then + Layouter(row.Line) + :AtBottomIn(self) + :AtLeftIn(self) + :AtRightIn(self) + :End() + else + Layouter(row.Line) + :Above(self.Rows[i + 1].Line) + :AtLeftIn(self) + :AtRightIn(self) + :End() + end + end + end, + + --- Removes the single oldest row from the head of `Rows`. With each + --- row tracking its own `Time`, capping no longer cascades through a + --- wrapped entry's chunks — a header at the head of the queue gets + --- popped on its own, and its continuations stay until they age out + --- on their own timers. + ---@param self UIChatFeedInterface + RemoveOldest = function(self) + local oldest = self.Rows[1] + if oldest then + oldest.Line:Destroy() + table.remove(self.Rows, 1) + end + end, + + --- Tears down every active row. Called when the user opens the chat + --- window (non-persist semantics) and from `OnDestroy`. + ---@param self UIChatFeedInterface + ClearAll = function(self) + for _, row in ipairs(self.Rows) do + row.Line:Destroy() + end + self.Rows = {} + end, + + --------------------------------------------------------------------------- + -- Visibility / lifecycle + --------------------------------------------------------------------------- + + --- Computes whether we should currently be on screen and ticking. + --- Feed visible iff: chat window is hidden AND we have at least one + --- active row. `SetNeedsFrameUpdate` toggles in lockstep so we don't + --- waste frame ticks while idle. + ---@param self UIChatFeedInterface + UpdateVisibility = function(self) + local windowVisible = ChatModel.GetSingleton().WindowVisible() + if not windowVisible and table.getn(self.Rows) > 0 then + self:Show() + self:SetNeedsFrameUpdate(true) + else + self:Hide() + self:SetNeedsFrameUpdate(false) + end + end, + + --- Per-frame timer pass. Walks each row, advances its `Time`, fades + --- out the last `FadeOutDuration` seconds of life, and destroys the + --- row once past `fade_time`. Each row ages independently — wrapped + --- entries arrive at the same instant so their chunks usually expire + --- together by virtue of starting from the same `Time = 0`, but the + --- cap or a future selective drop can take individual rows without + --- disturbing siblings. Re-evaluates visibility so the feed self- + --- hides when the last row expires. + ---@param self UIChatFeedInterface + ---@param delta number # seconds since the last frame + OnFrame = function(self, delta) + local fadeTime = ChatConfigModel.GetOptions().fade_time or 15 + local fadeOut = math.min(FadeOutDuration, fadeTime / 2) + local fadeStart = fadeTime - fadeOut + + local i = 1 + while i <= table.getn(self.Rows) do + local row = self.Rows[i] + row.Time = row.Time + delta + if row.Time >= fadeTime then + row.Line:Destroy() + table.remove(self.Rows, i) + else + if row.Time > fadeStart then + local alpha = 1.0 - (row.Time - fadeStart) / fadeOut + row.Line:SetAlpha(alpha, true) + end + i = i + 1 + end + end + + self:UpdateVisibility() + end, + + --- Empties our trash bag (destroying every derived observer) and + --- destroys any remaining feed rows. + ---@param self UIChatFeedInterface + OnDestroy = function(self) + self:ClearAll() + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +--- Owned and rebuilt by `ChatInterface`; touching the chat module after a +--- save here triggers the full chat-tree rebuild that picks up our changes. +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index baa7b92e356..c56957c3ff1 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -7,6 +7,7 @@ local Button = import("/lua/maui/button.lua").Button local ChatLinesInterface = import("/lua/ui/game/chat/ChatLinesInterface.lua").ChatLinesInterface local ChatEditInterface = import("/lua/ui/game/chat/ChatEditInterface.lua").ChatEditInterface +local ChatFeedInterface = import("/lua/ui/game/chat/ChatFeedInterface.lua").ChatFeedInterface local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") @@ -79,6 +80,7 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@field ResetPositionBtn Button # titlebar button that restores DefaultRect ---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible ---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed (window-level options only) +---@field ChatFeedInterface UIChatFeedInterface # sibling feed view; visible while the window is hidden ---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true local ChatInterface = ClassUI(Window) { @@ -101,6 +103,12 @@ local ChatInterface = ClassUI(Window) { self.ChatLinesInterface = ChatLinesInterface(self) self.ChatEditInterface = ChatEditInterface(self) + -- Feed view: a sibling control on the same parent frame so our own + -- `Show`/`Hide` cascade can't reach it. Pinned via LazyVars to our + -- line-area rect, so dragging or resizing the window carries the + -- feed along automatically. Destroyed in our `OnDestroy`. + self.ChatFeedInterface = ChatFeedInterface(parent, self) + -- Override the lines panel's name-click hook to set the chat -- recipient and re-focus the edit box. `OnCameraClicked` keeps the -- panel's default behaviour (jump the world camera). Ignore clicks @@ -378,9 +386,15 @@ local ChatInterface = ClassUI(Window) { import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() end, - --- Empties our trash bag so every derived observer we allocated is - --- destroyed — no `OnDirty` can fire into a torn-down `self`. + --- Tears down the sibling feed view (it lives outside our control tree + --- so `Hide`/`Destroy` cascades don't reach it) and empties our trash + --- bag — destroying every derived observer so no `OnDirty` can fire + --- into a torn-down `self`. OnDestroy = function(self) + if self.ChatFeedInterface then + self.ChatFeedInterface:Destroy() + self.ChatFeedInterface = nil + end self.Trash:Destroy() end, } diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 1a2735c4607..9e721a31366 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -10,8 +10,8 @@ local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").Chat 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") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") -local MauiWrapText = import("/lua/maui/text.lua").WrapText local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor @@ -19,7 +19,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. -local Debug = false +local Debug = true -- Reserve space on the right of the wrapper for the scrollbar widget. -- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny @@ -193,10 +193,15 @@ ChatLinesInterface = ClassUI(Group) { -- Pool sizing --------------------------------------------------------------------------- - --- Rebuilds the line pool to fit the current Pool height. Adds rows at - --- the bottom when we grow, destroys the tail when we shrink. Safe to - --- call repeatedly; callers are expected to follow up with `CalcVisible` - --- (and `RewrapAll` on a true resize). + --- Rebuilds the line pool to fit the current Pool height. Lines stack + --- bottom-up: `ChatLineInterfaces[1]` pins to the pool's bottom and + --- holds the newest visible message; subsequent slots stack above. When + --- there are fewer messages than slots, the empty (and `Hide()`-flagged) + --- slots sit at the top of the pool, so the chat reads bottom-anchored + --- like Discord / Slack — and matches the feed's stacking direction so + --- close ↔ open transitions look continuous. Safe to call repeatedly; + --- callers are expected to follow up with `CalcVisible` (and `RewrapAll` + --- on a true resize). ---@param self UIChatLinesInterface RebuildPool = function(self) local pool = self.Pool @@ -212,7 +217,7 @@ ChatLinesInterface = ClassUI(Group) { self.ChatLineInterfaces[1].OnNameClicked = self.LineNameClicked self.ChatLineInterfaces[1].OnCameraClicked = self.LineCameraClicked Layouter(self.ChatLineInterfaces[1]) - :AtLeftTopIn(pool) + :AtLeftBottomIn(pool) :Right(pool.Right) :End() end @@ -223,14 +228,14 @@ ChatLinesInterface = ClassUI(Group) { local neededLines = math.max(1, math.floor(pool.Height() / rowHeight)) local currentCount = table.getn(self.ChatLineInterfaces) - -- Grow: append rows below the previous one. + -- Grow: stack each new row above the previous one. for i = currentCount + 1, neededLines do self.ChatLineInterfaces[i] = ChatLineInterface(pool) self.ChatLineInterfaces[i]:SetFontSize(fontSize) self.ChatLineInterfaces[i].OnNameClicked = self.LineNameClicked self.ChatLineInterfaces[i].OnCameraClicked = self.LineCameraClicked Layouter(self.ChatLineInterfaces[i]) - :Below(self.ChatLineInterfaces[i - 1]) + :Above(self.ChatLineInterfaces[i - 1]) :AtLeftIn(pool) :Right(pool.Right) :End() @@ -276,36 +281,15 @@ ChatLinesInterface = ClassUI(Group) { -- Text wrapping --------------------------------------------------------------------------- - --- Wraps a single entry's text to fit the current row width. Results are - --- cached on the entry itself as `entry.WrappedText`. The first wrapped - --- line reserves space for the name prefix; continuation lines span the - --- wider area to the right of the team-colour column. + --- Wraps a single entry's text to fit the current row width, caching + --- the result on the entry itself. Delegates to `ChatUtils.WrapEntry` + --- so the feed view can wrap the same entries with the same logic + --- (and same width — both panels share row metrics) without reaching + --- back into us. ---@param self UIChatLinesInterface ---@param entry UIChatEntry WrapEntry = function(self, entry) - local measureLine = self.ChatLineInterfaces[1] - if not measureLine then - entry.WrappedText = { entry.Text or '' } - return - end - - local name = entry.Name or '' - local lines = MauiWrapText(entry.Text or '', - function(lineIndex) - if lineIndex == 1 then - return measureLine.Right() - - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) - else - return measureLine.Right() - - (measureLine.Name.Left() + 4) - end - end, - function(textChunk) - return measureLine.Text:GetStringAdvance(textChunk) - end) - - if table.empty(lines) then lines = { '' } end - entry.WrappedText = lines + ChatUtils.WrapEntry(entry, self.ChatLineInterfaces[1]) end, --- Re-wraps every entry in the history. Used on resize (width change) @@ -420,9 +404,13 @@ ChatLinesInterface = ClassUI(Group) { -- Visibility mapping --------------------------------------------------------------------------- - --- Projects `[ScrollTop, ScrollTop + poolSize)` in virtual space onto the - --- line pool. Skips over filtered-out entries, uses `SetHeader` for the - --- first wrapped line of an entry and `SetContinuation` for the rest. + --- Projects the visible virtual range onto the bottom-anchored line + --- pool. `ChatLineInterfaces[1]` (bottom of the pool) shows the newest + --- visible entry / wrapped chunk; subsequent slots walk back through + --- history toward older content, matching the `Above`-stacked layout + --- from `RebuildPool`. Skips filtered-out entries in either direction. + --- When fewer entries fit than the pool can hold, the surplus slots + --- (at the top of the pool) are cleared and hidden. ---@param self UIChatLinesInterface CalcVisible = function(self) if not self.ChatLineInterfaces[1] then return end @@ -432,7 +420,15 @@ ChatLinesInterface = ClassUI(Group) { local poolSize = table.getn(self.ChatLineInterfaces) local scrollTop = self.ScrollTop - -- Walk to the entry + wrapped-line that covers virtual position `scrollTop`. + -- The bottommost visible virtual position is the newest entry the + -- user can currently see; pool[1] (the bottom row) renders it. + -- `VirtualSize` reflects the post-filter count, so this stays + -- correct when muted senders are hidden mid-feed. + local visibleBottom = math.min(scrollTop + poolSize - 1, self.VirtualSize) + + -- Walk forward through history to find the entry + wrappedIdx that + -- covers `visibleBottom`. Same scan as the legacy loop but anchored + -- to the bottom of the visible window instead of its top. local entryIdx = 1 local wrappedIdx = 1 local virtualPos = 0 @@ -444,8 +440,8 @@ ChatLinesInterface = ClassUI(Group) { while entryIdx <= historyCount do local entry = history[entryIdx] local wrapCount = (entry.WrappedText and table.getn(entry.WrappedText)) or 1 - if virtualPos + wrapCount >= scrollTop then - wrappedIdx = scrollTop - virtualPos + if virtualPos + wrapCount >= visibleBottom then + wrappedIdx = visibleBottom - virtualPos if wrappedIdx < 1 then wrappedIdx = 1 end break end @@ -456,11 +452,17 @@ ChatLinesInterface = ClassUI(Group) { end end - -- Fill each pool row; advance the cursor through wrapped lines and - -- skip filtered entries as we go. + -- Fill the pool from bottom (poolIdx 1) upward. Each step decrements + -- the wrapped-line cursor; when a continuation chunk runs out, we + -- hop back to the previous valid entry (walking past filtered ones). + local currentVirtualPos = visibleBottom for poolIdx = 1, poolSize do local line = self.ChatLineInterfaces[poolIdx] - if entryIdx > historyCount then + local outOfRange = entryIdx < 1 + or entryIdx > historyCount + or currentVirtualPos < scrollTop + or currentVirtualPos < 1 + if outOfRange then line:Clear() line:Hide() else @@ -475,14 +477,17 @@ ChatLinesInterface = ClassUI(Group) { end line:Show() - local wrapCount = (wrapped and table.getn(wrapped)) or 1 - if wrappedIdx < wrapCount then - wrappedIdx = wrappedIdx + 1 + currentVirtualPos = currentVirtualPos - 1 + if wrappedIdx > 1 then + wrappedIdx = wrappedIdx - 1 else - wrappedIdx = 1 - entryIdx = entryIdx + 1 - while entryIdx <= historyCount and not self:IsValidEntry(history[entryIdx]) do - entryIdx = entryIdx + 1 + entryIdx = entryIdx - 1 + while entryIdx >= 1 and not self:IsValidEntry(history[entryIdx]) do + entryIdx = entryIdx - 1 + end + if entryIdx >= 1 then + local prevEntry = history[entryIdx] + wrappedIdx = (prevEntry.WrappedText and table.getn(prevEntry.WrappedText)) or 1 end end end @@ -508,6 +513,12 @@ ChatLinesInterface = ClassUI(Group) { if self.ChatLineInterfaces[1] then self:ScrollToBottom() end + + -- make sure chat messages stay hidden if window is hidden + local windowVisible = ChatModel.GetSingleton().WindowVisible() + if not windowVisible then + self:Hide() + end end, --------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua new file mode 100644 index 00000000000..d96ac66c039 --- /dev/null +++ b/lua/ui/game/chat/ChatUtils.lua @@ -0,0 +1,59 @@ + +local MauiWrapText = import("/lua/maui/text.lua").WrapText + +------------------------------------------------------------------------------- +-- Shared, view-agnostic helpers for the chat tree. Each function operates +-- on a `UIChatEntry` (or related primitive) and stays free of any one +-- view's internal layout — anything that both `ChatLinesInterface` and +-- `ChatFeedInterface` (or future views) can reuse without coupling them +-- to each other lives here. + +--- Wraps an entry's body text against `measureLine`'s row width and stores +--- the result as `entry.WrappedText`. The first wrapped chunk reserves +--- horizontal space for the entry's name prefix (so the body starts +--- after `Name.Right + 4`); subsequent chunks span the full body width +--- starting from the team-colour column. +--- +--- Always overwrites `entry.WrappedText` — callers gate (`if not +--- entry.WrappedText then ... end`) for the cache-hit path; resize and +--- font-size changes call this directly to force a fresh wrap. +--- +--- With `measureLine == nil` we degrade to a single-chunk wrap that just +--- hands back the raw text; lets callers without a measurement source +--- (an empty pool, a standalone-launched debug feed) still produce +--- something renderable instead of crashing on the missing controls. +---@param entry UIChatEntry +---@param measureLine UIChatLineInterface | nil +function WrapEntry(entry, measureLine) + if not measureLine then + entry.WrappedText = { entry.Text or '' } + return + end + + local name = entry.Name or '' + local lines = MauiWrapText(entry.Text or '', + function(lineIndex) + if lineIndex == 1 then + return measureLine.Right() + - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) + else + return measureLine.Right() + - (measureLine.Name.Left() + 4) + end + end, + function(textChunk) + return measureLine.Text:GetStringAdvance(textChunk) + end) + + if table.empty(lines) then lines = { '' } end + entry.WrappedText = lines +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 192fbe2c7cc..e1e2545c654 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -1,81 +1,56 @@ # Chat MVC refactor — remaining gaps -Inventory of legacy [`/lua/ui/game/chat.lua`](../chat.lua) behaviour that the new MVC tree does **not** yet replicate. Gaps are grouped by concern and cite line numbers in the old file so a future author can jump in. +Inventory of legacy [`/lua/ui/game/chat.legacy.lua`](../chat.legacy.lua) behaviour that the new MVC tree does **not** yet replicate. Gaps are grouped by concern and cite line numbers in the old file so a future author can jump in. Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file only tracks work that hasn't happened yet. --- -## Feed mode / fade / pin +## Feed mode finishing touches -The most conspicuous missing chunk. None of the "auto-fading feed of recent chat when the window is hidden" behaviour has been ported. +The bones of feed mode are in place — [`ChatFeedInterface`](ChatFeedInterface.lua) renders, fades, and clears in line with legacy expectations. What's still missing is the *configurable* layer: -- **Per-line fade timer** — `line.OnFrame` delta-time countdown driven by `ChatOptions.fade_time` ([chat.lua:471-492](../chat.lua)). -- **Window auto-close timer** — `GUI.bg.OnFrame` hides the whole window after `fade_time` with no new traffic ([chat.lua:1171-1176](../chat.lua)). -- **Translucent feed background** — `line.lineStickybg` when `feed_background` is enabled ([chat.lua:233-238, 465](../chat.lua)). Explicitly dropped from `ChatLineInterface` (see its comment at line 22). -- **Feed persist** — `feed_persist` keeps feed lines until their individual timer runs out ([chat.lua:912-920](../chat.lua)). -- **Window opacity** — `SetAlpha(win_alpha)` on window bg and lines ([chat.lua:501, 1150, 1199](../chat.lua)). -- **Pin toggle** — `GUI.bg.OnPinCheck` suspends fade while pinned ([chat.lua:1177-1187](../chat.lua)). No pin button in the new window. +- **Translucent feed background** — `feed_background` option has no effect. Legacy showed a `lineStickybg` strip per row when set ([chat.legacy.lua:233-238, 465](../chat.legacy.lua)). Feed rows are currently bare; would need a sticky background back on [`ChatLineInterface`](ChatLineInterface.lua), wired to the option, drawn behind the row when the row is in feed mode. Note that we explicitly dropped the legacy `StickyBg` field from `ChatLineInterface` because the chat-window `Show()` cascade was double-painting it; bringing it back needs the SetTexture/SetSolidColor cycling trick used for `CamIcon` / `FactionIcon`. +- **`feed_persist`** — option has no effect. Today we always `ClearAll()` on window-open and always gate `OnHistoryChanged` on `not WindowVisible()`. To support persist=true, both branches need to flip: don't clear on open, and queue rows even while window is visible (paused timer until reveal). Two-mode logic. +- **Pin button / pin toggle** — no pin button on the chat window. Pin should suspend both the window auto-close (`OnFrame` skips advancing toward `fade_time` while pinned) and the feed fade (per-row timers don't tick). Legacy: [chat.legacy.lua:1177-1187](../chat.legacy.lua). ## Message filtering -[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) is a stub returning `true`. The legacy filter gated on three things ([chat.lua:304-310](../chat.lua)): +[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) gates on the per-army `muted` map only. Two parts of the legacy filter are still missing: -- `ChatOptions.links` — hides camera-link messages. -- `ChatOptions[armyID]` — per-sender on/off checkboxes. -- Self-echo suppression and observer rules on receive. +- **`links` option** — `ChatConfigModel` defines the key, but `IsValidEntry` doesn't check it. Camera-link entries are always shown regardless of the user's preference. Legacy: [chat.legacy.lua:304-310](../chat.legacy.lua). +- **Self-echo / observer rules on receive** — [`ChatController.OnReceive`](ChatController.lua) doesn't filter incoming messages by sender / observer-mode. Legacy had a small set of receive-time rules that prevented self-echo loops and gated certain messages by observer state. Audit needed against [chat.legacy.lua:304-310](../chat.legacy.lua). -Per-army filter UI needs re-adding to [`ChatConfigInterface`](config/ChatConfigInterface.lua) (legacy: [chat.lua:1194-1198, 1437-1442](../chat.lua)). +## Color palette not wired -## Config options not reaching the view +[`ChatConfigModel`](config/ChatConfigModel.lua) defines the full palette (`all_color`, `allies_color`, `priv_color`, `link_color`, `notify_color`) and the dialog persists choices, but [`ChatLineInterface.SetHeader`](ChatLineInterface.lua) only uses `entry.Color` (the team-colour square) and a hard-coded `'ffc2f6ff'` for the body text. Legacy looked up `ChatOptions[entry.tokey]` against an 8-colour swatch array per line ([chat.legacy.lua:63, 446-450](../chat.legacy.lua)). -[`ChatConfigModel`](config/ChatConfigModel.lua) defines every key correctly, but only `font_size` and `win_alpha` are subscribed by the view (see [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) and [`ChatInterface` `__post_init`](ChatInterface.lua)). Not yet wired: - -- `all_color` / `allies_color` / `priv_color` / `link_color` / `notify_color` — the per-recipient text-colour palette ([chat.lua:63, 446-450](../chat.lua)). -- `fade_time`, `win_alpha`, `feed_background`, `feed_persist` — feed-mode options (blocked on the feed-mode port above). -- `links` — blocked on filtering port. -- `[armyID]` per-army filter keys — blocked on filtering port. - -No replacement exists for the legacy public `AddChatOptionSetCallback` ([chat.lua:1071-1102](../chat.lua)) — external subscribers to option changes have no API. - -## Camera / ping links - -`UIChatEntry.Camera` is declared on the model but never populated or rendered. - -- **Outgoing**: `chatEdit.camData` checkbox + `tempCam` recall ([chat.lua:750-754](../chat.lua)) not present on [`ChatEditInterface`](ChatEditInterface.lua). `ChatController.Send` never attaches `msg.camera`. -- **Incoming render**: `camIcon` bitmap on the line ([chat.lua:419-428](../chat.lua)) not on [`ChatLineInterface`](ChatLineInterface.lua). -- **Click to jump**: `line.Text` click → `GetCamera('WorldCamera'):RestoreSettings` ([chat.lua:223-229](../chat.lua)) — [`ChatLineInterface`](ChatLineInterface.lua) disables hit-test on the text control. - -## Private reply by clicking a name - -Legacy click on `line.name` set `ChatTo:Set(line.chatID)` and re-focused the edit ([chat.lua:199-212](../chat.lua)). [`ChatLineInterface`](ChatLineInterface.lua) disables hit-test on both name and text controls. No `last_sender` tracking either. +The fix: expose the colour-swatch array on the model (or at module level on the line file), have `SetHeader` index into it via the entry's `tokey`, and make sure `ApplyOptions` triggers a re-render when any palette key changes. ## Notify-command bridge -Slash commands in the old dispatcher fell through to `RunChatCommand` in [`/lua/ui/notify/commands.lua`](../../notify/commands.lua) ([chat.lua:729](../chat.lua)), so `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay` all worked from chat. The new [`ChatCommandRegistry`](commands/ChatCommandRegistry.lua) dispatcher does not call `RunChatCommand`, so those commands are dead. +Slash commands in the legacy dispatcher fell through to [`RunChatCommand`](../../notify/commands.lua) ([chat.legacy.lua:729](../chat.legacy.lua)) so `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay` worked from chat. The new [`ChatCommandRegistry`](commands/ChatCommandRegistry.lua) dispatcher does not, so those commands are dead. -## Command history recall (↑ / ↓) +Easy fix: an "unknown command" fallback in the dispatcher that hands off to `RunChatCommand` before reporting an error to the user. -Legacy kept a `commandHistory` ring and recalled it on `VK_UP` / `VK_DOWN` ([chat.lua:681-701](../chat.lua)). Not ported. The new [`ChatEditInterface.OnNonTextKeyPressed`](ChatEditInterface.lua) only handles `PgUp` / `PgDn`. +## Command history recall (↑ / ↓) -## Drag / resize / window-state +Legacy kept a `commandHistory` ring and recalled it on `VK_UP` / `VK_DOWN` ([chat.legacy.lua:681-701](../chat.legacy.lua)). Not ported. The new [`ChatEditInterface.OnNonTextKeyPressed`](ChatEditInterface.lua) maps Up / Down to `CommandHint:SelectNext` / `SelectPrev` while the hint is open; outside of an open hint, Up / Down do nothing. -Smaller things still missing on the window itself: +Both can co-exist: keep the hint behaviour while it's open, and fall through to history recall when `self.ChatCommandHintInterface == nil`. -- **Pin button** — gated on feed-mode port. -- **`OnMoveSet` / `OnResizeSet` focus grab** — the legacy window re-acquired edit focus after moves ([chat.lua:1124, 1131-1135](../chat.lua)). [`ChatInterface.OnResizeSet`](ChatInterface.lua) does not. -- **Button tooltips** — `chat_pin`, `chat_config`, `chat_close`, `chat_camera`, `chat_reset` ([chat.lua:1200, 1211-1214](../chat.lua)) — none attached in the new tree. -- **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.lua:1202-1209](../chat.lua)). Not ported. +## Drag / resize / window-state -## Army / observer markers +- **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.legacy.lua:1202-1209](../chat.legacy.lua)). [`ChatInterface.OnMouseWheel`](ChatInterface.lua) doesn't override `HandleEvent`. Whether this matters in practice depends on engine routing — the wheel may already reach the worldview when the chat window is `Hide()`-flagged. Verify before fixing. +- **Button tooltips** — no `Tooltip.AddCheckboxTooltip` / `AddControlTooltip` calls anywhere in the chat tree. Legacy had `chat_pin`, `chat_config`, `chat_close`, `chat_camera`, `chat_reset` ([chat.legacy.lua:1200, 1211-1214](../chat.legacy.lua)). -Per-line visuals mostly work (team-colour square + faction icon via [`ChatLineInterface.SetHeader`](ChatLineInterface.lua)). Missing: +## Per-line visuals -- **Own-army name disable** — legacy `line.name:Disable()` greyed the sender name on your own messages ([chat.lua:409-413](../chat.lua)). +- **Own-army name greying** — legacy `line.name:Disable()` greyed the sender name on your own messages so you could see at a glance which lines were yours ([chat.legacy.lua:409-413](../chat.legacy.lua)). Not ported to [`ChatLineInterface.SetHeader`](ChatLineInterface.lua). ## Legacy public API with no replacement -If any external mod still calls these (no in-tree callers remain), they will break: +If any external mod still calls these (no in-tree caller remains), they will break: - `GUI` table (chat window handles) - `ChatLines` @@ -85,7 +60,7 @@ If any external mod still calls these (no in-tree callers remain), they will bre - `ChatPageUp` / `ChatPageDown` — migrate to [`ChatInterface.OpenAndScrollLines`](ChatInterface.lua) - `CloseChatConfig` — migrate to [`ChatConfigInterface.Close`](config/ChatConfigInterface.lua) - `CloseChat` -- `AddChatOptionSetCallback` +- `AddChatOptionSetCallback` — no replacement; observers should `LazyVarDerive(ChatConfigModel.GetSingleton().Committed, ...)` instead - `SetLayout` - `GetArmyData` (the one defined in chat.lua; several other copies exist elsewhere) @@ -94,3 +69,16 @@ If any external mod still calls these (no in-tree callers remain), they will bre ## Already closed (do not re-list) Send path, receive path, `FindClients`, controller `Init`, external importers, skin-layout orphan, `ConsoleOutput` sim-side logging, empty-text Enter, `ActivateChat` (Enter-key hook with Shift → allies), `chat.lua` renamed to `chat.legacy.lua`. + +Closed in the most recent rounds of work: + +- **Camera links** — outgoing (`CamCheckbox`), incoming render (`CamIcon` on the line), click-to-jump (`OnCameraClicked` with both `Camera` and `Location` paths). +- **Private reply by clicking a name** — `OnNameClicked` overridable on [`ChatLinesInterface`](ChatLinesInterface.lua); the chat window installs a handler that sets `Recipient` and re-acquires edit focus. Self-name clicks are filtered. +- **Window auto-close timer** — `LastActivity` LazyVar in the model, `NotifyActivity()` heartbeat in the controller, [`ChatInterface.OnFrame`](ChatInterface.lua) compares `GetSystemTimeSeconds() - LastActivity()` to `fade_time`. Hooked into edit keystrokes, scroll, mouse wheel, drag, resize, recipient-picker hover, and `AppendEntry`. +- **Window opacity** — [`ChatInterface.OptionsObserver`](ChatInterface.lua) calls `SetAlpha(_, true)` on every `Committed` change; cascades to chrome / lines / edit / scrollbar. +- **Per-line fade timer** — implemented in [`ChatFeedInterface`](ChatFeedInterface.lua), not in the main view (fade only matters when the window is hidden, which is exactly what the feed handles). +- **Feed mode itself** — [`ChatFeedInterface`](ChatFeedInterface.lua) is a sibling of the chat window pinned to the line area via LazyVar bind, observes `History` + `WindowVisible`, has a per-row pool, fades each row in its last 2 seconds, clears on window-open, hides itself when there are no rows. +- **Per-army mute (`muted`)** — per-game (not persisted to prefs), checkbox column in [`ChatConfigInterface`](config/ChatConfigInterface.lua), `/mute` and `/unmute` slash commands, `IsValidEntry` filter, `SetMuted` / `SetMutedLive` controllers. +- **`OnMoveSet` focus grab** — [`ChatInterface.OnMoveSet`](ChatInterface.lua) re-acquires edit focus after a drag. +- **`font_size` reactive** — [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) reapplies on every `Committed` change. +- **Bottom-anchored line layout** — [`ChatLinesInterface.RebuildPool` / `CalcVisible`](ChatLinesInterface.lua) stack newest-at-bottom so chat and feed share the same vertical rhythm and open ↔ close transitions stay continuous. From d5f98c6f72efe8faf34766bdaae75153b17f4802 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 20:17:36 +0200 Subject: [PATCH 073/130] Add filter for links --- lua/ui/game/chat/ChatLinesInterface.lua | 17 +++++++++++++---- lua/ui/game/chat/GAPS.md | 4 ++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 9e721a31366..44b837fde2e 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -308,15 +308,24 @@ ChatLinesInterface = ClassUI(Group) { --------------------------------------------------------------------------- --- Whether an entry counts toward the virtual scroll size and should - --- appear in `CalcVisible`. Currently gates on the per-army mute map - --- from `ChatConfigModel.Committed`; camera-link filtering is still TODO. + --- appear in `CalcVisible`. Gates on: + --- * per-army mute map (`muted[ArmyID]` → drop) — per-game, set + --- via the config dialog or `/mute` / `/unmute`. + --- * `links` option (`Camera` or `Location` set + `links == false` + --- → drop) — mirrors the legacy filter at chat.legacy.lua:304-310. + --- Both `Camera` (full snapshot) and `Location` (sim-side point + --- or area hint) surface the camera-link affordance on the row, + --- so either field qualifies as a "link" message. ---@param self UIChatLinesInterface ---@param entry UIChatEntry ---@return boolean IsValidEntry = function(self, entry) if entry == nil then return false end - local muted = ChatConfigModel.GetOptions().muted - if muted and entry.ArmyID and muted[entry.ArmyID] then + local options = ChatConfigModel.GetOptions() + if options.muted and entry.ArmyID and options.muted[entry.ArmyID] then + return false + end + if (entry.Camera or entry.Location) and options.links == false then return false end return true diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index e1e2545c654..f6a83021dd3 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -16,9 +16,8 @@ The bones of feed mode are in place — [`ChatFeedInterface`](ChatFeedInterface. ## Message filtering -[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) gates on the per-army `muted` map only. Two parts of the legacy filter are still missing: +[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) gates on the per-army `muted` map and the `links` option. One part of the legacy filter is still missing: -- **`links` option** — `ChatConfigModel` defines the key, but `IsValidEntry` doesn't check it. Camera-link entries are always shown regardless of the user's preference. Legacy: [chat.legacy.lua:304-310](../chat.legacy.lua). - **Self-echo / observer rules on receive** — [`ChatController.OnReceive`](ChatController.lua) doesn't filter incoming messages by sender / observer-mode. Legacy had a small set of receive-time rules that prevented self-echo loops and gated certain messages by observer state. Audit needed against [chat.legacy.lua:304-310](../chat.legacy.lua). ## Color palette not wired @@ -82,3 +81,4 @@ Closed in the most recent rounds of work: - **`OnMoveSet` focus grab** — [`ChatInterface.OnMoveSet`](ChatInterface.lua) re-acquires edit focus after a drag. - **`font_size` reactive** — [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) reapplies on every `Committed` change. - **Bottom-anchored line layout** — [`ChatLinesInterface.RebuildPool` / `CalcVisible`](ChatLinesInterface.lua) stack newest-at-bottom so chat and feed share the same vertical rhythm and open ↔ close transitions stay continuous. +- **`links` option** — [`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) drops entries with `Camera` or `Location` when `options.links == false`, mirroring the legacy filter. `Location` (sim-side point/area hint) is treated as a link too since it surfaces the same camera-icon affordance on the row. From 9a24745d0daf6f804da35d02cb6cdcb5fbac5f3c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 20:26:06 +0200 Subject: [PATCH 074/130] Add guards for messages that we receive --- lua/ui/game/chat/ChatController.lua | 24 +++++++++++++++++++++++- lua/ui/game/chat/ChatEditInterface.lua | 5 ++--- lua/ui/game/chat/ChatUtils.lua | 6 ++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 9419771a175..cd3cd52ec1a 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -1,6 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") ------------------------------------------------------------------------------- -- Window visibility @@ -282,12 +283,33 @@ end --- Handler registered with `gamemain.RegisterChatFunc`. Normalises the --- message, delegates Notify-subsystem messages, resolves the sender's army --- data, and appends a chat line. +--- +--- Defensive against malformed input: messages that aren't tables, that +--- lack the `Chat` flag, or whose `text` isn't a string are dropped early. +--- The receive path is reachable from any gamemain `RegisterChatFunc` +--- caller — including external mods — so we can't trust the shape. ---@param sender string ---@param msg table function OnReceive(sender, msg) - sender = sender or "nil sender" + -- Coerce sender to a non-empty string so the formatting concatenations + -- below can't blow up on a number, table, or nil. The guard is wider + -- than `or "nil sender"` because that left non-string truthy values + -- (e.g. a number sender from a misbehaving caller) flowing through. + if type(sender) ~= 'string' or sender == '' then + sender = 'nil sender' + end + -- Hard shape guards: anything that isn't a populated chat-shaped + -- table never reaches the formatting / model writes below. + if type(msg) ~= 'table' then return end if not msg.Chat then return end + if type(msg.text) ~= 'string' then return end + + -- Length cap: matches the edit box's `SetMaxChars(MaxMessageLength)` + -- on the send side, so a peer that bypassed the input cap (mod, bug, + -- or hostile client) can't push us into laying out arbitrary-length + -- lines. UTF-8 length to mirror the input enforcement exactly. + if STR_Utf8Len(msg.text) > ChatUtils.MaxMessageLength then return end -- Notify routing: the Notify subsystem tags messages with `to='notify'` -- and owns the display decision. Only fall through to rendering a chat diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index c97c7312cd3..5331f08304e 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -11,6 +11,7 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") local ChatCompletion = import("/lua/ui/game/chat/ChatCompletion.lua") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") local ChatListInterface = import("/lua/ui/game/chat/ChatListInterface.lua").ChatListInterface local ChatCommandHintInterface = import("/lua/ui/game/chat/ChatCommandHintInterface.lua").ChatCommandHintInterface @@ -23,8 +24,6 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- distinct colour so overlapping controls can be told apart at a glance. local Debug = false -local MaxChars = 200 - ------------------------------------------------------------------------------- -- The chat input area: a chat-bubble button, a recipient label, and an edit -- box. Pressing Enter dispatches the text to the controller. Clicking the @@ -103,7 +102,7 @@ ChatEditInterface = ClassUI(Group) { UIUtil.SetupEditStd(self.EditBox, "ff00ff00", nil, "ffffffff", - UIUtil.highlightColor, UIUtil.bodyFont, 14, MaxChars) + UIUtil.highlightColor, UIUtil.bodyFont, 14, ChatUtils.MaxMessageLength) self.EditBox:SetDropShadow(true) self.EditBox:ShowBackground(false) self.EditBox:SetText('') diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua index d96ac66c039..01e66e03bbd 100644 --- a/lua/ui/game/chat/ChatUtils.lua +++ b/lua/ui/game/chat/ChatUtils.lua @@ -8,6 +8,12 @@ local MauiWrapText = import("/lua/maui/text.lua").WrapText -- `ChatFeedInterface` (or future views) can reuse without coupling them -- to each other lives here. +--- Maximum allowed UTF-8 character length for a chat message body. The +--- edit box enforces this on input via `Edit:SetMaxChars`; the receive +--- path uses it as a hard validator so a peer with a tampered or buggy +--- sender can't push us into laying out arbitrarily long lines. +MaxMessageLength = 200 + --- Wraps an entry's body text against `measureLine`'s row width and stores --- the result as `entry.WrappedText`. The first wrapped chunk reserves --- horizontal space for the entry's name prefix (so the body starts From 7683d4a5acf6ce595f865975836325389e5ea40d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 20:46:11 +0200 Subject: [PATCH 075/130] Implement feed background --- lua/ui/game/chat/ChatFeedInterface.lua | 49 ++++++++++++++++++++----- lua/ui/game/chat/ChatInterface.lua | 11 ++++-- lua/ui/game/chat/ChatLinesInterface.lua | 3 +- lua/ui/game/chat/GAPS.md | 3 +- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/lua/ui/game/chat/ChatFeedInterface.lua b/lua/ui/game/chat/ChatFeedInterface.lua index f074ecf2f28..71cea65b511 100644 --- a/lua/ui/game/chat/ChatFeedInterface.lua +++ b/lua/ui/game/chat/ChatFeedInterface.lua @@ -17,7 +17,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. -local Debug = true +local Debug = false --- Cap on how many feed rows are visible at once. Older rows above this --- are dropped immediately when a new row pushes in — feed mode is for @@ -29,6 +29,12 @@ local MaxFeedRows = 8 --- timeouts still get a visible fade rather than a hard pop. local FadeOutDuration = 2 +--- Base alpha (0..1) of the per-row readability strip when the +--- `feed_background` option is on. Multiplied per-frame by `win_alpha` +--- and the row's fade progress, so the BG dims with the window opacity +--- and disappears together with the line as the row ages out. +local FeedBackgroundAlpha = 0.5 + ------------------------------------------------------------------------------- -- A separate "feed" view of the chat history that surfaces messages while -- the main chat window is hidden. Mounted as a sibling of the chat window @@ -45,6 +51,7 @@ local FadeOutDuration = 2 ---@class UIChatFeedRow ---@field Line UIChatLineInterface # exactly one wrapped chunk: header on the entry's first row, continuation on the rest +---@field BG Bitmap # solid-colour readability strip behind `Line`; only paints when `feed_background` is on ---@field Entry UIChatEntry # the source message this line belongs to ---@field Time number # seconds since this row was added; each row ages and expires independently @@ -206,7 +213,20 @@ ChatFeedInterface = ClassUI(Group) { line:SetContinuation(entry, chunk) end line:SetAlpha(1.0, true) - table.insert(self.Rows, { Line = line, Entry = entry, Time = 0 }) + + -- Readability strip behind the row. Solid-black at full alpha; + -- per-frame `SetAlpha` modulates the actual opacity by the + -- window's `win_alpha`, the row's fade progress, and the + -- `feed_background` toggle (off → alpha 0). Lives on the feed + -- group (not the line) so we can drive its alpha independently + -- and skip the line's text/icon depth ordering. + local bg = Bitmap(self) + bg:SetSolidColor('ff000000') + bg:DisableHitTest() + Layouter(bg):Fill(line):End() + LayoutHelpers.DepthUnderParent(bg, line, 1) + + table.insert(self.Rows, { Line = line, BG = bg, Entry = entry, Time = 0 }) end self:LayoutRows() @@ -249,6 +269,7 @@ ChatFeedInterface = ClassUI(Group) { local oldest = self.Rows[1] if oldest then oldest.Line:Destroy() + oldest.BG:Destroy() table.remove(self.Rows, 1) end end, @@ -259,6 +280,7 @@ ChatFeedInterface = ClassUI(Group) { ClearAll = function(self) for _, row in ipairs(self.Rows) do row.Line:Destroy() + row.BG:Destroy() end self.Rows = {} end, @@ -283,9 +305,12 @@ ChatFeedInterface = ClassUI(Group) { end end, - --- Per-frame timer pass. Walks each row, advances its `Time`, fades - --- out the last `FadeOutDuration` seconds of life, and destroys the - --- row once past `fade_time`. Each row ages independently — wrapped + --- Per-frame timer pass. Walks each row, advances its `Time`, applies + --- alpha (per-row fade only for the line so the text stays crisp and + --- readable regardless of the window's opacity setting; window- + --- opacity × per-row fade × base intensity for the BG strip so the + --- backdrop dims with the user's preference), and destroys the row + --- once past `fade_time`. Each row ages independently — wrapped --- entries arrive at the same instant so their chunks usually expire --- together by virtue of starting from the same `Time = 0`, but the --- cap or a future selective drop can take individual rows without @@ -294,9 +319,12 @@ ChatFeedInterface = ClassUI(Group) { ---@param self UIChatFeedInterface ---@param delta number # seconds since the last frame OnFrame = function(self, delta) - local fadeTime = ChatConfigModel.GetOptions().fade_time or 15 - local fadeOut = math.min(FadeOutDuration, fadeTime / 2) + local options = ChatConfigModel.GetOptions() + local fadeTime = options.fade_time or 15 + local winAlpha = options.win_alpha or 1.0 + local fadeOut = math.min(FadeOutDuration, fadeTime / 2) local fadeStart = fadeTime - fadeOut + local bgAlpha = options.feed_background and FeedBackgroundAlpha or 0 local i = 1 while i <= table.getn(self.Rows) do @@ -304,12 +332,15 @@ ChatFeedInterface = ClassUI(Group) { row.Time = row.Time + delta if row.Time >= fadeTime then row.Line:Destroy() + row.BG:Destroy() table.remove(self.Rows, i) else + local fade = 1.0 if row.Time > fadeStart then - local alpha = 1.0 - (row.Time - fadeStart) / fadeOut - row.Line:SetAlpha(alpha, true) + fade = 1.0 - (row.Time - fadeStart) / fadeOut end + row.Line:SetAlpha(fade, true) + row.BG:SetAlpha(winAlpha * fade * bgAlpha, true) i = i + 1 end end diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index c56957c3ff1..94f27afb554 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -294,15 +294,18 @@ local ChatInterface = ClassUI(Window) { -- Committed chat options → window-level concerns only. Pool sizing, -- font, and filter changes are owned by the lines panel; we just -- handle `win_alpha` here. `SetAlpha(_, true)` cascades so chrome, - -- edit, and scrollbar all dim uniformly. + -- edit, and scrollbar all dim uniformly. The chat-line *text* is + -- then forced back to full opacity by re-cascading from `Pool` + -- (which only contains the line rows) — the scrollbar is a sibling + -- of `Pool` on `ChatLinesInterface`, not a child, so this reset + -- doesn't touch it. Net effect: text stays crisp at low alpha, + -- everything else still fades. self.OptionsObserver = self.Trash:Add( LazyVarDerive( ChatConfigModel.GetSingleton().Committed, function(lv) self:SetAlpha(lv().win_alpha or 1.0, true) - - -- do not dim chat lines - self.ChatLinesInterface:SetAlpha(1.0, true) + self.ChatLinesInterface.Pool:SetAlpha(1.0, true) end ) ) diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 44b837fde2e..325742943b8 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -19,7 +19,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. -local Debug = true +local Debug = false -- Reserve space on the right of the wrapper for the scrollbar widget. -- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny @@ -160,6 +160,7 @@ ChatLinesInterface = ClassUI(Group) { -- forwarding stubs in `__init`. Anchoring the scrollbar is also -- reactive (it tracks `Pool.Right`), so this is safe pre-layout. self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Pool) + self.Scrollbar:SetParent(self) if Debug then self.DebugBG = Bitmap(self) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index f6a83021dd3..8645ebe933b 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -10,7 +10,6 @@ Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file o The bones of feed mode are in place — [`ChatFeedInterface`](ChatFeedInterface.lua) renders, fades, and clears in line with legacy expectations. What's still missing is the *configurable* layer: -- **Translucent feed background** — `feed_background` option has no effect. Legacy showed a `lineStickybg` strip per row when set ([chat.legacy.lua:233-238, 465](../chat.legacy.lua)). Feed rows are currently bare; would need a sticky background back on [`ChatLineInterface`](ChatLineInterface.lua), wired to the option, drawn behind the row when the row is in feed mode. Note that we explicitly dropped the legacy `StickyBg` field from `ChatLineInterface` because the chat-window `Show()` cascade was double-painting it; bringing it back needs the SetTexture/SetSolidColor cycling trick used for `CamIcon` / `FactionIcon`. - **`feed_persist`** — option has no effect. Today we always `ClearAll()` on window-open and always gate `OnHistoryChanged` on `not WindowVisible()`. To support persist=true, both branches need to flip: don't clear on open, and queue rows even while window is visible (paused timer until reveal). Two-mode logic. - **Pin button / pin toggle** — no pin button on the chat window. Pin should suspend both the window auto-close (`OnFrame` skips advancing toward `fade_time` while pinned) and the feed fade (per-row timers don't tick). Legacy: [chat.legacy.lua:1177-1187](../chat.legacy.lua). @@ -82,3 +81,5 @@ Closed in the most recent rounds of work: - **`font_size` reactive** — [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) reapplies on every `Committed` change. - **Bottom-anchored line layout** — [`ChatLinesInterface.RebuildPool` / `CalcVisible`](ChatLinesInterface.lua) stack newest-at-bottom so chat and feed share the same vertical rhythm and open ↔ close transitions stay continuous. - **`links` option** — [`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) drops entries with `Camera` or `Location` when `options.links == false`, mirroring the legacy filter. `Location` (sim-side point/area hint) is treated as a link too since it surfaces the same camera-icon affordance on the row. +- **Translucent feed background** — [`ChatFeedInterface`](ChatFeedInterface.lua) gives each feed row a per-line readability strip (Bitmap on the feed group, depth-pinned under the line, edges via `Layouter:Fill(line)`). Visibility is gated on `feed_background`; the alpha composes window opacity (`win_alpha`) × per-row fade × `FeedBackgroundAlpha = 0.5`. The chat-window line pool is unaffected — the BG lives on the feed only, so the regular history view stays bare. +- **Receive-side defensive guards** — [`ChatController.OnReceive`](ChatController.lua) coerces non-string senders, drops non-table messages, requires `msg.text` to be a string, and rejects bodies longer than `ChatUtils.MaxMessageLength` (the same cap the edit box enforces on send). The length constant lives in `ChatUtils` so input and validation read from the same source. From 96755f924c11d39988d89539cf1b4175517ba159 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 20:50:37 +0200 Subject: [PATCH 076/130] Remove references to chat feed persist option as we will not support it --- lua/ui/game/chat/CLAUDE.md | 1 - lua/ui/game/chat/ChatFeedInterface.lua | 4 ++-- lua/ui/game/chat/GAPS.md | 1 - lua/ui/game/chat/chat-line-functionality.md | 2 -- lua/ui/game/chat/config/ChatConfigInterface.lua | 1 - lua/ui/game/chat/config/ChatConfigModel.lua | 3 --- lua/ui/game/chat/design.md | 2 -- lua/ui/help/tooltips.lua | 4 ---- 8 files changed, 2 insertions(+), 16 deletions(-) diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md index 6ff5255cc96..8285016f6b1 100644 --- a/lua/ui/game/chat/CLAUDE.md +++ b/lua/ui/game/chat/CLAUDE.md @@ -329,7 +329,6 @@ Each chat line (`GUI.chatLines[i]`) contains: | `fade_time` | 15 | Seconds before feed/window auto-hides | | `win_alpha` | 1.0 | Window opacity (stored 0–100, normalized on use) | | `feed_background` | false | Semi-transparent bg behind feed lines | -| `feed_persist` | true | Keep feed lines until individually timed out | | `send_type` | false | Default recipient: false = all, true = allies | | `links` | true | Show camera-link messages | | `[armyID]` | true | Per-army message filter | diff --git a/lua/ui/game/chat/ChatFeedInterface.lua b/lua/ui/game/chat/ChatFeedInterface.lua index 71cea65b511..d20ccb18756 100644 --- a/lua/ui/game/chat/ChatFeedInterface.lua +++ b/lua/ui/game/chat/ChatFeedInterface.lua @@ -46,8 +46,8 @@ local FeedBackgroundAlpha = 0.5 -- * `ChatModel.History` — incoming entries are appended as feed rows. -- * `ChatModel.WindowVisible` — feed visible iff window hidden + we have rows. -- Each row carries its own age timer ticked by `OnFrame`; rows past --- `fade_time` destroy themselves. There is no shared timer / pinning yet — --- those land in later steps along with `feed_persist` and `feed_background`. +-- `fade_time` destroy themselves. Pin (chrome-side toggle that suspends +-- the per-row fade) is the remaining piece that hasn't been wired yet. ---@class UIChatFeedRow ---@field Line UIChatLineInterface # exactly one wrapped chunk: header on the entry's first row, continuation on the rest diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 8645ebe933b..0474137fa0e 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -10,7 +10,6 @@ Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file o The bones of feed mode are in place — [`ChatFeedInterface`](ChatFeedInterface.lua) renders, fades, and clears in line with legacy expectations. What's still missing is the *configurable* layer: -- **`feed_persist`** — option has no effect. Today we always `ClearAll()` on window-open and always gate `OnHistoryChanged` on `not WindowVisible()`. To support persist=true, both branches need to flip: don't clear on open, and queue rows even while window is visible (paused timer until reveal). Two-mode logic. - **Pin button / pin toggle** — no pin button on the chat window. Pin should suspend both the window auto-close (`OnFrame` skips advancing toward `fade_time` while pinned) and the feed fade (per-row timers don't tick). Legacy: [chat.legacy.lua:1177-1187](../chat.legacy.lua). ## Message filtering diff --git a/lua/ui/game/chat/chat-line-functionality.md b/lua/ui/game/chat/chat-line-functionality.md index 8cb33d9b33d..1a2b8ff36ce 100644 --- a/lua/ui/game/chat/chat-line-functionality.md +++ b/lua/ui/game/chat/chat-line-functionality.md @@ -69,7 +69,6 @@ Projects the history onto the line pool: - Each visible line gets an `OnFrame` that increments `curHistory.time`; once `time > fade_time`, the line hides itself. - Continuation lines of a wrapped entry don't tick their own timer — they wait on the first wrapped line's timer (special-cased). - `feed_background` option controls the `lineStickybg`'s visibility per line. -- `feed_persist` option: when the window is manually closed, decides whether still-visible feed lines fade out naturally or get force-expired. - `ToggleChat` un-hides every line and hides every `lineStickybg` when opening the window; does the inverse on close. --- @@ -109,5 +108,4 @@ Every display option from the config dialog hits a chat-line property somewhere: - `fade_time` → feed-mode timeout comparator. - Colour indices (`*_color`) → text colour per line. - `feed_background` → per-line `lineStickybg` visibility. -- `feed_persist` → feed-mode close behaviour. - `links` + per-army filters → inclusion/exclusion from `IsValidEntry`. diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index e5185d4bd29..c91c58cf671 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -32,7 +32,6 @@ local ColorDefs = { local CheckboxDefs = { { Key = ChatConfigModel.KeySendType, Text = "Default recipient: allies" }, { Key = ChatConfigModel.KeyFeedBackground, Text = "Show feed background" }, - { Key = ChatConfigModel.KeyFeedPersist, Text = "Persist feed timeout" }, { Key = ChatConfigModel.KeyLinks, Text = "Show camera links" }, } diff --git a/lua/ui/game/chat/config/ChatConfigModel.lua b/lua/ui/game/chat/config/ChatConfigModel.lua index 31b42db9c8d..67d8df7e649 100644 --- a/lua/ui/game/chat/config/ChatConfigModel.lua +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -14,7 +14,6 @@ local ProfileKey = "chatoptions" ---@field fade_time number # seconds, 5-30 ---@field win_alpha number # 0.0-1.0 ---@field feed_background boolean ----@field feed_persist boolean ---@field send_type boolean # false = all, true = allies ---@field links boolean # show camera-link messages ---@field muted table # armyID -> true when muted; absent = not muted @@ -30,7 +29,6 @@ local DefaultOptions = { fade_time = 15, win_alpha = 1.0, feed_background = false, - feed_persist = true, send_type = false, links = true, muted = {}, @@ -51,7 +49,6 @@ KeyFontSize = 'font_size' KeyFadeTime = 'fade_time' KeyWinAlpha = 'win_alpha' KeyFeedBackground = 'feed_background' -KeyFeedPersist = 'feed_persist' KeySendType = 'send_type' KeyLinks = 'links' KeyMuted = 'muted' diff --git a/lua/ui/game/chat/design.md b/lua/ui/game/chat/design.md index 8d44b6c4cf6..71b86192668 100644 --- a/lua/ui/game/chat/design.md +++ b/lua/ui/game/chat/design.md @@ -159,7 +159,6 @@ The `chatContainer` implements the standard MAUI scrollable interface (`GetScrol When `GUI.bg` is hidden, the most recent lines are shown directly over the game world without the window frame. Each line: - Shows until `curHistory.time >= ChatOptions.fade_time`, then hides itself via `OnFrame`. - Optionally shows `lineStickybg` for readability (controlled by `ChatOptions.feed_background`). -- `ChatOptions.feed_persist` controls whether lines are force-expired when the window is manually closed. ### 5.5 Text Wrapping @@ -237,7 +236,6 @@ Registered in `keymap/keyactions.lua`: | `fade_time` | 5–30 | 15 | Seconds before feed lines/window auto-hide | | `win_alpha` | 0.2–1.0 | 1 | Window opacity (stored as 0–100, normalized on use) | | `feed_background` | bool | false | Show semi-transparent bg behind feed lines | -| `feed_persist` | bool | true | Keep feed lines visible until they individually time out | | `send_type` | bool | false | Default recipient: false = all, true = allies | | `links` | bool | true | Show camera-link messages | | `[armyID]` | bool | true | Per-army message filter (one key per army, set at game start) | diff --git a/lua/ui/help/tooltips.lua b/lua/ui/help/tooltips.lua index a6877f33a2d..41320ceb172 100644 --- a/lua/ui/help/tooltips.lua +++ b/lua/ui/help/tooltips.lua @@ -401,10 +401,6 @@ Tooltips = { title = 'Chat Feed Background', description = "Adds a black bar behind chat lines when the chat window is closed", }, - chat_feed_persist = { - title = "Persist Chat Feed Timeout", - description = "Allows chat to timeout normally in the chat feed after closing the chat window", - }, chat_send_type = { title = "Default recipient: allies", description = "When enabled, enter sends messages to allies and holding shift + enter sends to all. When not enabled, the behavior is reversed.", From cdfb8908e125d616917f3d8026b508455b92067a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 25 Apr 2026 21:05:31 +0200 Subject: [PATCH 077/130] Validate incoming messages --- lua/ui/game/chat/ChatController.lua | 86 +++++++++++++++++++++++------ lua/ui/game/chat/GAPS.md | 7 +-- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index cd3cd52ec1a..1add83b9984 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -280,14 +280,63 @@ end ------------------------------------------------------------------------------- -- Receiving (network) ---- Handler registered with `gamemain.RegisterChatFunc`. Normalises the +--- Pure shape validator for an incoming chat payload. Returns `true` only +--- when every required field is present with the expected type and every +--- optional field, when present, has the engine-API shape it must have for +--- downstream rendering / camera-jump code to treat it safely. +--- +--- Each rule is its own `return false` — malformed input is dropped, never +--- coerced or "repaired". A peer that ships an inconsistent shape is +--- either modded, buggy, or hostile; in any of those cases letting the +--- message through would let manipulated traffic render somewhere it +--- shouldn't. The receive path is reachable from any +--- `gamemain.RegisterChatFunc` caller, including external mods, so the +--- shape can't be trusted. +--- +--- Sender / observer-mode consistency lives in `OnReceive` rather than +--- here — those rules need session context (army data, focus army, replay +--- state) that this pure validator can't see. +---@param msg any +---@return boolean +local function IsValidIncomingMessage(msg) + -- Required: table-shaped, chat-flagged, with a string body. + if type(msg) ~= 'table' then return false end + if not msg.Chat then return false end + if type(msg.text) ~= 'string' then return false end + + -- Length cap — matches the edit box's `SetMaxChars(MaxMessageLength)` + -- on the send side, so a peer that bypassed the input cap can't push + -- us into laying out arbitrarily long lines. UTF-8 length mirrors the + -- input enforcement exactly. + if STR_Utf8Len(msg.text) > ChatUtils.MaxMessageLength then return false end + + -- Recipient must be one of the supported shapes. Without this guard, + -- a bare string like 'admin' or a non-string truthy value would fall + -- through to the `descriptor = ToStrings[to] or ToStrings.private` + -- fallback in `OnReceive`, letting a peer fake a "to you:" header on + -- what is actually a broadcast. + if msg.to ~= ChatModel.RecipientAll + and msg.to ~= ChatModel.RecipientAllies + and msg.to ~= 'notify' + and type(msg.to) ~= 'number' then + return false + end + + -- Optional payloads must match the shapes the engine APIs expect. + -- `msg.camera` is consumed by `WorldCamera:RestoreSettings`, which + -- requires a `SaveSettings`-shaped table; `msg.location` is dispatched + -- against `Position` / `Area` keys in `OnCameraClicked`. Anything that + -- isn't a table would crash those handlers when the user clicks the + -- cam icon, so reject up front rather than crashing on click. + if msg.camera ~= nil and type(msg.camera) ~= 'table' then return false end + if msg.location ~= nil and type(msg.location) ~= 'table' then return false end + + return true +end + +--- Handler registered with `gamemain.RegisterChatFunc`. Validates the --- message, delegates Notify-subsystem messages, resolves the sender's army --- data, and appends a chat line. ---- ---- Defensive against malformed input: messages that aren't tables, that ---- lack the `Chat` flag, or whose `text` isn't a string are dropped early. ---- The receive path is reachable from any gamemain `RegisterChatFunc` ---- caller — including external mods — so we can't trust the shape. ---@param sender string ---@param msg table function OnReceive(sender, msg) @@ -299,17 +348,10 @@ function OnReceive(sender, msg) sender = 'nil sender' end - -- Hard shape guards: anything that isn't a populated chat-shaped - -- table never reaches the formatting / model writes below. - if type(msg) ~= 'table' then return end - if not msg.Chat then return end - if type(msg.text) ~= 'string' then return end - - -- Length cap: matches the edit box's `SetMaxChars(MaxMessageLength)` - -- on the send side, so a peer that bypassed the input cap (mod, bug, - -- or hostile client) can't push us into laying out arbitrary-length - -- lines. UTF-8 length to mirror the input enforcement exactly. - if STR_Utf8Len(msg.text) > ChatUtils.MaxMessageLength then return end + -- Pure-shape validation: every type / length / payload-shape rule + -- lives in `IsValidIncomingMessage` so the dispatch logic below stays + -- focused on routing and rendering. + if not IsValidIncomingMessage(msg) then return end -- Notify routing: the Notify subsystem tags messages with `to='notify'` -- and owns the display decision. Only fall through to rendering a chat @@ -323,6 +365,16 @@ function OnReceive(sender, msg) return end + -- Observer-flag consistency: `msg.Observer` is set on the send side + -- only when the sender's `GetFocusArmy()` is -1, which means they + -- have no army entry in the session. A peer that ships + -- `Observer = true` while also resolving to a real army is malformed + -- — drop the message entirely. The two states are mutually exclusive + -- on a well-formed sender, so the inconsistency implies tampering or + -- a bug; "fixing" it by stripping the flag would let manipulated + -- traffic still render, just under a different label. + if msg.Observer and armyData then return end + local to = msg.to local descriptor = ToStrings[to] or ToStrings.private local towho = msg.Observer and LOC("to observers:") or LOC(descriptor.text) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 0474137fa0e..41869dabebe 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -14,9 +14,7 @@ The bones of feed mode are in place — [`ChatFeedInterface`](ChatFeedInterface. ## Message filtering -[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) gates on the per-army `muted` map and the `links` option. One part of the legacy filter is still missing: - -- **Self-echo / observer rules on receive** — [`ChatController.OnReceive`](ChatController.lua) doesn't filter incoming messages by sender / observer-mode. Legacy had a small set of receive-time rules that prevented self-echo loops and gated certain messages by observer state. Audit needed against [chat.legacy.lua:304-310](../chat.legacy.lua). +[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) gates on the per-army `muted` map and the `links` option, mirroring the legacy filter pair. ## Color palette not wired @@ -81,4 +79,5 @@ Closed in the most recent rounds of work: - **Bottom-anchored line layout** — [`ChatLinesInterface.RebuildPool` / `CalcVisible`](ChatLinesInterface.lua) stack newest-at-bottom so chat and feed share the same vertical rhythm and open ↔ close transitions stay continuous. - **`links` option** — [`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) drops entries with `Camera` or `Location` when `options.links == false`, mirroring the legacy filter. `Location` (sim-side point/area hint) is treated as a link too since it surfaces the same camera-icon affordance on the row. - **Translucent feed background** — [`ChatFeedInterface`](ChatFeedInterface.lua) gives each feed row a per-line readability strip (Bitmap on the feed group, depth-pinned under the line, edges via `Layouter:Fill(line)`). Visibility is gated on `feed_background`; the alpha composes window opacity (`win_alpha`) × per-row fade × `FeedBackgroundAlpha = 0.5`. The chat-window line pool is unaffected — the BG lives on the feed only, so the regular history view stays bare. -- **Receive-side defensive guards** — [`ChatController.OnReceive`](ChatController.lua) coerces non-string senders, drops non-table messages, requires `msg.text` to be a string, and rejects bodies longer than `ChatUtils.MaxMessageLength` (the same cap the edit box enforces on send). The length constant lives in `ChatUtils` so input and validation read from the same source. +- **Receive-side defensive guards** — [`ChatController.OnReceive`](ChatController.lua) coerces non-string senders, then runs everything else through a pure-shape validator (`IsValidIncomingMessage`) that checks: table-shaped, `Chat` flag set, `text` is a string, `text` length ≤ `ChatUtils.MaxMessageLength` (the same cap the edit box enforces on send), `to` is one of `RecipientAll` / `RecipientAllies` / `'notify'` / a number, and the optional `camera` / `location` payloads are tables when present. The dispatch loop then drops messages whose `Observer` flag contradicts the sender's army resolution (genuine observers have no army; an inconsistent combination implies tampering or a bug, not something to silently "repair"). Malformed input is dropped, never fixed. +- **Observer-source filter** — the existing `if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then return end` in [`ChatController.OnReceive`](ChatController.lua) already implements the legacy "players don't see observer chatter" rule (observers have no army → `armyData` nil → drop, unless the local viewer is also an observer or in a replay). From 720a2630408f689cef9118541939d2f075dd9f5c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 08:12:20 +0200 Subject: [PATCH 078/130] Implement colors and the pin --- lua/ui/game/chat/ChatController.lua | 30 +++++++++++++++++++ lua/ui/game/chat/ChatInterface.lua | 16 +++++++++- lua/ui/game/chat/ChatLineInterface.lua | 30 +++++++++++++++++++ lua/ui/game/chat/ChatModel.lua | 5 ++++ lua/ui/game/chat/ChatUtils.lua | 17 +++++++++++ .../game/chat/config/ChatConfigInterface.lua | 6 ++-- 6 files changed, 99 insertions(+), 5 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 1add83b9984..e418b9d8b2a 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -38,6 +38,20 @@ function NotifyActivity() ChatModel.GetSingleton().LastActivity:Set(GetSystemTimeSeconds()) end +--- Sets the title-bar pin state. While pinned, [`ChatInterface.OnFrame`] +--- skips the idle / `fade_time` auto-close check, so the chat window +--- stays open through arbitrary inactivity. Toggling pin off also stamps +--- a fresh `LastActivity` so the user gets a full `fade_time` window +--- after unpinning instead of being auto-closed immediately because the +--- timer kept counting against a stale activity stamp. +---@param pinned boolean +function SetPinned(pinned) + ChatModel.GetSingleton().Pinned:Set(pinned and true or false) + if not pinned then + NotifyActivity() + end +end + ------------------------------------------------------------------------------- -- Recipient @@ -72,6 +86,7 @@ function AppendLocalSystemMessage(text) Name = "System:", Text = text, Color = 'ffff6666', + BodyColor = 'ffff6666', ArmyID = 0, Recipient = ChatModel.RecipientAll, } @@ -264,10 +279,25 @@ local function AppendChatLine(args) -- `ChatLineInterface.FactionIcons` (observer). Real factions are 0..N-1 -- in engine data; the view expects 1-based indices. local faction = not args.IsObserver and armyData.faction or nil + + -- Pick the palette key for the body text. Observers always use the + -- link palette so observer chatter stands out; everyone else inherits + -- the channel descriptor's `colorkey`. Falls back to `'priv_color'` + -- for unrecognised recipients (matches the `ToStrings.private` + -- descriptor fallback used elsewhere in `OnReceive`). + local colorKey + if args.IsObserver then + colorKey = ChatConfigModel.KeyLinkColor + else + local descriptor = ToStrings[args.Recipient] or ToStrings.private + colorKey = descriptor.colorkey + end + AppendEntry { Name = args.Name, Text = args.Text or '', Color = armyData.color or 'ffffffff', + ColorKey = colorKey, ArmyID = armyData.ArmyID or 1, Faction = (faction or 4) + 1, Recipient = args.Recipient, diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 94f27afb554..83909ddd26a 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -329,16 +329,30 @@ local ChatInterface = ClassUI(Window) { --- to stamp `model.LastActivity`. Once the elapsed time since that stamp --- crosses `fade_time`, ask the controller to close — closing flips --- `model.WindowVisible`, which in turn disables further frame ticks. + --- Pinning the title-bar checkbox short-circuits the check entirely so + --- the user can keep the window up through long stretches of silence. ---@param self UIChatInterface ---@param delta number # seconds since the last frame, unused (we read absolute time) OnFrame = function(self, delta) + local model = ChatModel.GetSingleton() + if model.Pinned() then return end local fadeTime = ChatConfigModel.GetOptions().fade_time or 15 - local elapsed = GetSystemTimeSeconds() - ChatModel.GetSingleton().LastActivity() + local elapsed = GetSystemTimeSeconds() - model.LastActivity() if elapsed >= fadeTime then ChatController.CloseWindow() end end, + --- Engine-invoked when the user toggles the title-bar pin checkbox. + --- Forwards to the controller, which writes `model.Pinned`. Refocuses + --- the edit box because clicking the checkbox steals focus. + ---@param self UIChatInterface + ---@param checked boolean + OnPinCheck = function(self, checked) + ChatController.SetPinned(checked) + self.ChatEditInterface:AcquireFocus() + end, + --------------------------------------------------------------------------- -- Window event hooks --------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index eafbd13f831..2285c460e65 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -7,8 +7,17 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local Factions = import("/lua/factions.lua").Factions +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") + local Layouter = LayoutHelpers.ReusedLayoutFor +--- Body-text colour used when an entry has neither a `BodyColor` override +--- nor a `ColorKey` palette lookup that resolves. Matches the legacy chat +--- panel's previous hardcoded body colour so unrecognised / pre-palette +--- entries still render close to their old appearance. +local DefaultBodyColor = 'ffc2f6ff' + --- Flip to `true` to overlay a semi-transparent coloured bitmap over the --- control so its bounds are visible at runtime. Each chat interface uses a --- distinct colour so overlapping controls can be told apart at a glance. @@ -24,6 +33,25 @@ table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') local CamIconTexture = '/game/camera-btn/pinned_btn_up.dds' +--- Resolves the body-text colour for `entry`. Priority: +--- 1. `entry.BodyColor` — explicit override (system / synthetic lines). +--- 2. `entry.ColorKey` — palette lookup against `ChatConfigModel.GetOptions()`. +--- 3. `DefaultBodyColor` — fallback for entries from before the palette was wired. +--- Lives at module scope so both `SetHeader` and `SetContinuation` can call it +--- without each having to repeat the lookup. +---@param entry UIChatEntry +---@return string +local function ResolveBodyColor(entry) + if entry.BodyColor then return entry.BodyColor end + if entry.ColorKey then + local idx = ChatConfigModel.GetOptions()[entry.ColorKey] + if idx and ChatUtils.ColorPalette[idx] then + return ChatUtils.ColorPalette[idx] + end + end + return DefaultBodyColor +end + ------------------------------------------------------------------------------- -- A single chat row: team-coloured faction icon, sender name and message text. -- @@ -155,6 +183,7 @@ ChatLineInterface = ClassUI(Group) { self.Entry = entry self.Name:SetText(entry.Name or '') self.Text:SetText(wrappedText or entry.Text or '') + self.Text:SetColor(ResolveBodyColor(entry)) self.TeamColor:SetSolidColor(entry.Color or '00000000') local iconIndex = entry.Faction or table.getn(FactionIcons) @@ -192,6 +221,7 @@ ChatLineInterface = ClassUI(Group) { self.Entry = entry self.Name:SetText('') self.Text:SetText(wrappedText or '') + self.Text:SetColor(ResolveBodyColor(entry)) self.TeamColor:SetSolidColor('00000000') self.FactionIcon:SetSolidColor('00000000') self.CamIcon:SetSolidColor('00000000') diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index 0812ca8394a..d5f41fda29e 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -29,6 +29,8 @@ RecipientAllies = 'allies' ---@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 color +---@field BodyColor? string # explicit ARGB hex for the body text; bypasses the palette lookup (used by system / synthetic lines that always render the same colour) +---@field ColorKey? string # palette key (e.g. `'all_color'`, `'priv_color'`, `'link_color'`) resolved against `ChatConfigModel.GetOptions()` at render time; ignored when `BodyColor` is set ---@field ArmyID number # sender's army index ---@field Faction number # faction icon index (1-based) ---@field Recipient UIChatRecipient # the target this message was directed to @@ -45,6 +47,7 @@ RecipientAllies = 'allies' ---@field Recipient LazyVar # current send target ---@field WindowVisible LazyVar # whether the chat window is open ---@field LastActivity LazyVar # `GetSystemTimeSeconds()` of the most recent user / receive activity; observed by the chat window's idle / fade timer +---@field Pinned LazyVar # title-bar pin checkbox; while true the chat window's idle auto-close is suspended ---@type UIChatModel | nil local ModelInstance = nil @@ -57,6 +60,7 @@ function SetupSingleton() Recipient = Create(RecipientAll), WindowVisible = Create(false), LastActivity = Create(GetSystemTimeSeconds()), + Pinned = Create(false), } return ModelInstance end @@ -81,6 +85,7 @@ function __moduleinfo.OnReload(newModule) handle.Recipient:Set(ModelInstance.Recipient()) handle.WindowVisible:Set(ModelInstance.WindowVisible()) handle.LastActivity:Set(ModelInstance.LastActivity()) + handle.Pinned:Set(ModelInstance.Pinned()) end end diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua index 01e66e03bbd..ec9bffe65bb 100644 --- a/lua/ui/game/chat/ChatUtils.lua +++ b/lua/ui/game/chat/ChatUtils.lua @@ -14,6 +14,23 @@ local MauiWrapText = import("/lua/maui/text.lua").WrapText --- sender can't push us into laying out arbitrarily long lines. MaxMessageLength = 200 +--- 8-colour swatch palette indexed by `ChatConfigModel` colour keys +--- (`all_color`, `allies_color`, `priv_color`, `link_color`, +--- `notify_color`). The config dialog renders these as `BitmapCombo` +--- choices; `ChatLineInterface.SetHeader` looks them up at render time +--- via `entry.ColorKey` so palette changes take effect on the next +--- `CalcVisible` pass without a full rebuild. +ColorPalette = { + 'ffffffff', -- 1: white + 'ffff4242', -- 2: red + 'ffefff42', -- 3: yellow + 'ff4fff42', -- 4: green + 'ff42fff8', -- 5: cyan + 'ff424fff', -- 6: blue + 'ffff42eb', -- 7: magenta + 'ffff9f42', -- 8: orange +} + --- Wraps an entry's body text against `measureLine`'s row width and stores --- the result as `entry.WrappedText`. The first wrapped chunk reserves --- horizontal space for the entry's name prefix (so the body starts diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index c91c58cf671..996e2c55159 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -8,6 +8,7 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") local LazyVarDerive = import("/lua/lazyvar.lua").Derive @@ -18,9 +19,6 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- distinct colour so overlapping controls can be told apart at a glance. local Debug = false --- 8 ARGB solid colors selectable as message color swatches. -local Colors = { 'ffffffff', 'ffff4242', 'ffefff42', 'ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42' } - local ColorDefs = { { Key = ChatConfigModel.KeyAllColor, Text = "All" }, { Key = ChatConfigModel.KeyAlliesColor, Text = "Allies" }, @@ -90,7 +88,7 @@ local ChatConfigInterface = ClassUI(Window) { for i, def in ipairs(ColorDefs) do local row = { Label = UIUtil.CreateText(client, def.Text, 10, UIUtil.bodyFont), - Combo = BitmapCombo(client, Colors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01"), + Combo = BitmapCombo(client, ChatUtils.ColorPalette, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01"), Key = def.Key, } local key = def.Key From 7e335302a75a77fcc6577cb81f580bf12d250927 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 08:15:40 +0200 Subject: [PATCH 079/130] Always initialize chat window so that we can see the feed before opening it --- lua/ui/game/chat/ChatController.lua | 7 +++++++ lua/ui/game/chat/ChatInterface.lua | 20 +++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index e418b9d8b2a..72f480e4b35 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -662,6 +662,13 @@ function Init() import("/lua/ui/game/gamemain.lua").RegisterChatFunc(OnReceive, 'Chat') AddOnSyncHashedCallback(OnSyncChatMessages, 'ChatMessages', 'Chat') RegisterBuiltinCommands() + + -- Build the chat tree eagerly so the sibling feed view is mounted in + -- time to surface messages that arrive before the user has ever opened + -- the dialog. The window itself starts hidden (`model.WindowVisible` + -- defaults to false), so nothing renders until visibility flips — but + -- the feed's history observer is now subscribed and ready. + import("/lua/ui/game/chat/ChatInterface.lua").EnsureInstance() end ------------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 83909ddd26a..d3a0f552137 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -422,11 +422,23 @@ local ChatInterface = ClassUI(Window) { ---@type UIChatInterface | nil local Instance = nil ---- Shows the chat window, creating it on first call. -function Open() +--- Builds the chat window (and its sibling feed view) if they don't +--- already exist. Doesn't change visibility — `model.WindowVisible` +--- starts `false`, so the chat stays hidden until something flips it. +--- +--- `ChatController.Init` calls this at game start so the feed is alive +--- in time to surface messages that arrive before the user first opens +--- the chat dialog. Open / Toggle also call it as a safety net for +--- entry points that bypass `Init` (mods, debug helpers). +function EnsureInstance() if not Instance then Instance = ChatInterface(GetFrame(0)) end +end + +--- Shows the chat window, creating it on first call. +function Open() + EnsureInstance() ChatController.OpenWindow() end @@ -437,9 +449,7 @@ end --- Toggles the chat window, creating it on first call. function Toggle() - if not Instance then - Instance = ChatInterface(GetFrame(0)) - end + EnsureInstance() ChatController.ToggleWindow() end From 220d83c0e4d20b03f252fab09711476709bea3af Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 08:18:49 +0200 Subject: [PATCH 080/130] Unable to click your own name to whisper --- lua/ui/game/chat/ChatLineInterface.lua | 11 +++++++++++ lua/ui/game/chat/GAPS.md | 4 +--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index 2285c460e65..f725a92a5c1 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -186,6 +186,17 @@ ChatLineInterface = ClassUI(Group) { self.Text:SetColor(ResolveBodyColor(entry)) self.TeamColor:SetSolidColor(entry.Color or '00000000') + -- Grey the sender label on our own outgoing messages so the user + -- can pick out their own lines at a glance. Re-applied on every + -- `SetHeader` because pool slots get reused across entries from + -- different armies — the previous occupant's enable/disable state + -- would otherwise stick around. + if entry.ArmyID == GetFocusArmy() then + self.Name:Disable() + else + self.Name:Enable() + end + local iconIndex = entry.Faction or table.getn(FactionIcons) self.FactionIcon:SetTexture(UIUtil.UIFile(FactionIcons[iconIndex])) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 41869dabebe..4792f067380 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -39,9 +39,6 @@ Both can co-exist: keep the hint behaviour while it's open, and fall through to - **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.legacy.lua:1202-1209](../chat.legacy.lua)). [`ChatInterface.OnMouseWheel`](ChatInterface.lua) doesn't override `HandleEvent`. Whether this matters in practice depends on engine routing — the wheel may already reach the worldview when the chat window is `Hide()`-flagged. Verify before fixing. - **Button tooltips** — no `Tooltip.AddCheckboxTooltip` / `AddControlTooltip` calls anywhere in the chat tree. Legacy had `chat_pin`, `chat_config`, `chat_close`, `chat_camera`, `chat_reset` ([chat.legacy.lua:1200, 1211-1214](../chat.legacy.lua)). -## Per-line visuals - -- **Own-army name greying** — legacy `line.name:Disable()` greyed the sender name on your own messages so you could see at a glance which lines were yours ([chat.legacy.lua:409-413](../chat.legacy.lua)). Not ported to [`ChatLineInterface.SetHeader`](ChatLineInterface.lua). ## Legacy public API with no replacement @@ -78,6 +75,7 @@ Closed in the most recent rounds of work: - **`font_size` reactive** — [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) reapplies on every `Committed` change. - **Bottom-anchored line layout** — [`ChatLinesInterface.RebuildPool` / `CalcVisible`](ChatLinesInterface.lua) stack newest-at-bottom so chat and feed share the same vertical rhythm and open ↔ close transitions stay continuous. - **`links` option** — [`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) drops entries with `Camera` or `Location` when `options.links == false`, mirroring the legacy filter. `Location` (sim-side point/area hint) is treated as a link too since it surfaces the same camera-icon affordance on the row. +- **Own-army name greying** — [`ChatLineInterface.SetHeader`](ChatLineInterface.lua) calls `Name:Disable()` when `entry.ArmyID == GetFocusArmy()` and `Name:Enable()` otherwise, mirroring the legacy "your own lines look greyed" hint. Re-applied on every `SetHeader` so pool reuse can't carry over a previous occupant's state. - **Translucent feed background** — [`ChatFeedInterface`](ChatFeedInterface.lua) gives each feed row a per-line readability strip (Bitmap on the feed group, depth-pinned under the line, edges via `Layouter:Fill(line)`). Visibility is gated on `feed_background`; the alpha composes window opacity (`win_alpha`) × per-row fade × `FeedBackgroundAlpha = 0.5`. The chat-window line pool is unaffected — the BG lives on the feed only, so the regular history view stays bare. - **Receive-side defensive guards** — [`ChatController.OnReceive`](ChatController.lua) coerces non-string senders, then runs everything else through a pure-shape validator (`IsValidIncomingMessage`) that checks: table-shaped, `Chat` flag set, `text` is a string, `text` length ≤ `ChatUtils.MaxMessageLength` (the same cap the edit box enforces on send), `to` is one of `RecipientAll` / `RecipientAllies` / `'notify'` / a number, and the optional `camera` / `location` payloads are tables when present. The dispatch loop then drops messages whose `Observer` flag contradicts the sender's army resolution (genuine observers have no army; an inconsistent combination implies tampering or a bug, not something to silently "repair"). Malformed input is dropped, never fixed. - **Observer-source filter** — the existing `if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then return end` in [`ChatController.OnReceive`](ChatController.lua) already implements the legacy "players don't see observer chatter" rule (observers have no army → `armyData` nil → drop, unless the local viewer is also an observer or in a replay). From 99f39ebde6fa040c5c3d8fec05164cb8eb7e99b5 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 08:30:42 +0200 Subject: [PATCH 081/130] Add a color to messages with camera or location data --- lua/ui/game/chat/ChatController.lua | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 72f480e4b35..a4d9717da66 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -280,13 +280,21 @@ local function AppendChatLine(args) -- in engine data; the view expects 1-based indices. local faction = not args.IsObserver and armyData.faction or nil - -- Pick the palette key for the body text. Observers always use the - -- link palette so observer chatter stands out; everyone else inherits - -- the channel descriptor's `colorkey`. Falls back to `'priv_color'` - -- for unrecognised recipients (matches the `ToStrings.private` - -- descriptor fallback used elsewhere in `OnReceive`). + -- Pick the palette key for the body text: + -- * Camera-link messages (the sender attached a `Camera` snapshot or + -- a sim-side `Location` hint) always use the link palette so the + -- "click to jump" affordance is visually consistent regardless of + -- channel — matches the legacy override at chat.legacy.lua:443-446. + -- * Observer broadcasts also use the link palette so observer chatter + -- stands out from player traffic. + -- * Everyone else inherits the channel descriptor's `colorkey`. + -- Falls back to `'priv_color'` for unrecognised recipients (matches + -- the `ToStrings.private` descriptor fallback used elsewhere in + -- `OnReceive`). local colorKey - if args.IsObserver then + if args.Camera or args.Location then + colorKey = ChatConfigModel.KeyLinkColor + elseif args.IsObserver then colorKey = ChatConfigModel.KeyLinkColor else local descriptor = ToStrings[args.Recipient] or ToStrings.private From caf067528669a172a82fa31281456329c0c6c3f5 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 08:43:16 +0200 Subject: [PATCH 082/130] Update missing functionality --- lua/ui/game/chat/GAPS.md | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 4792f067380..a44ba679105 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -6,22 +6,6 @@ Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file o --- -## Feed mode finishing touches - -The bones of feed mode are in place — [`ChatFeedInterface`](ChatFeedInterface.lua) renders, fades, and clears in line with legacy expectations. What's still missing is the *configurable* layer: - -- **Pin button / pin toggle** — no pin button on the chat window. Pin should suspend both the window auto-close (`OnFrame` skips advancing toward `fade_time` while pinned) and the feed fade (per-row timers don't tick). Legacy: [chat.legacy.lua:1177-1187](../chat.legacy.lua). - -## Message filtering - -[`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) gates on the per-army `muted` map and the `links` option, mirroring the legacy filter pair. - -## Color palette not wired - -[`ChatConfigModel`](config/ChatConfigModel.lua) defines the full palette (`all_color`, `allies_color`, `priv_color`, `link_color`, `notify_color`) and the dialog persists choices, but [`ChatLineInterface.SetHeader`](ChatLineInterface.lua) only uses `entry.Color` (the team-colour square) and a hard-coded `'ffc2f6ff'` for the body text. Legacy looked up `ChatOptions[entry.tokey]` against an 8-colour swatch array per line ([chat.legacy.lua:63, 446-450](../chat.legacy.lua)). - -The fix: expose the colour-swatch array on the model (or at module level on the line file), have `SetHeader` index into it via the entry's `tokey`, and make sure `ApplyOptions` triggers a re-render when any palette key changes. - ## Notify-command bridge Slash commands in the legacy dispatcher fell through to [`RunChatCommand`](../../notify/commands.lua) ([chat.legacy.lua:729](../chat.legacy.lua)) so `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay` worked from chat. The new [`ChatCommandRegistry`](commands/ChatCommandRegistry.lua) dispatcher does not, so those commands are dead. @@ -39,7 +23,6 @@ Both can co-exist: keep the hint behaviour while it's open, and fall through to - **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.legacy.lua:1202-1209](../chat.legacy.lua)). [`ChatInterface.OnMouseWheel`](ChatInterface.lua) doesn't override `HandleEvent`. Whether this matters in practice depends on engine routing — the wheel may already reach the worldview when the chat window is `Hide()`-flagged. Verify before fixing. - **Button tooltips** — no `Tooltip.AddCheckboxTooltip` / `AddControlTooltip` calls anywhere in the chat tree. Legacy had `chat_pin`, `chat_config`, `chat_close`, `chat_camera`, `chat_reset` ([chat.legacy.lua:1200, 1211-1214](../chat.legacy.lua)). - ## Legacy public API with no replacement If any external mod still calls these (no in-tree caller remains), they will break: @@ -67,9 +50,9 @@ Closed in the most recent rounds of work: - **Camera links** — outgoing (`CamCheckbox`), incoming render (`CamIcon` on the line), click-to-jump (`OnCameraClicked` with both `Camera` and `Location` paths). - **Private reply by clicking a name** — `OnNameClicked` overridable on [`ChatLinesInterface`](ChatLinesInterface.lua); the chat window installs a handler that sets `Recipient` and re-acquires edit focus. Self-name clicks are filtered. - **Window auto-close timer** — `LastActivity` LazyVar in the model, `NotifyActivity()` heartbeat in the controller, [`ChatInterface.OnFrame`](ChatInterface.lua) compares `GetSystemTimeSeconds() - LastActivity()` to `fade_time`. Hooked into edit keystrokes, scroll, mouse wheel, drag, resize, recipient-picker hover, and `AppendEntry`. -- **Window opacity** — [`ChatInterface.OptionsObserver`](ChatInterface.lua) calls `SetAlpha(_, true)` on every `Committed` change; cascades to chrome / lines / edit / scrollbar. +- **Window opacity** — [`ChatInterface.OptionsObserver`](ChatInterface.lua) calls `SetAlpha(_, true)` on every `Committed` change; cascades to chrome / lines / edit / scrollbar. Re-asserts full opacity on `ChatLinesInterface.Pool` so chat text stays crisp at low alpha while chrome and scrollbar still dim. - **Per-line fade timer** — implemented in [`ChatFeedInterface`](ChatFeedInterface.lua), not in the main view (fade only matters when the window is hidden, which is exactly what the feed handles). -- **Feed mode itself** — [`ChatFeedInterface`](ChatFeedInterface.lua) is a sibling of the chat window pinned to the line area via LazyVar bind, observes `History` + `WindowVisible`, has a per-row pool, fades each row in its last 2 seconds, clears on window-open, hides itself when there are no rows. +- **Feed mode itself** — [`ChatFeedInterface`](ChatFeedInterface.lua) is a sibling of the chat window pinned to the line area via LazyVar bind, observes `History` + `WindowVisible`, has a per-row pool, fades each row in its last 2 seconds, clears on window-open, hides itself when there are no rows. Bootstrapped eagerly from [`ChatController.Init`](ChatController.lua) so it can surface messages received before the user first opens the dialog. - **Per-army mute (`muted`)** — per-game (not persisted to prefs), checkbox column in [`ChatConfigInterface`](config/ChatConfigInterface.lua), `/mute` and `/unmute` slash commands, `IsValidEntry` filter, `SetMuted` / `SetMutedLive` controllers. - **`OnMoveSet` focus grab** — [`ChatInterface.OnMoveSet`](ChatInterface.lua) re-acquires edit focus after a drag. - **`font_size` reactive** — [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) reapplies on every `Committed` change. @@ -79,3 +62,6 @@ Closed in the most recent rounds of work: - **Translucent feed background** — [`ChatFeedInterface`](ChatFeedInterface.lua) gives each feed row a per-line readability strip (Bitmap on the feed group, depth-pinned under the line, edges via `Layouter:Fill(line)`). Visibility is gated on `feed_background`; the alpha composes window opacity (`win_alpha`) × per-row fade × `FeedBackgroundAlpha = 0.5`. The chat-window line pool is unaffected — the BG lives on the feed only, so the regular history view stays bare. - **Receive-side defensive guards** — [`ChatController.OnReceive`](ChatController.lua) coerces non-string senders, then runs everything else through a pure-shape validator (`IsValidIncomingMessage`) that checks: table-shaped, `Chat` flag set, `text` is a string, `text` length ≤ `ChatUtils.MaxMessageLength` (the same cap the edit box enforces on send), `to` is one of `RecipientAll` / `RecipientAllies` / `'notify'` / a number, and the optional `camera` / `location` payloads are tables when present. The dispatch loop then drops messages whose `Observer` flag contradicts the sender's army resolution (genuine observers have no army; an inconsistent combination implies tampering or a bug, not something to silently "repair"). Malformed input is dropped, never fixed. - **Observer-source filter** — the existing `if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then return end` in [`ChatController.OnReceive`](ChatController.lua) already implements the legacy "players don't see observer chatter" rule (observers have no army → `armyData` nil → drop, unless the local viewer is also an observer or in a replay). +- **Colour palette wired** — 8-swatch [`ChatUtils.ColorPalette`](ChatUtils.lua) shared by the config dialog and the line renderer. [`ChatLineInterface`](ChatLineInterface.lua) resolves body-text colour through `ResolveBodyColor(entry)`, prioritising `entry.BodyColor` (explicit override for system / synthetic lines) over `entry.ColorKey` (palette lookup) over a hardcoded fallback. [`ChatController.AppendChatLine`](ChatController.lua) stamps `ColorKey` based on routing — camera-link / `Location` entries and observer broadcasts both pick up `link_color`; everyone else uses `ToStrings[recipient].colorkey`. Picking a different swatch in the config dialog repaints visible lines on the next `CalcVisible` pass. +- **Pin button** — [`ChatModel`](ChatModel.lua) carries a `Pinned: LazyVar`; [`ChatController.SetPinned`](ChatController.lua) writes it (and stamps a fresh `LastActivity` on unpin so the user gets a full `fade_time` window after toggling off). [`ChatInterface.OnPinCheck`](ChatInterface.lua) wires the existing title-bar checkbox to the controller; [`ChatInterface.OnFrame`](ChatInterface.lua) short-circuits the auto-close check while pinned, so the window stays open through arbitrary inactivity. +- **Eager chat bootstrap** — [`ChatController.Init`](ChatController.lua) calls `ChatInterface.EnsureInstance()` at game start so the chat tree (and its sibling feed) exists before any messages arrive. The window itself stays hidden by default; only the feed observers are needed up front, and they're now subscribed in time to surface chat the user receives before first opening the dialog. From a3412ebd8af4e318862875d649d0777309a92048 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 08:46:36 +0200 Subject: [PATCH 083/130] Add backwards compatibility to original api --- lua/ui/game/chat.lua | 153 ++++++++++++++++++++++++++++++++++++++- lua/ui/game/chat/GAPS.md | 16 +--- 2 files changed, 153 insertions(+), 16 deletions(-) diff --git a/lua/ui/game/chat.lua b/lua/ui/game/chat.lua index c1c214dbf08..c4d087d6929 100644 --- a/lua/ui/game/chat.lua +++ b/lua/ui/game/chat.lua @@ -1,8 +1,159 @@ +------------------------------------------------------------------------------- +-- DEPRECATED LEGACY CHAT API SHIM +-- +-- The original `/lua/ui/game/chat.lua` was replaced by the MVC tree under +-- `/lua/ui/game/chat/` (see [GAPS.md](chat/GAPS.md) and [CHANGES.md](chat/CHANGES.md)). +-- The original implementation is preserved on disk as `chat.legacy.lua` +-- for reference; this file is a compatibility layer for external mods that +-- still import the old `/lua/ui/game/chat.lua` path. +-- +-- Every export here logs a one-shot deprecation warning the first time it +-- is touched and forwards to the equivalent new API. Once mods have +-- migrated, this file can be deleted outright (along with `chat.legacy.lua`). +------------------------------------------------------------------------------- + +local ChatController = import("/lua/ui/game/chat/ChatController.lua") +local ChatInterface = import("/lua/ui/game/chat/ChatInterface.lua") +local ChatConfigInterface = import("/lua/ui/game/chat/config/ChatConfigInterface.lua") +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +-- One-shot per name, dedupe a chatty caller into a single warning so the +-- log doesn't drown when a mod is busy hammering a deprecated entry point. +local _warned = {} +local function _deprecate(name, replacement) + if _warned[name] then return end + _warned[name] = true + WARN(string.format( + "chat.lua %s is deprecated — use %s instead", + name, replacement or 'the new chat MVC API' + )) +end + +------------------------------------------------------------------------------- +-- Active entry points (still wired in keymaps / engine hooks) + --- Called by the engine when the user presses Enter outside the chat edit --- box — the default "open chat" shortcut. Thin shim that delegates to the --- chat controller, which picks the initial recipient from `send_type` and --- the Shift modifier before toggling the window. ---@param modifiers? table # {Shift, Ctrl, Alt, ...} function ActivateChat(modifiers) - import("/lua/ui/game/chat/ChatController.lua").ActivateChat(modifiers) + ChatController.ActivateChat(modifiers) +end + +------------------------------------------------------------------------------- +-- Deprecated forwards + +--- @deprecated use [ChatController.OnReceive](chat/ChatController.lua) instead +function ReceiveChat(sender, msg) + _deprecate('ReceiveChat', 'ChatController.OnReceive') + ChatController.OnReceive(sender, msg) +end + +--- @deprecated use [ChatController.OnReceive](chat/ChatController.lua) instead +function ReceiveChatFromSim(sender, msg) + _deprecate('ReceiveChatFromSim', 'ChatController.OnReceive') + ChatController.OnReceive(sender, msg) +end + +--- @deprecated use [ChatController.Init](chat/ChatController.lua) instead. The new `Init` takes no arguments — chat layout no longer needs a `mapGroup` reference because the tree mounts on `GetFrame(0)` directly. +function SetupChatLayout(_) + _deprecate('SetupChatLayout', 'ChatController.Init') + ChatController.Init() +end + +--- @deprecated no replacement; the new chat is hidden by default and follows `model.WindowVisible`, so the legacy "hide on NIS start" hook is no longer required +function OnNISBegin() + _deprecate('OnNISBegin', 'no replacement (no longer required)') +end + +--- @deprecated use [ChatInterface.OpenAndScrollLines](chat/ChatInterface.lua) with a negative delta +function ChatPageUp(mod) + _deprecate('ChatPageUp', 'ChatInterface.OpenAndScrollLines(-mod)') + ChatInterface.OpenAndScrollLines(-(mod or 10)) +end + +--- @deprecated use [ChatInterface.OpenAndScrollLines](chat/ChatInterface.lua) with a positive delta +function ChatPageDown(mod) + _deprecate('ChatPageDown', 'ChatInterface.OpenAndScrollLines(mod)') + ChatInterface.OpenAndScrollLines(mod or 10) +end + +--- @deprecated use [ChatConfigInterface.Close](chat/config/ChatConfigInterface.lua) instead +function CloseChatConfig() + _deprecate('CloseChatConfig', 'ChatConfigInterface.Close') + ChatConfigInterface.Close() +end + +--- @deprecated use [ChatController.CloseWindow](chat/ChatController.lua) instead +function CloseChat() + _deprecate('CloseChat', 'ChatController.CloseWindow') + ChatController.CloseWindow() +end + +--- @deprecated subscribe to [ChatConfigModel.GetSingleton().Committed](chat/config/ChatConfigModel.lua) via `LazyVarDerive` instead. Best-effort shim — fires the callback once with the current options so legacy callers see a value, then wires a one-way derived observer so subsequent changes propagate. Mods should migrate to a real `LazyVarDerive` they can destroy on teardown. +function AddChatOptionSetCallback(callback, _) + _deprecate('AddChatOptionSetCallback', 'LazyVarDerive(ChatConfigModel.GetSingleton().Committed, ...)') + if type(callback) ~= 'function' then return end + + -- Best-effort: keep a derived observer alive until the module is + -- reloaded. We don't hand it back to the caller (the legacy API + -- didn't expose a destroy path), so it leaks on intent — same + -- behaviour as the legacy callback list. + _warned[callback] = LazyVarDerive( + ChatConfigModel.GetSingleton().Committed, + function(lv) callback(lv()) end + ) +end + +--- @deprecated no replacement; chat layout is no longer skin-themed (see [CHANGES.md](chat/CHANGES.md)) +function SetLayout(_) + _deprecate('SetLayout', 'no replacement (single layout)') +end + +--- @deprecated multiple `GetArmyData`-style helpers exist elsewhere; the new chat tree uses a private one in `ChatController` +function GetArmyData(army) + _deprecate('GetArmyData', 'GetArmiesTable().armiesTable[ArmyID]') + local armies = GetArmiesTable() + if type(army) == 'number' then + return armies.armiesTable[army] + elseif type(army) == 'string' then + for i, v in armies.armiesTable do + if v.nickname == army then + v.ArmyID = i + return v + end + end + end +end + +------------------------------------------------------------------------------- +-- Deprecated state tables +-- +-- The legacy file exposed `GUI` and `ChatLines` as live tables of MAUI +-- handles. There's no clean way to recreate that against the MVC tree — +-- the new view doesn't pin its controls to a globally-addressable +-- structure. Mods that read these tables directly were always coupled to +-- internals that could move. +-- +-- We expose empty tables proxied through metatables so any access logs +-- a deprecation warning and returns nil. Existing `chat.GUI.bg` reads +-- still terminate eventually (with a clear log line) instead of pretending +-- the field exists. + +local function _stateProxy(name) + return setmetatable({}, { + __index = function(_, k) + _deprecate(name .. '.' .. tostring(k), 'the new chat MVC view tree') + return nil + end, + __newindex = function(_, k, _) + _deprecate(name .. '.' .. tostring(k) .. ' (assignment)', 'the new chat MVC view tree') + end, + }) end + +GUI = _stateProxy('GUI') +ChatLines = _stateProxy('ChatLines') diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index a44ba679105..391bb8d4e58 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -23,21 +23,6 @@ Both can co-exist: keep the hint behaviour while it's open, and fall through to - **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.legacy.lua:1202-1209](../chat.legacy.lua)). [`ChatInterface.OnMouseWheel`](ChatInterface.lua) doesn't override `HandleEvent`. Whether this matters in practice depends on engine routing — the wheel may already reach the worldview when the chat window is `Hide()`-flagged. Verify before fixing. - **Button tooltips** — no `Tooltip.AddCheckboxTooltip` / `AddControlTooltip` calls anywhere in the chat tree. Legacy had `chat_pin`, `chat_config`, `chat_close`, `chat_camera`, `chat_reset` ([chat.legacy.lua:1200, 1211-1214](../chat.legacy.lua)). -## Legacy public API with no replacement - -If any external mod still calls these (no in-tree caller remains), they will break: - -- `GUI` table (chat window handles) -- `ChatLines` -- `ReceiveChat` / `ReceiveChatFromSim` — migrate to [`ChatController.OnReceive`](ChatController.lua) -- `SetupChatLayout` -- `OnNISBegin` -- `ChatPageUp` / `ChatPageDown` — migrate to [`ChatInterface.OpenAndScrollLines`](ChatInterface.lua) -- `CloseChatConfig` — migrate to [`ChatConfigInterface.Close`](config/ChatConfigInterface.lua) -- `CloseChat` -- `AddChatOptionSetCallback` — no replacement; observers should `LazyVarDerive(ChatConfigModel.GetSingleton().Committed, ...)` instead -- `SetLayout` -- `GetArmyData` (the one defined in chat.lua; several other copies exist elsewhere) --- @@ -65,3 +50,4 @@ Closed in the most recent rounds of work: - **Colour palette wired** — 8-swatch [`ChatUtils.ColorPalette`](ChatUtils.lua) shared by the config dialog and the line renderer. [`ChatLineInterface`](ChatLineInterface.lua) resolves body-text colour through `ResolveBodyColor(entry)`, prioritising `entry.BodyColor` (explicit override for system / synthetic lines) over `entry.ColorKey` (palette lookup) over a hardcoded fallback. [`ChatController.AppendChatLine`](ChatController.lua) stamps `ColorKey` based on routing — camera-link / `Location` entries and observer broadcasts both pick up `link_color`; everyone else uses `ToStrings[recipient].colorkey`. Picking a different swatch in the config dialog repaints visible lines on the next `CalcVisible` pass. - **Pin button** — [`ChatModel`](ChatModel.lua) carries a `Pinned: LazyVar`; [`ChatController.SetPinned`](ChatController.lua) writes it (and stamps a fresh `LastActivity` on unpin so the user gets a full `fade_time` window after toggling off). [`ChatInterface.OnPinCheck`](ChatInterface.lua) wires the existing title-bar checkbox to the controller; [`ChatInterface.OnFrame`](ChatInterface.lua) short-circuits the auto-close check while pinned, so the window stays open through arbitrary inactivity. - **Eager chat bootstrap** — [`ChatController.Init`](ChatController.lua) calls `ChatInterface.EnsureInstance()` at game start so the chat tree (and its sibling feed) exists before any messages arrive. The window itself stays hidden by default; only the feed observers are needed up front, and they're now subscribed in time to surface chat the user receives before first opening the dialog. +- **Legacy public API shim** — [`/lua/ui/game/chat.lua`](../chat.lua) is now a thin compatibility layer that re-exports every legacy global mods used to import (`ReceiveChat`, `ReceiveChatFromSim`, `SetupChatLayout`, `OnNISBegin`, `ChatPageUp` / `ChatPageDown`, `CloseChat`, `CloseChatConfig`, `AddChatOptionSetCallback`, `SetLayout`, `GetArmyData`, `GUI`, `ChatLines`). Every entry point logs a one-shot `WARN` deprecation message the first time it's touched and forwards to the equivalent new API where one exists; `GUI` and `ChatLines` are metatable-proxied so any field read or assignment surfaces a clear warning instead of crashing on a missing field. Once the warnings stop appearing in users' logs, the shim and `chat.legacy.lua` can both be deleted. From dba126082701784659c9460d1f16351ab636378c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 09:06:52 +0200 Subject: [PATCH 084/130] Apply faction theme to chat window border and handlers --- lua/ui/game/chat.lua | 11 ++++++-- lua/ui/game/chat/CHANGES.md | 20 ++++++++++++--- lua/ui/game/chat/ChatInterface.lua | 40 +++++++++++++++++++----------- lua/ui/game/chat/GAPS.md | 8 ++++++ 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/lua/ui/game/chat.lua b/lua/ui/game/chat.lua index c4d087d6929..22ec2170289 100644 --- a/lua/ui/game/chat.lua +++ b/lua/ui/game/chat.lua @@ -108,9 +108,16 @@ function AddChatOptionSetCallback(callback, _) ) end ---- @deprecated no replacement; chat layout is no longer skin-themed (see [CHANGES.md](chat/CHANGES.md)) +--- @deprecated no caller-driven equivalent. The legacy `SetLayout` was the +--- *layout* hook (HUD-arrangement preset: `bottom` / `left` / `right`), +--- not the *skin* hook — it called `import(UIUtil.GetLayoutFilename('chat')).SetLayout()` +--- to apply layout-specific positions. The new chat uses a single rect +--- regardless of layout (see [CHANGES.md](chat/CHANGES.md)), so there is +--- nothing to re-apply. Skin-driven theming is independent and is +--- handled reactively via `UIUtil.SkinnableFile` (border, drag handles, +--- scrollbar, buttons all follow the active skin without an explicit call). function SetLayout(_) - _deprecate('SetLayout', 'no replacement (single layout)') + _deprecate('SetLayout', 'no replacement (chat is single-layout; skin theming auto-updates via SkinnableFile)') end --- @deprecated multiple `GetArmyData`-style helpers exist elsewhere; the new chat tree uses a private one in `ChatController` diff --git a/lua/ui/game/chat/CHANGES.md b/lua/ui/game/chat/CHANGES.md index 2b51eeae310..c5c8c75c0ef 100644 --- a/lua/ui/game/chat/CHANGES.md +++ b/lua/ui/game/chat/CHANGES.md @@ -22,11 +22,25 @@ Closing is still reachable via the close button, the `Escape` key on an empty ed --- -## Skin-specific chat-window theming is gone +## Layout-specific chat-window *positions* are gone (skin-driven theming is preserved) -The old `layouts/chat_layout.lua` file (loaded via `UIUtil.GetLayoutFilename('chat')`) applied skin-specific window textures and drag-handle art to the chat window. That file has been deleted along with its `bottom` / `left` / `right` entries in [`/lua/skins/layouts.lua`](../../../skins/layouts.lua). +Skins and layouts are independent axes: +- **Skin** drives texture / colour palette — resolved per-asset via `UIUtil.SkinnableFile`. +- **Layout** drives HUD widget arrangement (`bottom`, `left`, `right`) — defined in [`/lua/skins/layouts.lua`](../../../skins/layouts.lua) as a table mapping HUD-widget keys to per-widget layout files. -The new [`ChatInterface`](ChatInterface.lua) uses a single set of textures for every skin. Skin switching no longer re-themes the chat window. +The deleted `/lua/ui/game/layouts/chat_layout.lua` was the chat's *layout* entry, providing layout-specific positions and sizes (where the chat lived for each of `bottom` / `left` / `right`). That file is gone, along with its `chat` entries in `/lua/skins/layouts.lua`; the new [`ChatInterface`](ChatInterface.lua) uses a single rect (`DefaultRect`) regardless of layout, and the user can drag / resize from there. Switching layouts no longer repositions the chat window. + +**Theming is preserved on the skin axis.** [`ChatInterface`](ChatInterface.lua) loads its border, corner-grip, scrollbar, and titlebar-button textures through `UIUtil.SkinnableFile` rather than `UIUtil.UIFile`, so each path resolves against the current skin every time MAUI reads the bound LazyVar. Switching skins reactively repaints the chrome without touching the chat tree. The legacy `SetLayout` global that re-ran the chat's layout file is therefore unneeded — the [chat.lua compatibility shim](../chat.lua) keeps it around as a deprecated no-op. + +### Why no `OnLayoutChanged` hook + +[`gamemain.SetLayout(layout)`](../gamemain.lua) fans out to every HUD widget when the user picks a different HUD arrangement. The chat is intentionally **not** in that chain. Considered alternatives: + +- *Per-layout default rects* — would mean three more rect tables that mostly differ by where they choose to overlap other widgets. Not enough variation in the legacy `chat_layout.lua` to justify the surface area. +- *Auto-reset on layout change* — would clobber the user's saved rect on every layout switch. User-hostile. +- *Clamp-to-screen on layout change* — defensible, but `DefaultRect` already lives well inside any layout's safe area, and the new title-bar **Reset-position** button covers the "I've gotten lost" case explicitly without overwriting anyone's preferences. + +The chat is **user-positioned**: its rect persists under the prefs key `chat_window_v2` and survives layout / skin / session changes by design. Mods that need layout-aware repositioning can call into `model.WindowVisible` and write a new rect via the existing prefs path — no new public API. --- diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index d3a0f552137..ef44f91d4c0 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -22,32 +22,42 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- distinct colour so overlapping controls can be told apart at a glance. local Debug = false ---- Skin textures for the chat window frame. Mirrors the layout that ---- `/lua/ui/game/layouts/chat_layout.lua` applies to the legacy chat Window ---- so the new window matches the original visual style. +--- Skin textures for the chat window frame. `SkinnableFile` returns a +--- callable that resolves the path against the current skin every time +--- it's read — so the border bitmaps automatically pick up the user's +--- skin choice when bound through MAUI's LazyVar machinery, instead of +--- being frozen at module-load time the way `UIFile` would freeze them. local WindowTextures = { - tl = UIUtil.UIFile('/game/chat_brd/chat_brd_ul.dds'), - tr = UIUtil.UIFile('/game/chat_brd/chat_brd_ur.dds'), - tm = UIUtil.UIFile('/game/chat_brd/chat_brd_horz_um.dds'), - ml = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_l.dds'), - m = UIUtil.UIFile('/game/chat_brd/chat_brd_m.dds'), - mr = UIUtil.UIFile('/game/chat_brd/chat_brd_vert_r.dds'), - bl = UIUtil.UIFile('/game/chat_brd/chat_brd_ll.dds'), - bm = UIUtil.UIFile('/game/chat_brd/chat_brd_lm.dds'), - br = UIUtil.UIFile('/game/chat_brd/chat_brd_lr.dds'), + tl = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ul.dds'), + tr = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ur.dds'), + tm = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_horz_um.dds'), + ml = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_vert_l.dds'), + m = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_m.dds'), + mr = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_vert_r.dds'), + bl = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ll.dds'), + bm = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_lm.dds'), + br = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_lr.dds'), borderColor = 'ff415055', } --- Corner grip textures for the four resize handles sticking out of the --- window corners. Each handle carries `up` / `over` / `down` states that --- the `RolloverHandler` swaps through during hover-and-resize. +--- `SkinnableFile` again so the grips follow the active skin. +--- +--- The concatenated path strings widen to `string` rather than the +--- language server's `FileName` alias, which `SkinnableFile`'s parameter +--- annotation requires; suppress the resulting noise rather than littering +--- each line with a cast. +---@diagnostic disable: param-type-mismatch local function DragHandleTextures(corner) return { - up = UIUtil.UIFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_up.dds'), - over = UIUtil.UIFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_over.dds'), - down = UIUtil.UIFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_down.dds'), + up = UIUtil.SkinnableFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_up.dds'), + over = UIUtil.SkinnableFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_over.dds'), + down = UIUtil.SkinnableFile('/game/drag-handle/drag-handle-' .. corner .. '_btn_down.dds'), } end +---@diagnostic enable: param-type-mismatch --- Default window rect, kept as a module local so `ResetPosition` can --- restore it after the user has moved the window around. diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 391bb8d4e58..de418a6171a 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -51,3 +51,11 @@ Closed in the most recent rounds of work: - **Pin button** — [`ChatModel`](ChatModel.lua) carries a `Pinned: LazyVar`; [`ChatController.SetPinned`](ChatController.lua) writes it (and stamps a fresh `LastActivity` on unpin so the user gets a full `fade_time` window after toggling off). [`ChatInterface.OnPinCheck`](ChatInterface.lua) wires the existing title-bar checkbox to the controller; [`ChatInterface.OnFrame`](ChatInterface.lua) short-circuits the auto-close check while pinned, so the window stays open through arbitrary inactivity. - **Eager chat bootstrap** — [`ChatController.Init`](ChatController.lua) calls `ChatInterface.EnsureInstance()` at game start so the chat tree (and its sibling feed) exists before any messages arrive. The window itself stays hidden by default; only the feed observers are needed up front, and they're now subscribed in time to surface chat the user receives before first opening the dialog. - **Legacy public API shim** — [`/lua/ui/game/chat.lua`](../chat.lua) is now a thin compatibility layer that re-exports every legacy global mods used to import (`ReceiveChat`, `ReceiveChatFromSim`, `SetupChatLayout`, `OnNISBegin`, `ChatPageUp` / `ChatPageDown`, `CloseChat`, `CloseChatConfig`, `AddChatOptionSetCallback`, `SetLayout`, `GetArmyData`, `GUI`, `ChatLines`). Every entry point logs a one-shot `WARN` deprecation message the first time it's touched and forwards to the equivalent new API where one exists; `GUI` and `ChatLines` are metatable-proxied so any field read or assignment surfaces a clear warning instead of crashing on a missing field. Once the warnings stop appearing in users' logs, the shim and `chat.legacy.lua` can both be deleted. + +--- + +## Won't fix (decisions, not gaps) + +These look like missing pieces but are deliberate omissions; documenting here so they don't get re-opened later. See [CHANGES.md](CHANGES.md) for the rationale. + +- **Layout-specific chat positioning** — the chat is intentionally absent from [`gamemain.SetLayout`](../gamemain.lua)'s fan-out. The new chat is user-positioned (saved under prefs key `chat_window_v2`, recoverable via the title-bar Reset-position button), and switching HUD layouts no longer repositions it. The `chat.lua` `SetLayout` shim is therefore a true no-op rather than a forwarder. From 15ae9100aa203ba5a260e0096829adb1644fea32 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 09:26:35 +0200 Subject: [PATCH 085/130] Document the key events --- engine/User/CMauiEdit.lua | 51 ++++++++++++++++--------- lua/maui/edit.lua | 13 ++++++- lua/ui/game/chat/ChatEditInterface.lua | 33 +++++++++++++--- lua/ui/game/chat/ChatInterface.lua | 27 +++++++++++++ lua/ui/game/chat/ChatLinesInterface.lua | 13 +++++++ 5 files changed, 110 insertions(+), 27 deletions(-) diff --git a/engine/User/CMauiEdit.lua b/engine/User/CMauiEdit.lua index 86fef3e4112..00c786afc5f 100644 --- a/engine/User/CMauiEdit.lua +++ b/engine/User/CMauiEdit.lua @@ -1,5 +1,37 @@ ---@meta +--- Modifier-key state attached to an input event. Each field is `true` +--- only when the matching modifier was held at the moment the event +--- fired; absent entries are nil rather than `false`, so test with +--- `event.Modifiers.Shift` (truthy / nil) rather than equality. +---@class KeyModifiers +---@field Shift? boolean +---@field Ctrl? boolean +---@field Alt? boolean +---@field Left? boolean # left mouse button held (mouse events) +---@field Right? boolean # right mouse button held (mouse events) +---@field Middle? boolean # middle mouse button held (mouse events) + +--- Generic input-event payload delivered to MAUI control hooks. Each +--- control hook (`HandleEvent`, the keyboard / mouse callbacks on `Edit`, +--- `Button`, `Checkbox`, etc.) receives a single `KeyEvent` table; not +--- every field is meaningful for every event Type, so MouseX/Y are -1 +--- on key events and WheelDelta/Rotation are 0 outside wheel events. +--- +--- For `Edit.OnNonTextKeyPressed` the function gets `(self, keycode, event)` +--- — `keycode` is the raw VK_* code (compare against `UIUtil.VK_*`) and +--- `event.Modifiers` is the modifier state at the time of the press. +---@class KeyEvent +---@field Type string # "Char" / "KeyDown" / "ButtonPress" / "MouseEnter" / "MouseExit" / "WheelRotation" / ... +---@field Control Control # the control receiving the event +---@field KeyCode number # engine-translated keycode (post-IME, etc.) +---@field RawKeyCode number # OS-level VK_* keycode (compare against `UIUtil.VK_*`) +---@field Modifiers KeyModifiers # which modifiers / mouse buttons were held when the event fired +---@field MouseX number # cursor X in screen coords (-1 on non-mouse events) +---@field MouseY number # cursor Y in screen coords (-1 on non-mouse events) +---@field WheelDelta number # mouse-wheel delta (0 outside wheel events) +---@field WheelRotation number # mouse-wheel rotation in 1/120 ticks (0 outside wheel events) + ---@class moho.edit_methods : moho.control_methods local CMauiEdit = {} @@ -156,25 +188,6 @@ end function CMauiEdit:ShowCaret(show) end ----@class EventModifiers ----@field Alt? true ----@field Ctrl? true ----@field Left? true ----@field Right? true ----@field Shift? true ----@field Middle? true - ----@class KeyEvent ----@field Control Control ----@field KeyCode number ----@field Modifiers EventModifiers ----@field MouseX number ----@field MouseY number ----@field RawKeyCode number ----@field Type string ----@field WheelDelta number ----@field WheelRotation number - --- Called when the text has changed in the text box. Passes in the newly changed text --- and the previous text. ---@type fun(self: Edit, newText: string, oldText: string) diff --git a/lua/maui/edit.lua b/lua/maui/edit.lua index ab5d744fc5a..a823fc32ebb 100644 --- a/lua/maui/edit.lua +++ b/lua/maui/edit.lua @@ -140,8 +140,17 @@ Edit = ClassUI(moho.edit_methods, Control) { OnEnterPressed = function(self, text) end, - -- called when non text keys (that don't affect text editing) are pressed, passes in the windows VK key code - OnNonTextKeyPressed = function(self, keycode, modifiers) + --- Called when a non-text key (one that doesn't affect text editing — + --- so things like Home / End / Insert / Delete / arrow keys are + --- consumed by the engine for caret navigation and do **not** reach + --- this handler) is pressed. `keycode` is the OS-level VK_* code (use + --- `UIUtil.VK_*` to compare); `event` is the full event payload, with + --- modifier state under `event.Modifiers`. + ---@param self Edit + ---@param keycode number + ---@param event KeyEvent + ---@diagnostic disable-next-line: unused-local + OnNonTextKeyPressed = function(self, keycode, event) AddUnicodeCharToEditText(self, keycode) end, diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 5331f08304e..be7c3692a93 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -161,18 +161,39 @@ ChatEditInterface = ClassUI(Group) { return true end - -- Page Up / Page Down scroll the chat feed. Shift narrows to one row. - -- Matches the legacy chat.lua binding so muscle memory carries over. + -- Page Up / Page Down scroll the chat feed. Three modes per key: + -- * no modifier → 10 rows (page-ish) + -- * Shift → 1 row (fine grain) + -- * Ctrl → jump to the extreme; `Ctrl+PgDn` while already + -- at the bottom collapses the window. + -- Matches the legacy chat.lua page-key binding so muscle memory + -- carries over, with `Ctrl` covering the jump-to-extreme case that + -- Home / End would normally serve — those are consumed by the Edit + -- control for caret navigation before they reach this handler, so + -- `OnNonTextKeyPressed` never sees them. -- Up / Down cycle the command-hint selection while the hint is open. -- Lazy import of ChatInterface avoids the import cycle: ChatInterface -- imports this module at load time, so the reverse edge has to defer. - self.EditBox.OnNonTextKeyPressed = function(_, keycode, modifiers) + ---@param keycode number # OS-level VK_* code; compare against `UIUtil.VK_*` + ---@param event KeyEvent # full input-event payload; modifiers live at `event.Modifiers` + self.EditBox.OnNonTextKeyPressed = function(_, keycode, event) ChatController.NotifyActivity() - local step = modifiers and modifiers.Shift and 1 or 10 + local chatInterface = import("/lua/ui/game/chat/ChatInterface.lua") + local mods = event and event.Modifiers + local ctrl = mods and mods.Ctrl + local step = (mods and mods.Shift) and 1 or 10 if keycode == UIUtil.VK_PRIOR then - import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(-step) + if ctrl then + chatInterface.ScrollToTop() + else + chatInterface.ScrollLines(-step) + end elseif keycode == UIUtil.VK_NEXT then - import("/lua/ui/game/chat/ChatInterface.lua").ScrollLines(step) + if ctrl then + chatInterface.ScrollToBottomOrClose() + else + chatInterface.ScrollLines(step) + end elseif keycode == UIUtil.VK_UP and self.ChatCommandHintInterface then self.ChatCommandHintInterface:SelectNext() elseif keycode == UIUtil.VK_DOWN and self.ChatCommandHintInterface then diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index ef44f91d4c0..6009dc903ec 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -481,6 +481,33 @@ function ScrollPages(delta) end end +--- Snaps the chat feed to the oldest visible entry. No-op if the window +--- has never been opened. Not bound to a default key — the Edit control +--- consumes Home for caret navigation before `OnNonTextKeyPressed` fires +--- — but exposed for keymap entries (`UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").ScrollToTop()`) +--- and for mods that want a programmatic jump-to-top. +function ScrollToTop() + if Instance then + Instance.ChatLinesInterface:ScrollSetTop(nil, 1) + end +end + +--- Two-stage "jump to bottom" handler. If the chat is already pinned to +--- the newest entry, collapses the window — same intent as the legacy +--- "press End again to dismiss" feel without sneaking in a separate +--- toggle. Otherwise snaps to the bottom. No-op if the window has never +--- been opened. Not bound to a default key (see `ScrollToTop` for the +--- reason); exposed for keymap entries and mods. +function ScrollToBottomOrClose() + if not Instance then return end + local lines = Instance.ChatLinesInterface + if lines:IsAtBottom() then + ChatController.CloseWindow() + else + lines:ScrollToBottom() + end +end + --- Opens the chat window (creating it on first call) and scrolls the feed --- by `delta` rows. Entry point for the global PgUp / PgDn key bindings — --- so pressing PgUp with the window hidden both reveals it and starts diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 325742943b8..bfff23e42f2 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -410,6 +410,19 @@ ChatLinesInterface = ClassUI(Group) { self:CalcVisible() end, + --- True when `ScrollTop` is already pinned at the maximum legal value + --- — i.e. the newest entry is in the bottom-most pool slot and no + --- amount of "scroll down" would change anything. Useful for callers + --- that want a "if already at bottom, do something else" two-stage + --- behaviour (e.g. dismissing the window on a second jump-to-bottom). + ---@param self UIChatLinesInterface + ---@return boolean + IsAtBottom = function(self) + local poolSize = table.getn(self.ChatLineInterfaces) + local maxTop = math.max(1, self.VirtualSize - poolSize + 1) + return self.ScrollTop >= maxTop + end, + --------------------------------------------------------------------------- -- Visibility mapping --------------------------------------------------------------------------- From 435c64ce1fb63a0eeb816c6209975e15c8b2422a Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 09:38:09 +0200 Subject: [PATCH 086/130] Add tooltips for various interactions --- lua/ui/game/chat/ChatEditInterface.lua | 4 ++++ lua/ui/game/chat/ChatInterface.lua | 23 +++++++++++++++++++++++ lua/ui/game/chat/GAPS.md | 2 +- lua/ui/uiutil.lua | 1 + 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index be7c3692a93..4349c7e6263 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -1,6 +1,7 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Group = import("/lua/maui/group.lua").Group local Edit = import("/lua/maui/edit.lua").Edit @@ -85,6 +86,7 @@ ChatEditInterface = ClassUI(Group) { UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds'), UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds')) + Tooltip.AddCheckboxTooltip(self.CamCheckbox, 'chat_camera') self.EditBox = Edit(self) @@ -138,6 +140,7 @@ ChatEditInterface = ClassUI(Group) { -- cycle via `OnTextChanged`. `OnCharPressed` fires before insertion, -- so the `>=` beep catches the keystroke the cap is about to reject. self.EditBox.OnCharPressed = function(edit, charcode) + LOG(charcode) if charcode == UIUtil.VK_TAB then self:HandleTabCompletion() return true @@ -177,6 +180,7 @@ ChatEditInterface = ClassUI(Group) { ---@param keycode number # OS-level VK_* code; compare against `UIUtil.VK_*` ---@param event KeyEvent # full input-event payload; modifiers live at `event.Modifiers` self.EditBox.OnNonTextKeyPressed = function(_, keycode, event) + LOG(keycode) ChatController.NotifyActivity() local chatInterface = import("/lua/ui/game/chat/ChatInterface.lua") local mods = event and event.Modifiers diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 6009dc903ec..3e893ea7ef0 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -1,5 +1,6 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Window = import("/lua/maui/window.lua").Window local Bitmap = import("/lua/maui/bitmap.lua").Bitmap @@ -89,6 +90,7 @@ local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ---@field DragHandleControlMap table # resize-bitmap id → grips to highlight ---@field ResetPositionBtn Button # titlebar button that restores DefaultRect ---@field WindowVisibleObserver LazyVar # derived from ChatModel.WindowVisible +---@field PinnedObserver LazyVar # derived from ChatModel.Pinned; swaps the pin tooltip ---@field OptionsObserver LazyVar # derived from ChatConfigModel.Committed (window-level options only) ---@field ChatFeedInterface UIChatFeedInterface # sibling feed view; visible while the window is hidden ---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true @@ -159,6 +161,25 @@ local ChatInterface = ClassUI(Window) { end ) ) + + -- Title-bar button tooltips. The pin tooltip swaps between + -- `chat_pin` (autohide enabled, click to disable) and `chat_pinned` + -- (autohide disabled, click to enable) reactively from the model so + -- the wording matches the next click's effect. The `_closeBtn` / + -- `_configBtn` / `_pinBtn` fields are owned by `Window` but not in + -- its declared class fields, so the language server can't see them. + ---@diagnostic disable: undefined-field + Tooltip.AddButtonTooltip(self._closeBtn, 'chat_close') + Tooltip.AddButtonTooltip(self._configBtn, 'chat_config') + self.PinnedObserver = self.Trash:Add( + LazyVarDerive( + model.Pinned, + function(lv) + Tooltip.AddCheckboxTooltip(self._pinBtn, lv() and 'chat_pinned' or 'chat_pin') + end + ) + ) + ---@diagnostic enable: undefined-field end, --- Creates the four corner resize grips, wires the window's @@ -266,6 +287,8 @@ local ChatInterface = ClassUI(Window) { Layouter(self.ResetPositionBtn) :LeftOf(self._configBtn) :End() + + Tooltip.AddButtonTooltip(self.ResetPositionBtn, 'chat_reset') end, ---@param self UIChatInterface diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index de418a6171a..b73df56083a 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -21,7 +21,6 @@ Both can co-exist: keep the hint behaviour while it's open, and fall through to ## Drag / resize / window-state - **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.legacy.lua:1202-1209](../chat.legacy.lua)). [`ChatInterface.OnMouseWheel`](ChatInterface.lua) doesn't override `HandleEvent`. Whether this matters in practice depends on engine routing — the wheel may already reach the worldview when the chat window is `Hide()`-flagged. Verify before fixing. -- **Button tooltips** — no `Tooltip.AddCheckboxTooltip` / `AddControlTooltip` calls anywhere in the chat tree. Legacy had `chat_pin`, `chat_config`, `chat_close`, `chat_camera`, `chat_reset` ([chat.legacy.lua:1200, 1211-1214](../chat.legacy.lua)). --- @@ -50,6 +49,7 @@ Closed in the most recent rounds of work: - **Colour palette wired** — 8-swatch [`ChatUtils.ColorPalette`](ChatUtils.lua) shared by the config dialog and the line renderer. [`ChatLineInterface`](ChatLineInterface.lua) resolves body-text colour through `ResolveBodyColor(entry)`, prioritising `entry.BodyColor` (explicit override for system / synthetic lines) over `entry.ColorKey` (palette lookup) over a hardcoded fallback. [`ChatController.AppendChatLine`](ChatController.lua) stamps `ColorKey` based on routing — camera-link / `Location` entries and observer broadcasts both pick up `link_color`; everyone else uses `ToStrings[recipient].colorkey`. Picking a different swatch in the config dialog repaints visible lines on the next `CalcVisible` pass. - **Pin button** — [`ChatModel`](ChatModel.lua) carries a `Pinned: LazyVar`; [`ChatController.SetPinned`](ChatController.lua) writes it (and stamps a fresh `LastActivity` on unpin so the user gets a full `fade_time` window after toggling off). [`ChatInterface.OnPinCheck`](ChatInterface.lua) wires the existing title-bar checkbox to the controller; [`ChatInterface.OnFrame`](ChatInterface.lua) short-circuits the auto-close check while pinned, so the window stays open through arbitrary inactivity. - **Eager chat bootstrap** — [`ChatController.Init`](ChatController.lua) calls `ChatInterface.EnsureInstance()` at game start so the chat tree (and its sibling feed) exists before any messages arrive. The window itself stays hidden by default; only the feed observers are needed up front, and they're now subscribed in time to surface chat the user receives before first opening the dialog. +- **Button tooltips** — `chat_close` / `chat_config` on the title-bar buttons via `Tooltip.AddButtonTooltip` in [`ChatInterface.__init`](ChatInterface.lua); `chat_reset` on the reset-position button in [`SetupResetPositionButton`](ChatInterface.lua); `chat_pin` ↔ `chat_pinned` on `_pinBtn` driven reactively by a `model.Pinned` derived observer (`PinnedObserver`) so the wording matches the next click's effect; `chat_camera` on the edit-row checkbox in [`ChatEditInterface.__init`](ChatEditInterface.lua). - **Legacy public API shim** — [`/lua/ui/game/chat.lua`](../chat.lua) is now a thin compatibility layer that re-exports every legacy global mods used to import (`ReceiveChat`, `ReceiveChatFromSim`, `SetupChatLayout`, `OnNISBegin`, `ChatPageUp` / `ChatPageDown`, `CloseChat`, `CloseChatConfig`, `AddChatOptionSetCallback`, `SetLayout`, `GetArmyData`, `GUI`, `ChatLines`). Every entry point logs a one-shot `WARN` deprecation message the first time it's touched and forwards to the equivalent new API where one exists; `GUI` and `ChatLines` are metatable-proxied so any field read or assignment surfaces a clear warning instead of crashing on a missing field. Once the warnings stop appearing in users' logs, the shim and `chat.legacy.lua` can both be deleted. --- diff --git a/lua/ui/uiutil.lua b/lua/ui/uiutil.lua index 482ff4861de..427f5f79ce7 100644 --- a/lua/ui/uiutil.lua +++ b/lua/ui/uiutil.lua @@ -84,6 +84,7 @@ UIRP_PostGlow = 8 VK_BACKSPACE = 8 VK_TAB = 9 VK_ENTER = 13 +VK_CONTROL = 17 VK_ESCAPE = 27 VK_SPACE = 32 VK_PRIOR = 33 From 4dae368a3d3efa451e1a315d3b9e837e4abb12d1 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 09:42:51 +0200 Subject: [PATCH 087/130] Remove log statement --- lua/ui/game/chat/ChatEditInterface.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 4349c7e6263..756cb163a0d 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -140,7 +140,6 @@ ChatEditInterface = ClassUI(Group) { -- cycle via `OnTextChanged`. `OnCharPressed` fires before insertion, -- so the `>=` beep catches the keystroke the cap is about to reject. self.EditBox.OnCharPressed = function(edit, charcode) - LOG(charcode) if charcode == UIUtil.VK_TAB then self:HandleTabCompletion() return true @@ -180,7 +179,6 @@ ChatEditInterface = ClassUI(Group) { ---@param keycode number # OS-level VK_* code; compare against `UIUtil.VK_*` ---@param event KeyEvent # full input-event payload; modifiers live at `event.Modifiers` self.EditBox.OnNonTextKeyPressed = function(_, keycode, event) - LOG(keycode) ChatController.NotifyActivity() local chatInterface = import("/lua/ui/game/chat/ChatInterface.lua") local mods = event and event.Modifiers From 6f875b8471c1a161d7098f082cd6022626aa9371 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 09:45:18 +0200 Subject: [PATCH 088/130] Add support for legacy notify chat commands --- lua/ui/game/chat/GAPS.md | 7 +-- .../chat/commands/ChatCommandRegistry.lua | 45 ++++++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index b73df56083a..39ac3adc37f 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -6,12 +6,6 @@ Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file o --- -## Notify-command bridge - -Slash commands in the legacy dispatcher fell through to [`RunChatCommand`](../../notify/commands.lua) ([chat.legacy.lua:729](../chat.legacy.lua)) so `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay` worked from chat. The new [`ChatCommandRegistry`](commands/ChatCommandRegistry.lua) dispatcher does not, so those commands are dead. - -Easy fix: an "unknown command" fallback in the dispatcher that hands off to `RunChatCommand` before reporting an error to the user. - ## Command history recall (↑ / ↓) Legacy kept a `commandHistory` ring and recalled it on `VK_UP` / `VK_DOWN` ([chat.legacy.lua:681-701](../chat.legacy.lua)). Not ported. The new [`ChatEditInterface.OnNonTextKeyPressed`](ChatEditInterface.lua) maps Up / Down to `CommandHint:SelectNext` / `SelectPrev` while the hint is open; outside of an open hint, Up / Down do nothing. @@ -51,6 +45,7 @@ Closed in the most recent rounds of work: - **Eager chat bootstrap** — [`ChatController.Init`](ChatController.lua) calls `ChatInterface.EnsureInstance()` at game start so the chat tree (and its sibling feed) exists before any messages arrive. The window itself stays hidden by default; only the feed observers are needed up front, and they're now subscribed in time to surface chat the user receives before first opening the dialog. - **Button tooltips** — `chat_close` / `chat_config` on the title-bar buttons via `Tooltip.AddButtonTooltip` in [`ChatInterface.__init`](ChatInterface.lua); `chat_reset` on the reset-position button in [`SetupResetPositionButton`](ChatInterface.lua); `chat_pin` ↔ `chat_pinned` on `_pinBtn` driven reactively by a `model.Pinned` derived observer (`PinnedObserver`) so the wording matches the next click's effect; `chat_camera` on the edit-row checkbox in [`ChatEditInterface.__init`](ChatEditInterface.lua). - **Legacy public API shim** — [`/lua/ui/game/chat.lua`](../chat.lua) is now a thin compatibility layer that re-exports every legacy global mods used to import (`ReceiveChat`, `ReceiveChatFromSim`, `SetupChatLayout`, `OnNISBegin`, `ChatPageUp` / `ChatPageDown`, `CloseChat`, `CloseChatConfig`, `AddChatOptionSetCallback`, `SetLayout`, `GetArmyData`, `GUI`, `ChatLines`). Every entry point logs a one-shot `WARN` deprecation message the first time it's touched and forwards to the equivalent new API where one exists; `GUI` and `ChatLines` are metatable-proxied so any field read or assignment surfaces a clear warning instead of crashing on a missing field. Once the warnings stop appearing in users' logs, the shim and `chat.legacy.lua` can both be deleted. +- **Notify-command bridge** — [`ChatCommandRegistry.Dispatch`](commands/ChatCommandRegistry.lua) now falls through to [`/lua/ui/notify/commands.lua`](../../notify/commands.lua)'s `RunChatCommand` when no built-in command matches, mirroring the legacy fan-out. Builds the `args` shape it expects (lowercased command name in slot 1, lowercased remaining tokens after) and `pcall`s the call so a third-party command throwing doesn't propagate up through the chat send path. Only surfaces the "Invalid command — type /help" error if Notify also declines. Keeps `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay`, and any future `AddChatCommand` registration alive without us having to re-register them in our own registry. --- diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 2730ee402b6..16fa04eff09 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -1,4 +1,3 @@ - local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") ------------------------------------------------------------------------------- @@ -259,6 +258,45 @@ end ------------------------------------------------------------------------------- -- Dispatch +--- Last-chance fallback for slash commands that don't match anything in our +--- own registry. Hands off to the legacy [`RunChatCommand`](/lua/ui/notify/commands.lua) +--- entry point, which the [Notify](/lua/ui/notify/notify.lua) module +--- populates via `AddChatCommand` (`/enablenotify`, `/disablenotify`, +--- `/enablenotifyoverlay`, `/disablenotifyoverlay`, …). Mirrors the +--- legacy chat dispatcher's fall-through so those commands keep working +--- through the new chat without us having to re-register them. +--- +--- Named "Legacy" deliberately — anything that goes through here is +--- pre-MVC tech. New commands should be defined under +--- [`commands/builtin/`](commands/builtin/) and registered through +--- `Register` / `RegisterFromPath`; this path exists purely to avoid +--- breaking external callers that already use `AddChatCommand`. +--- +--- The args shape matches what the legacy dispatcher passed: the +--- lowercased command name in slot 1, lowercased remaining tokens after. +--- Wrapped in `pcall` for the same reason `cmd.Accept` / `cmd.Execute` +--- are — a third-party command throwing shouldn't leak up through the +--- chat send path. +---@param name string # the slash-stripped command word, original case +---@param tokens string[] # remaining tokens (after the command word) +---@return boolean handled +local function DispatchLegacy(name, tokens) + local args = { string.lower(name) } + for _, tok in ipairs(tokens) do + table.insert(args, string.lower(tok)) + end + local pcallOk, handled = pcall( + import("/lua/ui/notify/commands.lua").RunChatCommand, + args) + if not pcallOk then + WARN(string.format( + "/%s: legacy command fallback errored (%s).", + name, tostring(handled))) + return false + end + return handled and true or false +end + --- Parses a chat line that starts with '/' and invokes the matching command. --- Return values: --- (true, nil) → command ran (or was accept-rejected and already reported) @@ -280,6 +318,11 @@ function Dispatch(text) local cmd = Lookup(name) if not cmd then + -- Try the legacy `RunChatCommand` registry before giving up so + -- pre-MVC commands (e.g. Notify's `/enablenotify`) still run. + if DispatchLegacy(name, tokens) then + return true, nil + end return false, string.format("Invalid command: /%s. Type /help for a list.", name) end From 4c891de9b045a78fd1e76129417c4b9a6b9f5db1 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 09:51:20 +0200 Subject: [PATCH 089/130] Enable scrolling through chat feed --- lua/ui/game/chat/ChatFeedInterface.lua | 6 ++++++ lua/ui/game/chat/GAPS.md | 6 +----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lua/ui/game/chat/ChatFeedInterface.lua b/lua/ui/game/chat/ChatFeedInterface.lua index d20ccb18756..aa7b886126c 100644 --- a/lua/ui/game/chat/ChatFeedInterface.lua +++ b/lua/ui/game/chat/ChatFeedInterface.lua @@ -212,6 +212,12 @@ ChatFeedInterface = ClassUI(Group) { else line:SetContinuation(entry, chunk) end + -- After the header/continuation pass, because `SetHeader` toggles + -- `CamIcon:EnableHitTest()` based on whether the entry has a + -- camera/location attachment — calling `DisableInteraction` last + -- guarantees nothing on the row can swallow a click or wheel + -- event meant for the worldview underneath. + line:DisableHitTest(true) line:SetAlpha(1.0, true) -- Readability strip behind the row. Solid-black at full alpha; diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 39ac3adc37f..fb6fbd00722 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -12,11 +12,6 @@ Legacy kept a `commandHistory` ring and recalled it on `VK_UP` / `VK_DOWN` ([cha Both can co-exist: keep the hint behaviour while it's open, and fall through to history recall when `self.ChatCommandHintInterface == nil`. -## Drag / resize / window-state - -- **Wheel-forward when hidden** — legacy `GUI.bg.HandleEvent` forwarded scroll to the worldview when chat was hidden ([chat.legacy.lua:1202-1209](../chat.legacy.lua)). [`ChatInterface.OnMouseWheel`](ChatInterface.lua) doesn't override `HandleEvent`. Whether this matters in practice depends on engine routing — the wheel may already reach the worldview when the chat window is `Hide()`-flagged. Verify before fixing. - - --- ## Already closed (do not re-list) @@ -44,6 +39,7 @@ Closed in the most recent rounds of work: - **Pin button** — [`ChatModel`](ChatModel.lua) carries a `Pinned: LazyVar`; [`ChatController.SetPinned`](ChatController.lua) writes it (and stamps a fresh `LastActivity` on unpin so the user gets a full `fade_time` window after toggling off). [`ChatInterface.OnPinCheck`](ChatInterface.lua) wires the existing title-bar checkbox to the controller; [`ChatInterface.OnFrame`](ChatInterface.lua) short-circuits the auto-close check while pinned, so the window stays open through arbitrary inactivity. - **Eager chat bootstrap** — [`ChatController.Init`](ChatController.lua) calls `ChatInterface.EnsureInstance()` at game start so the chat tree (and its sibling feed) exists before any messages arrive. The window itself stays hidden by default; only the feed observers are needed up front, and they're now subscribed in time to surface chat the user receives before first opening the dialog. - **Button tooltips** — `chat_close` / `chat_config` on the title-bar buttons via `Tooltip.AddButtonTooltip` in [`ChatInterface.__init`](ChatInterface.lua); `chat_reset` on the reset-position button in [`SetupResetPositionButton`](ChatInterface.lua); `chat_pin` ↔ `chat_pinned` on `_pinBtn` driven reactively by a `model.Pinned` derived observer (`PinnedObserver`) so the wording matches the next click's effect; `chat_camera` on the edit-row checkbox in [`ChatEditInterface.__init`](ChatEditInterface.lua). +- **Wheel-forward when hidden / feed non-interactability** — two complementary fixes covering the same intent (input passes through to the worldview when chat isn't focused). [`ChatInterface.__init`](ChatInterface.lua) captures `Window.HandleEvent` as `OldHandleEvent` and wraps it: a wheel rotation while `IsHidden()` forwards to `worldview.ForwardMouseWheelInput` and returns true; everything else falls through to the original. [`ChatLineInterface.DisableInteraction`](ChatLineInterface.lua) disables hit-testing on every visible part of the row (group, `TeamColor`, `FactionIcon`, `Name`, `CamIcon`, `Text`); [`ChatFeedInterface.AppendRow`](ChatFeedInterface.lua) calls it after the `SetHeader` / `SetContinuation` pass (which itself toggles `CamIcon:EnableHitTest` based on the entry payload), so feed rows never swallow a click or wheel event meant for the world. The chat-window line pool is unaffected — `RebuildPool` allocates its own rows, so feed lines never migrate back into the interactive view. - **Legacy public API shim** — [`/lua/ui/game/chat.lua`](../chat.lua) is now a thin compatibility layer that re-exports every legacy global mods used to import (`ReceiveChat`, `ReceiveChatFromSim`, `SetupChatLayout`, `OnNISBegin`, `ChatPageUp` / `ChatPageDown`, `CloseChat`, `CloseChatConfig`, `AddChatOptionSetCallback`, `SetLayout`, `GetArmyData`, `GUI`, `ChatLines`). Every entry point logs a one-shot `WARN` deprecation message the first time it's touched and forwards to the equivalent new API where one exists; `GUI` and `ChatLines` are metatable-proxied so any field read or assignment surfaces a clear warning instead of crashing on a missing field. Once the warnings stop appearing in users' logs, the shim and `chat.legacy.lua` can both be deleted. - **Notify-command bridge** — [`ChatCommandRegistry.Dispatch`](commands/ChatCommandRegistry.lua) now falls through to [`/lua/ui/notify/commands.lua`](../../notify/commands.lua)'s `RunChatCommand` when no built-in command matches, mirroring the legacy fan-out. Builds the `args` shape it expects (lowercased command name in slot 1, lowercased remaining tokens after) and `pcall`s the call so a third-party command throwing doesn't propagate up through the chat send path. Only surfaces the "Invalid command — type /help" error if Notify also declines. Keeps `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay`, and any future `AddChatCommand` registration alive without us having to re-register them in our own registry. From 7c87ba33b778418fac711f088b1858e6849b6481 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 10:12:45 +0200 Subject: [PATCH 090/130] Add support for going up/down previous messages --- .claude/settings.local.json | 3 +- lua/ui/game/chat/ChatEditInterface.lua | 98 ++++++++++++++++++++++++-- lua/ui/game/chat/GAPS.md | 8 +-- 3 files changed, 95 insertions(+), 14 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 498d2dcd9f8..e046d37ef9f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(git mv *)", "Bash(git -C d:/faf-development/fa mv lua/ui/game/chat/commands/Gift.lua lua/ui/game/chat/commands/GiftUnits.lua)", - "Bash(grep -n \"feed mode\\\\|Feed mode\\\\|FeedMode\\\\|step 1\\\\|Step 1\\\\|## Feed\\\\|# Feed\\\\|## 1\\\\\\\\.\\\\|### 1\\\\\\\\.\\\\|plan.*feed\\\\|feed.*plan\" \"C:/Users/wbwij/.claude/projects/d--faf-development-fa/110ae14a-5ac0-4c8d-a019-efe340367afb.jsonl\")" + "Bash(grep -n \"feed mode\\\\|Feed mode\\\\|FeedMode\\\\|step 1\\\\|Step 1\\\\|## Feed\\\\|# Feed\\\\|## 1\\\\\\\\.\\\\|### 1\\\\\\\\.\\\\|plan.*feed\\\\|feed.*plan\" \"C:/Users/wbwij/.claude/projects/d--faf-development-fa/110ae14a-5ac0-4c8d-a019-efe340367afb.jsonl\")", + "Bash(grep -v \"//\")" ] } } diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 756cb163a0d..c935a5e0231 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -25,6 +25,12 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- distinct colour so overlapping controls can be told apart at a glance. local Debug = false +--- Cap on the command-history ring (newest at the tail). Older entries +--- are dropped when the buffer overflows. 32 is comfortably more than the +--- handful a typical session generates while staying small enough that +--- linear walks stay free. +local MaxCommandHistorySize = 32 + ------------------------------------------------------------------------------- -- The chat input area: a chat-bubble button, a recipient label, and an edit -- box. Pressing Enter dispatches the text to the controller. Clicking the @@ -41,6 +47,8 @@ local Debug = false ---@field RecipientObserver LazyVar # derived from ChatModel.Recipient ---@field Completion UIChatCompletion | nil # active Tab-cycle record, reset on text change ---@field SuppressCompletionReset boolean # true while our own SetText is running +---@field CommandHistory string[] # ring of previously-sent message texts (oldest first); recalled via Up / Down when the hint is closed +---@field RecallEntry number | nil # cursor into `CommandHistory` for the active recall walk; nil when no walk is in progress ---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true ChatEditInterface = ClassUI(Group) { @@ -56,6 +64,8 @@ ChatEditInterface = ClassUI(Group) { self.Completion = nil self.SuppressCompletionReset = false + self.CommandHistory = {} + self.RecallEntry = nil self.ChatBubble = Button(self, UIUtil.UIFile('/game/chat-box_btn/radio_btn_up.dds'), @@ -112,11 +122,13 @@ ChatEditInterface = ClassUI(Group) { -- Pressing Enter on an empty edit box closes the window — matches -- the legacy `chat.lua` shortcut where Enter serves as both "send" -- and "dismiss" depending on whether there's anything to send. - self.EditBox.OnEnterPressed = function(edit, text) + -- Successful sends are appended to the command-history ring so + -- Up / Down can recall them when the hint isn't open. + self.EditBox.OnEnterPressed = function(_, text) ChatController.NotifyActivity() if text and text ~= '' then ChatController.Send(text, self.CamCheckbox:IsChecked()) - edit:SetText('') + self:PushHistory(text) else ChatController.CloseWindow() end @@ -196,10 +208,20 @@ ChatEditInterface = ClassUI(Group) { else chatInterface.ScrollLines(step) end - elseif keycode == UIUtil.VK_UP and self.ChatCommandHintInterface then - self.ChatCommandHintInterface:SelectNext() - elseif keycode == UIUtil.VK_DOWN and self.ChatCommandHintInterface then - self.ChatCommandHintInterface:SelectPrev() + elseif keycode == UIUtil.VK_UP then + -- Hint open → cycle the selection; closed → walk back + -- through the command-history ring, oldest first. + if self.ChatCommandHintInterface then + self.ChatCommandHintInterface:SelectNext() + else + self:RecallPrevious() + end + elseif keycode == UIUtil.VK_DOWN then + if self.ChatCommandHintInterface then + self.ChatCommandHintInterface:SelectPrev() + else + self:RecallNext() + end end end @@ -312,6 +334,70 @@ ChatEditInterface = ClassUI(Group) { c.Consume = replacementLen end, + --------------------------------------------------------------------------- + -- Command history recall + + --- Appends a successfully-sent message to the command-history ring and + --- resets any active recall walk so the next Up press starts at the + --- newest entry. Trims the ring to `MaxCommandHistorySize`. + ---@param self UIChatEditInterface + ---@param text string + PushHistory = function(self, text) + table.insert(self.CommandHistory, text) + while table.getn(self.CommandHistory) > MaxCommandHistorySize do + table.remove(self.CommandHistory, 1) + end + self.RecallEntry = nil + end, + + --- Walks back toward older entries. Empty history is a no-op; the first + --- press lands on the newest entry, subsequent presses move one step + --- earlier each time and clamp at the oldest. + ---@param self UIChatEditInterface + RecallPrevious = function(self) + local count = table.getn(self.CommandHistory) + if count == 0 then return end + if self.RecallEntry then + self.RecallEntry = math.max(self.RecallEntry - 1, 1) + else + self.RecallEntry = count + end + self:ApplyRecall() + end, + + --- Walks forward toward newer entries. After the newest, `RecallEntry` + --- resets to nil so the next Down press blanks the edit (matching the + --- legacy "step past the end clears the line" feel). Empty history + --- with no active recall is a no-op; with no active recall but a + --- non-empty history, blanks the edit so users have a quick "wipe what + --- I'm typing" gesture. + ---@param self UIChatEditInterface + RecallNext = function(self) + local count = table.getn(self.CommandHistory) + if count == 0 then return end + if self.RecallEntry then + self.RecallEntry = math.min(self.RecallEntry + 1, count) + self:ApplyRecall() + if self.RecallEntry == count then + self.RecallEntry = nil + end + else + self.EditBox:SetText('') + end + end, + + --- Writes the entry at `RecallEntry` into the edit box and parks the + --- caret at the end. No-op if `RecallEntry` doesn't reference a real + --- entry — guards against being called between a destructive history + --- mutation and the next nav keystroke. + ---@param self UIChatEditInterface + ApplyRecall = function(self) + local entry = self.CommandHistory[self.RecallEntry or 0] + if not entry then return end + self.EditBox:SetText(entry) + self.EditBox:SetCaretPosition(STR_Utf8Len(entry)) + end, + --- Shows or hides the command hint based on the current edit-box text. --- Only opens when the text transitions to exactly `/` — so closing the --- hint via Escape leaves it closed while the user keeps typing past the diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index fb6fbd00722..559466bb95f 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -6,13 +6,6 @@ Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file o --- -## Command history recall (↑ / ↓) - -Legacy kept a `commandHistory` ring and recalled it on `VK_UP` / `VK_DOWN` ([chat.legacy.lua:681-701](../chat.legacy.lua)). Not ported. The new [`ChatEditInterface.OnNonTextKeyPressed`](ChatEditInterface.lua) maps Up / Down to `CommandHint:SelectNext` / `SelectPrev` while the hint is open; outside of an open hint, Up / Down do nothing. - -Both can co-exist: keep the hint behaviour while it's open, and fall through to history recall when `self.ChatCommandHintInterface == nil`. - ---- ## Already closed (do not re-list) @@ -42,6 +35,7 @@ Closed in the most recent rounds of work: - **Wheel-forward when hidden / feed non-interactability** — two complementary fixes covering the same intent (input passes through to the worldview when chat isn't focused). [`ChatInterface.__init`](ChatInterface.lua) captures `Window.HandleEvent` as `OldHandleEvent` and wraps it: a wheel rotation while `IsHidden()` forwards to `worldview.ForwardMouseWheelInput` and returns true; everything else falls through to the original. [`ChatLineInterface.DisableInteraction`](ChatLineInterface.lua) disables hit-testing on every visible part of the row (group, `TeamColor`, `FactionIcon`, `Name`, `CamIcon`, `Text`); [`ChatFeedInterface.AppendRow`](ChatFeedInterface.lua) calls it after the `SetHeader` / `SetContinuation` pass (which itself toggles `CamIcon:EnableHitTest` based on the entry payload), so feed rows never swallow a click or wheel event meant for the world. The chat-window line pool is unaffected — `RebuildPool` allocates its own rows, so feed lines never migrate back into the interactive view. - **Legacy public API shim** — [`/lua/ui/game/chat.lua`](../chat.lua) is now a thin compatibility layer that re-exports every legacy global mods used to import (`ReceiveChat`, `ReceiveChatFromSim`, `SetupChatLayout`, `OnNISBegin`, `ChatPageUp` / `ChatPageDown`, `CloseChat`, `CloseChatConfig`, `AddChatOptionSetCallback`, `SetLayout`, `GetArmyData`, `GUI`, `ChatLines`). Every entry point logs a one-shot `WARN` deprecation message the first time it's touched and forwards to the equivalent new API where one exists; `GUI` and `ChatLines` are metatable-proxied so any field read or assignment surfaces a clear warning instead of crashing on a missing field. Once the warnings stop appearing in users' logs, the shim and `chat.legacy.lua` can both be deleted. - **Notify-command bridge** — [`ChatCommandRegistry.Dispatch`](commands/ChatCommandRegistry.lua) now falls through to [`/lua/ui/notify/commands.lua`](../../notify/commands.lua)'s `RunChatCommand` when no built-in command matches, mirroring the legacy fan-out. Builds the `args` shape it expects (lowercased command name in slot 1, lowercased remaining tokens after) and `pcall`s the call so a third-party command throwing doesn't propagate up through the chat send path. Only surfaces the "Invalid command — type /help" error if Notify also declines. Keeps `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay`, and any future `AddChatCommand` registration alive without us having to re-register them in our own registry. +- **Command history recall (↑ / ↓)** — [`ChatEditInterface`](ChatEditInterface.lua) keeps a `CommandHistory` ring (capped at `MaxCommandHistorySize = 32`, oldest first) populated by `PushHistory` from `OnEnterPressed` after a successful send. `OnNonTextKeyPressed` routes `↑` / `↓` to `RecallPrevious` / `RecallNext` when the command hint is closed and continues to drive the hint cycle when it's open. Walking past the newest entry blanks the edit, mirroring the legacy "down clears the line" behaviour. --- From 769a1a43ba77f5cdcffee97a64ee6f48a6e456cf Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 10:35:55 +0200 Subject: [PATCH 091/130] Add a skill to make it easier to add chat commands --- .claude/skills/add-chat-command/SKILL.md | 209 +++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 .claude/skills/add-chat-command/SKILL.md diff --git a/.claude/skills/add-chat-command/SKILL.md b/.claude/skills/add-chat-command/SKILL.md new file mode 100644 index 00000000000..8ca59b3449b --- /dev/null +++ b/.claude/skills/add-chat-command/SKILL.md @@ -0,0 +1,209 @@ +--- +name: add-chat-command +description: Add a new in-game chat slash command (e.g. `/foo `) to the FAF chat system. Use when the user asks to add, register, or scaffold a new chat slash-command, or extend the `/help` listing with a new entry. Creates the command file under `lua/ui/game/chat/commands/builtin/`, wires it into `ChatController.RegisterBuiltinCommands`, and follows the parser/Accept/Execute split documented in `lua/ui/game/chat/commands/design.md`. +--- + +# Adding a chat slash-command + +Use this skill when the user wants a new `/something` command available in the in-game chat edit box. + +## Before you start — clarify with the user + +Ask only what you can't infer: + +1. **Canonical name** — the slash word users type (e.g. `whisper`). Lowercase, no spaces. Aliases optional. +2. **What it does** — one sentence; this becomes `Description` and is shown by `/help`. +3. **Parameters** — name, type, and whether each is optional. Types are listed below. If unsure, propose a shape based on similar existing commands and confirm. +4. **Side effect target** — does it call `ctx.Controller.X`, a `SimCallback`, a global engine function, or just print a system line? Most commands route through `ctx.Controller` to preserve MVC discipline. +5. **Gating** — should the command only register in some sessions (observer-only, replay-only, single-player-only, host-only)? If yes, use `ShouldRegister`. + +If the user gives a fully-specified ask ("add `/ping` that prints 'pong' to the local feed"), skip the Q&A and proceed. + +## Files you will touch + +| File | What changes | +|------|--------------| +| `lua/ui/game/chat/commands/builtin/.lua` | **New file**. Exports a single top-level `Command` table. | +| `lua/ui/game/chat/ChatController.lua` | Add one `Registry.RegisterFromPath(...)` line inside `RegisterBuiltinCommands` (around line 102). | + +Do **not** touch: +- [ChatCommandRegistry.lua](lua/ui/game/chat/commands/ChatCommandRegistry.lua) — the dispatcher is generic. +- [ChatCommandTypes.lua](lua/ui/game/chat/commands/ChatCommandTypes.lua) — only edit if the command needs a brand-new parameter resolver type. Prefer reusing existing types. +- [ChatModel.lua](lua/ui/game/chat/ChatModel.lua) — commands never write to the model directly; they go through the controller. + +## File naming + +- Filename matches the command in PascalCase: `whisper` → `Whisper.lua`, `gift-resources` → `GiftResources.lua`. +- Place under `lua/ui/game/chat/commands/builtin/`. +- One command per file. Even if two commands share a helper, give them their own files and lift the helper to a sibling module if needed. + +## Command descriptor template + +```lua + +------------------------------------------------------------------------------- +-- / [] — one-line summary mirroring the Description field, plus +-- any constraints worth flagging to a future maintainer (sim callbacks used, +-- gating rules, surprising defaults). + +---@type UIChatCommand +Command = { + Name = '', + Aliases = { '', '' }, -- optional; remove if none + Description = '', + Params = { + { Name = '', Type = 'String' }, + { Name = '', Type = 'Int', Optional = true }, + }, + ShouldRegister = function() -- optional; remove if always-on + return GetFocusArmy() ~= -1 -- e.g. hide from observers + end, + Accept = function(args, ctx) + -- Semantic checks against runtime state. Return (false, "/: reason.") + -- on rejection. The string is shown to the user as a local system line. + return true + end, + Execute = function(args, ctx) + -- Side effect. Prefer ctx.Controller.X over importing modules directly. + end, +} +``` + +Module file structure rules: + +- The export is a **bare top-level `Command` global**, not `return { Command = ... }`. The registry reads `module.Command` after `import`. Match the existing files — see `Whisper.lua` or `Recall.lua`. +- Do **not** call `Registry.Register` inside the file. Registration happens once, centrally, in `ChatController.RegisterBuiltinCommands`. +- Importing the file must have **no side effects**. The command stays inert until the controller registers it. +- Keep `import(...)` calls at the top of the file (e.g. `local ChatModel = import("/lua/ui/game/chat/ChatModel.lua")` — see `GiftResources.lua`). Don't import inside `Execute`. + +## Parameter types + +Defined in [ChatCommandTypes.lua](lua/ui/game/chat/commands/ChatCommandTypes.lua): + +| Type | Accepts | Resolved value | +|------|---------|----------------| +| `Recipient` | `"all"`, `"allies"`/`"team"`, nickname, army ID | `'all' \| 'allies' \| number` | +| `Player` | nickname or army ID (also accepts a leading `@`) | `number` (army ID) | +| `Int` | integer literal | `number` | +| `String` | one whitespace-delimited token | `string` | +| `Rest` | every remaining token, joined with single spaces | `string` | + +Rules: +- Param order = token-consumption order. +- Only the **last** param may be `Rest`. +- A missing required param produces `"/: missing argument ."` automatically — don't re-check in `Accept`. + +If the command needs a parameter shape none of these cover, add a new resolver to `Resolvers` in `ChatCommandTypes.lua` and the matching string to the `UIChatCommandParamType` alias. Prefer this over ad-hoc string parsing inside `Accept`. + +## Accept vs. Execute + +- **Parser** rejects *structural* errors (missing args, wrong types, unknown name) — you don't write code for these. +- **`Accept`** rejects *semantic* errors that depend on runtime state: target is yourself, observer trying a player-only action, target just disconnected. Return `(false, "/: human reason.")`. The string surfaces as a local system feed line. +- **`Execute`** assumes inputs are valid and runs the side effect. Don't re-validate. + +`Accept` may also normalize `args` in-place — e.g. `GiftResources.lua` rewrites `args.type` to `'mass'`/`'energy'` and back-fills `args.target` from the model when the user omitted it. + +## Error-string convention + +Every user-facing error from a command must start with `"/: "` so failures are self-identifying in the chat feed. Lowercase the message, end with a period. + +```lua +return false, "/whisper: can't whisper yourself." +``` + +## ShouldRegister gating + +Use when the command should be invisible (and unparseable) outside specific sessions. The hook runs once at registration; the command is dropped from the registry **and from `/help`** if it returns false. Examples already in the tree: + +- Observer-only: `return GetFocusArmy() == -1` +- Single-player-only: `return SessionIsGameOver() or SessionIsReplay() or ...` — see `EndMission.lua` for a real example. +- Replay-only: gate on `SessionIsReplay()`. + +Don't use `ShouldRegister` for "is this a valid moment to run" — that's `Accept`'s job. `ShouldRegister` is for "does this command exist at all in this session." + +## Wiring it up + +After writing the file, add one line to [ChatController.lua](lua/ui/game/chat/ChatController.lua) inside `RegisterBuiltinCommands` (around line 102). The list is loosely grouped — keep yours next to thematically similar entries: + +```lua +Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/.lua") +``` + +`RegisterFromPath` is defensive: a missing file, a bad import, a malformed `Command` export, or a throw inside `Register` are all logged and swallowed. So a broken new command can't take down the entire chat system — but verify your file actually got registered (see Verification below). + +## Calling back into the controller + +The execution context passed to `Accept` / `Execute`: + +```lua +---@class UIChatCommandContext +---@field Model UIChatModel +---@field Controller table -- ChatController module +---@field SourceText string -- raw "/whisper Jip hi" text +``` + +Common patterns: + +| Goal | How | +|------|-----| +| Change recipient | `ctx.Controller.SetRecipient(args.target)` | +| Print a local system line (no network) | `ctx.Controller.AppendLocalSystemMessage("text")` | +| Send a chat message via the network | `ctx.Controller.Send("text")` | +| Read current chat state | `ctx.Model.Recipient()`, `ctx.Model.History()`, etc. (call the LazyVar — never store) | +| Sim callback | `SimCallback({ Func = "...", Args = { ... } })` directly | + +Reading a LazyVar: always `ctx.Model.Recipient()` (call it). Never cache it in a local. See `CLAUDE.md` § Reactive State. + +## Worked example: `/ping` + +Adding a tiny `/ping` command that prints "pong" locally — useful as a smoke test. + +**1.** Create `lua/ui/game/chat/commands/builtin/Ping.lua`: + +```lua + +------------------------------------------------------------------------------- +-- /ping — prints "pong" as a local system line. No network traffic. Useful +-- as a smoke test that the command pipeline is alive end-to-end. + +---@type UIChatCommand +Command = { + Name = 'ping', + Description = 'Prints "pong" locally — smoke test for the chat command pipeline.', + Execute = function(_, ctx) + ctx.Controller.AppendLocalSystemMessage("pong") + end, +} +``` + +**2.** Register it in [ChatController.lua](lua/ui/game/chat/ChatController.lua): + +```lua +Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Ping.lua") +``` + +**3.** Verify (see below). + +## Verification + +After making the changes: + +1. **Static check** — re-read both files. Confirm: + - The file exports `Command = { ... }` at top level (not inside `return`). + - Required keys present: `Name` (non-empty string), `Execute` (function). Everything else is optional. + - Every error string returned from `Accept` starts with `"/: "`. + - The `RegisterFromPath` line is inside `RegisterBuiltinCommands`, not at module scope. +2. **Runtime check (ask the user to do this)**: + - Launch a skirmish or replay, open chat, type `/help`. The new command should appear in the list with the right description and aliases. + - Type the new command. Hit each error path (`Accept` rejections) to confirm the error strings render as local system lines. + - Check the game log for `WARN Chat command skipped: …` lines — those mean `RegisterFromPath` rejected the file (path typo, malformed export, bad `Name`/`Execute`). Fix and reload. + +If you can't run the game, say so explicitly rather than claiming the command works. + +## Don'ts + +- **Don't write to `ChatModel` from inside a command.** Go through `ctx.Controller`. Commands are MVC peers of views — read the model, mutate via the controller. +- **Don't call `Registry.Register` from the command file itself.** Central registration in `ChatController` is the only way; it makes hot-reload (the `Reload()` path) work and keeps the bootstrap order deterministic. +- **Don't add `` localization tags to error strings yet.** This is listed as open work in `commands/design.md` § 10 — keep parity with the existing English-only commands so the eventual sweep is uniform. +- **Don't reach into `ChatCommandTypes.lua` to add a one-off resolver.** If a command needs special parsing of one argument, do it inside `Accept`. Only add a new resolver type when two or more commands would share it. +- **Don't import view files** (`Chat*Interface.lua`). Commands are headless — they should run in any context that has a model and controller. From f69e87736155023c3ce5377aa40141e1ff299d13 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 10:38:16 +0200 Subject: [PATCH 092/130] Refine the configuration window --- lua/ui/game/chat/GAPS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 559466bb95f..109a1f2fe62 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -6,6 +6,17 @@ Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file o --- +## Config-dialog polish + +The behavioural parity is essentially complete — what remains is finish on the options dialog itself. None of these break anything; they make the new dialog feel less unfinished than the legacy one. + +- **Per-option tooltips** — legacy [`CreateConfigWindow`](../chat.legacy.lua) attached a tooltip to every control: `chat_color` on each of the five colour combos (`all_color`, `allies_color`, `priv_color`, `link_color`, `notify_color`), `chat_fontsize` on the font-size slider, `chat_fadetime` on the fade-time slider, `chat_alpha` on the window-alpha slider, `chat_filter` on the `links` checkbox, `chat_send_type` on "Default recipient: allies", and `chat_feed_background` on "Show feed background". Tooltip strings are already in [`/lua/ui/help/tooltips.lua`](../../help/tooltips.lua) — this is purely a wiring pass in [`ChatConfigInterface`](config/ChatConfigInterface.lua) using `Tooltip.AddControlTooltip` / `AddCheckboxTooltip` / `AddComboTooltip`. + +- **Skinned chrome and corner drag handles** — legacy's config window built its own [`/game/panel/panel_brd_*`](../chat.legacy.lua) border textures plus four corner drag-handle bitmaps mirroring the chat window's chrome. [`ChatConfigInterface`](config/ChatConfigInterface.lua) uses the bare `Window` default styling; functional (still draggable via the title bar) but visually inconsistent with the chat window. Cosmetic — lowest priority. + +- **Z-order against other dialogs** — legacy [`CreateConfigWindow`](../chat.legacy.lua) called `multifunction.CloseMapDialog()` before opening and pinned `GUI.config.Depth` to `GetFrame(0):GetTopmostDepth() + 1` so the dialog couldn't end up behind another popup. [`ChatConfigInterface.Open`](config/ChatConfigInterface.lua) does neither. Conflict surface is small (you'd have to deliberately stack the chat config on top of another dialog), but it's a one-liner of defensive code. + +--- ## Already closed (do not re-list) From 77c0c428a62ebdec65f4923de9d35cd0b2d27014 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 13:51:29 +0200 Subject: [PATCH 093/130] Add missing functionality to the chat configuration window --- lua/ui/game/chat/GAPS.md | 16 +-- .../game/chat/config/ChatConfigInterface.lua | 100 +++++++++++++++--- 2 files changed, 91 insertions(+), 25 deletions(-) diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md index 109a1f2fe62..a54148f94df 100644 --- a/lua/ui/game/chat/GAPS.md +++ b/lua/ui/game/chat/GAPS.md @@ -6,24 +6,16 @@ Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file o --- -## Config-dialog polish - -The behavioural parity is essentially complete — what remains is finish on the options dialog itself. None of these break anything; they make the new dialog feel less unfinished than the legacy one. - -- **Per-option tooltips** — legacy [`CreateConfigWindow`](../chat.legacy.lua) attached a tooltip to every control: `chat_color` on each of the five colour combos (`all_color`, `allies_color`, `priv_color`, `link_color`, `notify_color`), `chat_fontsize` on the font-size slider, `chat_fadetime` on the fade-time slider, `chat_alpha` on the window-alpha slider, `chat_filter` on the `links` checkbox, `chat_send_type` on "Default recipient: allies", and `chat_feed_background` on "Show feed background". Tooltip strings are already in [`/lua/ui/help/tooltips.lua`](../../help/tooltips.lua) — this is purely a wiring pass in [`ChatConfigInterface`](config/ChatConfigInterface.lua) using `Tooltip.AddControlTooltip` / `AddCheckboxTooltip` / `AddComboTooltip`. - -- **Skinned chrome and corner drag handles** — legacy's config window built its own [`/game/panel/panel_brd_*`](../chat.legacy.lua) border textures plus four corner drag-handle bitmaps mirroring the chat window's chrome. [`ChatConfigInterface`](config/ChatConfigInterface.lua) uses the bare `Window` default styling; functional (still draggable via the title bar) but visually inconsistent with the chat window. Cosmetic — lowest priority. - -- **Z-order against other dialogs** — legacy [`CreateConfigWindow`](../chat.legacy.lua) called `multifunction.CloseMapDialog()` before opening and pinned `GUI.config.Depth` to `GetFrame(0):GetTopmostDepth() + 1` so the dialog couldn't end up behind another popup. [`ChatConfigInterface.Open`](config/ChatConfigInterface.lua) does neither. Conflict surface is small (you'd have to deliberately stack the chat config on top of another dialog), but it's a one-liner of defensive code. - ---- - ## Already closed (do not re-list) Send path, receive path, `FindClients`, controller `Init`, external importers, skin-layout orphan, `ConsoleOutput` sim-side logging, empty-text Enter, `ActivateChat` (Enter-key hook with Shift → allies), `chat.lua` renamed to `chat.legacy.lua`. Closed in the most recent rounds of work: +- **Config-dialog tooltips** — every interactive control in [`ChatConfigInterface`](config/ChatConfigInterface.lua) carries the legacy tooltip key it had before: `chat_color` on each colour combo and its label, `chat_fontsize` / `chat_fadetime` / `chat_alpha` on each slider and its label, and `chat_filter` / `chat_send_type` / `chat_feed_background` on the three behaviour checkboxes via `Tooltip.AddControlTooltip` / `AddCheckboxTooltip`. Tooltip strings come from [`/lua/ui/help/tooltips.lua`](../../help/tooltips.lua) without modification. +- **Config-dialog skinned chrome** — [`ChatConfigInterface`](config/ChatConfigInterface.lua) now passes a `WindowTextures` table built from `panel_brd_*` `SkinnableFile`s into `Window.__init`, matching the legacy chat-options dialog (the chat window itself uses the bespoke `chat_brd_*` set; the two are intentionally different sizes). Four decorative corner grips (`drag-handle-{ul,ur,ll,lr}_btn_up.dds`) are added in `__init` with hit-test disabled and laid out overhanging the corners — purely cosmetic since `lockSize` is `true`. +- **Config-dialog Z-order** — [`ChatConfigInterface.Open`](config/ChatConfigInterface.lua) calls `multifunction.CloseMapDialog()` before constructing the window and pins `Instance.Depth` to `GetFrame(0):GetTopmostDepth() + 1` so a later popup can't slide on top of the chat options. + - **Camera links** — outgoing (`CamCheckbox`), incoming render (`CamIcon` on the line), click-to-jump (`OnCameraClicked` with both `Camera` and `Location` paths). - **Private reply by clicking a name** — `OnNameClicked` overridable on [`ChatLinesInterface`](ChatLinesInterface.lua); the chat window installs a handler that sets `Recipient` and re-acquires edit focus. Self-name clicks are filtered. - **Window auto-close timer** — `LastActivity` LazyVar in the model, `NotifyActivity()` heartbeat in the controller, [`ChatInterface.OnFrame`](ChatInterface.lua) compares `GetSystemTimeSeconds() - LastActivity()` to `fade_time`. Hooked into edit keystrokes, scroll, mouse wheel, drag, resize, recipient-picker hover, and `AppendEntry`. diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 996e2c55159..297f8fc922e 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -1,5 +1,6 @@ local UIUtil = import("/lua/ui/uiutil.lua") local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") local Window = import("/lua/maui/window.lua").Window local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo @@ -19,18 +20,41 @@ local Layouter = LayoutHelpers.ReusedLayoutFor --- distinct colour so overlapping controls can be told apart at a glance. local Debug = false +--- Skin textures for the config window frame. Mirrors the legacy chat-options +--- dialog, which used the generic `panel_brd_*` chrome rather than the chat +--- window's bespoke `chat_brd_*` art (the two windows are different sizes). +--- `SkinnableFile` returns a callable that re-resolves against the active +--- skin, so the border bitmaps follow the user's skin choice. +---@diagnostic disable: param-type-mismatch +local WindowTextures = { + tl = UIUtil.SkinnableFile('/game/panel/panel_brd_ul.dds'), + tr = UIUtil.SkinnableFile('/game/panel/panel_brd_ur.dds'), + tm = UIUtil.SkinnableFile('/game/panel/panel_brd_horz_um.dds'), + ml = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_l.dds'), + m = UIUtil.SkinnableFile('/game/panel/panel_brd_m.dds'), + mr = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_r.dds'), + bl = UIUtil.SkinnableFile('/game/panel/panel_brd_ll.dds'), + bm = UIUtil.SkinnableFile('/game/panel/panel_brd_lm.dds'), + br = UIUtil.SkinnableFile('/game/panel/panel_brd_lr.dds'), + borderColor = 'ff415055', +} +---@diagnostic enable: param-type-mismatch + +-- Each colour combo points at the same `chat_color` tooltip — legacy did the +-- same; the per-row label already tells the user *which* recipient the swatch +-- is for, so the tooltip's job is just to explain the control. local ColorDefs = { - { Key = ChatConfigModel.KeyAllColor, Text = "All" }, - { Key = ChatConfigModel.KeyAlliesColor, Text = "Allies" }, - { Key = ChatConfigModel.KeyPrivColor, Text = "Private" }, - { Key = ChatConfigModel.KeyLinkColor, Text = "Links" }, - { Key = ChatConfigModel.KeyNotifyColor, Text = "Notify" }, + { Key = ChatConfigModel.KeyAllColor, Text = "All", Tooltip = 'chat_color' }, + { Key = ChatConfigModel.KeyAlliesColor, Text = "Allies", Tooltip = 'chat_color' }, + { Key = ChatConfigModel.KeyPrivColor, Text = "Private", Tooltip = 'chat_color' }, + { Key = ChatConfigModel.KeyLinkColor, Text = "Links", Tooltip = 'chat_color' }, + { Key = ChatConfigModel.KeyNotifyColor, Text = "Notify", Tooltip = 'chat_color' }, } local CheckboxDefs = { - { Key = ChatConfigModel.KeySendType, Text = "Default recipient: allies" }, - { Key = ChatConfigModel.KeyFeedBackground, Text = "Show feed background" }, - { Key = ChatConfigModel.KeyLinks, Text = "Show camera links" }, + { Key = ChatConfigModel.KeySendType, Text = "Default recipient: allies", Tooltip = 'chat_send_type' }, + { Key = ChatConfigModel.KeyFeedBackground, Text = "Show feed background", Tooltip = 'chat_feed_background' }, + { Key = ChatConfigModel.KeyLinks, Text = "Show camera links", Tooltip = 'chat_filter' }, } ------------------------------------------------------------------------------- @@ -63,6 +87,10 @@ local CheckboxDefs = { ---@field BtnReset Button ---@field BtnOk Button ---@field BtnCancel Button +---@field DragTL Bitmap # decorative top-left corner grip +---@field DragTR Bitmap # decorative top-right corner grip +---@field DragBL Bitmap # decorative bottom-left corner grip +---@field DragBR Bitmap # decorative bottom-right corner grip ---@field PendingObserver LazyVar # derived from ChatConfigModel.Pending ---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true local ChatConfigInterface = ClassUI(Window) { @@ -72,7 +100,7 @@ local ChatConfigInterface = ClassUI(Window) { __init = function(self, parent) Window.__init(self, parent, "Chat Configuration", false, false, false, true, false, "chat_config_v7", { Left = 200, Top = 200, Right = 500, Bottom = 640, - }) + }, WindowTextures) -- Single trash bag for everything we allocate that needs explicit -- destruction — currently just the derived observer LazyVars. @@ -95,6 +123,10 @@ local ChatConfigInterface = ClassUI(Window) { row.Combo.OnClick = function(_, index) ChatConfigController.SetOption(key, index) end + -- Tooltip on both label and combo so the user gets the same + -- explanation no matter which side of the row they hover. + Tooltip.AddControlTooltip(row.Label, def.Tooltip) + Tooltip.AddControlTooltip(row.Combo, def.Tooltip) self.ColorRows[i] = row end @@ -118,6 +150,8 @@ local ChatConfigInterface = ClassUI(Window) { self.SliderFontSize.OnValueChanged = function(_, value) self.LabelFontSize:SetText(string.format("Font Size: %d", value)) end + Tooltip.AddControlTooltip(self.LabelFontSize, 'chat_fontsize') + Tooltip.AddControlTooltip(self.SliderFontSize, 'chat_fontsize') self.LabelFadeTime = UIUtil.CreateText(client, "Fade Time: 15s", 10, UIUtil.bodyFont) self.SliderFadeTime = IntegerSlider(client, false, @@ -131,6 +165,8 @@ local ChatConfigInterface = ClassUI(Window) { self.SliderFadeTime.OnValueChanged = function(_, value) self.LabelFadeTime:SetText(string.format("Fade Time: %ds", value)) end + Tooltip.AddControlTooltip(self.LabelFadeTime, 'chat_fadetime') + Tooltip.AddControlTooltip(self.SliderFadeTime, 'chat_fadetime') self.LabelWinAlpha = UIUtil.CreateText(client, "Window Alpha: 100%", 10, UIUtil.bodyFont) self.SliderWinAlpha = IntegerSlider(client, false, @@ -144,6 +180,8 @@ local ChatConfigInterface = ClassUI(Window) { self.SliderWinAlpha.OnValueChanged = function(_, value) self.LabelWinAlpha:SetText(string.format("Window Alpha: %d%%", value)) end + Tooltip.AddControlTooltip(self.LabelWinAlpha, 'chat_alpha') + Tooltip.AddControlTooltip(self.SliderWinAlpha, 'chat_alpha') -- ---- Checkboxes ---- self.LabelBehavior = UIUtil.CreateText(client, "Behavior", 12, UIUtil.titleFont) @@ -155,6 +193,7 @@ local ChatConfigInterface = ClassUI(Window) { cb.OnCheck = function(_, checked) ChatConfigController.SetOption(key, checked) end + Tooltip.AddCheckboxTooltip(cb, def.Tooltip) self.Checkboxes[i] = cb end @@ -181,8 +220,14 @@ local ChatConfigInterface = ClassUI(Window) { end -- ---- Buttons ---- + -- Apply also pops the chat window open. The user just spent time + -- tuning options against it, so showing the result immediately — + -- even if the window was hidden before — is what they expect. self.BtnApply = UIUtil.CreateButtonStd(client, '/widgets02/small', "Apply", 14) - self.BtnApply.OnClick = function() ChatConfigController.Apply() end + self.BtnApply.OnClick = function() + ChatConfigController.Apply() + import("/lua/ui/game/chat/ChatInterface.lua").Open() + end self.BtnReset = UIUtil.CreateButtonStd(client, '/widgets02/small', "Reset", 14) self.BtnReset.OnClick = function() ChatConfigController.Reset() end @@ -199,6 +244,20 @@ local ChatConfigInterface = ClassUI(Window) { import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() end + -- ---- Decorative corner grips ---- + -- Bitmaps overhanging the four corners, mirroring the chat window's + -- chrome so the config dialog visually belongs to the same family. + -- Hit-test stays off — `lockSize` is true on this window, so the grips + -- are pure decoration; routing clicks through them would only confuse + -- the underlying Title-bar drag handler. + self.DragTL = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) + self.DragTR = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) + self.DragBL = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) + self.DragBR = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) + for _, grip in { self.DragTL, self.DragTR, self.DragBL, self.DragBR } do + grip:DisableHitTest() + end + -- ---- Reactive: sync all controls whenever pending options change ---- -- `LazyVarDerive` gives us a fresh per-subscriber LazyVar so we don't -- stomp other subscribers on Pending (see the chat CLAUDE.md). @@ -206,9 +265,9 @@ local ChatConfigInterface = ClassUI(Window) { self.PendingObserver = self.Trash:Add( LazyVarDerive( model.Pending, - function(lv) - self:RefreshFromOptions(lv() - ) + function(pendingLazy) + local pending = pendingLazy() + self:RefreshFromOptions(pending) end ) ) @@ -337,6 +396,14 @@ local ChatConfigInterface = ClassUI(Window) { local bottomPadScaled = LayoutHelpers.ScaleNumber(16) self.Bottom:Set(function() return self.BtnCancel.Bottom() + bottomPadScaled end) + -- Corner grips overhang the window edge. Offsets mirror the chat + -- window's grips so the visual reads identically across the two + -- dialogs. `Over(self, 5)` keeps them above the window chrome. + Layouter(self.DragTL):AtLeftTopIn(self, -26, -8):Over(self, 5):End() + Layouter(self.DragTR):AtRightTopIn(self, -22, -8):Over(self, 5):End() + Layouter(self.DragBL):AtLeftBottomIn(self, -26, -8):Over(self, 5):End() + Layouter(self.DragBR):AtRightBottomIn(self, -22, -8):Over(self, 5):End() + if Debug then self.DebugBG = Bitmap(self) self.DebugBG:SetSolidColor('40ff8040') @@ -398,7 +465,14 @@ function Open() return end + -- Mirror the legacy chat-options behaviour: dismiss any open map dialog + -- (build/order popup, etc.) before opening, then pin our depth above + -- everything else so a later popup can't slide on top of us. Without + -- this the config dialog can end up sandwiched behind another popup + -- and look stuck. + import("/lua/ui/game/multifunction.lua").CloseMapDialog() Instance = ChatConfigInterface(GetFrame(0)) + Instance.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) end --- Closes and destroys the config dialog. From e7be56c30306b481e4bba0f6457bc2c6ddc7076d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 13:51:54 +0200 Subject: [PATCH 094/130] Moving the chat window is considered activity so that it does not close --- lua/ui/game/chat/ChatInterface.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 3e893ea7ef0..4513e90c56e 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -410,6 +410,15 @@ local ChatInterface = ClassUI(Window) { self.DragBR:SetTexture(self.DragBR.textures.up) end, + --- Engine-invoked continuously while the user drags the window by its + --- title bar. Mirrors `OnResize`: a long drag must not let the auto-close + --- timer expire mid-move, so every frame counts as activity. The engine + --- passes `(x, y, firstFrame)` after `self`, but we don't need them — + --- Lua silently drops trailing args. + OnMove = function(self) + ChatController.NotifyActivity() + end, + --- Engine-invoked when the user finishes dragging the window. The drag --- handler steals focus mid-move, so re-acquire it so the user can keep --- typing without a second click on the edit box. From 36c041cc090caa4a32ac0001f9312271f6fc57ba Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 13:52:15 +0200 Subject: [PATCH 095/130] Add more guard rails for Claude --- annotation.md | 47 ++++++ lua/ui/CLAUDE.md | 395 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 lua/ui/CLAUDE.md diff --git a/annotation.md b/annotation.md index 7e6fd8f4a11..4c1f107b86e 100644 --- a/annotation.md +++ b/annotation.md @@ -103,3 +103,50 @@ BaseManager = ClassSimple { ``` Two examples of annotating a class. Note that fields need to be added manually, specifically those that are populated in the instance of a class. + +## Class fields + +Every field assigned to `self` inside a constructor or initialization function (`__init`, `__post_init`, `Create`, `OnCreate`, etc.) must have a matching `---@field` annotation on the class definition. This gives the language server full type information across the whole file and makes the class self-documenting at a glance. + +### Rule + +Annotate the class immediately above the class declaration. List every field in the order it appears in the constructor. For fields whose type is an array of a named struct, define that struct as its own `---@class` above the main class. + +### Example + +```lua +---@class UIChatConfigColorRow +---@field label Text +---@field combo BitmapCombo +---@field key string + +---@class UIChatConfigInterface : Window +---@field LabelColors Text +---@field ColorRows UIChatConfigColorRow[] +---@field LabelFontSize Text +---@field SliderFontSize IntegerSlider +---@field LabelBehavior Text +---@field Checkboxes Checkbox[] +---@field BtnApply Button +---@field BtnOk Button +local ChatConfigInterface = ClassUI(Window) { + __init = function(self, parent, ...) + self.LabelColors = UIUtil.CreateText(...) + self.ColorRows = {} + self.LabelFontSize = UIUtil.CreateText(...) + self.SliderFontSize = IntegerSlider(...) + self.LabelBehavior = UIUtil.CreateText(...) + self.Checkboxes = {} + self.BtnApply = UIUtil.CreateButtonStd(...) + self.BtnOk = UIUtil.CreateButtonStd(...) + end, +} +``` + +### What counts as a field + +- Every `self.Foo` written in any constructor or init function (`__init`, `__post_init`, `Create`, `OnCreate`, etc.). +- Fields inherited from the parent class do **not** need repeating — the `: Parent` in the `---@class` declaration inherits them. +- Methods defined inside the class table (`Foo = function(self, ...) end` entries) do **not** need `---@field` — the language server picks them up from the function signature. +- Temporary locals inside a method are not fields and need no annotation. +- Optional fields (assigned only on some code paths) should be marked with `?`, e.g. `---@field DebugBG? Bitmap`. diff --git a/lua/ui/CLAUDE.md b/lua/ui/CLAUDE.md new file mode 100644 index 00000000000..4abd43add8a --- /dev/null +++ b/lua/ui/CLAUDE.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/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. From 21e2c135cdb255de1ae436d78a7e0c3bdd345880 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 14:03:10 +0200 Subject: [PATCH 096/130] Always snap windows to the available screen area --- lua/maui/window.lua | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/lua/maui/window.lua b/lua/maui/window.lua index 69e3fec232b..d4a47ac058a 100644 --- a/lua/maui/window.lua +++ b/lua/maui/window.lua @@ -477,18 +477,25 @@ Window = ClassUI(Group) { self.Left:Set(math.max(math.min(location.right, parent.Right()) - oldWidth), parent.Left()) end -- new version in preference file that does support UI scaling - else - local top = location.top - local left = location.left - local width = location.width - local height = location.height + else + local top = location.top + local left = location.left + -- width/height are stored inverse-scaled so the saved size + -- survives a ui_scale change; rescale them now. + local width = LayoutHelpers.ScaleNumber(location.width) + local height = LayoutHelpers.ScaleNumber(location.height) + + -- Clamp into the parent rect so a saved position can't put + -- the window off-screen after a resolution change shrinks + -- the parent. Mirrors the old-prefs branch above and the + -- title-bar drag clamp in `TitleGroup.HandleEvent`. + left = math.max(parent.Left(), math.min(left, parent.Right() - width)) + top = math.max(parent.Top(), math.min(top, parent.Bottom() - height)) self.Left:Set(left) self.Top:Set(top) - - -- we can scale these accordingly as we applied the inverse on saving - self.Right:Set(LayoutHelpers.ScaleNumber(width) + left) - self.Bottom:Set(LayoutHelpers.ScaleNumber(height) + top) + self.Right:Set(left + width) + self.Bottom:Set(top + height) end elseif defaultPosition then -- Scale only if it's a number, else it's already scaled lazyvar From ef22a6f5aca25dd653a41febe2f3e4970e869f61 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 14:03:19 +0200 Subject: [PATCH 097/130] Update claude.md for the chat window --- lua/ui/game/chat/CLAUDE.md | 452 +++++++++++++------------------------ 1 file changed, 153 insertions(+), 299 deletions(-) diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md index 8285016f6b1..7bcdd0e88ec 100644 --- a/lua/ui/game/chat/CLAUDE.md +++ b/lua/ui/game/chat/CLAUDE.md @@ -1,6 +1,8 @@ # Chat — Refactoring Guide -This directory contains the refactored in-game chat system. The goal is to replace the monolithic `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. +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). --- @@ -12,283 +14,194 @@ Controller ──writes──► Model (LazyVars) ──OnDirty──► Vie └──────────────────── user input ──────────────────────┘ ``` -- **Model** — a flat set of `LazyVar` instances. No UI, no networking. The single source of truth. -- **View** — UI controls that subscribe to model LazyVars via `OnDirty`. They never touch each other or call back into the controller. -- **Controller** — receives network messages and user input, validates them, and writes to the model. +- **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. --- -## Reactive State — How LazyVar Works - -`LazyVar` (`/lua/lazyvar.lua`) is the reactive primitive in this codebase. - -```lua -local Create = import("/lua/lazyvar.lua").Create - --- A LazyVar holding a plain value -local recipient = Create('all') -- initial value - --- Read the value by calling it -print(recipient()) -- 'all' - --- Write a new value -recipient:Set('allies') -- triggers OnDirty on recipient and dependents - --- React to changes -recipient.OnDirty = function(self) - toText:SetText(self()) -- view pulls the new value -end - --- A LazyVar that derives from another LazyVar (computed) -local label = Create() -label:Set(function() - return 'Sending to: ' .. recipient() -- re-evaluates whenever recipient changes -end) -label.OnDirty = function(self) - someText:SetText(self()) -end -``` - -### Rules - -1. **Never cache a LazyVar's value in a local.** Always call it (`lv()`) at the moment you need it so the dependency graph stays correct. -2. **`OnDirty` is a pull notification, not a push.** It tells you the value *may* have changed; you call `self()` inside `OnDirty` to get the new value. -3. **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 a fresh LazyVar, hang your handler on **that**, and read the upstream LazyVar from its compute. The first read registers your observer in the upstream's `used_by` table, so future changes propagate to your handler without ever touching the upstream's `OnDirty` slot. - -Use `Derive(source, onDirty)` from `/lua/lazyvar.lua` — it bundles the three-step dance (create, set OnDirty, `Set` a reader) into one call: - -```lua --- DON'T — clobbers any other subscriber: -model.History.OnDirty = function(lv) self:OnHistoryChanged(lv()) end +## File Map --- DO — derive a per-subscriber LazyVar: -self.HistoryObserver = Derive(model.History, function(lv) - self:OnHistoryChanged(lv()) -end) +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. --- And on teardown: -self.HistoryObserver:Destroy() -``` +### Top-level -**`Create` vs `Derive`** — `Create(value)` makes a LazyVar holding a static initial value. If you pass a function or another LazyVar, it is stored *verbatim* as the cached value — not interpreted as a dependency. `Derive(source, onDirty)` makes a LazyVar that tracks `source` and fires `onDirty` whenever it changes. When you want to observe an existing LazyVar, you want `Derive`; `Create` won't wire up the dependency edge. +| 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 | -This rule applies to every LazyVar in the system — including ones in our own `Model` files. Treat `Foo.OnDirty` as private to whoever creates `Foo`. -4. **Never write to the model inside an `OnDirty`.** That is controller logic; keep views read-only. -5. **Destroy LazyVars when the owning control is destroyed** to avoid dangling `OnDirty` callbacks. The standard pattern is a `TrashBag` (see `/lua/system/trashbag.lua`): allocate `self.Trash = TrashBag()` in `__init`, hand every derived observer to it via `self.Trash:Add(...)`, and destroy the bag in `OnDestroy`: +### `config/` - ```lua - __init = function(self, ...) - self.Trash = TrashBag() - self.HistoryObserver = self.Trash:Add(Derive(model.History, function(lv) - self:OnHistoryChanged(lv()) - end)) - end, - OnDestroy = function(self) - self.Trash:Destroy() - end, - ``` +| 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 | - `Trash:Add` returns what you pass it, so the assignment stays a one-liner. +### `commands/` -### What the autolobby got wrong +| 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 | -The autolobby passed `State` tables down through constructors and method calls (prop drilling). When state changed, the controller had to know which child controls needed updating and call them explicitly. This is brittle — adding a new view element means touching the controller. With LazyVars, the view self-subscribes; the controller stays ignorant of the view entirely. +To add a slash command, follow the [`add-chat-command`](../../../../.claude/skills/add-chat-command/SKILL.md) skill. --- ## Model -Defined in `ChatModel.lua`. All fields are LazyVars. No UI imports allowed in this file. +### `UIChatModel` ([ChatModel.lua](ChatModel.lua)) ```lua ---@class UIChatModel ----@field recipient LazyVar<'all'|'allies'|number> # current send target ----@field history LazyVar # append-only; Set a new table ref to trigger dirty ----@field options LazyVar # persisted chat preferences ----@field windowVisible LazyVar # whether the chat window is open +---@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 ``` -`UIChatEntry` (plain table, not a LazyVar itself): +`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 "Sender to allies:" ----@field text string # raw message body ----@field tokey string # ChatOptions key for color lookup ----@field color string # ARGB hex team color ----@field armyID number # for per-army filter ----@field faction number # faction icon index ----@field camera? table # WorldCamera settings if this is a ping link +---@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 (`time`, `new`) belongs to the **view**, not to entries. - ---- - -## Controller - -Defined in `ChatController.lua`. The only file allowed to call `SessionSendChatMessage`, write to the model, or register with `gamemain.RegisterChatFunc`. +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. -### Receiving - -``` -gamemain.ReceiveChat(sender, data) [engine callback] - └── chatFuncs['Chat'](sender, data) [registered by controller on init] - └── ChatController:OnReceive(sender, msg) - ├── validate (drop non-Chat, unknown senders) - ├── handle notify subsystem - └── model.history:Set(appendedTable) -``` - -### Sending - -``` -ChatController:Send(text) - ├── slash-command check → commands.RunChatCommand - ├── taunt check → taunt.CheckForAndHandleTaunt - ├── build msg table {to, Chat, text, camera?} - ├── resolve client list → FindClients / FindClients(id) - └── SessionSendChatMessage(clients?, msg) - + echo locally for private messages -``` - -### Init +### `UIChatConfigModel` ([config/ChatConfigModel.lua](config/ChatConfigModel.lua)) ```lua -function ChatController:Init(mapGroup) - -- build the model - -- build the view, passing the model - -- register with gamemain - import("/lua/ui/game/gamemain.lua").RegisterChatFunc( - function(sender, data) self:OnReceive(sender, data) end, - 'Chat' - ) -end +---@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`. -## Standalone Invocation - -Every complete UI component in this system (chat window, config dialog, edit view) **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. +`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. -### How hotkeys work in this codebase +| 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) | -`keyactions.lua` defines an action table. Each entry's `action` string is evaluated by the engine: +--- -```lua --- keyactions.lua -local keyActionsChat = { - ['chat_toggle'] = { - action = 'UI_Lua import("/lua/ui/game/chat/ChatView.lua").Toggle()', - category = 'chat', - }, - ['chat_config'] = { - action = 'UI_Lua import("/lua/ui/game/chat/ChatConfigView.lua").Toggle()', - category = 'chat', - }, -} -``` +## Controller -`keydescriptions.lua` provides the display name shown in the key-binding settings UI: +### Receiving -```lua -['chat_toggle'] = 'Toggle chat window', -['chat_config'] = 'Toggle chat options', ``` - -### Convention for every view module - -Each view file must export a `Toggle()` function (and optionally `Open()` / `Close()`) at module level. The function must be safe to call at any time: - -```lua --- ChatConfigView.lua - -local instance = nil - -function Toggle() - if instance then - instance:Destroy() - instance = nil - else - Open() - end -end - -function Open() - if instance then return end - -- obtain or create the model singleton, then build the view - local model = import("/lua/ui/game/chat/ChatModel.lua").GetSingleton() - instance = CreateConfigWindow(GetFrame(0), model) -end - -function Close() - if instance then - instance:Destroy() - instance = nil - end -end +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 ``` -`GetFrame(0)` is always available in a UI context, so no parent reference needs to be threaded in. A component that cannot be opened this way is not truly standalone. - -### No default key bindings required +`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. -You do not need to assign a default key to every component — the binding table entry is enough to make it available in the key-binding UI and invocable from the console during development: +### Sending ``` -UI_Lua import("/lua/ui/game/chat/ChatConfigView.lua").Toggle() +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) ``` ---- - -## View - -Defined in `ChatView.lua` (window + feed) and `ChatEditView.lua` (input area). Views receive the model at construction and subscribe via `OnDirty`. They never import the controller. - -### ChatView +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: -Observes: -- `model.history.OnDirty` → re-render visible lines -- `model.windowVisible.OnDirty` → show/hide `GUI.bg` -- `model.options.OnDirty` → apply font size, colors, alpha, rewrap text +| 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 | -Owns internally: -- `chatHistory` display-side shadow: a parallel array of `{time, visible}` per entry — **not** stored on the entries themselves -- The line pool (`GUI.chatLines[]`) and scroll container -- The fade timer (`OnFrame` on `GUI.bg`) - -### ChatEditView +### Init -Observes: -- `model.recipient.OnDirty` → update the "To Allies:" label +`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. -Calls directly: -- `ChatController.Send(text, cameraState?)` — user pressed Enter -- `ChatController.SetRecipient(target)` — user picked from the dropdown or clicked a name in the feed +--- -### ChatConfigView +## Views -Observes: -- `model.Pending.OnDirty` → sync control states +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. -Calls directly: -- `ChatConfigController.SetOption(key, value)` — user changed a control -- `ChatConfigController.Apply / Reset / Cancel` — user clicked the corresponding button +| 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 the model and controller modules directly at the top of the file rather than receiving callback tables in their constructor: +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") -local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") ``` -This keeps dependencies visible at the top of the file and avoids the boilerplate of threading callback tables through constructors. 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. +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. --- @@ -296,95 +209,36 @@ This keeps dependencies visible at the top of the file and avoids the boilerplat | Element | File | Parent | |---------|------|--------| -| Chat window (`GUI.bg`) | `ChatView.lua` | `GetFrame(0)` | -| Scroll container + line pool | `ChatView.lua` | `GUI.bg` client area | -| Feed lines (hidden-window mode) | `ChatView.lua` | same line pool | -| Input edit box | `ChatEditView.lua` | `GUI.bg` client area | -| Recipient label ("To Allies:") | `ChatEditView.lua` | edit group | -| Chat-bubble dropdown | `ChatEditView.lua` | edit group | -| Camera-attach checkbox | `ChatEditView.lua` | edit group | -| Options config window | `ChatConfigView.lua` | `GetFrame(0)` | - -Each chat line (`GUI.chatLines[i]`) contains: -- `teamColor` — solid-colour bitmap (team colour) -- `factionIcon` — faction logo -- `name` — clickable text; click → `onRecipientChange(armyID)` -- `text` — message body; click (if `entry.camera`) → `WorldCamera:RestoreSettings` -- `lineStickybg` — feed-mode readability background +| 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)` | --- -## Options - -`UIChatOptions` is a plain table loaded from and saved to the player profile. The model holds one LazyVar for the whole options table. A new table reference must be `Set` to trigger dirty (do not mutate in place). - -| Key | Default | Meaning | -|-----|---------|---------| -| `all_color` | 1 | Color index (1–8) for "all" messages | -| `allies_color` | 2 | Color index for ally messages | -| `priv_color` | 3 | Color index for private messages | -| `link_color` | 4 | Color index for camera-link messages | -| `notify_color` | 8 | Color index for Notify messages | -| `font_size` | 14 | Chat font size (12–18) | -| `fade_time` | 15 | Seconds before feed/window auto-hides | -| `win_alpha` | 1.0 | Window opacity (stored 0–100, normalized on use) | -| `feed_background` | false | Semi-transparent bg behind feed lines | -| `send_type` | false | Default recipient: false = all, true = allies | -| `links` | true | Show camera-link messages | -| `[armyID]` | true | Per-army message filter | - ---- - -## Class Field Annotations - -Every field assigned to `self` inside `__init` must have a matching `---@field` annotation on the class definition. This gives the language server full type information across the whole file and makes the class self-documenting at a glance. - -### Rule - -Annotate the class immediately above the `ClassUI(...)` call. List every `self.X` field in the order it appears in `__init`. For fields whose type is an array of a named struct, define that struct as its own `---@class` above the main class. +## Standalone Invocation -### Example +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: -```lua ----@class UIChatConfigColorRow ----@field label Text ----@field combo BitmapCombo ----@field key string - ----@class UIChatConfigInterface : Window ----@field LabelColors Text ----@field ColorRows UIChatConfigColorRow[] ----@field LabelFontSize Text ----@field SliderFontSize IntegerSlider ----@field LabelBehavior Text ----@field Checkboxes Checkbox[] ----@field BtnApply Button ----@field BtnOk Button -local ChatConfigInterface = ClassUI(Window) { - __init = function(self, parent, ...) - self.LabelColors = UIUtil.CreateText(...) - self.ColorRows = {} - self.LabelFontSize = UIUtil.CreateText(...) - self.SliderFontSize = IntegerSlider(...) - self.LabelBehavior = UIUtil.CreateText(...) - self.Checkboxes = {} - self.BtnApply = UIUtil.CreateButtonStd(...) - self.BtnOk = UIUtil.CreateButtonStd(...) - end, -} -``` +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. -### What counts as a field +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). -- Every `self.Foo` written in `__init` or `__post_init`. -- Fields inherited from the parent class (e.g. `Window`) do **not** need repeating — the `: Window` in the class declaration inherits them. -- Temporary locals inside a method are not fields and need no annotation. +> 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. --- -## What Not To Do +## Don'ts -- **Do not store UI references in the model.** The model must be constructable with no UI present. -- **Do not write to the model from a view.** Views call into the controller; the controller writes. -- **Do not mutate a LazyVar's held table in place.** Create a new table and `Set` it; otherwise dependents never go dirty. -- **Do not replicate the autolobby's drilling pattern.** State is on the model; views subscribe — no parent needs to push updates into children. +- **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. From faa9a42f7d63e937303d1479ead1eeda22f568f2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 14:05:41 +0200 Subject: [PATCH 098/130] Fix drag handlers being themed to the UEF initially --- lua/ui/game/chat/ChatInterface.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index 4513e90c56e..e3f1742c345 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -188,18 +188,23 @@ local ChatInterface = ClassUI(Window) { --- grips so resize events still reach the Window's own resize bitmaps. ---@param self UIChatInterface SetupDragHandles = function(self) - self.DragTL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) - self.DragTR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) - self.DragBL = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) - self.DragBR = Bitmap(self, UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) + self.DragTL = Bitmap(self) + self.DragTR = Bitmap(self) + self.DragBL = Bitmap(self) + self.DragBR = Bitmap(self) self.DragTL.textures = DragHandleTextures('ul') self.DragTR.textures = DragHandleTextures('ur') self.DragBL.textures = DragHandleTextures('ll') self.DragBR.textures = DragHandleTextures('lr') + -- Seed each grip with its `up` skinnable texture rather than a frozen + -- `UIFile` path. Otherwise the bitmaps display whichever skin was + -- active at module-load time (typically UEF) until the first + -- hover-exit hands `SetTexture` the live skinnable value. for _, grip in { self.DragTL, self.DragTR, self.DragBL, self.DragBR } do grip:DisableHitTest() + grip:SetTexture(grip.textures.up) end Layouter(self.DragTL):AtLeftTopIn(self, -26, -8):Over(self, 5):End() From 0d63273b3fe7fb44ab5f36e4e6548f7d269de642 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 14:39:43 +0200 Subject: [PATCH 099/130] Add chat messages about the amount of shared resources --- loc/DE/strings_db.lua | 6 ++++ loc/RU/strings_db.lua | 6 ++++ loc/US/strings_db.lua | 6 ++++ lua/SimUtils.lua | 34 +++++++++++++++++++++ lua/aibrains/components/chat.lua | 47 ++++++++++++++++++----------- lua/ui/game/chat/ChatController.lua | 19 ++++++++++++ 6 files changed, 101 insertions(+), 17 deletions(-) diff --git a/loc/DE/strings_db.lua b/loc/DE/strings_db.lua index 598b7ac8459..681fdde68f7 100644 --- a/loc/DE/strings_db.lua +++ b/loc/DE/strings_db.lua @@ -4205,6 +4205,12 @@ chat_0012="Nachrichtenfilter" chat_0013="Nachrichtenfarben" chat_0014="Fenstereinstellungen" chat_0015="Chat konfigurieren" +chat_resources_received_both="%s hat dir %d Masse und %d Energie geschickt." +chat_resources_received_mass="%s hat dir %d Masse geschickt." +chat_resources_received_energy="%s hat dir %d Energie geschickt." +chat_resources_overflow_both="Lager voll — %d Masse und %d Energie sind verloren gegangen. Baue mehr Lagerkapazität." +chat_resources_overflow_mass="Lager voll — %d Masse ist verloren gegangen. Baue mehr Lagerkapazität." +chat_resources_overflow_energy="Lager voll — %d Energie ist verloren gegangen. Baue mehr Lagerkapazität." chat_win_0001="An %s:" chat_win_0002="Chat (%d - %d von %d Zeilen)" cheating_fragment_0000="benutzt" diff --git a/loc/RU/strings_db.lua b/loc/RU/strings_db.lua index 4b31f5f3c3f..4e45636a47a 100644 --- a/loc/RU/strings_db.lua +++ b/loc/RU/strings_db.lua @@ -4339,6 +4339,12 @@ chat_0012="Фильтры сообщений" chat_0013="Цвет сообщений" chat_0014="Настройки окна" chat_0015="Настройка чата" +chat_resources_received_both="%s передал вам %d массы и %d энергии." +chat_resources_received_mass="%s передал вам %d массы." +chat_resources_received_energy="%s передал вам %d энергии." +chat_resources_overflow_both="Хранилище заполнено — потеряно %d массы и %d энергии. Постройте больше хранилищ." +chat_resources_overflow_mass="Хранилище заполнено — потеряно %d массы. Постройте больше хранилищ." +chat_resources_overflow_energy="Хранилище заполнено — потеряно %d энергии. Постройте больше хранилищ." chat_win_0001="Игроку %s:" chat_win_0002="Чат (%d - %d из %d строк)" cheating_fragment_0000="is" diff --git a/loc/US/strings_db.lua b/loc/US/strings_db.lua index a3e4e249ebf..f8867301a11 100644 --- a/loc/US/strings_db.lua +++ b/loc/US/strings_db.lua @@ -3741,6 +3741,12 @@ chat_0012="Message Filters" chat_0013="Message Colors" chat_0014='Show Feed Background' chat_0015='Persist Feed Timeout' +chat_resources_received_both="%s sent you %d mass and %d energy." +chat_resources_received_mass="%s sent you %d mass." +chat_resources_received_energy="%s sent you %d energy." +chat_resources_overflow_both="Storage full — %d mass and %d energy overflowed. Build more storage." +chat_resources_overflow_mass="Storage full — %d mass overflowed. Build more storage." +chat_resources_overflow_energy="Storage full — %d energy overflowed. Build more storage." cheating_fragment_0000="is" cheating_fragment_0001="are" cheating_fragment_0002=" cheating!" diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua index 1922e274f25..e707e49ad57 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -1479,8 +1479,42 @@ function GiveResourcesToPlayer(data) local massTaken = fromBrain:TakeResource('MASS', data.Mass * fromBrain:GetEconomyStored('MASS')) local energyTaken = fromBrain:TakeResource('ENERGY', data.Energy * fromBrain:GetEconomyStored('ENERGY')) + -- `GiveResource` silently caps at the receiver's max storage, and + -- storage stats only update next tick, so derive what actually lands + -- up front from `MaxStorage - Stored`. + local massCapacity = toBrain:GetArmyStat('Economy_MaxStorage_Mass', 0).Value + - toBrain:GetEconomyStored('MASS') + local energyCapacity = toBrain:GetArmyStat('Economy_MaxStorage_Energy', 0).Value + - toBrain:GetEconomyStored('ENERGY') + local massGiven = math.min(massTaken, massCapacity) + local energyGiven = math.min(energyTaken, energyCapacity) + toBrain:GiveResource('MASS', massTaken) toBrain:GiveResource('ENERGY', energyTaken) + + -- Whisper from giver → receiver so the line reads with the giver's + -- attribution. Three LOC keys rather than one templated string so each + -- locale gets a clean sentence per case. + local mass = math.floor(massGiven) + local energy = math.floor(energyGiven) + local toArmy = data.To --[[@as integer]] + local fromName = fromBrain.Nickname or tostring(data.From) + if mass > 0 and energy > 0 then + fromBrain:SendChatToPlayer(toArmy, + "%s sent you %d mass and %d energy.", + { fromName, mass, energy } + ) + elseif mass > 0 then + fromBrain:SendChatToPlayer(toArmy, + "%s sent you %d mass.", + { fromName, mass } + ) + elseif energy > 0 then + fromBrain:SendChatToPlayer(toArmy, + "%s sent you %d energy.", + { fromName, energy } + ) + end end ---@param data {From: Army, To: Army} diff --git a/lua/aibrains/components/chat.lua b/lua/aibrains/components/chat.lua index 9a0844dfe6a..55536e876ca 100644 --- a/lua/aibrains/components/chat.lua +++ b/lua/aibrains/components/chat.lua @@ -33,18 +33,20 @@ AIBrainChatComponent = ClassSimple { --- Broadcasts a message to every connected UI as an "all" chat line. ---@param self AIBrainChatComponent ---@param text string + ---@param args? any[] # optional `string.format` arguments; UI applies `LOCF(text, unpack(args))` on receive ---@param location? AIBrainChatLocation - SendChatToAll = function(self, text, location) - self:SendChatTo('all', text, location) + SendChatToAll = function(self, text, args, location) + self:SendChatTo('all', text, args, location) end, --- Broadcasts a message to the AI's allies. `Sync.ChatMessages` reaches --- every UI, so the non-ally filter is applied client-side on display. ---@param self AIBrainChatComponent ---@param text string + ---@param args? any[] ---@param location? AIBrainChatLocation - SendChatToAllies = function(self, text, location) - self:SendChatTo('allies', text, location) + SendChatToAllies = function(self, text, args, location) + self:SendChatTo('allies', text, args, location) end, --- Whispers a message to a specific army. No ally constraint — the AI is @@ -53,20 +55,23 @@ AIBrainChatComponent = ClassSimple { ---@param self AIBrainChatComponent ---@param army integer ---@param text string + ---@param args? any[] ---@param location? AIBrainChatLocation - SendChatToPlayer = function(self, army, text, location) - self:SendChatTo(army, text, location) + SendChatToPlayer = function(self, army, text, args, location) + self:SendChatTo(army, text, args, location) end, --- Addresses a message back at this brain's own army. Useful for - --- debug-style output and campaign hints that should only reach whoever - --- is watching this AI's perspective (typically just observers with - --- full vision, or a human controller in campaign setups). + --- debug-style output, campaign hints, and sim-event announcements + --- that should only reach the army the event happened to (resource + --- gifts received, ACU under attack, etc.) — `IsLocalRecipient` + --- ensures only that army's UI renders the line. ---@param self AIBrainChatComponent | AIBrain ---@param text string + ---@param args? any[] ---@param location? AIBrainChatLocation - SendChatToSelf = function(self, text, location) - self:SendChatTo(self:GetArmyIndex(), text, location) + SendChatToSelf = function(self, text, args, location) + self:SendChatTo(self:GetArmyIndex(), text, args, location) end, --- Shared implementation: builds the message, stamps it with the @@ -77,22 +82,30 @@ AIBrainChatComponent = ClassSimple { --- double-posting if the same message arrives more than once (see --- `ChatController.OnSyncChatMessages`). --- - --- `location`, if provided, rides on the message as `msg.location` and - --- is surfaced to the UI as `entry.Location` — the click handler in - --- `ChatInterface` translates it to a `MoveTo`/`MoveToRegion` call at - --- click time, so there is no need to synthesise a camera snapshot - --- sim-side. + --- `args`, if provided, rides on the message as `msg.Args` and is + --- consumed by the UI's receive path: `LOCF(msg.text, unpack(msg.Args))` + --- runs once per recipient against their own locale, so callers can + --- pass a `` format-string template plus raw values (army + --- nicknames, resource amounts, …) instead of pre-formatting and + --- losing localisation. + --- + --- `location`, if provided, rides as `msg.location` and is surfaced to + --- the UI as `entry.Location` — the click handler in `ChatInterface` + --- translates it to a `MoveTo`/`MoveToRegion` call at click time, so + --- there is no need to synthesise a camera snapshot sim-side. ---@param self AIBrainChatComponent | AIBrain ---@param to AIBrainChatRecipient ---@param text string + ---@param args? any[] ---@param location? AIBrainChatLocation - SendChatTo = function(self, to, text, location) + SendChatTo = function(self, to, text, args, location) if type(text) ~= 'string' or text == '' then return end local msg = { Chat = true, to = to, text = text, + Args = args, From = self:GetArmyIndex(), location = location, } diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index a4d9717da66..a7a82a6aadc 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -369,6 +369,12 @@ local function IsValidIncomingMessage(msg) if msg.camera ~= nil and type(msg.camera) ~= 'table' then return false end if msg.location ~= nil and type(msg.location) ~= 'table' then return false end + -- Optional `Args` payload for `LOCF`-style format-on-receive. When + -- present it must be a table; `OnReceive` unpacks it into `LOCF` along + -- with `msg.text` so the rendering happens UI-side and respects the + -- viewer's locale. + if msg.Args ~= nil and type(msg.Args) ~= 'table' then return false end + return true end @@ -391,6 +397,18 @@ function OnReceive(sender, msg) -- focused on routing and rendering. if not IsValidIncomingMessage(msg) then return end + -- LOCF-style format-on-receive. When the sender ships `Args` alongside + -- the message text, the text is treated as a `string.format` template + -- (typically a `` tag with `%s` / `%d` placeholders) and + -- formatted UI-side so the result respects the viewer's locale. The + -- formatted text replaces `msg.text` for every downstream consumer + -- (length-cap notwithstanding — the cap was already applied to the + -- pre-format template, so `LOCF` can legitimately exceed it; that's + -- by design for system events where the args are trusted sim values). + if msg.Args then + msg.text = LOCF(msg.text, unpack(msg.Args)) + end + -- Notify routing: the Notify subsystem tags messages with `to='notify'` -- and owns the display decision. Only fall through to rendering a chat -- line if Notify declines (returns false). @@ -453,6 +471,7 @@ end --- handler is the *only* source of chat in a replay. ---@param msgs table[] function OnSyncChatMessages(msgs) + reprsl(msgs) if type(msgs) ~= 'table' then return end local history = ChatModel.GetSingleton().History() From 119f895ed8a6f72845e7e62c18a312a4df20e7eb Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 14:48:39 +0200 Subject: [PATCH 100/130] Clean up specs for development --- lua/ui/game/chat/CHANGES.md | 100 ------ lua/ui/game/chat/GAPS.md | 49 --- lua/ui/game/chat/chat-line-functionality.md | 111 ------- lua/ui/game/chat/design.md | 334 -------------------- 4 files changed, 594 deletions(-) delete mode 100644 lua/ui/game/chat/CHANGES.md delete mode 100644 lua/ui/game/chat/GAPS.md delete mode 100644 lua/ui/game/chat/chat-line-functionality.md delete mode 100644 lua/ui/game/chat/design.md diff --git a/lua/ui/game/chat/CHANGES.md b/lua/ui/game/chat/CHANGES.md deleted file mode 100644 index c5c8c75c0ef..00000000000 --- a/lua/ui/game/chat/CHANGES.md +++ /dev/null @@ -1,100 +0,0 @@ -# Chat MVC refactor — behavioural differences - -Catalogue of every intentional change vs. the legacy [`/lua/ui/game/chat.lua`](../chat.lua) after it was replaced by the MVC tree under [`/lua/ui/game/chat/`](.). Additive — new features belong in the individual module docs, not here. - ---- - -## Sim-side `ConsoleOutput` messages - -Sim-originated chat messages carrying a `ConsoleOutput` field are log-only — they never open a chat line. The legacy path inspected this field inside `ReceiveChatFromSim`; the new [`ChatController.OnReceive`](ChatController.lua) drops any message whose `Chat` flag is false, so the `ConsoleOutput` branch moved up the stack. - -**New home**: [`gamemain.lua` `SendChat`](../gamemain.lua) — the sim-chat replay loop prints `ConsoleOutput` messages directly instead of forwarding them to the controller. Every other message still goes through `sendChat(chat.sender, chat.msg)`. - -This keeps the controller focused on displayable messages and preserves the old log behaviour for sim diagnostics. - ---- - -## `PgDn` at scroll-bottom no longer closes the window - -Legacy `ChatPageDown(mod)` had a quirk: pressing it when the feed was already scrolled to the bottom (or the window was hidden) would toggle the window. The new [`ChatInterface.OpenAndScrollLines`](ChatInterface.lua) keybind entry-point opens-then-scrolls; `PgDn` on an already-open, already-bottom window now does nothing. - -Closing is still reachable via the close button, the `Escape` key on an empty edit box, and the `debug_chat_window` keybind. - ---- - -## Layout-specific chat-window *positions* are gone (skin-driven theming is preserved) - -Skins and layouts are independent axes: -- **Skin** drives texture / colour palette — resolved per-asset via `UIUtil.SkinnableFile`. -- **Layout** drives HUD widget arrangement (`bottom`, `left`, `right`) — defined in [`/lua/skins/layouts.lua`](../../../skins/layouts.lua) as a table mapping HUD-widget keys to per-widget layout files. - -The deleted `/lua/ui/game/layouts/chat_layout.lua` was the chat's *layout* entry, providing layout-specific positions and sizes (where the chat lived for each of `bottom` / `left` / `right`). That file is gone, along with its `chat` entries in `/lua/skins/layouts.lua`; the new [`ChatInterface`](ChatInterface.lua) uses a single rect (`DefaultRect`) regardless of layout, and the user can drag / resize from there. Switching layouts no longer repositions the chat window. - -**Theming is preserved on the skin axis.** [`ChatInterface`](ChatInterface.lua) loads its border, corner-grip, scrollbar, and titlebar-button textures through `UIUtil.SkinnableFile` rather than `UIUtil.UIFile`, so each path resolves against the current skin every time MAUI reads the bound LazyVar. Switching skins reactively repaints the chrome without touching the chat tree. The legacy `SetLayout` global that re-ran the chat's layout file is therefore unneeded — the [chat.lua compatibility shim](../chat.lua) keeps it around as a deprecated no-op. - -### Why no `OnLayoutChanged` hook - -[`gamemain.SetLayout(layout)`](../gamemain.lua) fans out to every HUD widget when the user picks a different HUD arrangement. The chat is intentionally **not** in that chain. Considered alternatives: - -- *Per-layout default rects* — would mean three more rect tables that mostly differ by where they choose to overlap other widgets. Not enough variation in the legacy `chat_layout.lua` to justify the surface area. -- *Auto-reset on layout change* — would clobber the user's saved rect on every layout switch. User-hostile. -- *Clamp-to-screen on layout change* — defensible, but `DefaultRect` already lives well inside any layout's safe area, and the new title-bar **Reset-position** button covers the "I've gotten lost" case explicitly without overwriting anyone's preferences. - -The chat is **user-positioned**: its rect persists under the prefs key `chat_window_v2` and survives layout / skin / session changes by design. Mods that need layout-aware repositioning can call into `model.WindowVisible` and write a new rect via the existing prefs path — no new public API. - ---- - -## Empty-text Enter always closes the window - -Legacy behaviour: pressing `Enter` on an empty edit box called `ToggleChat()` — i.e. close if open, open if hidden. Since Enter only fires when the edit box has focus (and focus implies the window is already visible), the new [`ChatEditInterface.OnEnterPressed`](ChatEditInterface.lua) unconditionally calls `ChatController.CloseWindow()` on empty text. Net effect: identical. - ---- - -## Legacy `chat.lua` renamed to `chat.legacy.lua` - -The original monolithic file is preserved on disk as [`chat.legacy.lua`](../chat.legacy.lua) so its source is still available as a reference while porting the remaining gaps. **No live importer remains** — every caller was repointed before the rename. The file can be deleted outright once [GAPS.md](GAPS.md) is empty. - -## Engine registration moved out of module-load - -`/lua/ui/game/chat.lua` registered its receive function and built-in commands as a side-effect of being imported. The new controller exposes an explicit [`ChatController.Init()`](ChatController.lua) which [`gamemain.lua`](../gamemain.lua) calls during UI setup, next to `taunt.Init()` and `build_templates.Init()`. - -**Why**: mods can hook `ChatController` (replacing `Init`, `OnReceive`, or `RegisterBuiltinCommands`) before any wiring happens. A module-load-time register would run before mods get a chance to override. - ---- - -## `ReceiveChat` → `OnReceive` at every injection point - -The legacy `chat.lua.ReceiveChat(sender, msg)` is now [`ChatController.OnReceive(sender, msg)`](ChatController.lua). All existing injection points have been repointed: - -| Caller | Purpose | -|---|---| -| [`AIChatSorian.AISendChatMessage`](../../../AIChatSorian.lua) | AI-to-player chat | -| [`taunt.lua` taunt display](../taunt.lua) | Taunt text as a chat line | -| [`gamemain.lua` `SendChat`](../gamemain.lua) | Sim-replayed chat | - -Message shape and semantics are unchanged — callers pass the same `{Chat, text, to, ...}` tables. - ---- - -## `FindClients` has a new home - -Moved from `/lua/ui/game/chat.lua` to [`ChatController.FindClients`](ChatController.lua). Behaviour is byte-identical (same observer-mode, same ally-resolution logic). Callers updated: - -- [`notify.lua`](../../notify/notify.lua) -- [`score.lua`](../score.lua) -- [`PaintingCanvasAdapter.lua`](../painting/ShareAdapters/PaintingCanvasAdapter.lua) - ---- - -## `CloseChatConfig` → `ChatConfigInterface.Close` - -The legacy standalone config-close entry point is now the `Close()` method on the new config module. Callers updated: - -- [`tabs.lua`](../tabs.lua) -- [`multifunction.lua`](../multifunction.lua) - ---- - -## `ChatPageUp` / `ChatPageDown` → `OpenAndScrollLines` - -The `chat_page_up`, `chat_page_down`, `chat_line_up`, `chat_line_down` key bindings in [`keyactions.lua`](../../../keymap/keyactions.lua) now call [`ChatInterface.OpenAndScrollLines(±n)`](ChatInterface.lua) with a signed delta (negative = older messages) instead of separate up / down module functions. diff --git a/lua/ui/game/chat/GAPS.md b/lua/ui/game/chat/GAPS.md deleted file mode 100644 index a54148f94df..00000000000 --- a/lua/ui/game/chat/GAPS.md +++ /dev/null @@ -1,49 +0,0 @@ -# Chat MVC refactor — remaining gaps - -Inventory of legacy [`/lua/ui/game/chat.legacy.lua`](../chat.legacy.lua) behaviour that the new MVC tree does **not** yet replicate. Gaps are grouped by concern and cite line numbers in the old file so a future author can jump in. - -Accepted & intentional differences live in [CHANGES.md](CHANGES.md); this file only tracks work that hasn't happened yet. - ---- - -## Already closed (do not re-list) - -Send path, receive path, `FindClients`, controller `Init`, external importers, skin-layout orphan, `ConsoleOutput` sim-side logging, empty-text Enter, `ActivateChat` (Enter-key hook with Shift → allies), `chat.lua` renamed to `chat.legacy.lua`. - -Closed in the most recent rounds of work: - -- **Config-dialog tooltips** — every interactive control in [`ChatConfigInterface`](config/ChatConfigInterface.lua) carries the legacy tooltip key it had before: `chat_color` on each colour combo and its label, `chat_fontsize` / `chat_fadetime` / `chat_alpha` on each slider and its label, and `chat_filter` / `chat_send_type` / `chat_feed_background` on the three behaviour checkboxes via `Tooltip.AddControlTooltip` / `AddCheckboxTooltip`. Tooltip strings come from [`/lua/ui/help/tooltips.lua`](../../help/tooltips.lua) without modification. -- **Config-dialog skinned chrome** — [`ChatConfigInterface`](config/ChatConfigInterface.lua) now passes a `WindowTextures` table built from `panel_brd_*` `SkinnableFile`s into `Window.__init`, matching the legacy chat-options dialog (the chat window itself uses the bespoke `chat_brd_*` set; the two are intentionally different sizes). Four decorative corner grips (`drag-handle-{ul,ur,ll,lr}_btn_up.dds`) are added in `__init` with hit-test disabled and laid out overhanging the corners — purely cosmetic since `lockSize` is `true`. -- **Config-dialog Z-order** — [`ChatConfigInterface.Open`](config/ChatConfigInterface.lua) calls `multifunction.CloseMapDialog()` before constructing the window and pins `Instance.Depth` to `GetFrame(0):GetTopmostDepth() + 1` so a later popup can't slide on top of the chat options. - -- **Camera links** — outgoing (`CamCheckbox`), incoming render (`CamIcon` on the line), click-to-jump (`OnCameraClicked` with both `Camera` and `Location` paths). -- **Private reply by clicking a name** — `OnNameClicked` overridable on [`ChatLinesInterface`](ChatLinesInterface.lua); the chat window installs a handler that sets `Recipient` and re-acquires edit focus. Self-name clicks are filtered. -- **Window auto-close timer** — `LastActivity` LazyVar in the model, `NotifyActivity()` heartbeat in the controller, [`ChatInterface.OnFrame`](ChatInterface.lua) compares `GetSystemTimeSeconds() - LastActivity()` to `fade_time`. Hooked into edit keystrokes, scroll, mouse wheel, drag, resize, recipient-picker hover, and `AppendEntry`. -- **Window opacity** — [`ChatInterface.OptionsObserver`](ChatInterface.lua) calls `SetAlpha(_, true)` on every `Committed` change; cascades to chrome / lines / edit / scrollbar. Re-asserts full opacity on `ChatLinesInterface.Pool` so chat text stays crisp at low alpha while chrome and scrollbar still dim. -- **Per-line fade timer** — implemented in [`ChatFeedInterface`](ChatFeedInterface.lua), not in the main view (fade only matters when the window is hidden, which is exactly what the feed handles). -- **Feed mode itself** — [`ChatFeedInterface`](ChatFeedInterface.lua) is a sibling of the chat window pinned to the line area via LazyVar bind, observes `History` + `WindowVisible`, has a per-row pool, fades each row in its last 2 seconds, clears on window-open, hides itself when there are no rows. Bootstrapped eagerly from [`ChatController.Init`](ChatController.lua) so it can surface messages received before the user first opens the dialog. -- **Per-army mute (`muted`)** — per-game (not persisted to prefs), checkbox column in [`ChatConfigInterface`](config/ChatConfigInterface.lua), `/mute` and `/unmute` slash commands, `IsValidEntry` filter, `SetMuted` / `SetMutedLive` controllers. -- **`OnMoveSet` focus grab** — [`ChatInterface.OnMoveSet`](ChatInterface.lua) re-acquires edit focus after a drag. -- **`font_size` reactive** — [`ChatLinesInterface.ApplyOptions`](ChatLinesInterface.lua) reapplies on every `Committed` change. -- **Bottom-anchored line layout** — [`ChatLinesInterface.RebuildPool` / `CalcVisible`](ChatLinesInterface.lua) stack newest-at-bottom so chat and feed share the same vertical rhythm and open ↔ close transitions stay continuous. -- **`links` option** — [`ChatLinesInterface.IsValidEntry`](ChatLinesInterface.lua) drops entries with `Camera` or `Location` when `options.links == false`, mirroring the legacy filter. `Location` (sim-side point/area hint) is treated as a link too since it surfaces the same camera-icon affordance on the row. -- **Own-army name greying** — [`ChatLineInterface.SetHeader`](ChatLineInterface.lua) calls `Name:Disable()` when `entry.ArmyID == GetFocusArmy()` and `Name:Enable()` otherwise, mirroring the legacy "your own lines look greyed" hint. Re-applied on every `SetHeader` so pool reuse can't carry over a previous occupant's state. -- **Translucent feed background** — [`ChatFeedInterface`](ChatFeedInterface.lua) gives each feed row a per-line readability strip (Bitmap on the feed group, depth-pinned under the line, edges via `Layouter:Fill(line)`). Visibility is gated on `feed_background`; the alpha composes window opacity (`win_alpha`) × per-row fade × `FeedBackgroundAlpha = 0.5`. The chat-window line pool is unaffected — the BG lives on the feed only, so the regular history view stays bare. -- **Receive-side defensive guards** — [`ChatController.OnReceive`](ChatController.lua) coerces non-string senders, then runs everything else through a pure-shape validator (`IsValidIncomingMessage`) that checks: table-shaped, `Chat` flag set, `text` is a string, `text` length ≤ `ChatUtils.MaxMessageLength` (the same cap the edit box enforces on send), `to` is one of `RecipientAll` / `RecipientAllies` / `'notify'` / a number, and the optional `camera` / `location` payloads are tables when present. The dispatch loop then drops messages whose `Observer` flag contradicts the sender's army resolution (genuine observers have no army; an inconsistent combination implies tampering or a bug, not something to silently "repair"). Malformed input is dropped, never fixed. -- **Observer-source filter** — the existing `if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then return end` in [`ChatController.OnReceive`](ChatController.lua) already implements the legacy "players don't see observer chatter" rule (observers have no army → `armyData` nil → drop, unless the local viewer is also an observer or in a replay). -- **Colour palette wired** — 8-swatch [`ChatUtils.ColorPalette`](ChatUtils.lua) shared by the config dialog and the line renderer. [`ChatLineInterface`](ChatLineInterface.lua) resolves body-text colour through `ResolveBodyColor(entry)`, prioritising `entry.BodyColor` (explicit override for system / synthetic lines) over `entry.ColorKey` (palette lookup) over a hardcoded fallback. [`ChatController.AppendChatLine`](ChatController.lua) stamps `ColorKey` based on routing — camera-link / `Location` entries and observer broadcasts both pick up `link_color`; everyone else uses `ToStrings[recipient].colorkey`. Picking a different swatch in the config dialog repaints visible lines on the next `CalcVisible` pass. -- **Pin button** — [`ChatModel`](ChatModel.lua) carries a `Pinned: LazyVar`; [`ChatController.SetPinned`](ChatController.lua) writes it (and stamps a fresh `LastActivity` on unpin so the user gets a full `fade_time` window after toggling off). [`ChatInterface.OnPinCheck`](ChatInterface.lua) wires the existing title-bar checkbox to the controller; [`ChatInterface.OnFrame`](ChatInterface.lua) short-circuits the auto-close check while pinned, so the window stays open through arbitrary inactivity. -- **Eager chat bootstrap** — [`ChatController.Init`](ChatController.lua) calls `ChatInterface.EnsureInstance()` at game start so the chat tree (and its sibling feed) exists before any messages arrive. The window itself stays hidden by default; only the feed observers are needed up front, and they're now subscribed in time to surface chat the user receives before first opening the dialog. -- **Button tooltips** — `chat_close` / `chat_config` on the title-bar buttons via `Tooltip.AddButtonTooltip` in [`ChatInterface.__init`](ChatInterface.lua); `chat_reset` on the reset-position button in [`SetupResetPositionButton`](ChatInterface.lua); `chat_pin` ↔ `chat_pinned` on `_pinBtn` driven reactively by a `model.Pinned` derived observer (`PinnedObserver`) so the wording matches the next click's effect; `chat_camera` on the edit-row checkbox in [`ChatEditInterface.__init`](ChatEditInterface.lua). -- **Wheel-forward when hidden / feed non-interactability** — two complementary fixes covering the same intent (input passes through to the worldview when chat isn't focused). [`ChatInterface.__init`](ChatInterface.lua) captures `Window.HandleEvent` as `OldHandleEvent` and wraps it: a wheel rotation while `IsHidden()` forwards to `worldview.ForwardMouseWheelInput` and returns true; everything else falls through to the original. [`ChatLineInterface.DisableInteraction`](ChatLineInterface.lua) disables hit-testing on every visible part of the row (group, `TeamColor`, `FactionIcon`, `Name`, `CamIcon`, `Text`); [`ChatFeedInterface.AppendRow`](ChatFeedInterface.lua) calls it after the `SetHeader` / `SetContinuation` pass (which itself toggles `CamIcon:EnableHitTest` based on the entry payload), so feed rows never swallow a click or wheel event meant for the world. The chat-window line pool is unaffected — `RebuildPool` allocates its own rows, so feed lines never migrate back into the interactive view. -- **Legacy public API shim** — [`/lua/ui/game/chat.lua`](../chat.lua) is now a thin compatibility layer that re-exports every legacy global mods used to import (`ReceiveChat`, `ReceiveChatFromSim`, `SetupChatLayout`, `OnNISBegin`, `ChatPageUp` / `ChatPageDown`, `CloseChat`, `CloseChatConfig`, `AddChatOptionSetCallback`, `SetLayout`, `GetArmyData`, `GUI`, `ChatLines`). Every entry point logs a one-shot `WARN` deprecation message the first time it's touched and forwards to the equivalent new API where one exists; `GUI` and `ChatLines` are metatable-proxied so any field read or assignment surfaces a clear warning instead of crashing on a missing field. Once the warnings stop appearing in users' logs, the shim and `chat.legacy.lua` can both be deleted. -- **Notify-command bridge** — [`ChatCommandRegistry.Dispatch`](commands/ChatCommandRegistry.lua) now falls through to [`/lua/ui/notify/commands.lua`](../../notify/commands.lua)'s `RunChatCommand` when no built-in command matches, mirroring the legacy fan-out. Builds the `args` shape it expects (lowercased command name in slot 1, lowercased remaining tokens after) and `pcall`s the call so a third-party command throwing doesn't propagate up through the chat send path. Only surfaces the "Invalid command — type /help" error if Notify also declines. Keeps `/enablenotify`, `/disablenotify`, `/enablenotifyoverlay`, `/disablenotifyoverlay`, and any future `AddChatCommand` registration alive without us having to re-register them in our own registry. -- **Command history recall (↑ / ↓)** — [`ChatEditInterface`](ChatEditInterface.lua) keeps a `CommandHistory` ring (capped at `MaxCommandHistorySize = 32`, oldest first) populated by `PushHistory` from `OnEnterPressed` after a successful send. `OnNonTextKeyPressed` routes `↑` / `↓` to `RecallPrevious` / `RecallNext` when the command hint is closed and continues to drive the hint cycle when it's open. Walking past the newest entry blanks the edit, mirroring the legacy "down clears the line" behaviour. - ---- - -## Won't fix (decisions, not gaps) - -These look like missing pieces but are deliberate omissions; documenting here so they don't get re-opened later. See [CHANGES.md](CHANGES.md) for the rationale. - -- **Layout-specific chat positioning** — the chat is intentionally absent from [`gamemain.SetLayout`](../gamemain.lua)'s fan-out. The new chat is user-positioned (saved under prefs key `chat_window_v2`, recoverable via the title-bar Reset-position button), and switching HUD layouts no longer repositions it. The `chat.lua` `SetLayout` shim is therefore a true no-op rather than a forwarder. diff --git a/lua/ui/game/chat/chat-line-functionality.md b/lua/ui/game/chat/chat-line-functionality.md deleted file mode 100644 index 1a2b8ff36ce..00000000000 --- a/lua/ui/game/chat/chat-line-functionality.md +++ /dev/null @@ -1,111 +0,0 @@ -# Chat Line Functionality — Inventory - -Catalogue of every behaviour related to chat lines in the original -`lua/ui/game/chat.lua`, grouped by concern. Intended as the planning input for -decomposing the chat-line code into the MVC structure used elsewhere in -`lua/ui/game/chat/`. - ---- - -## 1. Line construction (`CreateChatLine`, chat.lua:178–240) - -Each line is a `Group` with five child controls: - -- **`teamColor`** — a solid-colour square on the left, coloured with the sender's team colour. -- **`factionIcon`** — a bitmap overlaid on the team-colour square, showing the sender's faction (or an observer icon for observers). -- **`name`** — bold text prefix showing the sender and who the message is for (e.g. `"PlayerX to Allies:"`). Clickable for private reply. -- **`text`** — the message body. Clickable *only* when the entry carries a `camera` payload. -- **`lineStickybg`** — a semi-transparent `aa000000` bar that fills the line, depth-under the content, hidden by default. Shown in feed mode when `feed_background` is on, so lines stay readable over the game world. -- **`camIcon`** — optional camera-pin bitmap inserted between `name` and `text` when the entry has a camera link; created lazily in `CalcVisible`, destroyed when the line's entry no longer has one. - ---- - -## 2. Pool sizing — dynamic line count (`CreateChatLines`, chat.lua:177–290) - -- Computes how many lines fit in `chatContainer` as `floor(container.Height / line.Height)`. -- Adds new lines (`Below` the previous) when the window grows; destroys excess lines when it shrinks. -- First-time setup places line 1 at `AtLeftTopIn(container, 0, 0)`, then stacks more while `prev.Bottom + line.Height < container.Bottom`. -- Each line's `Height` is set lazily to `name.Height + 2` (so it scales with `font_size`). - ---- - -## 3. Scroll container (`SetupChatScroll`, chat.lua:296–364) - -Implements the standard MAUI scrollable interface on `chatContainer`: - -- **Virtual size** = sum of `wrappedtext` lengths across only *filtered* entries (`IsValidEntry`: per-army filter + link filter). Cached in `prevsize` / `prevtabsize`. -- `GetScrollValues`, `ScrollLines`, `ScrollPages`, `ScrollSetTop`, `IsScrollable` — the usual scrollbar API. -- `ScrollToBottom` — jumps to the most-recent line. -- Mouse wheel on the chat window maps to `ScrollSetTop` (in `CreateChat` / `OnMouseWheel`). -- Page Up / Page Down hotkeys (`ChatPageUp(mod)`, `ChatPageDown(mod)`), with Shift reducing the page size to 1. - ---- - -## 4. Visibility mapping (`CalcVisible`, chat.lua:366–507) - -Projects the history onto the line pool: - -- Walks `chatHistory` skipping filtered-out entries until the target scroll offset is reached. -- First wrapped line of an entry shows the name, team-color square, faction icon, etc.; continuation lines show only indented text with an empty name slot. -- Text colour per line picked from `ChatOptions[tokey]` (`all_color` / `allies_color` / `priv_color` / `link_color` / `notify_color`); camera-link lines use `link_color`. -- Name is disabled (greyed) when the line's `armyID` matches the focus army (your own messages). -- Camera icon inserted / removed based on whether the current entry has a `camera` field; also shifts the text's `Left` over by the icon's width. -- `line:SetAlpha(ChatOptions.win_alpha)` applied every refresh so opacity changes take effect immediately. - ---- - -## 5. Text wrapping (`WrapText` / `RewrapLog`, chat.lua:1223–1247) - -- `WrapText(data)` delegates to `maui/text.lua.WrapText` and returns an array of wrapped lines. -- Width callback uses `chatLines[1]`'s actual pixel width: the **first** wrapped line reserves space for the name prefix (measured via `text:GetStringAdvance(name)`), subsequent lines indent past just the team-color/faction column. -- Called once when a message arrives (in `ReceiveChatFromSim`). -- `RewrapLog()` re-wraps every entry in `chatHistory` on window resize or option change. - ---- - -## 6. Feed mode (window hidden, chat.lua:455–494) - -- When `GUI.bg` is hidden, the most recent lines render over the game world via the existing pool (the window chrome is just hidden, the line controls stay). -- Each visible line gets an `OnFrame` that increments `curHistory.time`; once `time > fade_time`, the line hides itself. -- Continuation lines of a wrapped entry don't tick their own timer — they wait on the first wrapped line's timer (special-cased). -- `feed_background` option controls the `lineStickybg`'s visibility per line. -- `ToggleChat` un-hides every line and hides every `lineStickybg` when opening the window; does the inverse on close. - ---- - -## 7. Filtering (chat.lua:304–323) - -- Per-army toggle: `ChatOptions[entry.armyID]` (one checkbox per non-civilian army in the config dialog). -- Link filter: camera-link entries require `ChatOptions.links` to display. -- Filtered entries are excluded from both the virtual scroll size and from `CalcVisible`'s walk. -- When a new entry arrives whose army is filtered out, `ScrollToBottom` is skipped. - ---- - -## 8. Interactivity - -- **Name `ButtonPress`** (chat.lua:199–211) → if the line has a `chatID`, it shows the window (if hidden), sets `ChatTo` to that army, focuses the edit box, and ticks the "private" checkbox. -- **Text `ButtonPress`** (chat.lua:223–229) → if the entry carries `cameraData`, `GetCamera('WorldCamera'):RestoreSettings(cameraData)` — the "camera link" feature that lets a sender point teammates at a position. - ---- - -## 9. Display-lifecycle state stored on history entries (the design-doc smell) - -These three fields currently live on `chatHistory` entries, mixing data with view state: - -- `new` — true until the entry has been shown once (controls whether the fade timer starts from zero or from the entry's existing time). -- `time` — seconds-since-displayed fade counter. -- `wrappedtext` — per-width wrapped text array; rebuilt by `RewrapLog` on resize. - ---- - -## 10. Styling tied to `ChatOptions` - -Every display option from the config dialog hits a chat-line property somewhere: - -- `font_size` → line `Height` and text point size. -- `win_alpha` → `line:SetAlpha` on refresh. -- `fade_time` → feed-mode timeout comparator. -- Colour indices (`*_color`) → text colour per line. -- `feed_background` → per-line `lineStickybg` visibility. -- `links` + per-army filters → inclusion/exclusion from `IsValidEntry`. diff --git a/lua/ui/game/chat/design.md b/lua/ui/game/chat/design.md deleted file mode 100644 index 71b86192668..00000000000 --- a/lua/ui/game/chat/design.md +++ /dev/null @@ -1,334 +0,0 @@ -# Chat System — Design Document - -**Purpose:** Captures the existing functionality of the in-game chat system as a basis for a refactoring effort. - ---- - -## 1. Entry Points and Lifecycle - -### 1.1 Initialization - -`gamemain.lua` drives the full lifecycle: - -1. **`SetLayout()`** — called from `gamemain.lua:SetLayout()`. Delegates to the skin-specific layout file (`layouts/chat_layout.lua`) which positions the chat `Window` and its sub-controls relative to the screen frame. -2. **`SetupChatLayout(mapGroup)`** — called from `gamemain.lua` at game-start. Calls `CreateChat()` and then registers `ReceiveChat` as the handler for the `'Chat'` identifier via `gamemain.RegisterChatFunc`. - -### 1.2 `CreateChat()` - -Builds the full chat UI tree: -- `CreateChatBackground()` → a draggable, resizable `Window` (`GUI.bg`) -- `CreateChatEdit()` → the text-input group (`GUI.chatEdit`) -- `CreateChatLines()` → the array of display lines (`GUI.chatLines[]`) -- Wires up all `OnResize`, `OnMove`, `OnFrame`, `OnClose`, `OnPinCheck`, and `OnConfigClick` callbacks -- Calls `ToggleChat()` at the end (so the window starts hidden) - ---- - -## 2. Message Transport - -### 2.1 Sending — `SessionSendChatMessage` - -The engine function `SessionSendChatMessage(clients?, msg)` delivers a Lua table as a chat message to one or more peers. The `clients` argument is either omitted (broadcast to all) or a list of client indices returned by `FindClients`. - -All callers encode meaning in specific fields of `msg`. The following fields are observed across the codebase: - -| Field | Type | Meaning | -|-------|------|---------| -| `to` | `'all'` \| `'allies'` \| `'notify'` \| number | Recipient scope | -| `Chat` | bool | Must be `true` for the standard chat display path | -| `text` | string | Message body | -| `from` | string | Override sender name (used for private-message echo) | -| `echo` | bool | Marks a message that was sent to a specific player (shown to the sender) | -| `Observer` | bool | Marks an observer-originated message | -| `camera` | table | Camera state (from `WorldCamera:SaveSettings()`) attached to a "ping" link | -| `ConsoleOutput` | string | Alternative payload — printed to console, not displayed in chat feed | -| `Taunt` | bool | Routes message to the taunt subsystem | -| `Template` | bool | Build-template share (from `build_templates.lua`) | -| `SendResumedBy` | bool | Game-resume notification (from `pause.lua`) | -| `ShareablePainting` | table | Painting data (from `PaintingCanvasAdapter.lua`) | -| `Identifier` | string | Modern routing key for `RegisterChatFunc` dispatch (preferred) | -| `data` | table | Payload for Notify messages (`{category, source, trigger, time?}`) | - -> **Size limit:** `SessionSendChatMessage` silently errors out above ~4 KB per message. Paintings chunk their payload to stay under this limit. - -### 2.2 `FindClients(id?)` - -Utility in `chat.lua` that returns a list of client indices. - -- No argument → allied clients of the focus army (or all observer clients if observing) -- `id` (army number) → clients controlling that specific army - -Imported directly by `score.lua`, `notify.lua`, and `painting/ShareAdapters/PaintingCanvasAdapter.lua`. - ---- - -## 3. Receiving — The Dispatch Chain - -### 3.1 Engine callback: `gamemain.ReceiveChat(sender, data)` - -This is the **single engine callback** invoked whenever any peer calls `SessionSendChatMessage`. It dispatches via the `chatFuncs` registry: - -``` -gamemain.ReceiveChat(sender, data) - │ - ├─ data.Identifier present → chatFuncs[data.Identifier](sender, data) [preferred path] - │ - └─ legacy fallback → iterate chatFuncs, call func if data[identifier] is truthy -``` - -### 3.2 `RegisterChatFunc(func, identifier)` - -Registers a handler. Current registrations: - -| Identifier | Handler | Registered in | -|------------|---------|---------------| -| `'Chat'` | `chat.ReceiveChat` | `chat.SetupChatLayout` | -| `'SendResumedBy'` | `SendResumedBy` (local fn) | `gamemain` init | -| `'Taunt'` | (legacy field match) | via field `data.Taunt` | -| `'Template'` | build-template handler | via field `data.Template` | -| `'ShareablePainting'` | painting adapter | via field `data.ShareablePainting` | - -### 3.3 `chat.ReceiveChat(sender, msg)` - -The `'Chat'`-identifier handler. Two responsibilities: - -1. **Sim callback** (non-replay, non-console): fires `GiveResourcesToPlayer` with zero resources as a sim-side hook to synchronise chat receipt across the sim boundary. -2. Delegates to `ReceiveChatFromSim(sender, msg)` for all display logic (skipped during replay — the replay system drives `ReceiveChatFromSim` directly from `gamemain`). - -### 3.4 `ReceiveChatFromSim(sender, msg)` - -Performs final validation and appends to `chatHistory`: - -1. `msg.ConsoleOutput` → `print()` only, no chat display, early return. -2. `msg.Chat ~= true` → dropped silently. -3. `msg.to == 'notify'` → routed through `notify.processIncomingMessage`; if that returns `false` the message is suppressed. -4. `armyData` lookup by sender name; drops unknown senders in non-replay multiplayer. -5. Builds an `entry` record with `name`, `tokey`, `color`, `armyID`, `faction`, `text`, `wrappedtext`, `new`, `camera`. -6. Inserts into `chatHistory` and triggers a scroll-to-bottom refresh. - ---- - -## 4. AI Chat Path - -AI chat bypasses `SessionSendChatMessage` entirely. - -`AIChatSorian.AISendChatMessage(towho, msg)` calls `chat.ReceiveChat(msg.aisender, msg)` directly on the local client — no network round-trip. The `aisender` field carries the AI player's name string. Taunts from AI are routed to `taunt.RecieveAITaunt` instead. - ---- - -## 5. Chat Display - -### 5.1 `chatHistory` - -A module-local table of entry records. Each record: - -```lua -{ - name = string, -- formatted "sender to-string" - tokey = string, -- key into ChatOptions for color lookup - color = string, -- ARGB hex of sender's team color - armyID = number, -- index for per-army filter - faction = number, -- faction icon index - text = string, -- raw message text - wrappedtext = string[], -- text wrapped to current window width - new = bool, -- true until first displayed - camera = table|nil, -- camera settings if a ping link is attached - time = number|nil -- fade timer (seconds since display, set lazily) -} -``` - -### 5.2 Chat Lines (`GUI.chatLines[]`) - -A pool of `Group` controls, one per visible row. Each row contains: -- `teamColor` — solid-colour bitmap (team colour border) -- `factionIcon` — faction logo bitmap -- `name` — clickable text label (clicking sets `ChatTo` to that player for private reply) -- `text` — message text; clickable if the entry has `camera` data (restores camera on click) -- `lineStickybg` — semi-transparent background shown in feed mode - -Line count is recalculated on resize to fill the container exactly. - -### 5.3 Scrolling - -The `chatContainer` implements the standard MAUI scrollable interface (`GetScrollValues`, `ScrollLines`, `ScrollPages`, `ScrollSetTop`, `IsScrollable`). The virtual size is the total wrapped-line count across all *filtered* history entries. - -`CalcVisible()` maps the current scroll position into `chatHistory`, handles line wrapping, and populates each `GUI.chatLines[i]` accordingly. - -### 5.4 Feed Mode (window hidden) - -When `GUI.bg` is hidden, the most recent lines are shown directly over the game world without the window frame. Each line: -- Shows until `curHistory.time >= ChatOptions.fade_time`, then hides itself via `OnFrame`. -- Optionally shows `lineStickybg` for readability (controlled by `ChatOptions.feed_background`). - -### 5.5 Text Wrapping - -`WrapText(data)` delegates to `maui/text.lua.WrapText`. Width is measured in screen pixels by querying `GUI.chatLines[1]`'s actual pixel width, accounting for the name prefix on the first continuation line. All history entries are re-wrapped on resize (`RewrapLog()`). - ---- - -## 6. Sending — Input Flow - -### 6.1 Recipient Selection - -`ChatTo` is a `lazyvar` holding: -- `'all'` — all players + observers -- `'allies'` — allied players only -- A number (army index) — private message to one player - -`ActivateChat(modifiers)` resolves the initial value based on `ChatOptions.send_type` and the Shift modifier. - -The chat-bubble button (`group.chatBubble`) opens `CreateChatList`, a dropdown showing all armies plus "All" and "Allies". Selecting an entry sets `ChatTo`. - -Clicking a **name** in the feed also sets `ChatTo` to that player's army index. - -### 6.2 `OnEnterPressed(text)` - -Executed when the user submits a message: - -1. **Slash commands** — if text starts with `/`, parse words, call `RunChatCommand(args)` (from `notify/commands.lua`); if handled, return early. -2. Empty text or whitespace-only → `ToggleChat()` (closes window). -3. Taunt check — `taunt.CheckForAndHandleTaunt(text)`; if it matches a taunt string, send via taunt path and return. -4. Build `msg` table: `{to = ChatTo(), Chat = true, text = text}`. -5. Attach `camera` if the camData checkbox is checked or `tempCam` is set. -6. Dispatch via `SessionSendChatMessage` with appropriate client list based on `ChatTo()` and `GetFocusArmy()`: - - `'allies'` + player → `FindClients()` - - `'allies'` + observer → `FindClients()` + `msg.Observer = true` - - number (private) → `FindClients(ChatTo())`, then echo locally via `ReceiveChat` - - `'all'` + player → `SessionSendChatMessage(msg)` (no explicit client list = broadcast) - - `'all'` + observer → `FindClients()` + `msg.Observer = true` -7. Append to `commandHistory`. - -### 6.3 Command History - -Arrow-Up/Down in the edit box cycles through `commandHistory`. Each entry is the full `msg` table, so camera state is restored alongside text. - -### 6.4 Camera Attachment - -The camData `Checkbox` in the edit area lets the user attach the current `WorldCamera` settings to a message. Clicking a received message that has `camera` data calls `WorldCamera:RestoreSettings(cameraData)`. - -### 6.5 Keyboard Shortcuts - -Registered in `keymap/keyactions.lua`: - -| Key | Action | -|-----|--------| -| Page Up | `chat.ChatPageUp(10)` | -| Page Down | `chat.ChatPageDown(10)` | -| Shift+Page Up | `chat.ChatPageUp(1)` | -| Shift+Page Down | `chat.ChatPageDown(1)` | -| Page Up (in edit box) | same as above | -| Page Down (in edit box) | same as above | - ---- - -## 7. Chat Options (Preferences) - -`ChatOptions` is loaded from the profile at module initialisation and saved back on Apply/OK. - -| Key | Type | Default | Meaning | -|-----|------|---------|---------| -| `all_color` | 1–8 | 1 | Color index for "all" messages | -| `allies_color` | 1–8 | 2 | Color index for ally messages | -| `priv_color` | 1–8 | 3 | Color index for private messages | -| `link_color` | 1–8 | 4 | Color index for camera-link messages | -| `notify_color` | 1–8 | 8 | Color index for Notify messages | -| `font_size` | 12–18 | 14 | Chat font size in points | -| `fade_time` | 5–30 | 15 | Seconds before feed lines/window auto-hide | -| `win_alpha` | 0.2–1.0 | 1 | Window opacity (stored as 0–100, normalized on use) | -| `feed_background` | bool | false | Show semi-transparent bg behind feed lines | -| `send_type` | bool | false | Default recipient: false = all, true = allies | -| `links` | bool | true | Show camera-link messages | -| `[armyID]` | bool | true | Per-army message filter (one key per army, set at game start) | - -**Color palette:** 8 fixed ARGB hex values (`chatColors[]`), shown as swatches in the config window. - -**Callback system:** External code can subscribe to options changes via `AddChatOptionSetCallback(callback, id?)`. The callback is called immediately with current options and again whenever the user applies new options. - ---- - -## 8. Window Behaviour - -- **Pin button** — prevents the auto-hide timer from running. -- **Auto-hide timer** — `GUI.bg.OnFrame` increments `curTime`; when it exceeds `fade_time` the window is hidden via `ToggleChat()`. Any user activity (typing, scrolling, receiving a message) resets `curTime`. -- **Resize** — drag handles at all four corners. `OnResizeSet` triggers `RewrapLog()` + `CreateChatLines()` + `CalcVisible()`. -- **Reset position button** — snaps the window back to its default screen position. -- **Config button** — opens/closes `CreateConfigWindow()` (a separate draggable `Window`). -- **Close button** — calls `ToggleChat()`. -- **Mouse wheel** on hidden window — forwarded to the world-view zoom. - ---- - -## 9. Notify Subsystem Integration - -`notify.lua` registers ACU-upgrade messages with `to = 'notify'` and `Chat = true`. - -In `ReceiveChatFromSim`, the `to == 'notify'` check calls `notify.processIncomingMessage(sender, msg)`: -- Returns `false` → message suppressed (category disabled, rate-limited, or NIS mode). -- Returns `true` (possibly mutating `msg.text` to a default message) → falls through to normal display. - -Notify messages use `notify_color` for their text color. - ---- - -## 10. Special Message Types Bypassing the Chat Feed - -These use `SessionSendChatMessage` as transport but do **not** display in the chat window: - -| Sender | Fields | Effect | -|--------|--------|--------| -| `diplomacy.lua` | `{to='all', ConsoleOutput=msg}` | Printed to game console only | -| `pause.lua` | `{SendResumedBy=true}` | Triggers `SendResumedBy` handler in gamemain | -| `build_templates.lua` | `{Template=true, data=…}` | Shares a build template to an ally | -| `casting/mouse.lua` | custom fields | Observer mouse-position broadcast | -| `painting/PaintingCanvasAdapter.lua` | `{ShareablePainting=…}` | Painting canvas data (chunked, ~4 KB limit) | - ---- - -## 11. Taunt Integration - -`taunt.lua` intercepts text entered in the chat edit box via `CheckForAndHandleTaunt(text)` before the message is sent. If matched: -- Sends `{Taunt=true, data=tauntIndex}` via `SessionSendChatMessage` (no explicit client list). -- The receiving side handles this through the `Taunt` field match in the legacy chatFuncs dispatch. -- On receipt, the taunt text is fed back into `chat.ReceiveChat` as a normal `Chat=true` message for display. - ---- - -## 12. File Map - -| File | Role | -|------|------| -| `lua/ui/game/chat.lua` | Core module: UI creation, history, display, sending | -| `lua/ui/game/gamemain.lua` | Engine callback (`ReceiveChat`), `RegisterChatFunc` registry, lifecycle calls | -| `lua/ui/game/layouts/chat_layout.lua` | Layout skin: positions the chat Window | -| `lua/ui/notify/notify.lua` | Notify subsystem: ACU upgrade messages, filter state | -| `lua/ui/notify/commands.lua` | `/command` dispatch table | -| `lua/AIChatSorian.lua` | AI chat: bypasses network, calls `chat.ReceiveChat` directly | -| `lua/ui/game/taunt.lua` | Taunt interception and display | -| `lua/ui/game/ping.lua` | Map-ping messages with attached camera state | -| `lua/ui/game/score.lua` | Resource-sharing chat notifications | -| `lua/ui/game/pause.lua` | Pause/resume chat notifications | -| `lua/ui/game/diplomacy.lua` | Draw-offer console output via chat transport | -| `lua/ui/game/build_templates.lua` | Build-template sharing via chat transport | -| `lua/ui/game/casting/mouse.lua` | Observer mouse-position broadcast via chat transport | -| `lua/ui/game/painting/…/PaintingCanvasAdapter.lua` | Painting sharing via chunked chat messages | -| `lua/keymap/keyactions.lua` | Keyboard shortcut bindings for chat scroll | - ---- - -## 13. Known Design Issues (Refactoring Targets) - -1. **Single monolithic file** — `chat.lua` mixes UI creation, layout, message-routing logic, history management, text wrapping, options persistence, and the config dialog into one ~1560-line file. - -2. **`GUI` is a module-global shared with the layout file** — `GUI` is obtained from `/lua/ui/controls.lua` and mutated freely; there is no clear ownership boundary. - -3. **`chatHistory` entries carry display state** — `time` and `new` are display-lifecycle fields stored on data records, coupling the history model to the feed renderer. - -4. **Dual receive paths** — `ReceiveChat` and `ReceiveChatFromSim` exist because of the sim-callback detour; the naming is confusing and the split responsibilities are not obvious. - -5. **`msg` table schema is implicit** — no type annotations or schema definition; each caller adds ad-hoc fields. The `Identifier` field was added as a preferred routing key but most senders still rely on the legacy field-match fallback. - -6. **`FindClients` is imported by multiple unrelated modules** — its coupling to army/team logic could be separated into a `clientutils`-style module (a partial precedent exists in `gamemain.lua` which already imports `clientutils.GetAll()`). - -7. **`ChatOptions` is read at module load time** — changes require a full `GUI.bg:OnOptionsSet()` cycle; there is no reactive binding except via `AddChatOptionSetCallback`. - -8. **Window state leaks into history entries** — `entry.time` and `entry.new` are display-lifecycle fields stored on data records; they should be display-side state. From bbb816963a5350a9a106330dc3d73dc4d1511455 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 14:55:12 +0200 Subject: [PATCH 101/130] Add chat message when units are gifted --- loc/DE/strings_db.lua | 4 +++ loc/RU/strings_db.lua | 4 +++ loc/US/strings_db.lua | 7 +++--- lua/SimUtils.lua | 57 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/loc/DE/strings_db.lua b/loc/DE/strings_db.lua index 681fdde68f7..8b4b754d6da 100644 --- a/loc/DE/strings_db.lua +++ b/loc/DE/strings_db.lua @@ -4211,6 +4211,10 @@ chat_resources_received_energy="%s hat dir %d Energie geschickt." chat_resources_overflow_both="Lager voll — %d Masse und %d Energie sind verloren gegangen. Baue mehr Lagerkapazität." chat_resources_overflow_mass="Lager voll — %d Masse ist verloren gegangen. Baue mehr Lagerkapazität." chat_resources_overflow_energy="Lager voll — %d Energie ist verloren gegangen. Baue mehr Lagerkapazität." +chat_units_received_one="%s hat dir eine Einheit übergeben." +chat_units_received_many="%s hat dir %d Einheiten übergeben." +chat_engineers_received_one="%s hat dir einen Ingenieur übergeben." +chat_engineers_received_many="%s hat dir %d Ingenieure übergeben." chat_win_0001="An %s:" chat_win_0002="Chat (%d - %d von %d Zeilen)" cheating_fragment_0000="benutzt" diff --git a/loc/RU/strings_db.lua b/loc/RU/strings_db.lua index 4e45636a47a..4ae7d5b822b 100644 --- a/loc/RU/strings_db.lua +++ b/loc/RU/strings_db.lua @@ -4345,6 +4345,10 @@ chat_resources_received_energy="%s передал вам %d энергии." chat_resources_overflow_both="Хранилище заполнено — потеряно %d массы и %d энергии. Постройте больше хранилищ." chat_resources_overflow_mass="Хранилище заполнено — потеряно %d массы. Постройте больше хранилищ." chat_resources_overflow_energy="Хранилище заполнено — потеряно %d энергии. Постройте больше хранилищ." +chat_units_received_one="%s передал вам юнита." +chat_units_received_many="%s передал вам %d юнитов." +chat_engineers_received_one="%s передал вам инженера." +chat_engineers_received_many="%s передал вам %d инженеров." chat_win_0001="Игроку %s:" chat_win_0002="Чат (%d - %d из %d строк)" cheating_fragment_0000="is" diff --git a/loc/US/strings_db.lua b/loc/US/strings_db.lua index f8867301a11..c70b0dcf0bd 100644 --- a/loc/US/strings_db.lua +++ b/loc/US/strings_db.lua @@ -3744,9 +3744,10 @@ chat_0015='Persist Feed Timeout' chat_resources_received_both="%s sent you %d mass and %d energy." chat_resources_received_mass="%s sent you %d mass." chat_resources_received_energy="%s sent you %d energy." -chat_resources_overflow_both="Storage full — %d mass and %d energy overflowed. Build more storage." -chat_resources_overflow_mass="Storage full — %d mass overflowed. Build more storage." -chat_resources_overflow_energy="Storage full — %d energy overflowed. Build more storage." +chat_units_received_one="%s shared a unit with you." +chat_units_received_many="%s shared %d units with you." +chat_engineers_received_one="%s shared an engineer with you." +chat_engineers_received_many="%s shared %d engineers with you." cheating_fragment_0000="is" cheating_fragment_0001="are" cheating_fragment_0002=" cheating!" diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua index e707e49ad57..0974bc32ed6 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -753,6 +753,63 @@ function GiveUnitsToPlayer(data, units) end TransferUnitsOwnership(units, toArmy) + + -- Whisper from giver → receiver, with an `Area` location so the + -- receiver can click the cam-icon to jump to where the units are. + -- The bounding box is computed from the units' positions before + -- they scatter; padded slightly so a single-unit gift gives the + -- camera region a non-degenerate framing rectangle. + ---@cast units Unit[] + local count = table.getn(units) + if count > 0 then + local init = units[1]:GetPosition() + local x0, x1, z0, z1 = init[1], init[1], init[3], init[3] + for _, unit in units do + local pos = unit:GetPosition() + if pos[1] < x0 then x0 = pos[1] end + if pos[1] > x1 then x1 = pos[1] end + if pos[3] < z0 then z0 = pos[3] end + if pos[3] > z1 then z1 = pos[3] end + end + local pad = 5 + local area = { x0 = x0 - pad, x1 = x1 + pad, y0 = z0 - pad, y1 = z1 + pad } + local fromBrain = ArmyBrains[owner] + local fromName = fromBrain.Nickname or tostring(owner) + + -- Specialize the wording when every shared unit is an engineer + -- — "shared 5 engineers" reads more naturally than "shared 5 + -- units" when the transfer is e.g. a builder pool. Mixed + -- transfers fall through to the generic noun. + local allEngineers = true + for _, unit in units do + if not EntityCategoryContains(categories.ENGINEER, unit) then + allEngineers = false + break + end + end + + local locKey, fallback + if allEngineers then + if count == 1 then + locKey, fallback = 'chat_engineers_received_one', '%s shared an engineer with you.' + else + locKey, fallback = 'chat_engineers_received_many', '%s shared %d engineers with you.' + end + else + if count == 1 then + locKey, fallback = 'chat_units_received_one', '%s shared a unit with you.' + else + locKey, fallback = 'chat_units_received_many', '%s shared %d units with you.' + end + end + + local args = count == 1 and { fromName } or { fromName, count } + fromBrain:SendChatToPlayer(toArmy, + '' .. fallback, + args, + { Area = area } + ) + end end end From 2f96e34272a182f84801e56594fe9815d4068ed0 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 14:55:19 +0200 Subject: [PATCH 102/130] Update layout file --- lua/ui/game/chat/LAYOUT.md | 223 +++++++++++++++++-------------------- 1 file changed, 102 insertions(+), 121 deletions(-) diff --git a/lua/ui/game/chat/LAYOUT.md b/lua/ui/game/chat/LAYOUT.md index 3dd1ba46e1c..2887e9ecdd5 100644 --- a/lua/ui/game/chat/LAYOUT.md +++ b/lua/ui/game/chat/LAYOUT.md @@ -1,16 +1,11 @@ # 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 MVC contract. +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. Notation throughout: -- **(S)** — value scales with the UI factor (`pixelScaleFactor`). Includes - values passed through layout helpers, `ScaleNumber(N)`, and font-derived - metrics like `GetFontHeight()`. -- **(F)** — fixed actual pixels. Bitmap-intrinsic dimensions on `Bitmap` / - `Button` / `Checkbox` controls don't auto-scale; they render at their texture - size regardless of UI factor. +- **(S)** — scales with the UI factor (`pixelScaleFactor`). Includes values passed through `Layouter`, `LayoutHelpers.ScaleNumber(N)`, and font metrics like `GetFontHeight()`. +- **(F)** — fixed actual pixels. Bitmap-intrinsic dimensions on `Bitmap` / `Button` / `Checkbox` controls don't auto-scale; they render at their texture size regardless of UI factor. - **(D)** — derived from other LazyVars (e.g. `Bottom = Top + Height`). --- @@ -22,20 +17,21 @@ ChatInterface (Window) │ Left/Top/Right/Bottom = DefaultRect (S, drag/resize moves these) │ client = inside skin-border insets │ -├── DragTL/TR/BL/BR (F bitmap) AtLeftTopIn(self, -26, -8) etc. -├── ResetPositionBtn (F bitmap) AnchorToLeft(_configBtn, 4) +├── DragTL/TR/BL/BR (F bitmap) AtLeftTopIn(self, -26, -8) etc. +├── _pinCheckbox / _configBtn / _closeBtn (F bitmap) Window-chrome row +├── ResetPositionBtn (F bitmap) AnchorToLeft(_configBtn, 4) │ └── client (Window's inner group) │ ├── Lines (ChatLinesInterface, Group) - │ │ Left = client.Left + pad(S) - │ │ Right = client.Right - pad(S) - │ │ Top = client.Top + pad(S) - │ │ Bottom = Edit.Top - 12(S) ← anchored to Edit's top + │ │ Left = client.Left + 8(S) + │ │ Right = client.Right - 8(S) + │ │ Top = client.Top + 2(S) + │ │ Bottom = Edit.Top - 4(S) ← AnchorToTop(Edit, 4) │ │ │ ├── Pool (Group) │ │ │ Left = ChatLinesInterface.Left - │ │ │ Right = ChatLinesInterface.Right - 20(S) ← scrollbar reserve + │ │ │ Right = ChatLinesInterface.Right - ScrollbarReserve(S) │ │ │ Top = ChatLinesInterface.Top │ │ │ Bottom = ChatLinesInterface.Bottom │ │ │ @@ -47,43 +43,65 @@ ChatInterface (Window) │ anchored to Pool's right edge │ └── Edit (ChatEditInterface) - │ Left = client.Left - │ Right = client.Right - │ Bottom = client.Bottom - │ Height = EditBox.Height(S) + ScaleNumber(6)(S) ← the padding - │ Top = Bottom - Height (D) + │ Left = ChatInterface.Left ← anchored to window, not client + │ Right = ChatInterface.Right + │ Bottom = ChatInterface.Bottom - 6(S) + │ Height = 19(S) ← fixed in parent (ChatInterface.lua) + │ Top = Bottom − Height (D) + │ :Over(client) ← visually layered over client area │ ├── ChatBubble (F ≈ 24×24 bitmap) - │ Left = self.Left + 3(S) - │ Top = AtVerticalCenterIn(self) - │ = self.Top + (self.Height(S) - 24(F)) / 2 + │ AtLeftIn(self, 6(S)) + │ AtVerticalCenterIn(self) │ ├── RecipientLabel (S font, ≈ font_h) - │ Left = ChatBubble.Right + 2(S) - │ Top = AtVerticalCenterIn(self) - │ = self.Top + (self.Height(S) - font_h(S)) / 2 + │ AnchorToRight(ChatBubble, 6(S)) + │ AtVerticalCenterIn(self) │ - ├── EditBox (S font, height = GetFontHeight) - │ Left = RecipientLabel.Right + 4(S) - │ Right = CamCheckbox.Left - 4(S) - │ Top = AtVerticalCenterIn(self) = self.Top + pad/2 + ├── EditBox (S font) + │ AnchorToRight(RecipientLabel, 4(S)) + │ AnchorToLeft(CamCheckbox, 4(S)) + │ AtVerticalCenterIn(self) │ Height = GetFontHeight()(S) │ ├── CamCheckbox (F ≈ 24×24 bitmap) - │ Right = self.Right - 4(S) - │ Top = AtVerticalCenterIn(self) + │ AtRightIn(self, 12(S)) + │ AtVerticalCenterIn(self, −2) ← 2-pixel upward nudge │ - ├── ChatList (popup, child of self, on demand) + ├── ChatListInterface (popup, child of self, on demand) │ Above(ChatBubble, 15(S)) │ AtLeftIn(ChatBubble, 15(S)) │ - └── CommandHint (popup, child of self, on demand) + └── ChatCommandHintInterface (popup, child of self, on demand) Above(EditBox, 14(S)) AtLeftIn(EditBox) ``` --- +## ChatFeedInterface (sibling feed shown while the window is closed) + +``` +self +│ When bound to the chat window (ChatController.Init does this): +│ │ Left = ChatInterface.Lines.Left ← reactive LazyVar bind +│ │ Right = ChatInterface.Lines.Right +│ │ Top = ChatInterface.Lines.Top +│ │ Bottom = ChatInterface.Lines.Bottom +│ │ +│ When standalone (debug Toggle, no window): +│ │ AtLeftBottomIn(parent, 8(S), 60(S)) +│ │ Width = 420(S) +│ │ Height = 160(S) +│ +└── Rows[i] stacked; each row carries its own `Time` for + independent fade. Row geometry mirrors ChatLineInterface. +``` + +The reactive `Left/Right/Top/Bottom = ChatLinesInterface.X` bind is one-way and read-only — drag/resize the chat window with the feed visible (e.g. during a transition) and the feed tracks for free; no observer glue, no model write. Visibility is owned entirely by the feed: it's shown only when the window is hidden **and** at least one row exists. + +--- + ## ChatLineInterface (one row in the line pool) ``` @@ -101,15 +119,17 @@ self │ ├── CamIcon RightOf(Name, 4(S)) │ (F ≈ 20×16) AtVerticalCenterIn(TeamColor) -│ :Width(20)(S) :Height(16)(S) -│ ← hidden when entry.Camera is nil +│ Width=20(S), Height=16(S) +│ ← shown when entry.Camera **or** entry.Location is set │ └── Text (S) Left = Name.Right + 2(S) - (or CamIcon.Right + 4 if camera attached) + (or CamIcon.Right + 4 when an icon is shown) Right = self.Right Top = AtVerticalCenterIn(TeamColor) ``` +`SetHeader` / `SetContinuation` / `Clear` re-anchor `Text.Left` between Name and CamIcon depending on whether the row displays the camera/location affordance. Continuation rows clear the icon entirely (`SolidColor 00000000`, hit-test off), so wrapped-text lines align flush under the first chunk. + --- ## ChatCommandHintInterface (slash-command popup) @@ -132,9 +152,7 @@ self └── Borders LTBG/RTBG/.../BBG hug outside of self ``` -The scrollbar's "top" is inverted: ordinals grow upward (1 at the bottom), so -`GetScrollValues` reports `top = N - ScrollBottom - MaxVisibleRows + 2`. Drag -the thumb up → highest ordinals visible at the top of the popup. +The scrollbar's "top" is inverted: ordinals grow upward (1 at the bottom), so `GetScrollValues` reports `top = N - ScrollBottom - MaxVisibleRows + 2`. Drag the thumb up → highest ordinals visible at the top of the popup. --- @@ -150,14 +168,35 @@ self │ ├── BG Left = self.Left - 6(S) │ │ Width = self.Width + 8(S) ← BG bleeds outside self │ │ Top/Bottom = text ± 1(S) -│ └── Badge? AtLeftIn(self, 3(S)) -│ AtVerticalCenterIn(Text) +│ └── ChatFactionBadge (per-player entries; absent on the all/allies entries) +│ AtLeftIn(self, 3(S)) +│ AtVerticalCenterIn(Text) │ └── Borders LTBG/RTBG/.../BBG ``` --- +## ChatFactionBadge (faction icon over team colour) + +Used by `ChatListInterface` for per-player entries and intended for any other chat surface that surfaces a player. + +``` +self (Group) +│ Default size: 14 × 14(S) +│ Consumers override via Layouter or LayoutHelpers.SetDimensions +│ +├── Color (Bitmap) Fill(self), DepthOverParent +1 +│ SolidColor = team colour (defaults to white) +│ +└── Icon (Bitmap) Fill(self), DepthOverParent +2 + Texture = faction icon (or observer icon when faction is nil) +``` + +Both children fill the badge; depth ordering puts the faction icon over the team-colour tile, and the tile shows through the icon's transparent pixels. + +--- + ## What scales, what doesn't | Control | Width × Height | Notes | @@ -167,6 +206,7 @@ self | `CamIcon` | (S) 20 × 16 | `Layouter:Width`/`:Height` literal — auto-scaled. | | `TeamColor` | (S) N × N | `Width = Height = Name.Height + 2`. | | `FactionIcon` | (S) fills TeamColor | | +| `ChatFactionBadge` | (S) default 14 × 14 | Auto-scaled; both children Fill the badge. | | `ResetPositionBtn` | (F) bitmap intrinsic | | | Drag handles | (F) bitmap intrinsic | | | Text controls | (S) font-derived | `Name`, `RecipientLabel`, `EditBox`, message `Text`. | @@ -175,91 +215,32 @@ self --- -## Chat-edit vertical layout at 100% / 150% / 200% - -`font_h ≈ 17 / 25 / 33` (S) · `bitmap_h ≈ 24 / 24 / 24` (F) - -### `pad = ScaleNumber(6)` (current) +## Edit row at scale -| | 100% | 150% | 200% | -|-------------------------|-------------------------|-------------------------|-------------------------| -| `self.Height` | `17 + 6 = 23` | `25 + 9 = 34` | `33 + 12 = 45` | -| `EditBox.Top` | `self.Top + 3` | `self.Top + 4.5` | `self.Top + 6` | -| `ChatBubble.Top` center | `(23-24)/2 = -0.5` | `(34-24)/2 = 5` | `(45-24)/2 = 10.5` | -| `ChatBubble.Top` value | `self.Top − 1` | `self.Top + 5` | `self.Top + 10` | -| `CamCheckbox.Top` | `self.Top − 1` | `self.Top + 5` | `self.Top + 10` | -| `RecipientLabel.Top` | `self.Top + 3` | `self.Top + 4.5` | `self.Top + 6` | -| **net** | buttons 4 px above text | buttons 0.5 px above | buttons 4 px above | -| | (over-correct) | (looks OK) | (looks OK-ish, but 4 px | -| | | | empty above text) | - -### `pad = 0` (legacy, no padding) - -| | 100% | 150% | 200% | -|-------------------------|-------------------------|-------------------------|-------------------------| -| `self.Height` | `17` | `25` | `33` | -| `EditBox.Top` | `self.Top + 0` | `self.Top + 0` | `self.Top + 0` | -| `ChatBubble.Top` | `self.Top − 4` | `self.Top + 0` | `self.Top + 5` | -| **net** | buttons 4 px above text | buttons AT text top | buttons 5 px BELOW text | -| | (legacy "frame" look) | (looks low — empty | top (looks low — | -| | | space below button) | growing gap below) | - ---- +`font_h ≈ 17 / 25 / 33` (S) · `bitmap_h ≈ 24` (F) · `Edit.Height = 19` (S, fixed in parent) -## The structural issue - -Buttons are **fixed pixels (F)**. Text is **scaled pixels (S)**. As the UI -factor grows, `font_h` overtakes `bitmap_h`. `AtVerticalCenterIn` aligns -geometric centres — but the eye reads alignment between the bitmap centre -and the **text's optical centre** (about `font_h / 3` from the top, because -of ascender/descender asymmetry). - -| pad value | Behaviour | -|-----------|-----------| -| `pad = 0` | button geometric-centre == text geometric-centre. Works only when `font_h ≈ bitmap_h` (i.e. ~100% UI scale). Drifts visibly at higher scales. | -| `pad = 6(S)` | everything centred in a slightly bigger box; text sits higher within `self`, which "fixes" higher scales but over-corrects at 100% (text leaves its natural baseline). | - -Neither single constant works at every scale because the offset we want -between button and text scales **with `font_h`**, not with the UI factor -alone. - -### Two paths forward - -Both anchor the bitmap buttons to the text optical line instead of geometric -centre: - -**(A) Per-button `SetFunction`** - -```lua -ChatBubble.Top:SetFunction(function() - return self.EditBox.Top() - + self.EditBox.Height() / 3 - - self.ChatBubble.Height() / 2 -end) --- and self.Height = EditBox.Height (drop the pad) -``` +| UI scale | `Edit.Height(S)` (raw px) | `bitmap_h(F)` | Bitmap vs row | +|----------|---------------------------|---------------|------------------| +| 100% | 19 | 24 | 2.5 px overhang | +| 150% | 28.5 | 24 | 2.25 px headroom | +| 200% | 38 | 24 | 7 px headroom | -**(B) Helper `OpticalCenterIn(child, edit)`** that does (A); apply to each -bitmap-sized child (`ChatBubble`, `CamCheckbox`). `RecipientLabel` and the -edit text stay on `AtVerticalCenterIn` since they're font-sized and already -align with each other at every scale. +`AtVerticalCenterIn(self)` keeps both bitmap buttons visually centred at every scale. The small overhang at 100% sits on top of the line-area background and is not visually disruptive in practice. `CamCheckbox` adds an extra `-2` topOffset to compensate for asymmetric padding inside its texture. -The critical observation: **only the bitmap-sized children (`ChatBubble`, -`CamCheckbox`) misbehave across scales.** The font-sized children -(`RecipientLabel`, `EditBox`) align fine with each other at every scale -because they share the same intrinsic height. The fix only needs to touch the -bitmap children. +The fixed `Edit.Height = 19(S)` is intentional: the row appears to grow around the bitmap as scale increases, while the affordance art stays at its source resolution. This is the desired behaviour — bitmaps lose detail when stretched, but text gains it. Earlier iterations tried to derive `Edit.Height` from the font size; that worked but made the bitmaps drift visibly across the centre line at low scales because the row shrank below the texture height. Fixing the row height in scaled units kept the bitmap-vs-text relationship constant. --- ## Where each value lives in the code -| Concern | File | -|------------------------------------|---------------------------------------------------------------------------| -| `DefaultRect`, drag handles, window chrome | [`ChatInterface.lua`](ChatInterface.lua) | -| `Lines` ↔ `Edit` anchoring (window-level) | [`ChatInterface.lua` `__post_init`](ChatInterface.lua) | -| Pool / Scrollbar layout, scroll state, wrapping, filtering | [`ChatLinesInterface.lua`](ChatLinesInterface.lua) | -| `ChatBubble` / `RecipientLabel` / `EditBox` / `CamCheckbox` layout | [`ChatEditInterface.lua` `__post_init`](ChatEditInterface.lua) | -| Row geometry (`TeamColor`, `Name`, `CamIcon`, `Text`) | [`ChatLineInterface.lua` `__post_init`](ChatLineInterface.lua) | -| Hint popup width / height / row positioning | [`ChatCommandHintInterface.lua`](ChatCommandHintInterface.lua) | -| Recipient picker entries + BG bleed | [`ChatListInterface.lua` `CreateEntry`](ChatListInterface.lua) | +| Concern | File | +|----------------------------------------------------------|-------------------------------------------------------------------------------| +| `DefaultRect`, drag handles, window chrome | [`ChatInterface.lua`](ChatInterface.lua) | +| `Lines` ↔ `Edit` anchoring (window-level) | [`ChatInterface.lua` `__post_init`](ChatInterface.lua) | +| Sibling feed bound to lines rect | [`ChatFeedInterface.lua` `__post_init`](ChatFeedInterface.lua) | +| Pool / Scrollbar layout, scroll state, wrapping, filtering | [`ChatLinesInterface.lua`](ChatLinesInterface.lua) | +| `ChatBubble` / `RecipientLabel` / `EditBox` / `CamCheckbox` layout | [`ChatEditInterface.lua` `__post_init`](ChatEditInterface.lua) | +| Row geometry (`TeamColor`, `Name`, `CamIcon`, `Text`) | [`ChatLineInterface.lua` `__post_init`](ChatLineInterface.lua) | +| Faction badge composition (recipient picker, per row) | [`ChatFactionBadge.lua`](ChatFactionBadge.lua) | +| Hint popup width / height / row positioning | [`ChatCommandHintInterface.lua`](ChatCommandHintInterface.lua) | +| Recipient picker entries + BG bleed | [`ChatListInterface.lua` `CreateEntry`](ChatListInterface.lua) | From a8f8ddd224d14b4c3e4d3e936f836c1afe2fe7d3 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 15:07:40 +0200 Subject: [PATCH 103/130] Add additional padding to camera area for chat message --- lua/SimUtils.lua | 2 +- lua/ui/game/chat/commands/design.md | 168 ---------------------------- 2 files changed, 1 insertion(+), 169 deletions(-) delete mode 100644 lua/ui/game/chat/commands/design.md diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua index 0974bc32ed6..5d3b813ef7c 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -771,7 +771,7 @@ function GiveUnitsToPlayer(data, units) if pos[3] < z0 then z0 = pos[3] end if pos[3] > z1 then z1 = pos[3] end end - local pad = 5 + local pad = 30 local area = { x0 = x0 - pad, x1 = x1 + pad, y0 = z0 - pad, y1 = z1 + pad } local fromBrain = ArmyBrains[owner] local fromName = fromBrain.Nickname or tostring(owner) diff --git a/lua/ui/game/chat/commands/design.md b/lua/ui/game/chat/commands/design.md deleted file mode 100644 index 042cb47f2fa..00000000000 --- a/lua/ui/game/chat/commands/design.md +++ /dev/null @@ -1,168 +0,0 @@ -# Chat Commands — Design - -**Purpose:** Handle `/slash` commands entered in the chat edit box. Parses the command, validates arguments, optionally checks legitimacy, executes side effects (usually on the controller), and surfaces failures as local-only system lines in the chat feed. - ---- - -## 1. Architecture - -``` -ChatEditInterface.EditBox:OnEnterPressed(text) - └── ChatController.Send(text) - └── text starts with '/'? - └── ChatCommandRegistry.Dispatch(text) - ├── Tokenize(text) -- "/whisper Jip" → ("whisper", {"Jip"}) - ├── Lookup(name) -- name + aliases - ├── ParseArgs(cmd, tokens) -- typed, coerced, validated - ├── cmd.Accept(args, ctx) -- semantic legitimacy check - └── cmd.Execute(args, ctx) -- run side effect (usually ctx.Controller.*) -``` - -- **Registry** — flat `name → command` table plus `alias → name`. Exports `Register`, `Unregister`, `Lookup`, `GetAll`, `Dispatch`. -- **Types** — a table of `{recipient, player, int, string, rest}` resolvers. Each takes a raw token and returns `(ok, value_or_error)`. -- **Builtins** — `/all`, `/allies`, `/whisper`, `/help`. Loaded lazily on the first `Dispatch` call. - -Commands do not touch the model directly. They call through `ctx.Controller`, preserving the MVC rule from `CLAUDE.md`. - ---- - -## 2. Command Descriptor - -```lua ----@class UIChatCommand ----@field Name string # canonical name without leading slash ----@field Aliases? string[] # alternative names (e.g. {'w','pm'} for whisper) ----@field Description string # one-line summary shown by /help ----@field Params? UIChatCommandParam[] # declarative parameter schema ----@field Accept? fun(args, ctx): boolean, string? # runtime legitimacy check ----@field Execute fun(args, ctx) # the actual side effect - ----@class UIChatCommandParam ----@field Name string ----@field Type 'Recipient' | 'Player' | 'Int' | 'String' | 'Rest' ----@field Optional boolean? -``` - -Rules: - -- `Name` and every entry of `Aliases` are case-insensitive. -- `Params` order is the order tokens will be consumed. -- Only the last param may be `Rest`; it greedy-consumes every remaining token, joining them with single spaces. -- `Accept` and `Execute` both receive the already-typed `args` table and a shared `ctx`. - -## 3. Parameter Types - -Each resolver is `fun(token: string): ok, value | error`. - -| Type | Accepts | Resolves to | -|------|---------|-------------| -| `Recipient` | `"all"`, `"allies"`, `"team"`, nickname, army ID | `UIChatRecipient` (`'all' \| 'allies' \| number`) | -| `Player` | nickname or army ID | `number` (army ID) — same rules as `Recipient` but rejects `all`/`allies` | -| `Int` | integer literal | `number` | -| `String` | a single whitespace-delimited token | `string` | -| `Rest` | one or more remaining tokens | `string` (tokens joined by single spaces) | - -Army lookup goes through `GetArmiesTable()`, matching the source `ChatListInterface` already uses for the recipient picker. Civilian armies are excluded. - -## 4. Execution Context - -```lua ----@class UIChatCommandContext ----@field Model UIChatModel ----@field Controller table -- ChatController module ----@field SourceText string -- the original "/whisper Jip" text -``` - -Passing `ctx` rather than each command importing the controller/model keeps commands decoupled from the chat tree and trivially testable. - -## 5. Error Surfaces - -`Dispatch` returns `(handled, errorText)`: - -| Return | Meaning | Caller action | -|--------|---------|---------------| -| `(true, nil)` | command ran | return | -| `(false, errText)` | parse/accept/unknown error | print `errText` as a local system line, return | -| `(false, nil)` | lone `/` or empty body | treat as normal text | - -Error strings are produced at a single site in the registry so they stay uniform: - -| Cause | Example | -|-------|---------| -| Unknown name | `Invalid command: /xyz. Type /help for a list.` | -| Missing arg | `/whisper: missing argument .` | -| Bad arg | `/whisper: no player named 'bob'.` | -| Rejected by `Accept` | whatever string `Accept` returned | - -Printing goes through `ChatController.AppendLocalSystemMessage(text)`, which appends a synthetic `UIChatEntry` to `model.History`. No network traffic; the line renders through the existing `ChatListInterface` path with no view changes. - -## 6. Adding a Command - -```lua -local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") -local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") - -Registry.Register { - Name = 'whisper', - Aliases = { 'w', 'pm' }, - Description = 'Whisper to a specific player.', - Params = { - { Name = 'target', Type = 'Player' }, - }, - Accept = function(args) - local armies = GetArmiesTable() - if armies and args.target == armies.focusArmy then - return false, "/whisper: can't whisper yourself." - end - return true - end, - Execute = function(args, ctx) - ctx.Controller.SetRecipient(args.target) - end, -} -``` - -`/whisper Jip` and `/whisper 3` both route here with `args.target` already normalized to an army ID — one command definition, two user-facing forms. - -## 7. `Accept` vs. `Execute` - -- **Parser** handles *structural* errors: missing args, wrong types, unknown name. -- **`Accept`** handles *semantic* errors that depend on runtime state: whispering yourself, command disabled in replay, target just disconnected. -- **`Execute`** runs the side effect and trusts its inputs. - -Splitting `Accept` out keeps the failure path uniform (always surfaces as a system feed line with the reason) and leaves room for things like tab-completion previews that call `Accept` without `Execute`. - -## 8. Bootstrap - -Each built-in command lives in its own file under `commands/builtin/` (e.g. `All.lua`, `Allies.lua`, `Whisper.lua`, `Help.lua`) and exports a single top-level `Command` table. Importing a command file has no side effects — a command is inert until the controller registers it. One command per file keeps the diff footprint of adding, removing, or overriding a command local to that file, which is the whole reason builtins were split out of the old monolithic module. The registry and parameter-type resolvers stay at `commands/` so the infrastructure sits above the commands that use it. - -`ChatController.RegisterBuiltinCommands()` is the single registration site: it imports each command file and hands its `Command` export to `ChatCommandRegistry.Register`. It is idempotent, so it can be called from multiple init paths without harm. `ChatController.Send` invokes it lazily on the first slash-prefixed message so the feature works without an explicit init hook; `ChatController:Init` also calls it at startup. - -External modules (notify, mods, future subsystems) register their own commands by calling `Registry.Register` directly, independent of the builtins. - -## 9. Integration with `ChatController.Send` - -```lua -function Send(text) - if text and string.sub(text, 1, 1) == '/' then - local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") - local handled, err = Registry.Dispatch(text) - if handled then return end - if err then - AppendLocalSystemMessage(err) - return - end - -- lone '/' falls through - end - -- ... taunt check, network send ... -end -``` - -The slash branch is the first step of the send pipeline, matching `CLAUDE.md §Sending`. - -## 10. Open Questions - -1. **Nicknames with spaces.** Current tokenizer splits on whitespace. If nicknames with spaces are real, we need either quoted strings (`/whisper "Jip E"`) or a smarter `player` resolver that greedy-matches across tokens. Left as future work. -2. **Localization.** Error strings and command descriptions should go through `` like other chat text; currently hardcoded English. -3. **Replay/observer gating.** Some commands are meaningless in replay. `accept` can enforce per-command; a shared `ctx.mode` flag (`'live' | 'replay' | 'observer'`) would avoid each command re-deriving it. -4. **Tab completion / history.** The registry exposes `GetAll()` so an edit-view enhancement can offer completion for command names and (via `Params[i].Type`) argument suggestions. Not wired up here. From 933fd80e78461da028c7c8ec8fc5edc0166dcdf4 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 15:32:05 +0200 Subject: [PATCH 104/130] Fix chat lines not populating properly --- lua/ui/game/chat/ChatLinesInterface.lua | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index bfff23e42f2..1cacc410778 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -262,6 +262,7 @@ ChatLinesInterface = ClassUI(Group) { ---@param self UIChatLinesInterface ---@param options UIChatOptions ApplyOptions = function(self, options) + local oldPoolSize = table.getn(self.ChatLineInterfaces) local size = options.font_size or 14 for _, line in ipairs(self.ChatLineInterfaces) do line:SetFontSize(size) @@ -275,6 +276,7 @@ ChatLinesInterface = ClassUI(Group) { -- Recompute what's visible so entries newly excluded by -- `IsValidEntry` drop out of the feed immediately. self:RefreshVirtualSize() + self:RecomputeScrollTopForPoolChange(oldPoolSize) self:CalcVisible() end, @@ -401,6 +403,28 @@ ChatLinesInterface = ClassUI(Group) { return true end, + --- Adjusts `ScrollTop` to compensate for a change in pool size (window + --- resize, font-size change). Keeps the bottom of the visible window — + --- the entry currently rendered at `pool[1]` — pinned across the change: + --- when the pool grows, the new slots above reveal *older* entries + --- instead of staying blank, and an at-bottom view stays at the bottom. + --- Caller is responsible for following up with `CalcVisible`. + --- + --- Without this step, growing the pool past the previous `visibleBottom` + --- range leaves the new top slots stuck on the `currentVirtualPos < scrollTop` + --- branch in `CalcVisible` — they Clear+Hide instead of being filled with + --- older history. Scrolling later "fixes" it because `ScrollSetTop` writes + --- a fresh `ScrollTop` that lets `CalcVisible` walk further back. + ---@param self UIChatLinesInterface + ---@param oldPoolSize number # pool length before the resize / RebuildPool call + RecomputeScrollTopForPoolChange = function(self, oldPoolSize) + local oldVisibleBottom = math.min(self.ScrollTop + oldPoolSize - 1, self.VirtualSize) + local newPoolSize = table.getn(self.ChatLineInterfaces) + local newMaxTop = math.max(1, self.VirtualSize - newPoolSize + 1) + local newScrollTop = math.max(1, oldVisibleBottom - newPoolSize + 1) + self.ScrollTop = math.max(1, math.min(newMaxTop, newScrollTop)) + end, + --- Snaps to the bottom of the virtual list. ---@param self UIChatLinesInterface ScrollToBottom = function(self) @@ -553,7 +577,9 @@ ChatLinesInterface = ClassUI(Group) { --- every drag frame is too expensive — see `OnResizeFinished`. ---@param self UIChatLinesInterface OnResizeLive = function(self) + local oldPoolSize = table.getn(self.ChatLineInterfaces) self:RebuildPool() + self:RecomputeScrollTopForPoolChange(oldPoolSize) self:CalcVisible() end, @@ -561,8 +587,10 @@ ChatLinesInterface = ClassUI(Group) { --- the user finishes a resize drag. ---@param self UIChatLinesInterface OnResizeFinished = function(self) + local oldPoolSize = table.getn(self.ChatLineInterfaces) self:RebuildPool() self:RewrapAll() + self:RecomputeScrollTopForPoolChange(oldPoolSize) self:CalcVisible() end, From fb255f680012711dc20c7cbc425f80848d5a56e8 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 15:32:16 +0200 Subject: [PATCH 105/130] Fix width of edit box --- lua/ui/game/chat/ChatEditInterface.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index c935a5e0231..f0b0c69a55d 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -254,10 +254,19 @@ ChatEditInterface = ClassUI(Group) { :AtVerticalCenterIn(self, -2) :End() + -- Width must be re-derived from the now-anchored Left/Right. + -- Without this it stays pinned at the literal `:Width(200)` placeholder + -- set in `__init` (needed there so `SetupEditStd` can read the layout + -- without tripping the default circular `Width = Right - Left` chain), + -- and the visible typing area gets capped at 200 px regardless of + -- where `Right` actually anchors — so the text box visibly fails to + -- extend out toward the camera checkbox at higher UI scales or wider + -- windows. Layouter(self.EditBox) :AnchorToRight(self.RecipientLabel, 4) :AnchorToLeft(self.CamCheckbox, 4) :AtVerticalCenterIn(self) + :ResetWidth() -- drop the `:Width(200)` from `__init` :Height(function() return self.EditBox:GetFontHeight() end) :End() From 3f63cc3f38c8e1e396fa9b831b2ca5ce26f05cbb Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 15:49:34 +0200 Subject: [PATCH 106/130] Always rewrap text --- lua/ui/game/chat/ChatLinesInterface.lua | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index 1cacc410778..d7178da7b23 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -572,19 +572,15 @@ ChatLinesInterface = ClassUI(Group) { -- Resize hooks (driven by the parent window's resize events) --------------------------------------------------------------------------- - --- Cheap resize pass: rebuild the pool to the new height and re-render - --- against existing wraps. Wrap widths are width-dependent but rewrapping - --- every drag frame is too expensive — see `OnResizeFinished`. ---@param self UIChatLinesInterface OnResizeLive = function(self) local oldPoolSize = table.getn(self.ChatLineInterfaces) self:RebuildPool() + self:RewrapAll() self:RecomputeScrollTopForPoolChange(oldPoolSize) self:CalcVisible() end, - --- Expensive resize pass: rebuild + rewrap + re-render. Call once when - --- the user finishes a resize drag. ---@param self UIChatLinesInterface OnResizeFinished = function(self) local oldPoolSize = table.getn(self.ChatLineInterfaces) From 3af81fa4297a47dcac024f99d4e87da51a529ba2 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 15:55:47 +0200 Subject: [PATCH 107/130] Remove local settings --- .claude/settings.local.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e046d37ef9f..00000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git mv *)", - "Bash(git -C d:/faf-development/fa mv lua/ui/game/chat/commands/Gift.lua lua/ui/game/chat/commands/GiftUnits.lua)", - "Bash(grep -n \"feed mode\\\\|Feed mode\\\\|FeedMode\\\\|step 1\\\\|Step 1\\\\|## Feed\\\\|# Feed\\\\|## 1\\\\\\\\.\\\\|### 1\\\\\\\\.\\\\|plan.*feed\\\\|feed.*plan\" \"C:/Users/wbwij/.claude/projects/d--faf-development-fa/110ae14a-5ac0-4c8d-a019-efe340367afb.jsonl\")", - "Bash(grep -v \"//\")" - ] - } -} From a3501fedad214aea83690796c9fb863ca034a709 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 15:57:50 +0200 Subject: [PATCH 108/130] Add gitignore to prevent local files of Claude to pollute the repository --- .claude/.gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .claude/.gitignore diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 00000000000..1afb24d3965 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,5 @@ +settings.local.json +.credentials.json +*.local.* +scratch/ +*.draft.md From 02adfbbeae8dbf58aeec8d38c98feb27bb893b54 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 15:58:52 +0200 Subject: [PATCH 109/130] Remove stray debug message --- lua/ui/game/chat/ChatController.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index a7a82a6aadc..5a01efc1f05 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -471,7 +471,6 @@ end --- handler is the *only* source of chat in a replay. ---@param msgs table[] function OnSyncChatMessages(msgs) - reprsl(msgs) if type(msgs) ~= 'table' then return end local history = ChatModel.GetSingleton().History() From b3af5bbe46418be1ec303894565c83e679f21d83 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 16:06:11 +0200 Subject: [PATCH 110/130] Add current tick to message id --- lua/ui/game/chat/ChatController.lua | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 5a01efc1f05..f26da7eafa8 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -576,14 +576,12 @@ function Send(text, attachCamera) msg.camera = GetCamera('WorldCamera'):SaveSettings() end - -- Stamp a near-unique id on the message *before* it leaves this function. - -- The same `msg` table travels through two parallel delivery paths — the - -- live `SessionSendChatMessage` broadcast and the sim-routed - -- `SendChatMessage`→`Sync.ChatMessages` path — and the receiver-side - -- dedupe uses this id to tell the two apart. `tostring(msg)` yields the - -- table's address, which collides only if the same address is reused for - -- another chat message within the dedupe window — vanishingly rare. - msg.Id = tostring(msg) + -- Stamp an id used by `OnSyncChatMessages` to dedupe between the live + -- `SessionSendChatMessage` path and the sim-routed `Sync.ChatMessages` + -- path. The `seen` set spans the whole history, so the id must survive + -- table-address recycling — the tick suffix means a collision needs + -- both a recycled address *and* the same tick. + msg.Id = string.format("%d %s", GameTick(), tostring(msg)) -- Replay-parser backwards compat: external replay tools scrape chat out -- of recorded `GiveResourcesToPlayer` callback args. We fire one zero- From 33c839d30417f0ab579c32be1ee5619a7ba711f9 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 16:19:44 +0200 Subject: [PATCH 111/130] Properly annotate the chat payload --- lua/ChatUtils.lua | 24 +++++--- lua/shared/ChatPayload.lua | 86 +++++++++++++++++++++++++++++ lua/ui/game/chat/ChatController.lua | 79 ++++---------------------- lua/ui/game/chat/ChatUtils.lua | 12 ++-- 4 files changed, 122 insertions(+), 79 deletions(-) create mode 100644 lua/shared/ChatPayload.lua diff --git a/lua/ChatUtils.lua b/lua/ChatUtils.lua index ba84c3ab1f5..0a28151c913 100644 --- a/lua/ChatUtils.lua +++ b/lua/ChatUtils.lua @@ -9,6 +9,7 @@ -- trusted message should stay on the UI side. local SimUtils = import("/lua/simutils.lua") +local ChatPayload = import("/lua/shared/ChatPayload.lua") --- Per-client recipient filter. The sim runs deterministically on every --- client, but `Sync` is per-client and `GetFocusArmy()` reads the local @@ -24,7 +25,7 @@ local SimUtils = import("/lua/simutils.lua") --- the sender. --- * Numeric `to` is a private whisper — only the sender and the named --- recipient pass. ----@param msg {From: integer, to: 'all' | 'allies' | integer} +---@param msg ChatPayload ---@return boolean function IsLocalRecipient(msg) local focus = GetFocusArmy() @@ -48,7 +49,7 @@ end --- `AIBrainChatComponent` for AI-emitted lines, and any future sim system --- that wants to drop a line into the chat feed) so the recipient policy --- is enforced sim-side in exactly one place. ----@param msg table +---@param msg ChatPayload function RelayChatMessage(msg) if IsLocalRecipient(msg) then Sync.ChatMessages = Sync.ChatMessages or {} @@ -94,12 +95,21 @@ end --- line to appear in every UI's chat feed can call `SendChatMessage` with a --- synthesised `Msg` table (remember to set `Chat = true` and a non-empty --- `text`, and leave `From` alone — we overwrite it). ----@param data {Msg: table} +---@param data {Msg: ChatPayload} function SendChatMessage(data) - if type(data) ~= 'table' or type(data.Msg) ~= 'table' then return end + if type(data) ~= 'table' then return end local msg = data.Msg - if msg.Chat ~= true then return end - if type(msg.text) ~= 'string' or msg.text == '' then return end + + -- Pure shape validation — type, length, recipient shape, optional + -- payload table-types. Bouncing here saves the `Sync.ChatMessages` + -- round-trip on every other client when the UI receive path would + -- have dropped the message anyway. + if not ChatPayload.IsValidPayload(msg) then return end + + -- `'notify'` is a UI subsystem channel — players reach this relay path + -- only with broadcast or whisper recipients, so reject the shared + -- validator's broader allow-list here. + if msg.to == 'notify' then return end -- Trusted sender stamp; ignore whatever the client put in `msg.From`. local from = SimUtils.GetCurrentCommandSourceArmy() @@ -107,7 +117,7 @@ function SendChatMessage(data) -- Private-message guard: a numeric `to` is an army ID the sender is -- whispering to. Cross-alliance whispers are rejected. - if type(msg.to) == 'number' and not IsAlly(from, msg.to) then + if type(msg.to) == 'number' and not IsAlly(from, msg.to --[[@as integer]]) then return end diff --git a/lua/shared/ChatPayload.lua b/lua/shared/ChatPayload.lua new file mode 100644 index 00000000000..711f05b2fa9 --- /dev/null +++ b/lua/shared/ChatPayload.lua @@ -0,0 +1,86 @@ + +-- Pure, side-agnostic shape validation for chat payloads. Loaded by both the +-- sim relay (`/lua/ChatUtils.lua`) and the UI receive path +-- (`/lua/ui/game/chat/ChatController.lua`) so the shape rules and the length +-- cap can't drift between sender and receiver. +-- +-- Anything that needs session context (sender identity, focus army, ally +-- relationships, replay state) belongs on the call site, not here. + +---@alias ChatPayloadRecipient +---| 'all' # broadcast to every connected client +---| 'allies' # broadcast to allied players (or all observers when observing) +---| 'notify' # UI subsystem channel — internal traffic, not player chat +---| number # army ID for a private whisper + +--- Wire-format chat payload — what travels through both +--- `SessionSendChatMessage` and the sim-routed `Sync.ChatMessages`. Defined +--- here so the sim relay (`/lua/ChatUtils.lua`) and the UI receive path +--- (`/lua/ui/game/chat/ChatController.lua`) validate against the same +--- contract. `From` is intentionally optional: originating clients leave it +--- blank and the sim relay overwrites it with the trusted command-source +--- army before broadcasting. +---@class ChatPayload +---@field Chat true # must be exactly `true` — gate flag for the chat handlers +---@field text string # UTF-8 message body, length capped at `MaxMessageLength` +---@field to ChatPayloadRecipient # recipient channel +---@field Identifier? string # usually `'Chat'`; legacy / synthetic paths may set other values +---@field Observer? boolean # sender was in observer mode (`GetFocusArmy() == -1`) +---@field camera? table # `WorldCamera:SaveSettings()` snapshot for click-to-jump links +---@field location? table # lightweight location hint — see `UIChatEntryLocation` for the inner shape +---@field Args? any[] # `LOCF`-style format args spread alongside `text` on render +---@field Id? string # sender-stamped near-unique id; dedupes the two delivery paths +---@field From number # sim-stamped trusted sender army index — written by the relay before broadcast, so every consumer past `RelayChatMessage` sees it set + +--- Maximum UTF-8 character length for a chat message body. The UI edit box +--- enforces this on input via `Edit:SetMaxChars`; both the sim relay and +--- the UI receive path gate on the same bound so a peer that bypassed the +--- input cap can't push every client into laying out arbitrarily long +--- lines. +MaxMessageLength = 200 + +--- Type guard for the `ChatPayload` shape. Returns `true` only when every +--- required field is present with the expected type and every optional +--- field, when present, has the engine-API shape it must have for +--- downstream rendering / camera-jump code to treat it safely. After a +--- `true` return, callers can narrow with `--[[@as ChatPayload]]`. +--- +--- Each rule is its own `return false` — malformed input is dropped, never +--- coerced or "repaired". A peer that ships an inconsistent shape is +--- either modded, buggy, or hostile; in any of those cases letting the +--- message through would let manipulated traffic render somewhere it +--- shouldn't. +--- +--- The recipient set permits `'notify'` (the UI subsystem channel). Sim +--- callers that don't relay `'notify'` traffic must reject it separately +--- at their call site. +---@param msg any +---@return boolean +function IsValidPayload(msg) + if type(msg) ~= 'table' then return false end + if msg.Chat ~= true then return false end + if type(msg.text) ~= 'string' or msg.text == '' then return false end + if STR_Utf8Len(msg.text) > MaxMessageLength then return false end + + -- Recipient must be one of the supported shapes. Without this guard, + -- a bare string like 'admin' or a non-string truthy value would fall + -- through to the UI's recipient-formatting fallback and let a peer + -- fake a "to you:" header on what is actually a broadcast. + if msg.to ~= 'all' + and msg.to ~= 'allies' + and msg.to ~= 'notify' + and type(msg.to) ~= 'number' then + return false + end + + -- Optional payloads consumed UI-side by `WorldCamera:RestoreSettings` + -- and the camera-link click handler must be tables; reject other + -- shapes here so malformed values don't crash those handlers on click. + if msg.camera ~= nil and type(msg.camera) ~= 'table' then return false end + if msg.location ~= nil and type(msg.location) ~= 'table' then return false end + + -- Optional `Args` payload used by `LOCF`-style format-on-receive lines. + if msg.Args ~= nil and type(msg.Args) ~= 'table' then return false end + + return true +end diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index f26da7eafa8..82ff35794bf 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -1,7 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") -local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") +local ChatPayload = import("/lua/shared/ChatPayload.lua") ------------------------------------------------------------------------------- -- Window visibility @@ -318,71 +318,11 @@ end ------------------------------------------------------------------------------- -- Receiving (network) ---- Pure shape validator for an incoming chat payload. Returns `true` only ---- when every required field is present with the expected type and every ---- optional field, when present, has the engine-API shape it must have for ---- downstream rendering / camera-jump code to treat it safely. ---- ---- Each rule is its own `return false` — malformed input is dropped, never ---- coerced or "repaired". A peer that ships an inconsistent shape is ---- either modded, buggy, or hostile; in any of those cases letting the ---- message through would let manipulated traffic render somewhere it ---- shouldn't. The receive path is reachable from any ---- `gamemain.RegisterChatFunc` caller, including external mods, so the ---- shape can't be trusted. ---- ---- Sender / observer-mode consistency lives in `OnReceive` rather than ---- here — those rules need session context (army data, focus army, replay ---- state) that this pure validator can't see. ----@param msg any ----@return boolean -local function IsValidIncomingMessage(msg) - -- Required: table-shaped, chat-flagged, with a string body. - if type(msg) ~= 'table' then return false end - if not msg.Chat then return false end - if type(msg.text) ~= 'string' then return false end - - -- Length cap — matches the edit box's `SetMaxChars(MaxMessageLength)` - -- on the send side, so a peer that bypassed the input cap can't push - -- us into laying out arbitrarily long lines. UTF-8 length mirrors the - -- input enforcement exactly. - if STR_Utf8Len(msg.text) > ChatUtils.MaxMessageLength then return false end - - -- Recipient must be one of the supported shapes. Without this guard, - -- a bare string like 'admin' or a non-string truthy value would fall - -- through to the `descriptor = ToStrings[to] or ToStrings.private` - -- fallback in `OnReceive`, letting a peer fake a "to you:" header on - -- what is actually a broadcast. - if msg.to ~= ChatModel.RecipientAll - and msg.to ~= ChatModel.RecipientAllies - and msg.to ~= 'notify' - and type(msg.to) ~= 'number' then - return false - end - - -- Optional payloads must match the shapes the engine APIs expect. - -- `msg.camera` is consumed by `WorldCamera:RestoreSettings`, which - -- requires a `SaveSettings`-shaped table; `msg.location` is dispatched - -- against `Position` / `Area` keys in `OnCameraClicked`. Anything that - -- isn't a table would crash those handlers when the user clicks the - -- cam icon, so reject up front rather than crashing on click. - if msg.camera ~= nil and type(msg.camera) ~= 'table' then return false end - if msg.location ~= nil and type(msg.location) ~= 'table' then return false end - - -- Optional `Args` payload for `LOCF`-style format-on-receive. When - -- present it must be a table; `OnReceive` unpacks it into `LOCF` along - -- with `msg.text` so the rendering happens UI-side and respects the - -- viewer's locale. - if msg.Args ~= nil and type(msg.Args) ~= 'table' then return false end - - return true -end - --- Handler registered with `gamemain.RegisterChatFunc`. Validates the --- message, delegates Notify-subsystem messages, resolves the sender's army --- data, and appends a chat line. ---@param sender string ----@param msg table +---@param msg ChatPayload function OnReceive(sender, msg) -- Coerce sender to a non-empty string so the formatting concatenations -- below can't blow up on a number, table, or nil. The guard is wider @@ -393,9 +333,14 @@ function OnReceive(sender, msg) end -- Pure-shape validation: every type / length / payload-shape rule - -- lives in `IsValidIncomingMessage` so the dispatch logic below stays - -- focused on routing and rendering. - if not IsValidIncomingMessage(msg) then return end + -- lives in `ChatPayload.IsValidPayload` (shared with the sim relay) so + -- the dispatch logic below stays focused on routing and rendering. The + -- receive path is reachable from any `gamemain.RegisterChatFunc` + -- caller, including external mods, so the shape can't be trusted. + -- Sender / observer-mode consistency stays here in `OnReceive` since + -- those rules need session context (army data, focus army, replay + -- state) the shared validator can't see. + if not ChatPayload.IsValidPayload(msg) then return end -- LOCF-style format-on-receive. When the sender ships `Args` alongside -- the message text, the text is treated as a `string.format` template @@ -469,7 +414,7 @@ end --- --- Replays are the case where `SessionSendChatMessage` never fires: this --- handler is the *only* source of chat in a replay. ----@param msgs table[] +---@param msgs ChatPayload[] function OnSyncChatMessages(msgs) if type(msgs) ~= 'table' then return end @@ -502,7 +447,7 @@ end --- just sent. Called only from `Send`; not registered with gamemain. ---@param senderData table # local player's army data ---@param recipientData table # target of the private message ----@param msg table # outgoing message (uses `text`, `to`, `camera`) +---@param msg ChatPayload # outgoing message (uses `text`, `to`, `camera`) local function OnEcho(senderData, recipientData, msg) local name = string.format("%s %s:", LOC(ToStrings.to.caps), recipientData.nickname) AppendChatLine { diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua index ec9bffe65bb..21d66250d83 100644 --- a/lua/ui/game/chat/ChatUtils.lua +++ b/lua/ui/game/chat/ChatUtils.lua @@ -1,5 +1,6 @@ local MauiWrapText = import("/lua/maui/text.lua").WrapText +local ChatPayload = import("/lua/shared/ChatPayload.lua") ------------------------------------------------------------------------------- -- Shared, view-agnostic helpers for the chat tree. Each function operates @@ -8,11 +9,12 @@ local MauiWrapText = import("/lua/maui/text.lua").WrapText -- `ChatFeedInterface` (or future views) can reuse without coupling them -- to each other lives here. ---- Maximum allowed UTF-8 character length for a chat message body. The ---- edit box enforces this on input via `Edit:SetMaxChars`; the receive ---- path uses it as a hard validator so a peer with a tampered or buggy ---- sender can't push us into laying out arbitrarily long lines. -MaxMessageLength = 200 +--- Re-export of the chat-message length cap; the single source of truth +--- lives in `/lua/shared/ChatPayload.lua` so the sim relay and the UI +--- receive path can't drift on the bound. Call sites that already +--- reference `ChatUtils.MaxMessageLength` (the edit box's `SetMaxChars`, +--- the receive validator) keep working without learning a new path. +MaxMessageLength = ChatPayload.MaxMessageLength --- 8-colour swatch palette indexed by `ChatConfigModel` colour keys --- (`all_color`, `allies_color`, `priv_color`, `link_color`, From 260f37ccac23901f5cee044bb1ea609fde6748c8 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 16:26:35 +0200 Subject: [PATCH 112/130] Construct chat message based on the actual units transferred --- lua/SimUtils.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua index 5d3b813ef7c..efeb57e0ca8 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -752,19 +752,18 @@ function GiveUnitsToPlayer(data, units) end end - TransferUnitsOwnership(units, toArmy) + local transferredUnits = TransferUnitsOwnership(units, toArmy) -- Whisper from giver → receiver, with an `Area` location so the -- receiver can click the cam-icon to jump to where the units are. -- The bounding box is computed from the units' positions before -- they scatter; padded slightly so a single-unit gift gives the -- camera region a non-degenerate framing rectangle. - ---@cast units Unit[] - local count = table.getn(units) - if count > 0 then - local init = units[1]:GetPosition() + local count = transferredUnits and table.getn(transferredUnits) or 0 + if transferredUnits and count > 0 then + local init = transferredUnits[1]:GetPosition() local x0, x1, z0, z1 = init[1], init[1], init[3], init[3] - for _, unit in units do + for _, unit in transferredUnits do local pos = unit:GetPosition() if pos[1] < x0 then x0 = pos[1] end if pos[1] > x1 then x1 = pos[1] end @@ -781,7 +780,7 @@ function GiveUnitsToPlayer(data, units) -- units" when the transfer is e.g. a builder pool. Mixed -- transfers fall through to the generic noun. local allEngineers = true - for _, unit in units do + for _, unit in transferredUnits do if not EntityCategoryContains(categories.ENGINEER, unit) then allEngineers = false break From bb667e95de57a06f66a5b94659ba75a8802b63be Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 16:35:04 +0200 Subject: [PATCH 113/130] Localize the chat recipient field(s) --- lua/ui/game/chat/ChatController.lua | 22 ++++++---------------- lua/ui/game/chat/ChatEditInterface.lua | 14 ++++++++------ lua/ui/game/chat/ChatModel.lua | 6 ++++++ lua/ui/game/chat/ChatUtils.lua | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 82ff35794bf..8bdb5fa1fa8 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -1,6 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") local ChatPayload = import("/lua/shared/ChatPayload.lua") ------------------------------------------------------------------------------- @@ -243,21 +244,10 @@ local function GetArmyData(army) end end -------------------------------------------------------------------------------- --- Recipient label formatting --- --- Keyed by recipient value so both `RecipientAll` ('all') and --- `RecipientAllies` ('allies') index directly, with 'private'/'notify'/'to' --- as named fallbacks. Loc keys mirror the legacy `chat.lua` table so the --- rendered prefix reads identically. - -local ToStrings = { - [ChatModel.RecipientAll] = { text = 'to all:', caps = 'To All:', colorkey = 'all_color' }, - [ChatModel.RecipientAllies] = { text = 'to allies:', caps = 'To Allies:', colorkey = 'allies_color' }, - private = { text = 'to you:', caps = 'To You:', colorkey = 'priv_color' }, - notify = { text = 'to allies:', caps = 'To Allies:', colorkey = 'notify_color' }, - to = { text = 'to', caps = 'To', colorkey = 'all_color' }, -} +-- Recipient-label / chat-line-prefix descriptors. Lives in `ChatUtils` so +-- the controller, the edit interface, and any future view can share one +-- copy of the strings — see `ChatUtils.ToStrings` for the table shape. +local ToStrings = ChatUtils.ToStrings ------------------------------------------------------------------------------- -- Chat line construction @@ -357,7 +347,7 @@ function OnReceive(sender, msg) -- Notify routing: the Notify subsystem tags messages with `to='notify'` -- and owns the display decision. Only fall through to rendering a chat -- line if Notify declines (returns false). - if msg.to == 'notify' and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then + if msg.to == ChatModel.RecipientNotify and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then return end diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index f0b0c69a55d..29254815044 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -478,19 +478,21 @@ ChatEditInterface = ClassUI(Group) { end end, - --- Updates the label from the current recipient value. + --- Updates the label from the current recipient value. Strings come + --- from `ChatUtils.ToStrings` so the label respects the viewer's locale + --- and stays in lockstep with the chat-line prefixes rendered by + --- `ChatController`. ---@param self UIChatEditInterface ---@param recipient UIChatRecipient RefreshRecipient = function(self, recipient) - if recipient == ChatModel.RecipientAll then - self.RecipientLabel:SetText("To All:") - elseif recipient == ChatModel.RecipientAllies then - self.RecipientLabel:SetText("To Allies:") + local descriptor = ChatUtils.ToStrings[recipient] + if descriptor then + self.RecipientLabel:SetText(LOC(descriptor.caps) --[[@as string]]) elseif type(recipient) == 'number' then local armies = GetArmiesTable() local army = armies and armies.armiesTable and armies.armiesTable[recipient] local name = army and army.nickname or tostring(recipient) - self.RecipientLabel:SetText("To " .. name .. ":") + self.RecipientLabel:SetText(string.format("%s %s:", LOC(ChatUtils.ToStrings.to.caps), name)) end end, diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index d5f41fda29e..fd83b0bf449 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -10,6 +10,12 @@ RecipientAll = 'all' --- Broadcast to allied players (or all observers when observing). RecipientAllies = 'allies' +--- UI subsystem channel — used by the Notify system (and any future +--- internal chat producer) to flag traffic that isn't user-driven. +--- Not part of `UIChatRecipient` because users can't send to this +--- channel; it appears only on incoming messages. +RecipientNotify = 'notify' + ---@alias UIChatRecipient 'all' | 'allies' | number # number = army ID for a private message ------------------------------------------------------------------------------- diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua index 21d66250d83..9aed101c15e 100644 --- a/lua/ui/game/chat/ChatUtils.lua +++ b/lua/ui/game/chat/ChatUtils.lua @@ -16,6 +16,24 @@ local ChatPayload = import("/lua/shared/ChatPayload.lua") --- the receive validator) keep working without learning a new path. MaxMessageLength = ChatPayload.MaxMessageLength +--- Recipient-label / chat-line-prefix descriptors. Conceptually a label +--- table — the keys are not recipient constants but localization +--- categories, even when the string happens to coincide with a recipient +--- value (`all`/`allies`/`notify`). The receiver indexes by `msg.to` and +--- falls back to `private` for whispers; the edit interface and config +--- dialog look up `to` for the generic "To :" prefix. Loc keys +--- mirror the legacy `chat.lua` table so the rendered prefix reads +--- identically. Each entry carries a `text` (lowercase, e.g. +--- `"to all:"`), a `caps` (titlecase, e.g. `"To All:"`), and a +--- `colorkey` resolved against the palette at render time. +ToStrings = { + all = { text = 'to all:', caps = 'To All:', colorkey = 'all_color' }, + allies = { text = 'to allies:', caps = 'To Allies:', colorkey = 'allies_color' }, + private = { text = 'to you:', caps = 'To You:', colorkey = 'priv_color' }, + notify = { text = 'to allies:', caps = 'To Allies:', colorkey = 'notify_color' }, + to = { text = 'to', caps = 'To', colorkey = 'all_color' }, +} + --- 8-colour swatch palette indexed by `ChatConfigModel` colour keys --- (`all_color`, `allies_color`, `priv_color`, `link_color`, --- `notify_color`). The config dialog renders these as `BitmapCombo` From d04350f90bb116527176f087594176fba253716e Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 21:17:32 +0200 Subject: [PATCH 114/130] Auto-reload commands and compact comments --- .../chat/commands/ChatCommandRegistry.lua | 84 +++++++------------ .../game/chat/commands/ChatCommandTypes.lua | 34 +++----- lua/ui/game/chat/commands/builtin/All.lua | 23 ++++- lua/ui/game/chat/commands/builtin/Allies.lua | 23 ++++- lua/ui/game/chat/commands/builtin/Clear.lua | 24 +++++- .../commands/builtin/DebugDumpControls.lua | 18 ++-- .../game/chat/commands/builtin/DebugLog.lua | 17 ++-- .../chat/commands/builtin/DebugStatistics.lua | 18 ++-- .../game/chat/commands/builtin/Debugger.lua | 17 ++-- .../game/chat/commands/builtin/EndMission.lua | 19 +++-- .../chat/commands/builtin/GiftResources.lua | 30 +++++-- .../game/chat/commands/builtin/GiftUnits.lua | 35 +++++--- lua/ui/game/chat/commands/builtin/Help.lua | 23 ++++- lua/ui/game/chat/commands/builtin/Load.lua | 22 ++--- lua/ui/game/chat/commands/builtin/Mute.lua | 26 ++++-- lua/ui/game/chat/commands/builtin/Pause.lua | 18 ++-- lua/ui/game/chat/commands/builtin/Recall.lua | 27 ++++-- lua/ui/game/chat/commands/builtin/Restart.lua | 19 +++-- lua/ui/game/chat/commands/builtin/Resume.lua | 17 ++-- lua/ui/game/chat/commands/builtin/Save.lua | 21 +++-- lua/ui/game/chat/commands/builtin/Speed.lua | 22 ++--- lua/ui/game/chat/commands/builtin/Taunt.lua | 21 +++-- .../chat/commands/builtin/ToEngineers.lua | 25 ++++-- lua/ui/game/chat/commands/builtin/ToTick.lua | 20 +++-- lua/ui/game/chat/commands/builtin/Unmute.lua | 25 ++++-- lua/ui/game/chat/commands/builtin/Whisper.lua | 24 +++++- 26 files changed, 421 insertions(+), 231 deletions(-) diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 16fa04eff09..3212a5ee93a 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -1,22 +1,21 @@ local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") ------------------------------------------------------------------------------- --- Registry + parser + dispatcher for chat slash-commands. --- --- See design.md for the full shape. The short version: --- Register(cmd) adds a UIChatCommand to the registry --- Dispatch(text) parses and runs a "/…" line, returning (handled, errorText) +-- Registry + parser + dispatcher for chat slash-commands. See design.md. +--- One declared parameter slot in a command's signature; resolver is picked by `Type`. ---@class UIChatCommandParam ---@field Name string ---@field Type UIChatCommandParamType ---@field Optional boolean? +--- Per-invocation context handed to `Accept` and `Execute`; holds model + controller + raw input. ---@class UIChatCommandContext ---@field Model UIChatModel ---@field Controller table ---@field SourceText string +--- A registered slash-command — name, optional aliases/params/gates, and the dispatcher's hooks. ---@class UIChatCommand ---@field Name string ---@field Aliases? string[] @@ -26,16 +25,18 @@ local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") ---@field Accept? fun(args: table, ctx: UIChatCommandContext): boolean, string? ---@field Execute fun(args: table, ctx: UIChatCommandContext) +--- Registered commands by lower-cased canonical name. ---@type table local Commands = {} +--- Lower-cased alias → canonical command name; merged into lookup so `/w` resolves to `/whisper`. ---@type table local Aliases = {} ------------------------------------------------------------------------------- -- Registration ---- Removes a command and its aliases. +--- Removes a command and its aliases from the registry. ---@param name string function Unregister(name) local key = string.lower(name) @@ -49,20 +50,14 @@ function Unregister(name) Commands[key] = nil end ---- Registers a command. Overwrites any previous registration with the same ---- canonical name; aliases from the previous registration are cleared first. ---- ---- A command can opt out of registration entirely by returning `false` from ---- its optional `ShouldRegister` hook — used for session-conditional commands ---- (observer-only, replay-only, single-player-only, etc.). We still call ---- `Unregister` first so a reload that newly disqualifies a command can't ---- leave its previous entry in the registry. +--- Overwrites any previous registration with the same canonical name; +--- aliases from the previous registration are cleared first. A +--- `ShouldRegister` returning false drops the command for this session. ---@param cmd UIChatCommand function Register(cmd) assert(cmd and cmd.Name, "Chat command requires a name.") assert(cmd.Execute, "Chat command requires an execute function.") - -- some commands are game state specific if cmd.ShouldRegister and not cmd.ShouldRegister() then return end @@ -84,13 +79,9 @@ function Register(cmd) end end ---- Defensive wrapper around `Register`: takes a module path, loads it, and ---- registers its `Command` export — all inside pcalls so one broken file ---- can't take down the entire registration pass (and with it the chat ---- system + anything that depends on `ChatController.Init`). ---- ---- Every failure — missing file, import error, missing or malformed ---- `Command` export, Register throwing — is logged and swallowed. +--- Loads a command file and registers its `Command` export inside +--- pcalls so one broken file can't take down the registration pass. +--- Every failure is logged and swallowed. ---@param path string function RegisterFromPath(path) if not DiskGetFileInfo(path) then @@ -131,7 +122,7 @@ function RegisterFromPath(path) end end ---- Returns a flat list of every registered command (canonical entries only). +--- Canonical entries only. ---@return UIChatCommand[] function GetAll() local result = {} @@ -141,7 +132,7 @@ function GetAll() return result end ---- Looks up a command by name or alias. Case-insensitive. +--- Returns the command matching `name` (canonical or alias), case-insensitive. ---@param name string ---@return UIChatCommand? function Lookup(name) @@ -153,9 +144,8 @@ function Lookup(name) return nil end ---- Returns every registered command whose canonical name or any alias begins ---- with the given prefix (case-insensitive). Each command appears at most ---- once even if multiple of its aliases match. Results are sorted by name. +--- Commands whose canonical name or any alias begins with `prefix` +--- (case-insensitive, deduped, sorted by name). ---@param prefix string ---@return UIChatCommand[] function FindMatching(prefix) @@ -188,7 +178,6 @@ end ------------------------------------------------------------------------------- -- Parsing ---- Splits the body of a slash-command into (name, remainingTokens). --- "whisper Jip hello" → "whisper", {"Jip", "hello"} ---@param body string ---@return string?, string[] @@ -204,8 +193,6 @@ local function Tokenize(body) return name, tokens end ---- Walks a command's declared parameters, pulling tokens and invoking the ---- matching resolver. Returns the populated args table or a user-facing error. ---@param cmd UIChatCommand ---@param tokens string[] ---@return table?, string? @@ -258,25 +245,14 @@ end ------------------------------------------------------------------------------- -- Dispatch ---- Last-chance fallback for slash commands that don't match anything in our ---- own registry. Hands off to the legacy [`RunChatCommand`](/lua/ui/notify/commands.lua) ---- entry point, which the [Notify](/lua/ui/notify/notify.lua) module ---- populates via `AddChatCommand` (`/enablenotify`, `/disablenotify`, ---- `/enablenotifyoverlay`, `/disablenotifyoverlay`, …). Mirrors the ---- legacy chat dispatcher's fall-through so those commands keep working ---- through the new chat without us having to re-register them. ---- ---- Named "Legacy" deliberately — anything that goes through here is ---- pre-MVC tech. New commands should be defined under ---- [`commands/builtin/`](commands/builtin/) and registered through ---- `Register` / `RegisterFromPath`; this path exists purely to avoid ---- breaking external callers that already use `AddChatCommand`. +--- Fall-through to legacy `RunChatCommand` for pre-MVC commands +--- registered via Notify's `AddChatCommand` (`/enablenotify`, etc.). +--- New commands should live under `commands/builtin/`. --- ---- The args shape matches what the legacy dispatcher passed: the ---- lowercased command name in slot 1, lowercased remaining tokens after. ---- Wrapped in `pcall` for the same reason `cmd.Accept` / `cmd.Execute` ---- are — a third-party command throwing shouldn't leak up through the ---- chat send path. +--- Args shape matches the legacy dispatcher: lowercased name in slot 1, +--- lowercased remaining tokens after. Wrapped in pcall for the same +--- reason as Accept/Execute — third-party commands throwing must not +--- leak up through the chat send path. ---@param name string # the slash-stripped command word, original case ---@param tokens string[] # remaining tokens (after the command word) ---@return boolean handled @@ -318,8 +294,6 @@ function Dispatch(text) local cmd = Lookup(name) if not cmd then - -- Try the legacy `RunChatCommand` registry before giving up so - -- pre-MVC commands (e.g. Notify's `/enablenotify`) still run. if DispatchLegacy(name, tokens) then return true, nil end @@ -340,10 +314,8 @@ function Dispatch(text) } if cmd.Accept then - -- Accept is user code — a crash here is a bug, not a rejection. Treat - -- it as a soft failure so the chat send path doesn't propagate the - -- throw up through the edit box's event handler. The full stack goes - -- to the log; chat only gets the "check the log" hint. + -- Accept is user code; treat a throw as a soft failure so it + -- doesn't propagate up through the edit-box event handler. local pcallOk, ok, reason = pcall(cmd.Accept, args, ctx) if not pcallOk then WARN(string.format("/%s: Accept threw (%s).", cmd.Name, tostring(ok))) @@ -356,8 +328,8 @@ function Dispatch(text) end end - -- Same pcall treatment for Execute. Side effects that ran before the - -- throw aren't rolled back — this just keeps the chat input usable. + -- Same pcall as Accept. Side effects before the throw aren't rolled + -- back; this just keeps the chat input usable. local executeOk, err = pcall(cmd.Execute, args, ctx) if not executeOk then WARN(string.format("/%s: Execute threw (%s).", cmd.Name, tostring(err))) diff --git a/lua/ui/game/chat/commands/ChatCommandTypes.lua b/lua/ui/game/chat/commands/ChatCommandTypes.lua index 414be65992d..756adb21eee 100644 --- a/lua/ui/game/chat/commands/ChatCommandTypes.lua +++ b/lua/ui/game/chat/commands/ChatCommandTypes.lua @@ -1,21 +1,12 @@ ------------------------------------------------------------------------------- --- Parameter-type resolvers for chat commands. Each resolver takes a raw token --- (string) and returns (ok, value_or_error): --- true, value → successfully coerced/validated --- false, errorString → rejected; errorString is user-facing --- --- Resolvers are intentionally pure: they read from the session (armies table) --- but never write state. Adding a new type means adding one function to the --- `Resolvers` table. +-- Parameter-type resolvers for chat commands. Each resolver takes a raw +-- token and returns (ok, value_or_error). Resolvers are pure (read from +-- the session but don't write). ---- Looks up an army by nickname or by numeric army ID. Civilian armies are ---- excluded to match the behaviour of the recipient picker. ---- ---- A leading `@` is stripped before matching so `@Jip` and `Jip` are ---- equivalent — this lets the chat-edit `@nick` autocomplete (see ---- ChatCompletion) feed straight into commands like `/whisper @Jip` without ---- the user having to delete the `@` first. +--- Looks up an army by nickname or numeric ID. Civilian armies excluded +--- to match the recipient picker. A leading `@` is stripped so `@Jip` +--- works the same as `Jip`, mirroring the `@nick` autocomplete. ---@param token string ---@return boolean ok ---@return number | string armyIDOrError @@ -25,7 +16,6 @@ local function ResolveArmy(token) return false, "no army table available." end - -- do not include '@' of '@nick' when matching against army nicknames or IDs; this allows the user to quickly find usernames if string.sub(token, 1, 1) == '@' then token = string.sub(token, 2) end @@ -47,13 +37,14 @@ local function ResolveArmy(token) return false, string.format("no player named '%s'.", token) end +--- Tag identifying which `Resolvers` entry parses a parameter token; one tag per supported type. ---@alias UIChatCommandParamType 'Recipient' | 'Player' | 'Int' | 'String' | 'Rest' +--- Param-type → resolver table; each resolver returns `(true, value)` on success or `(false, errMsg)`. ---@type table Resolvers = {} ---- Accepts "all", "allies"/"team", a nickname, or an army ID. ---- Resolves to a `UIChatRecipient` (the same type the model stores). +--- "all", "allies"/"team", nickname, or army ID → `UIChatRecipient`. Resolvers.Recipient = function(token) local lower = string.lower(token) if lower == 'all' then @@ -64,13 +55,12 @@ Resolvers.Recipient = function(token) return ResolveArmy(token) end ---- Accepts a nickname or army ID. Rejects "all"/"allies". ---- Resolves to a numeric army ID. +--- Nickname or army ID → numeric army ID. Rejects "all"/"allies". Resolvers.Player = function(token) return ResolveArmy(token) end ---- Integer literal. +--- Parses a token as an integer; rejects fractional or non-numeric input. Resolvers.Int = function(token) local n = tonumber(token) if not n or math.floor(n) ~= n then @@ -79,7 +69,7 @@ Resolvers.Int = function(token) return true, n end ---- Single whitespace-delimited token. +--- Passthrough: accepts any token as a string. Resolvers.String = function(token) return true, token end diff --git a/lua/ui/game/chat/commands/builtin/All.lua b/lua/ui/game/chat/commands/builtin/All.lua index 7b639ee0927..d746c088aa6 100644 --- a/lua/ui/game/chat/commands/builtin/All.lua +++ b/lua/ui/game/chat/commands/builtin/All.lua @@ -1,9 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") -------------------------------------------------------------------------------- --- /all — switch the send target to every player and observer. - +--- /all — switch send target to every player and observer. ---@type UIChatCommand Command = { Name = 'all', @@ -12,3 +10,22 @@ Command = { ctx.Controller.SetRecipient(ChatModel.RecipientAll) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Allies.lua b/lua/ui/game/chat/commands/builtin/Allies.lua index 2e8410a3463..48dbd61103e 100644 --- a/lua/ui/game/chat/commands/builtin/Allies.lua +++ b/lua/ui/game/chat/commands/builtin/Allies.lua @@ -1,9 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") -------------------------------------------------------------------------------- --- /allies (aka /team) — switch the send target to allies only. - +--- /allies — switch send target to allies only. ---@type UIChatCommand Command = { Name = 'allies', @@ -13,3 +11,22 @@ Command = { ctx.Controller.SetRecipient(ChatModel.RecipientAllies) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Clear.lua b/lua/ui/game/chat/commands/builtin/Clear.lua index fc30d568d5f..c2aab4d3494 100644 --- a/lua/ui/game/chat/commands/builtin/Clear.lua +++ b/lua/ui/game/chat/commands/builtin/Clear.lua @@ -1,10 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") -------------------------------------------------------------------------------- --- /clear — wipes the local chat history. Sets a fresh empty table ref on the --- model's `History` so observers (the line view) go dirty and redraw. - +--- /clear — wipes local chat history. ---@type UIChatCommand Command = { Name = 'clear', @@ -13,3 +10,22 @@ Command = { ChatModel.GetSingleton().History:Set({}) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua index 4a71a7e52de..b933f06a76d 100644 --- a/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua +++ b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua @@ -1,9 +1,5 @@ -------------------------------------------------------------------------------- --- /debug-dump-controls — toggles `ui_DebugAltClick`, the console flag that --- swaps Alt+left-click into a "switch focus army to whichever army owns this --- unit" shortcut. Only registered when the game was launched with `/debug`. - +--- /debug-dump-controls — invoke `UI_DumpControlsUnderCursor`; only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debug-dump-controls', @@ -19,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/DebugLog.lua b/lua/ui/game/chat/commands/builtin/DebugLog.lua index 7b5ff34ecc0..41db20cae6b 100644 --- a/lua/ui/game/chat/commands/builtin/DebugLog.lua +++ b/lua/ui/game/chat/commands/builtin/DebugLog.lua @@ -1,8 +1,5 @@ -------------------------------------------------------------------------------- --- /debug-log — toggle the log window (same entry point the debug hotkey --- uses). Only registered when the game was launched with `/debug`. - +--- /debug-log — toggle the log window; only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debug-log', @@ -18,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/DebugStatistics.lua b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua index 9a77ad41f75..7fa2370d163 100644 --- a/lua/ui/game/chat/commands/builtin/DebugStatistics.lua +++ b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua @@ -1,9 +1,5 @@ -------------------------------------------------------------------------------- --- /debug-statistics — runs the engine's `ShowStats` console command, which --- cycles the overlay that reports frame time, memory, etc. Only registered --- when the game was launched with `/debug`. - +--- /debug-statistics — cycle the engine's `ShowStats` overlay; only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debug-statistics', @@ -19,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Debugger.lua b/lua/ui/game/chat/commands/builtin/Debugger.lua index ffa5455d46b..3957b10e03d 100644 --- a/lua/ui/game/chat/commands/builtin/Debugger.lua +++ b/lua/ui/game/chat/commands/builtin/Debugger.lua @@ -1,8 +1,5 @@ -------------------------------------------------------------------------------- --- /debugger — opens the Lua debugger attached to the running session. --- Only registered when the game was launched with `/debug`. - +--- /debugger — open the Lua debugger; only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debugger', @@ -18,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/EndMission.lua b/lua/ui/game/chat/commands/builtin/EndMission.lua index dfa7fa063f2..94523eaaf61 100644 --- a/lua/ui/game/chat/commands/builtin/EndMission.lua +++ b/lua/ui/game/chat/commands/builtin/EndMission.lua @@ -1,10 +1,5 @@ -------------------------------------------------------------------------------- --- /end-mission — forfeits the current session and opens the score screen. --- Delegates to the same `EndGame` function the escape-menu button uses, so --- campaign vs. skirmish branching stays consistent. Available in --- single-player and replay. - +--- /end-mission — forfeit the current session and open the score screen. ---@type UIChatCommand Command = { Name = 'end-mission', @@ -20,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/GiftResources.lua b/lua/ui/game/chat/commands/builtin/GiftResources.lua index 40a7d188705..5a7cc8a5973 100644 --- a/lua/ui/game/chat/commands/builtin/GiftResources.lua +++ b/lua/ui/game/chat/commands/builtin/GiftResources.lua @@ -1,15 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") -------------------------------------------------------------------------------- --- /gift-resources [target] — gift a fraction of your mass --- or energy to an ally. Percent is an integer 1-100; type is "mass" or --- "energy". When no target is given, the current chat recipient is used, --- but only if it's a specific player. --- --- The sim-side handler (`GiveResourcesToPlayer`) takes fractions (0-1), so --- a user-friendly 1-100 is divided here before the callback. - +--- Normalises a resource-kind token to 'mass' or 'energy'; returns nil on no match. local function NormalizeType(token) local lower = string.lower(token or '') if lower == 'mass' or lower == 'm' then @@ -20,6 +12,7 @@ local function NormalizeType(token) return nil end +--- /gift-resources [target] — gift a fraction of mass or energy to an ally. ---@type UIChatCommand Command = { Name = 'gift-resources', @@ -75,3 +68,22 @@ Command = { }) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/GiftUnits.lua b/lua/ui/game/chat/commands/builtin/GiftUnits.lua index e58a66a5529..afaee6309ca 100644 --- a/lua/ui/game/chat/commands/builtin/GiftUnits.lua +++ b/lua/ui/game/chat/commands/builtin/GiftUnits.lua @@ -1,10 +1,5 @@ -------------------------------------------------------------------------------- --- /gift-units — transfer the current selection to an allied player. --- Mirrors the Shift-click gift on the score panel: observers can't gift, --- a lone ACU can't be gifted, and the sim side still re-checks alliance --- and `ManualUnitShare` before transferring ownership. - +--- /gift-units — transfer current selection to an ally; sim re-checks alliance and `ManualUnitShare`. ---@type UIChatCommand Command = { Name = 'gift-units', @@ -18,9 +13,8 @@ Command = { return false, "/gift-units: observers can't gift units." end - -- Fall back to the unit currently under the cursor. `armyIndex` on - -- the rollover is zero-based, so bump it to match the armies-table - -- convention the rest of the command uses. + -- Fall back to the unit under the cursor. `armyIndex` is 0-based; + -- the armies table is 1-based. if args.target == nil then local info = GetRolloverInfo() if not info or not info.armyIndex then @@ -47,11 +41,30 @@ Command = { return true end, Execute = function(args) - -- `true` as the second arg tells the engine to pass the current - -- selection through to the sim handler as `units`. + -- `true` second arg passes the current selection to the sim + -- handler as `units`. SimCallback({ Func = "GiveUnitsToPlayer", Args = { From = GetFocusArmy(), To = args.target }, }, true) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Help.lua b/lua/ui/game/chat/commands/builtin/Help.lua index ddd126cb4c1..e3dc81e32f9 100644 --- a/lua/ui/game/chat/commands/builtin/Help.lua +++ b/lua/ui/game/chat/commands/builtin/Help.lua @@ -1,9 +1,7 @@ local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") -------------------------------------------------------------------------------- --- /help (aka /?) — prints every registered command as a local system line. - +--- /help — prints every registered command as a local system line. ---@type UIChatCommand Command = { Name = 'help', @@ -33,3 +31,22 @@ Command = { end end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Load.lua b/lua/ui/game/chat/commands/builtin/Load.lua index 352462af71e..36e159155e2 100644 --- a/lua/ui/game/chat/commands/builtin/Load.lua +++ b/lua/ui/game/chat/commands/builtin/Load.lua @@ -1,15 +1,7 @@ local Prefs = import("/lua/user/prefs.lua") -------------------------------------------------------------------------------- --- /load [name] — load a save by name (default: the quick-save slot). The --- path is built the same way `QuickSave` does in `gamemain.lua`, so an --- omitted name lines up exactly with the slot `/save` writes to. --- --- Load errors surface as the engine's standard failure dialog via --- `LoadSavedGame`'s return values; the command stays silent on success --- because the game is already transitioning out. - +--- /load [name] — load a save; default is the quick-save slot, matching `QuickSave`'s path. ---@type UIChatCommand Command = { Name = 'load', @@ -38,8 +30,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Mute.lua b/lua/ui/game/chat/commands/builtin/Mute.lua index 69482e3f96c..57622ab3a4b 100644 --- a/lua/ui/game/chat/commands/builtin/Mute.lua +++ b/lua/ui/game/chat/commands/builtin/Mute.lua @@ -1,12 +1,7 @@ local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") -------------------------------------------------------------------------------- --- /mute — hide future messages from a specific player for this --- game. Goes straight to `Committed` via `SetMutedLive` so the filter picks --- up the change immediately; `Pending` is untouched so an open config dialog --- keeps its draft. - +--- /mute — hide messages from a player for the rest of this game. ---@type UIChatCommand Command = { Name = 'mute', @@ -25,3 +20,22 @@ Command = { ChatConfigController.SetMutedLive(args.target, true) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Pause.lua b/lua/ui/game/chat/commands/builtin/Pause.lua index 47f0017319b..6ab707d1e16 100644 --- a/lua/ui/game/chat/commands/builtin/Pause.lua +++ b/lua/ui/game/chat/commands/builtin/Pause.lua @@ -1,9 +1,5 @@ -------------------------------------------------------------------------------- --- /pause — pause the local simulation. Available in single-player and --- replay; multiplayer pausing goes through a vote/request flow handled by --- the existing hotkey, so this command stays out of the way there. - +--- /pause — pause the local simulation; not registered in multiplayer (vote/request hotkey owns that). ---@type UIChatCommand Command = { Name = 'pause', @@ -19,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Recall.lua b/lua/ui/game/chat/commands/builtin/Recall.lua index 14e238297b2..e498e995b6f 100644 --- a/lua/ui/game/chat/commands/builtin/Recall.lua +++ b/lua/ui/game/chat/commands/builtin/Recall.lua @@ -1,11 +1,5 @@ -------------------------------------------------------------------------------- --- /recall — cast a "yes" vote on the team recall. Mirrors clicking the --- `Recall` button in the diplomacy panel. Observers can't vote. --- --- Only the "yes" case is exposed for now; voting no is rare enough that the --- diplomacy UI suffices. - +--- /recall — vote yes on the team recall. Only "yes" is exposed; voting no stays in the diplomacy UI. ---@type UIChatCommand Command = { Name = 'recall', @@ -23,3 +17,22 @@ Command = { }) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Restart.lua b/lua/ui/game/chat/commands/builtin/Restart.lua index 3ff9d7e4cbd..62a71d92cd3 100644 --- a/lua/ui/game/chat/commands/builtin/Restart.lua +++ b/lua/ui/game/chat/commands/builtin/Restart.lua @@ -1,10 +1,5 @@ -------------------------------------------------------------------------------- --- /restart — immediately restart the current session. Available in --- single-player and replay (skipped in multiplayer). Skips the confirmation --- dialog that the escape-menu's Restart button shows — the command itself --- is deliberate enough. - +--- /restart — restart the current session; skips the escape-menu's confirmation dialog. ---@type UIChatCommand Command = { Name = 'restart', @@ -20,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Resume.lua b/lua/ui/game/chat/commands/builtin/Resume.lua index dc7480c6936..1f50a90c158 100644 --- a/lua/ui/game/chat/commands/builtin/Resume.lua +++ b/lua/ui/game/chat/commands/builtin/Resume.lua @@ -1,8 +1,5 @@ -------------------------------------------------------------------------------- --- /resume — un-pause the local simulation. Symmetric with `/pause`; --- available in single-player and replay. - +--- /resume — un-pause the local simulation; symmetric with `/pause`. ---@type UIChatCommand Command = { Name = 'resume', @@ -18,8 +15,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Save.lua b/lua/ui/game/chat/commands/builtin/Save.lua index 69c5bf60dd3..c2ef265bbd5 100644 --- a/lua/ui/game/chat/commands/builtin/Save.lua +++ b/lua/ui/game/chat/commands/builtin/Save.lua @@ -1,12 +1,5 @@ -------------------------------------------------------------------------------- --- /save [name] — quick-save the current session. Without a name, uses the --- localised default ("QuickSave" in English) so repeated saves overwrite the --- same slot — matching the quick-save hotkey in `keyactions.lua`. --- --- Accepts `Rest` so a multi-word name goes through as-is: `/save before boss` --- saves to "before boss". - +--- /save [name] — quick-save; default name matches the quick-save hotkey so repeats overwrite the slot. ---@type UIChatCommand Command = { Name = 'save', @@ -26,8 +19,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Speed.lua b/lua/ui/game/chat/commands/builtin/Speed.lua index 0d222fd533a..f1467ed9428 100644 --- a/lua/ui/game/chat/commands/builtin/Speed.lua +++ b/lua/ui/game/chat/commands/builtin/Speed.lua @@ -1,13 +1,5 @@ -------------------------------------------------------------------------------- --- /speed — set the simulation speed multiplier via the `WLD_GameSpeed` --- console var. Range is engine-side (typically -10..+10); invalid values --- are ignored by the engine rather than throwing. --- --- Available in single-player and replay. Multiplayer speed changes go --- through a vote/request flow on the host, not a direct console write, so --- the command is unregistered there. - +--- /speed — set sim speed via `WLD_GameSpeed`; not registered in multiplayer (host vote/request flow). ---@type UIChatCommand Command = { Name = 'speed', @@ -26,8 +18,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Taunt.lua b/lua/ui/game/chat/commands/builtin/Taunt.lua index 92fedb556b2..340cb1be467 100644 --- a/lua/ui/game/chat/commands/builtin/Taunt.lua +++ b/lua/ui/game/chat/commands/builtin/Taunt.lua @@ -1,12 +1,5 @@ -------------------------------------------------------------------------------- --- /taunt — broadcast a numbered taunt from the `taunts` table in --- `/lua/ui/game/taunt.lua`. Same entry point as the legacy `/N` shortcut, --- exposed here under a discoverable name so the command-hint popup lists it. --- --- Out-of-range indices are still sent on the wire; receivers silently ignore --- unknown entries in `taunt.RecieveTaunt`, matching the legacy behaviour. - +--- /taunt — broadcast a numbered taunt; receivers silently ignore out-of-range indices. ---@type UIChatCommand Command = { Name = 'taunt', @@ -31,8 +24,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/ToEngineers.lua b/lua/ui/game/chat/commands/builtin/ToEngineers.lua index 591190e0374..e7a98effcd0 100644 --- a/lua/ui/game/chat/commands/builtin/ToEngineers.lua +++ b/lua/ui/game/chat/commands/builtin/ToEngineers.lua @@ -1,9 +1,5 @@ -------------------------------------------------------------------------------- --- /to-engineers — narrow the current selection to just the engineers. If --- nothing is selected, or none of the selected units are engineers, the --- command reports an error rather than silently clearing the selection. - +--- /to-engineers — narrow selection to engineers; errors rather than silently clearing on no-match. ---@type UIChatCommand Command = { Name = 'to-engineers', @@ -24,3 +20,22 @@ Command = { SelectUnits(args.engineers) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/ToTick.lua b/lua/ui/game/chat/commands/builtin/ToTick.lua index 769c8bff190..56737be5648 100644 --- a/lua/ui/game/chat/commands/builtin/ToTick.lua +++ b/lua/ui/game/chat/commands/builtin/ToTick.lua @@ -1,10 +1,5 @@ -------------------------------------------------------------------------------- --- /debug-wind — fast-forward the simulation to `tick` at maximum --- speed, then pause. Maxes out `WLD_GameSpeed` first so the sim runs as --- fast as the engine allows, then hands off to `wld_RunWithTheWind` which --- halts on its own when the target tick is reached. - +--- /to-tick — fast-forward to `tick` and pause; only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'to-tick', @@ -30,7 +25,6 @@ Command = { Execute = function(args) ConExecute("wld_RunWithTheWind 1") - -- wait till we get there and pause ForkThread( function() while GetGameTick() < args.tick - 5 do @@ -46,8 +40,18 @@ Command = { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. function __moduleinfo.OnDirty() - import(__moduleinfo.name) + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) end --#endregion diff --git a/lua/ui/game/chat/commands/builtin/Unmute.lua b/lua/ui/game/chat/commands/builtin/Unmute.lua index 8a8bab21027..5f7a9ebe8e6 100644 --- a/lua/ui/game/chat/commands/builtin/Unmute.lua +++ b/lua/ui/game/chat/commands/builtin/Unmute.lua @@ -1,11 +1,7 @@ local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") -------------------------------------------------------------------------------- --- /unmute — reverse of `/mute`. Clears the mute flag for the given --- player so their messages start showing again (both new arrivals and any --- that landed in the history while they were muted). - +--- /unmute — reverse of `/mute`; re-shows new arrivals and history that landed while muted. ---@type UIChatCommand Command = { Name = 'unmute', @@ -17,3 +13,22 @@ Command = { ChatConfigController.SetMutedLive(args.target, false) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion diff --git a/lua/ui/game/chat/commands/builtin/Whisper.lua b/lua/ui/game/chat/commands/builtin/Whisper.lua index 50b0f68ad05..3d1d6ab405a 100644 --- a/lua/ui/game/chat/commands/builtin/Whisper.lua +++ b/lua/ui/game/chat/commands/builtin/Whisper.lua @@ -1,8 +1,5 @@ -------------------------------------------------------------------------------- --- /whisper (aka /w, /pm) — private-message a specific player. --- The `target` parameter type is resolved by `ChatCommandTypes.lua`. - +--- /whisper — private-message a specific player. ---@type UIChatCommand Command = { Name = 'whisper', @@ -22,3 +19,22 @@ Command = { ctx.Controller.SetRecipient(args.target) end, } + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-registers this command so saved edits take effect. +---@param newModule any +function __moduleinfo.OnReload(newModule) + import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua").Register(newModule.Command) +end + +--- Hot-reload hook: schedules the re-import so `OnReload` fires with the freshly-loaded module. +function __moduleinfo.OnDirty() + ForkThread(function() + WaitFrames(1) + import(__moduleinfo.name) + end) +end + +--#endregion From 005ef0a7c55638049ee40b00860d4b9447111b14 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 21:34:20 +0200 Subject: [PATCH 115/130] Compact comments of chat config --- .../game/chat/config/ChatConfigController.lua | 26 +++--- .../game/chat/config/ChatConfigInterface.lua | 88 ++++++------------- lua/ui/game/chat/config/ChatConfigModel.lua | 37 ++++---- 3 files changed, 60 insertions(+), 91 deletions(-) diff --git a/lua/ui/game/chat/config/ChatConfigController.lua b/lua/ui/game/chat/config/ChatConfigController.lua index 7457cf8892d..eef2f7f86fc 100644 --- a/lua/ui/game/chat/config/ChatConfigController.lua +++ b/lua/ui/game/chat/config/ChatConfigController.lua @@ -1,13 +1,13 @@ local Prefs = import("/lua/user/prefs.lua") +--- Convenience accessor for the config model singleton. local function Model() return import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetSingleton() end ---- Commits the pending options: marks them active for this session and ---- persists everything except `muted`. Mutes are intentionally per-game so ---- they don't follow the player into the next match. +--- Marks pending options active and persists everything except `muted`, +--- which is intentionally per-game. function Apply() local model = Model() local options = table.copy(model.Pending()) @@ -21,20 +21,19 @@ function Apply() ) end ---- Resets the pending options back to the built-in defaults. +--- Reverts the draft (Pending) to factory defaults; does not commit until Apply. function Reset() Model().Pending:Set( import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetDefaults() ) end ---- Discards all pending edits, reverting to the last committed options. +--- Discards the draft and re-syncs Pending from Committed. function Cancel() local model = Model() model.Pending:Set(table.copy(model.Committed())) end ---- Updates a single field in the pending options. --- Creates a new table copy to ensure the Pending LazyVar goes dirty. ---@param key string ---@param value any @@ -45,9 +44,8 @@ function SetOption(key, value) model.Pending:Set(draft) end ---- Toggles the muted flag for a specific army on the pending options. ---- Absent entries are treated as "not muted"; setting `muted = false` clears ---- the key so the table stays compact. +--- Setting `muted = false` clears the key so the table stays compact; +--- absent keys read as "not muted". ---@param armyID number ---@param muted boolean function SetMuted(armyID, muted) @@ -63,11 +61,9 @@ function SetMuted(armyID, muted) model.Pending:Set(draft) end ---- Applies a mute state directly to `Committed` so slash-command usage ---- (`/mute`, `/unmute`) takes effect immediately without going through the ---- full Apply/Cancel dance. Pending is left alone — if the config dialog is ---- open it keeps its draft, and the next open re-syncs Pending from ---- Committed via `SetupSingleton`/`Cancel`. +--- Writes directly to `Committed` so `/mute` and `/unmute` take effect +--- immediately. Pending is left alone — an open config dialog keeps its +--- draft, and the next open re-syncs Pending from Committed. ---@param armyID number ---@param muted boolean function SetMutedLive(armyID, muted) @@ -86,7 +82,7 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Called by the module manager when this module becomes dirty. +--- Hot-reload hook: re-imports this module on save. function __moduleinfo.OnDirty() import(__moduleinfo.name) end diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 297f8fc922e..902f043c454 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -15,16 +15,10 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false ---- Skin textures for the config window frame. Mirrors the legacy chat-options ---- dialog, which used the generic `panel_brd_*` chrome rather than the chat ---- window's bespoke `chat_brd_*` art (the two windows are different sizes). ---- `SkinnableFile` returns a callable that re-resolves against the active ---- skin, so the border bitmaps follow the user's skin choice. +--- Generic `panel_brd_*` chrome rather than the chat window's bespoke +--- art — the two dialogs are different sizes. ---@diagnostic disable: param-type-mismatch local WindowTextures = { tl = UIUtil.SkinnableFile('/game/panel/panel_brd_ul.dds'), @@ -40,9 +34,8 @@ local WindowTextures = { } ---@diagnostic enable: param-type-mismatch --- Each colour combo points at the same `chat_color` tooltip — legacy did the --- same; the per-row label already tells the user *which* recipient the swatch --- is for, so the tooltip's job is just to explain the control. +-- Same `chat_color` tooltip on every colour combo — the per-row label +-- already names the recipient, so the tooltip just explains the control. local ColorDefs = { { Key = ChatConfigModel.KeyAllColor, Text = "All", Tooltip = 'chat_color' }, { Key = ChatConfigModel.KeyAlliesColor, Text = "Allies", Tooltip = 'chat_color' }, @@ -60,15 +53,18 @@ local CheckboxDefs = { ------------------------------------------------------------------------------- -- Window class +--- One label-plus-bitmap-combo row in the colour section; `Key` is the option this row writes. ---@class UIChatConfigColorRow ---@field Label Text ---@field Combo BitmapCombo ---@field Key string +--- One per-player mute row; the checkbox writes the `muted[ArmyID]` entry on the Pending options. ---@class UIChatConfigMuteRow ---@field Checkbox Checkbox ---@field ArmyID number +--- Chat options dialog: edits a draft (`Pending`) and commits via Apply; nothing here writes the model directly. ---@class UIChatConfigInterface : Window ---@field Trash TrashBag # owns every derived subscription-LazyVar ---@field LabelColors Text @@ -102,9 +98,6 @@ local ChatConfigInterface = ClassUI(Window) { Left = 200, Top = 200, Right = 500, Bottom = 640, }, WindowTextures) - -- Single trash bag for everything we allocate that needs explicit - -- destruction — currently just the derived observer LazyVars. - -- Emptied in `OnDestroy`. self.Trash = TrashBag() local client = self:GetClientGroup() @@ -123,8 +116,7 @@ local ChatConfigInterface = ClassUI(Window) { row.Combo.OnClick = function(_, index) ChatConfigController.SetOption(key, index) end - -- Tooltip on both label and combo so the user gets the same - -- explanation no matter which side of the row they hover. + -- Tooltip on both label and combo so either hover works. Tooltip.AddControlTooltip(row.Label, def.Tooltip) Tooltip.AddControlTooltip(row.Combo, def.Tooltip) self.ColorRows[i] = row @@ -199,8 +191,7 @@ local ChatConfigInterface = ClassUI(Window) { -- ---- Muted players ---- -- One checkbox per non-civilian army other than the local player. - -- The list is captured at dialog-open time; closing and reopening the - -- dialog rebuilds against fresh session state. + -- Captured at dialog-open; closing and reopening rebuilds state. self.LabelMuted = UIUtil.CreateText(client, "Muted players", 12, UIUtil.titleFont) self.MuteRows = {} @@ -220,9 +211,8 @@ local ChatConfigInterface = ClassUI(Window) { end -- ---- Buttons ---- - -- Apply also pops the chat window open. The user just spent time - -- tuning options against it, so showing the result immediately — - -- even if the window was hidden before — is what they expect. + -- Apply also opens the chat window so the user immediately sees + -- the result of the tuning they just did. self.BtnApply = UIUtil.CreateButtonStd(client, '/widgets02/small', "Apply", 14) self.BtnApply.OnClick = function() ChatConfigController.Apply() @@ -245,11 +235,8 @@ local ChatConfigInterface = ClassUI(Window) { end -- ---- Decorative corner grips ---- - -- Bitmaps overhanging the four corners, mirroring the chat window's - -- chrome so the config dialog visually belongs to the same family. - -- Hit-test stays off — `lockSize` is true on this window, so the grips - -- are pure decoration; routing clicks through them would only confuse - -- the underlying Title-bar drag handler. + -- Pure decoration — `lockSize` is true on this window, so routing + -- clicks through them would only confuse the title-bar drag. self.DragTL = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) self.DragTR = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) self.DragBL = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) @@ -258,9 +245,7 @@ local ChatConfigInterface = ClassUI(Window) { grip:DisableHitTest() end - -- ---- Reactive: sync all controls whenever pending options change ---- - -- `LazyVarDerive` gives us a fresh per-subscriber LazyVar so we don't - -- stomp other subscribers on Pending (see the chat CLAUDE.md). + -- ---- Reactive: sync controls when pending options change ---- local model = ChatConfigModel.GetSingleton() self.PendingObserver = self.Trash:Add( LazyVarDerive( @@ -279,12 +264,10 @@ local ChatConfigInterface = ClassUI(Window) { local client = self:GetClientGroup() local pad = 8 - -- Colors section header Layouter(self.LabelColors) :AtLeftTopIn(client, pad, pad) :End() - -- Color rows: label left, combo to its right ---@type Control local prev = self.LabelColors for _, row in ipairs(self.ColorRows) do @@ -302,7 +285,6 @@ local ChatConfigInterface = ClassUI(Window) { prev = row.Label end - -- Sliders Layouter(self.LabelFontSize) :Below(prev, 12) :AtLeftIn(client, pad) @@ -336,13 +318,11 @@ local ChatConfigInterface = ClassUI(Window) { :Width(200) :End() - -- Behavior section header Layouter(self.LabelBehavior) :Below(self.SliderWinAlpha, 12) :AtLeftIn(client, pad) :End() - -- Checkboxes prev = self.LabelBehavior for _, cb in ipairs(self.Checkboxes) do Layouter(cb) @@ -352,7 +332,6 @@ local ChatConfigInterface = ClassUI(Window) { prev = cb end - -- Muted players section Layouter(self.LabelMuted) :Below(prev, 12) :AtLeftIn(client, pad) @@ -367,7 +346,7 @@ local ChatConfigInterface = ClassUI(Window) { prev = row.Checkbox end - -- Buttons: Apply | Reset on one row, OK | Cancel on the next + -- Apply | Reset on one row, OK | Cancel on the next. Layouter(self.BtnApply) :Below(prev, 12) :AtLeftIn(client, pad) @@ -388,17 +367,12 @@ local ChatConfigInterface = ClassUI(Window) { :AtVerticalCenterIn(self.BtnOk) :End() - -- Fit the window height to its content. Width stays driven by - -- Left/Right from the default rect — don't pin Width here, or the - -- drag handler's Right:Set(Left + Width) will snap the window to - -- whatever Width was pinned to (the textures render against Right, - -- so a Width/Right mismatch is invisible until the first drag). + -- Don't pin Width here — the drag handler's Right:Set(Left + Width) + -- would snap to whatever Width got pinned to. Width stays driven + -- by Left/Right from the default rect. local bottomPadScaled = LayoutHelpers.ScaleNumber(16) self.Bottom:Set(function() return self.BtnCancel.Bottom() + bottomPadScaled end) - -- Corner grips overhang the window edge. Offsets mirror the chat - -- window's grips so the visual reads identically across the two - -- dialogs. `Over(self, 5)` keeps them above the window chrome. Layouter(self.DragTL):AtLeftTopIn(self, -26, -8):Over(self, 5):End() Layouter(self.DragTR):AtRightTopIn(self, -22, -8):Over(self, 5):End() Layouter(self.DragBL):AtLeftBottomIn(self, -26, -8):Over(self, 5):End() @@ -412,7 +386,7 @@ local ChatConfigInterface = ClassUI(Window) { end end, - --- Syncs every control to reflect the given options table. + --- Syncs every control to the supplied options snapshot. Driven by the Pending observer. ---@param self UIChatConfigInterface ---@param options UIChatOptions RefreshFromOptions = function(self, options) @@ -440,12 +414,12 @@ local ChatConfigInterface = ClassUI(Window) { end end, + --- Title-bar close button. Closes the dialog without applying. OnClose = function(self) import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() end, - --- Empties our trash bag so every derived observer we allocated is - --- destroyed — no `OnDirty` can fire into a torn-down `self`. + --- Destroys derived observers so dangling OnDirty callbacks don't fire into a dead self. ---@param self UIChatConfigInterface OnDestroy = function(self) self.Trash:Destroy() @@ -455,37 +429,33 @@ local ChatConfigInterface = ClassUI(Window) { ------------------------------------------------------------------------------- -- Module-level singleton and standalone entry points +--- Singleton handle; nil until `Open` builds the dialog for the first time. ---@type UIChatConfigInterface | nil local Instance = nil ---- Opens the config dialog, creating it if it does not exist yet. +--- Standalone entry point: shows the config dialog, building it on first open. function Open() if Instance then Instance:Show() return end - -- Mirror the legacy chat-options behaviour: dismiss any open map dialog - -- (build/order popup, etc.) before opening, then pin our depth above - -- everything else so a later popup can't slide on top of us. Without - -- this the config dialog can end up sandwiched behind another popup - -- and look stuck. + -- Dismiss any open map dialog (build/order popup, etc.) and pin our + -- depth above everything so a later popup can't slide on top of us. import("/lua/ui/game/multifunction.lua").CloseMapDialog() Instance = ChatConfigInterface(GetFrame(0)) Instance.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) end ---- Closes and destroys the config dialog. +--- Standalone entry point: tears down the dialog if it exists. function Close() 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 end ---- Toggles the config dialog open or closed. +--- Standalone entry point: flips visibility, building the dialog if needed. function Toggle() if Instance then Close() @@ -497,7 +467,7 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Called by the module manager when this module is reloaded. +--- Hot-reload hook: reopens the dialog on the freshly loaded module if it was open. ---@param newModule any function __moduleinfo.OnReload(newModule) if Instance then @@ -505,7 +475,7 @@ function __moduleinfo.OnReload(newModule) end end ---- Called by the module manager when this module becomes dirty. +--- Hot-reload hook: tears down the old instance and re-imports this module. function __moduleinfo.OnDirty() if Instance then Instance:Destroy() diff --git a/lua/ui/game/chat/config/ChatConfigModel.lua b/lua/ui/game/chat/config/ChatConfigModel.lua index 67d8df7e649..79fb4aaee2b 100644 --- a/lua/ui/game/chat/config/ChatConfigModel.lua +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -4,6 +4,7 @@ local Create = import("/lua/lazyvar.lua").Create local ProfileKey = "chatoptions" +--- User-tunable chat options; persisted via prefs except for the `muted` table, which is per-game. ---@class UIChatOptions ---@field all_color number # color index 1-8 for "all" messages ---@field allies_color number # color index 1-8 for ally messages @@ -18,6 +19,7 @@ local ProfileKey = "chatoptions" ---@field links boolean # show camera-link messages ---@field muted table # armyID -> true when muted; absent = not muted +--- Factory defaults; merged on top of any saved profile during `SetupSingleton`. ---@type UIChatOptions local DefaultOptions = { all_color = 1, @@ -36,9 +38,7 @@ local DefaultOptions = { ------------------------------------------------------------------------------- --- Option keys exported as module globals so views and controllers can address --- fields without magic strings. Each constant's value matches the field name --- on `UIChatOptions`. +-- Option keys, exported so call sites address fields without magic strings. KeyAllColor = 'all_color' KeyAlliesColor = 'allies_color' @@ -54,34 +54,37 @@ KeyLinks = 'links' KeyMuted = 'muted' ------------------------------------------------------------------------------- --- Value ranges for numeric options. Exported as module globals so the view --- can construct sliders without duplicating the limits. +-- Slider ranges, exported so the view doesn't duplicate the limits. +--- Inclusive `[Min, Max]` range with a step `Inc`; consumed by the config dialog's IntegerSlider rows. ---@class UIChatSliderRange ---@field Min number ---@field Max number ---@field Inc number +--- Range for the chat font-size slider (`font_size` option). ---@type UIChatSliderRange FontSizeRange = { Min = 12, Max = 18, Inc = 1 } +--- Range for the idle-fade slider in seconds (`fade_time` option). ---@type UIChatSliderRange FadeTimeRange = { Min = 5, Max = 30, Inc = 1 } ---- Window alpha is stored as 0.0-1.0 but edited via an integer percent slider. +--- Window opacity slider range; stored as 0.0-1.0 but edited as an integer percent. ---@type UIChatSliderRange WinAlphaSliderRange = { Min = 20, Max = 100, Inc = 1 } +--- Two-LazyVar split: `Committed` is observed by chat; `Pending` is the dialog's editable draft. ---@class UIChatConfigModel ---@field Committed LazyVar # the active, saved options observed by the chat feed ---@field Pending LazyVar # the draft being edited in the config dialog +--- Singleton handle; nil until `SetupSingleton` (or `GetSingleton`) builds the model. ---@type UIChatConfigModel | nil local ModelInstance = nil ---- Creates and initializes the model singleton from the player profile. ---- Mutes are deliberately per-game: any `muted` payload read from prefs is ---- discarded, and `Apply` strips it before saving. +--- Mutes are per-game: any `muted` payload read from prefs is discarded +--- here, and `Apply` strips it before saving. ---@return UIChatConfigModel function SetupSingleton() local saved = Prefs.GetFieldFromCurrentProfile(ProfileKey) or {} @@ -96,27 +99,26 @@ function SetupSingleton() return ModelInstance end ---- Returns the model singleton, creating it if it does not exist yet. +--- Returns the model singleton, creating it on first access. ---@return UIChatConfigModel function GetSingleton() return ModelInstance or SetupSingleton() end ---- Shorthand for `GetSingleton().Committed()` — the current, applied ---- options snapshot. Use it for one-shot reads at the point of use; views ---- that need to react to changes should still subscribe via ---- `LazyVarDerive(GetSingleton().Committed, ...)`. +--- Shorthand for one-shot reads of `Committed`. Reactive consumers +--- should still subscribe via `LazyVarDerive`. ---@return UIChatOptions function GetOptions() return GetSingleton().Committed() end ---- Returns a fresh copy of the built-in defaults. +--- Returns a fresh copy of the factory-default options. ---@return UIChatOptions function GetDefaults() return table.copy(DefaultOptions) end +--- Returns the prefs profile key under which Apply persists options. ---@return string function GetProfileKey() return ProfileKey @@ -125,7 +127,8 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Called by the module manager when this module is reloaded. +--- Hot-reload hook: rebuilds the singleton on the new module and copies +--- current LazyVar values across so observers don't see a state reset. ---@param newModule any function __moduleinfo.OnReload(newModule) if ModelInstance then @@ -135,7 +138,7 @@ function __moduleinfo.OnReload(newModule) end end ---- Called by the module manager when this module becomes dirty. +--- Hot-reload hook: re-imports this module on save. function __moduleinfo.OnDirty() import(__moduleinfo.name) end From 5812483782127275a7777dcd088fd78bc57dc9ee Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 21:35:31 +0200 Subject: [PATCH 116/130] Compact comments of debug functionality --- lua/ui/game/chat/ChatDebug.lua | 53 +++++++++++++--------------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/lua/ui/game/chat/ChatDebug.lua b/lua/ui/game/chat/ChatDebug.lua index 5eabe15c897..3c5d6c35281 100644 --- a/lua/ui/game/chat/ChatDebug.lua +++ b/lua/ui/game/chat/ChatDebug.lua @@ -1,15 +1,12 @@ ------------------------------------------------------------------------------- --- Helpers wired to the `debug_chat_*` hotkeys in --- `/lua/keymap/debugKeyActions.lua`. Each function exercises a distinct --- chat path so the rendering / scrolling / camera / recipient flows can be --- inspected without needing a second client to actually send messages. +-- Helpers for the `debug_chat_*` hotkeys in +-- `/lua/keymap/debugKeyActions.lua`. local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") local ChatController = import("/lua/ui/game/chat/ChatController.lua") ---- A sample paragraph long enough to force the chat line wrapper to span ---- multiple rows at every supported font size. +--- Long enough to force wrapping at every supported font size. local LongText = "The quick brown fox jumps over the lazy dog and then doubles back, " .. "dodges a passing T2 mobile artillery shell, ramps off a discarded " .. @@ -17,10 +14,9 @@ local LongText = "bark — at which point the dog wakes up and demands to know who " .. "authorised the construction of the ramp in the first place." ---- Synthesises a chat entry stamped with the local focus army's metadata so ---- the rendering colour and faction icon match a real outgoing message. The ---- entry carries a fresh `Id` so the dedupe in `OnSyncChatMessages` doesn't ---- swallow it later. +--- Stamps the entry with the local focus army's metadata so the colour +--- and faction icon match a real outgoing message. Fresh `Id` so the +--- `OnSyncChatMessages` dedupe doesn't swallow it later. ---@param overrides table # fields merged on top of the synth defaults ---@return UIChatEntry local function SynthEntry(overrides) @@ -46,13 +42,12 @@ end -- Window & dialog toggles ------------------------------------------------------------------------------- ---- Toggles the chat window. Thin wrapper so the hotkey action string can ---- live alongside the rest of the chat-debug helpers. +--- Debug hotkey: flips the chat window's visibility. function ToggleWindow() import("/lua/ui/game/chat/ChatInterface.lua").Toggle() end ---- Toggles the chat config dialog. +--- Debug hotkey: flips the chat options dialog's visibility. function ToggleConfig() import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() end @@ -61,30 +56,26 @@ end -- Synthetic message injection ------------------------------------------------------------------------------- ---- Appends a local-only system line. Exercises ---- `ChatController.AppendLocalSystemMessage` and the system-style colour. +--- Debug hotkey: appends a synthetic system message. function AppendSystemMessage() ChatController.AppendLocalSystemMessage( '[debug] system message at ' .. tostring(GetSystemTimeSeconds()) ) end ---- Appends a single short synthetic chat entry. Exercises the basic ---- model→view path and the auto-scroll-to-bottom on history change. +--- Debug hotkey: appends a one-line synthetic message from the local player. function AppendShortMessage() ChatController.AppendEntry(SynthEntry({})) end ---- Appends a synthetic entry whose body is long enough to wrap onto several ---- rows at every supported font size. Exercises `ChatLinesInterface.WrapEntry` ---- and the continuation-row layout. +--- Body wraps onto several rows at every supported font size — exercises +--- the continuation-row layout. function AppendLongMessage() ChatController.AppendEntry(SynthEntry({ Text = LongText })) end ---- Appends ten short entries in a single batch. Exercises pool sizing ---- (the visible window grows past the line cap), virtual-size accounting, ---- and the snap-to-bottom behaviour on rapid arrivals. +--- Ten entries in one batch — exercises pool sizing past the line cap and +--- snap-to-bottom on rapid arrivals. function AppendBurst() for i = 1, 10 do ChatController.AppendEntry(SynthEntry({ @@ -93,11 +84,8 @@ function AppendBurst() end end ---- Appends an entry with a `Location` hint pointing at the current world ---- camera focus. Exercises the camera-icon toggle on the row and the ---- `Camera:MoveTo` jump on click. The point is captured at hotkey time, so ---- pressing the key, panning the camera, and clicking the icon should ---- bounce the camera back to the original spot. +--- Captures the camera focus at hotkey time so panning and clicking the +--- camera icon should bounce back to the original spot. function AppendCameraMessage() local cam = GetCamera('WorldCamera') local settings = cam:SaveSettings() @@ -111,13 +99,12 @@ end -- Recipient state ------------------------------------------------------------------------------- ---- Forces the current send target to "all". Exercises the recipient-label ---- LazyVar binding in the edit row. +--- Debug hotkey: forces the recipient back to "All". function SetRecipientAll() ChatController.SetRecipient(ChatModel.RecipientAll) end ---- Forces the current send target to "allies". +--- Debug hotkey: forces the recipient back to "Allies". function SetRecipientAllies() ChatController.SetRecipient(ChatModel.RecipientAllies) end @@ -126,8 +113,7 @@ end -- History reset ------------------------------------------------------------------------------- ---- Wipes the history log. Exercises the empty-pool branch in ---- `ChatLinesInterface.CalcVisible` and the model-side dirty propagation. +--- Debug hotkey: wipes the entire history log. function ClearHistory() ChatModel.GetSingleton().History:Set({}) end @@ -135,6 +121,7 @@ end ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-imports this module on save. function __moduleinfo.OnDirty() import(__moduleinfo.name) end From e10e4b11391fae01f9e00f69d46454afe7de8fb4 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 21:40:59 +0200 Subject: [PATCH 117/130] Compact remaining comments --- lua/ui/game/chat/ChatCommandHintInterface.lua | 146 +++------ lua/ui/game/chat/ChatCompletion.lua | 45 ++- lua/ui/game/chat/ChatController.lua | 280 ++++++------------ lua/ui/game/chat/ChatEditInterface.lua | 160 ++++------ lua/ui/game/chat/ChatFactionBadge.lua | 18 +- lua/ui/game/chat/ChatFeedInterface.lua | 160 ++++------ lua/ui/game/chat/ChatInterface.lua | 249 +++++----------- lua/ui/game/chat/ChatLineInterface.lua | 108 +++---- lua/ui/game/chat/ChatLinesInterface.lua | 225 ++++---------- lua/ui/game/chat/ChatListInterface.lua | 63 ++-- lua/ui/game/chat/ChatModel.lua | 32 +- lua/ui/game/chat/ChatUtils.lua | 57 ++-- 12 files changed, 491 insertions(+), 1052 deletions(-) diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index 98bb5bd861d..19d1de927e0 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -10,9 +10,6 @@ local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") local Layouter = LayoutHelpers.ReusedLayoutFor ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false local RowFontSize = 12 @@ -20,17 +17,13 @@ local RowFontName = 'Arial' local HorizontalPadding = 12 local VerticalPadding = 2 ---- Cap the popup height: at most this many command rows are visible at ---- once; anything beyond scrolls. Chosen to match the point where the ---- popup becomes visually overwhelming on a 1080p screen. local MaxVisibleRows = 6 ---- Width reserved on the right of the popup for the scrollbar. Reserved ---- unconditionally so the layout doesn't reflow when the scrollbar shows ---- / hides with the match count. +--- Reserved unconditionally so the layout doesn't reflow when the +--- scrollbar shows / hides with the match count. local ScrollbarWidth = 24 ---- Renders a command the same way `/help` does: name, params, aliases, description. +--- Renders a command the same way `/help` does. ---@param cmd UIChatCommand ---@return string local function FormatCommand(cmd) @@ -51,11 +44,11 @@ local function FormatCommand(cmd) end ------------------------------------------------------------------------------- --- Command-hint popup. Shows commands whose name or aliases prefix-match the --- user's input. Reuses a pool of row controls across refreshes — entries are --- shown/hidden and re-positioned via a per-row `ordinal` LazyVar rather than --- rebuilt from scratch. +-- Command-hint popup. Shows commands whose name or aliases prefix-match. +-- Reuses a pool of row controls across refreshes — rows are +-- shown/hidden and re-positioned via a per-row `ordinal` LazyVar. +--- One pooled hint row; ordinal 0 means hidden, otherwise it's the row's position from the bottom. ---@class UIChatHintRow ---@field Text Text ---@field BG Bitmap @@ -64,6 +57,7 @@ end ---@field Hovered boolean ---@field Paint fun() # re-applies BG solid-colour from Hovered + owner.Selected +--- Slash-command auto-suggest popup anchored above the edit box; reuses a row pool across refreshes. ---@class UIChatCommandHintInterface : Group ---@field Edit Edit ---@field OnSelect? fun(cmd: UIChatCommand) @@ -99,27 +93,24 @@ ChatCommandHintInterface = ClassUI(Group) { self.LastText = '' self.VisibleCount = Create(0) self.Selected = Create(0) + self.Selected.OnDirty = function() self:RepaintRows() end self.ScrollBottom = Create(1) + self.ScrollBottom.OnDirty = function() self:UpdateRowVisibility() end - -- Solid backdrop so the tiny per-row highlight bitmaps (which only - -- span Text.Top-1..Text.Bottom+1) don't leave visible gaps between - -- rows. The row BGs now sit transparent on top of this for hover / - -- selection highlighting. + -- Backdrop fills the gaps between the per-row highlight strips + -- (which only span Text.Top-1..Text.Bottom+1). self.Background = Bitmap(self) self.Background:SetSolidColor('ff000000') self.Background:DisableHitTest() - -- Sample the row height from a throwaway Text. + -- `probe.Height()` is already in scaled pixels; the padding + -- constant has to be scaled by hand to track UI scale. ---@diagnostic disable-next-line: param-type-mismatch local probe = UIUtil.CreateText(self, '/sample', RowFontSize, RowFontName) ---@diagnostic disable-next-line: undefined-field - -- `probe.Height()` is already in scaled pixels (Layouter stores - -- everything scaled); the padding constant has to be scaled by hand - -- so it tracks the user's UI scale setting. self.RowHeight = Create(probe.Height() + LayoutHelpers.ScaleNumber(VerticalPadding)) probe:Destroy() - -- Decorative borders (same skin as ChatListInterface). self.LTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) self.LTBG:DisableHitTest() self.RTBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ur.dds')) @@ -136,21 +127,13 @@ ChatCommandHintInterface = ClassUI(Group) { self.TBG:DisableHitTest() self.BBG = Bitmap(self, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) self.BBG:DisableHitTest() - - -- Repaint highlighted rows when the selection moves. We own `Selected` - -- so binding its OnDirty directly is safe (see CLAUDE.md §LazyVar). - self.Selected.OnDirty = function() self:RepaintRows() end - - -- Scroll changes: re-hide/show rows so only the current window is - -- visible. We own `ScrollBottom`, so direct OnDirty is safe. - self.ScrollBottom.OnDirty = function() self:UpdateRowVisibility() end end, ---@param self UIChatCommandHintInterface ---@param parent Control __post_init = function(self, parent) - -- Width: fit the widest fully-formatted row (/name (aka …) — desc) - -- so the popup doesn't reflow horizontally as rows change. + -- Width fits the widest fully-formatted row so the popup doesn't + -- reflow horizontally as rows change. local probeText = '/help' for _, cmd in ipairs(Registry.GetAll()) do local candidate = FormatCommand(cmd) @@ -164,13 +147,6 @@ ChatCommandHintInterface = ClassUI(Group) { local textWidth = probe.Width() probe:Destroy() - -- Reserve scrollbar width unconditionally so the popup doesn't - -- reflow when the scrollbar appears / disappears with match count. - -- - -- `textWidth` is already in scaled pixels (read off a laid-out probe), - -- so we can't pass the sum to `:Width(number)` — Layouter would call - -- `ScaleNumber` on the whole expression and double-scale the text - -- portion. Use a function form and scale only the raw constants. local extraScaled = LayoutHelpers.ScaleNumber(HorizontalPadding * 2 + ScrollbarWidth) Layouter(self) :Width(function() return textWidth + extraScaled end) @@ -182,16 +158,12 @@ ChatCommandHintInterface = ClassUI(Group) { return rows * self.RowHeight() end) - -- Unified backdrop covers the entire popup. Rows are transparent by - -- default and only paint when hovered or selected, so the backdrop - -- fills the slivers between row highlight strips. self.Background.Left:SetFunction(function() return self.Left() end) self.Background.Right:SetFunction(function() return self.Right() end) self.Background.Top:SetFunction(function() return self.Top() end) self.Background.Bottom:SetFunction(function() return self.Bottom() end) self.Background.Depth:SetFunction(function() return self.Depth() end) - -- Borders hug the outside of self on all eight sides. Layouter(self.LTBG):Right(self.Left):Bottom(self.Top):End() Layouter(self.RTBG):Left(self.Right):Bottom(self.Top):End() Layouter(self.RBBG):Left(self.Right):Top(self.Bottom):End() @@ -202,17 +174,10 @@ ChatCommandHintInterface = ClassUI(Group) { Layouter(self.BBG):Left(self.Left):Right(self.Right):Top(self.Bottom):End() ---@diagnostic enable: undefined-field - -- Vertical scrollbar along the right edge. `CreateVertScrollbarFor` - -- calls `scrollbar:SetScrollable(self)`, which drives dispatch into - -- `self:GetScrollValues` / `ScrollLines` / `ScrollPages` / - -- `ScrollSetTop` / `IsScrollable` (see below). A negative - -- `offset_right` pulls the bar inside the popup bounds instead of - -- overlapping the right border art. + -- Negative offset_right pulls the bar inside the popup bounds + -- instead of overlapping the right border art. self.Scrollbar = UIUtil.CreateVertScrollbarFor(self, -ScrollbarWidth) - -- Toggle the scrollbar visibility with match count. `Hide()` here is - -- safe — the hint popup is created fresh each time the user types - -- `/`, so the Show() cascade on the outer chat window can't undo it. local function syncScrollbarVisibility() if self.VisibleCount() > MaxVisibleRows then self.Scrollbar:Show() @@ -231,8 +196,8 @@ ChatCommandHintInterface = ClassUI(Group) { end end, - --- Builds a reusable row (text + highlight bitmap + hover handler). - --- The row is laid out lazily by `LayoutRow` / `LayoutRowBackground`. + --- Reusable row (text + highlight + hover handler). Layout deferred + --- to `GetOrCreateRow` / `LayoutRowBackground`. ---@param self UIChatCommandHintInterface ---@return UIChatHintRow BuildRow = function(self) @@ -278,8 +243,7 @@ ChatCommandHintInterface = ClassUI(Group) { return row end, - --- Repaints every row to reflect the current `Selected` ordinal. Called - --- from the Selected observer and whenever Refresh rebuilds the list. + --- Re-runs each row's paint to sync hover and selection highlights. ---@param self UIChatCommandHintInterface RepaintRows = function(self) for _, row in pairs(self.Rows) do @@ -287,9 +251,7 @@ ChatCommandHintInterface = ClassUI(Group) { end end, - --- Shows rows whose ordinal falls inside the current scroll window and - --- hides the rest. Rebound on `ScrollBottom` changes and called from - --- Refresh after the row set has been updated. + --- Shows or hides each row based on the current scroll window. ---@param self UIChatCommandHintInterface UpdateRowVisibility = function(self) local scrollBottom = self.ScrollBottom() @@ -307,9 +269,7 @@ ChatCommandHintInterface = ClassUI(Group) { end end, - --- Scrolls so `ordinal` falls inside the visible window. Called from - --- `SelectNext` / `SelectPrev` so keyboard navigation drags the scroll - --- along with the highlight. + --- Scrolls the visible window so `ordinal` is on screen. ---@param self UIChatCommandHintInterface ---@param ordinal number EnsureOrdinalVisible = function(self, ordinal) @@ -323,17 +283,14 @@ ChatCommandHintInterface = ClassUI(Group) { end, ------------------------------------------------------------------------- - -- Scrollable interface — wired to by the MAUI `Scrollbar` control. - -- - -- The scrollbar thinks top-down (thumb at top = top of content), but our - -- ordinals grow bottom-up (ord 1 at the bottom of the popup, ord N at - -- the top). We convert at the boundary so the thumb tracks visually: - -- thumb at top → highest ordinals visible at top of popup. - -- + -- Scrollable interface for the MAUI `Scrollbar`. The scrollbar thinks + -- top-down but our ordinals grow bottom-up; convert at the boundary + -- so the thumb tracks visually. -- topdown_top = n - ScrollBottom - MaxVisibleRows + 2 - -- ScrollBottom = n - topdown_top - MaxVisibleRows + 2 (inverse) + -- ScrollBottom = n - topdown_top - MaxVisibleRows + 2 (inverse) ------------------------------------------------------------------------- + --- Scrollbar contract: returns `(min, max, top, bottom)` in top-down coordinates. ---@param self UIChatCommandHintInterface ---@param axis string GetScrollValues = function(self, axis) @@ -344,6 +301,7 @@ ChatCommandHintInterface = ClassUI(Group) { return 1, n, top, math.min(top + MaxVisibleRows - 1, n) end, + --- Scrolls by a line count. ---@param self UIChatCommandHintInterface ---@param axis string ---@param delta number @@ -352,6 +310,7 @@ ChatCommandHintInterface = ClassUI(Group) { self:ScrollSetTop(axis, top + math.floor(delta)) end, + --- Scrolls by full visible-window pages. ---@param self UIChatCommandHintInterface ---@param axis string ---@param delta number @@ -360,6 +319,7 @@ ChatCommandHintInterface = ClassUI(Group) { self:ScrollSetTop(axis, top + math.floor(delta) * MaxVisibleRows) end, + --- Jumps to an absolute top-down position; flips it back to bottom-up `ScrollBottom`. ---@param self UIChatCommandHintInterface ---@param axis string ---@param top number # in scrollbar (top-down) coordinates @@ -375,23 +335,21 @@ ChatCommandHintInterface = ClassUI(Group) { end end, + --- Whether there are more matches than fit in the visible window. ---@param self UIChatCommandHintInterface ---@param axis string IsScrollable = function(self, axis) return self.VisibleCount() > MaxVisibleRows end, - --- Mouse wheel over the popup scrolls the visible window. One notch - --- (~120 wheel units) moves one row. + --- Wheel scroll handler. ---@param self UIChatCommandHintInterface ---@param rotation number OnMouseWheel = function(self, rotation) self:ScrollLines(nil, -math.floor(rotation / 100)) end, - --- Wraps `Selected` to the next visible dynamic row. No-op when there - --- are no matches. Scrolls the view so the new selection stays on - --- screen, matching keyboard-nav expectations. + --- Advances the selection one row down (wraps to the top). ---@param self UIChatCommandHintInterface SelectNext = function(self) local n = self.VisibleCount() @@ -402,9 +360,7 @@ ChatCommandHintInterface = ClassUI(Group) { self:EnsureOrdinalVisible(next) end, - --- Wraps `Selected` to the previous visible dynamic row. No-op when - --- there are no matches. Scrolls the view so the new selection stays - --- on screen. + --- Moves the selection one row up (wraps to the bottom). ---@param self UIChatCommandHintInterface SelectPrev = function(self) local n = self.VisibleCount() @@ -415,7 +371,7 @@ ChatCommandHintInterface = ClassUI(Group) { self:EnsureOrdinalVisible(prev) end, - --- Returns the currently-selected command, or nil when nothing matches. + --- Returns the currently highlighted command, or nil if none. ---@param self UIChatCommandHintInterface ---@return UIChatCommand? GetSelected = function(self) @@ -425,8 +381,7 @@ ChatCommandHintInterface = ClassUI(Group) { return row and row.Target or nil end, - --- Lazily pulls a dynamic row out of the pool, creating it if needed and - --- wiring its position binding to its own ordinal LazyVar. + --- Returns the row at `idx`, building one on demand the first time. ---@param self UIChatCommandHintInterface ---@param idx number ---@return UIChatHintRow @@ -443,9 +398,7 @@ ChatCommandHintInterface = ClassUI(Group) { row.Text.Bottom:SetFunction(function() local ord = row.Ordinal() if ord <= 0 then return self.Top() end - -- `slot` = 1 at the bottom visible row, MaxVisibleRows at the - -- top. Rows outside the window get positioned at `self.Top()` - -- and hidden by `UpdateRowVisibility`. + -- slot 1 = bottom visible row, MaxVisibleRows = top. local slot = ord - self.ScrollBottom() + 1 if slot < 1 or slot > MaxVisibleRows then return self.Top() end return self.Bottom() - (slot - 1) * self.RowHeight() @@ -456,8 +409,8 @@ ChatCommandHintInterface = ClassUI(Group) { return row end, - --- Binds the row's highlight bitmap to span the popup width at the row's - --- vertical position, one depth below the text so clicks hit the bitmap. + --- Spans popup width at the row's vertical position; one depth below + --- the text so clicks hit the bitmap. ---@param self UIChatCommandHintInterface ---@param row UIChatHintRow LayoutRowBackground = function(self, row) @@ -471,16 +424,14 @@ ChatCommandHintInterface = ClassUI(Group) { ---@diagnostic enable: undefined-field end, - --- Updates the popup to reflect the current edit-box text. Reuses existing - --- rows: each matching command is assigned to the row at its ordinal, and - --- rows beyond the match count are hidden (ordinal = 0). + --- Reuses existing rows: each match is assigned to the row at its + --- ordinal, rows beyond the match count get ordinal = 0 (hidden). ---@param self UIChatCommandHintInterface ---@param text string Refresh = function(self, text) local matches = {} if text and string.sub(text, 1, 1) == '/' then local prefix = string.sub(text, 2) - -- Only the first word is the command name. local space = string.find(prefix, '%s') if space then prefix = string.sub(prefix, 1, space - 1) end @@ -504,13 +455,10 @@ ChatCommandHintInterface = ClassUI(Group) { self.VisibleCount:Set(table.getn(matches)) - -- Any time the match set changes, start the scroll window at the - -- bottom. `UpdateRowVisibility` below applies Show/Hide using the - -- fresh window. + -- Reset scroll to bottom on every match-set change. self.ScrollBottom:Set(1) - -- Keep the previously-selected ordinal when possible; otherwise land - -- on the first match (or clear the selection when nothing matches). + -- Keep the previously-selected ordinal when possible. local n = table.getn(matches) local cur = self.Selected() if n == 0 then @@ -518,8 +466,8 @@ ChatCommandHintInterface = ClassUI(Group) { elseif cur < 1 or cur > n then self.Selected:Set(1) else - -- Ordinal is unchanged but the target underneath probably isn't — - -- force a repaint so colors match the new row assignments. + -- Ordinal unchanged but the target underneath isn't — + -- repaint so colours match the new row assignments. self:RepaintRows() end @@ -527,7 +475,7 @@ ChatCommandHintInterface = ClassUI(Group) { ---@diagnostic enable: undefined-field end, - --- Registers the callback invoked when the user clicks a hint row. + --- Registers a callback to fire when a row is committed (Tab or click). ---@param self UIChatCommandHintInterface ---@param callback fun(cmd: UIChatCommand) SetOnSelect = function(self, callback) diff --git a/lua/ui/game/chat/ChatCompletion.lua b/lua/ui/game/chat/ChatCompletion.lua index 95186442b5d..23c4f75222a 100644 --- a/lua/ui/game/chat/ChatCompletion.lua +++ b/lua/ui/game/chat/ChatCompletion.lua @@ -2,21 +2,15 @@ local CommandRegistry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") ------------------------------------------------------------------------------- --- Pure tab-completion helper for the chat edit box. +-- Pure tab-completion for the chat edit box. -- -- Compute(text, caret) → UIChatCompletion | nil -- --- The caller (ChatEditInterface) owns the cycle state; this module only --- produces a fresh record. Positions are codepoint offsets (0-indexed) so --- they line up with Edit:GetCaretPosition / SetCaretPosition and remain --- stable across UTF-8 input. --- --- Branches: --- 1. Command — text starts with '/' AND the caret sits inside the first --- token. Candidates come from the registry. --- 2. Name — otherwise, complete the current word against non-civilian --- army nicknames. +-- The caller owns the cycle state. Positions are 0-indexed codepoint +-- offsets so they line up with Edit:GetCaretPosition / SetCaretPosition +-- and stay stable across UTF-8 input. +--- One Tab-cycle's state: candidates plus where in the source they replace; caller owns the cycle position. ---@class UIChatCompletion ---@field Anchor number # codepoint offset (0-indexed) where the replaced word begins ---@field Consume number # codepoint count of the original word consumed from `Anchor` @@ -25,8 +19,7 @@ local CommandRegistry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.l ---@field Index number # 1-based index of the currently applied candidate ---@field Suffix string # text appended after the candidate (' ' when unambiguous) ---- Returns the codepoint of the last space at or before `caret`, or 0 if none. ---- Space is ASCII so a codepoint-by-codepoint walk is safe. +--- Codepoint of the last space at or before `caret`, or 0 if none. ---@param text string ---@param caret number ---@return number @@ -41,8 +34,8 @@ local function LastSpaceBefore(text, caret) return 0 end ---- Returns the codepoint position of the next space at or after `caret + 1`, ---- or the total codepoint length when the word runs to end-of-text. +--- Codepoint position of the next space at or after `caret + 1`, or +--- `textLen` if the word runs to end-of-text. ---@param text string ---@param caret number ---@param textLen number @@ -59,9 +52,8 @@ local function NextSpaceAfter(text, caret, textLen) end --- Non-civilian nicknames from the armies table, minus the local player. ---- `focusArmy` is the local player's army ID; it's 0 for observers (so the ---- comparison below is a no-op in observer mode, which is the behaviour we ---- want — observers have no nickname to complete anyway). +--- `focusArmy` is 0 for observers, making the comparison a no-op — fine, +--- observers have no nickname to complete anyway. ---@return string[] local function CollectNicknames() local out = {} @@ -83,9 +75,9 @@ local function StartsWithCI(s, prefix) return string.lower(string.sub(s, 1, string.len(prefix))) == string.lower(prefix) end ---- Computes a completion record for the caret position in `text`, or nil if ---- nothing matches. The returned record's `Consume` covers the full word ---- under the caret (so completing mid-word overwrites the tail too). +--- Returns a completion record for the caret position, or nil if nothing +--- matches. `Consume` covers the full word under the caret so mid-word +--- completion overwrites the tail too. ---@param text string ---@param caret number ---@return UIChatCompletion? @@ -100,8 +92,8 @@ function Compute(text, caret) local isCommand = (wordStart == 0) and (STR_Utf8SubString(text, 1, 1) == '/') -- Only append a trailing space when the completion is unambiguous AND - -- the word runs to end-of-text; adding one before an existing space would - -- double up. + -- the word runs to end-of-text — otherwise we'd double up an existing + -- separator. local atEnd = wordEnd == textLen if isCommand then @@ -126,10 +118,9 @@ function Compute(text, caret) local prefix = STR_Utf8SubString(text, wordStart + 1, caret - wordStart) if prefix == '' then return nil end - -- Allow `@nick` shorthand: strip the leading `@` for matching but keep - -- it in every candidate so the inserted text is still `@Jip`. Command - -- param resolvers strip a leading `@` symmetrically (see - -- ChatCommandTypes), so `/whisper @Jip` works the same as `/whisper Jip`. + -- `@nick` shorthand: strip the `@` for matching but keep it in + -- candidates so `/whisper @Jip` still works. ChatCommandTypes + -- strips `@` symmetrically on the resolver side. local atSign = '' local matchPrefix = prefix if string.sub(prefix, 1, 1) == '@' then diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 8bdb5fa1fa8..481cb444421 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -17,7 +17,7 @@ function CloseWindow() ChatModel.GetSingleton().WindowVisible:Set(false) end ---- Toggles the chat window open or closed. +--- Flips chat window visibility. function ToggleWindow() local lv = ChatModel.GetSingleton().WindowVisible lv:Set(not lv()) @@ -25,26 +25,17 @@ end ------------------------------------------------------------------------------- -- Activity heartbeat --- --- Every UI surface that wants to count as "user is engaged with chat" calls --- this — keystrokes, scrolling, recipient-picker hovers, etc. The chat --- window observes `model.LastActivity` to drive its idle / fade timeout, but --- any future subscriber (a feed-mode line fader, an away-status indicator) --- can read the same field without rewiring the call sites. - ---- Records a user / system activity event by stamping `LastActivity` with the ---- current system time. Cheap and idempotent — call freely from anywhere ---- that detects engagement with the chat UI. + +--- Stamps `LastActivity` with the current system time. The chat window's +--- idle / fade timer subscribes to this; call from any UI surface that +--- counts as engagement. function NotifyActivity() ChatModel.GetSingleton().LastActivity:Set(GetSystemTimeSeconds()) end ---- Sets the title-bar pin state. While pinned, [`ChatInterface.OnFrame`] ---- skips the idle / `fade_time` auto-close check, so the chat window ---- stays open through arbitrary inactivity. Toggling pin off also stamps ---- a fresh `LastActivity` so the user gets a full `fade_time` window ---- after unpinning instead of being auto-closed immediately because the ---- timer kept counting against a stale activity stamp. +--- While pinned, `ChatInterface.OnFrame` skips the idle auto-close check. +--- Unpinning re-stamps `LastActivity` so the user gets a full `fade_time` +--- window instead of being auto-closed against the stale stamp. ---@param pinned boolean function SetPinned(pinned) ChatModel.GetSingleton().Pinned:Set(pinned and true or false) @@ -65,10 +56,8 @@ end ------------------------------------------------------------------------------- -- Messages ---- Appends an entry to the history log. Called by the receive path as well as ---- by locally-echoed outgoing messages. Doubles as an activity heartbeat — ---- every new line counts as engagement, so a burst of incoming chat keeps ---- the window from auto-fading mid-conversation. +--- Appends an entry to the history log and stamps `LastActivity`. Used by +--- the receive path and by locally-echoed outgoing messages. ---@param entry UIChatEntry function AppendEntry(entry) local model = ChatModel.GetSingleton() @@ -78,9 +67,8 @@ function AppendEntry(entry) NotifyActivity() end ---- Appends a synthetic, local-only system line to the history. Used by the ---- slash-command dispatcher to surface parse/accept errors in the chat feed ---- without sending anything over the network. +--- Appends a local-only system line. Used by the slash-command dispatcher +--- to surface parse/accept errors without sending over the network. ---@param text string function AppendLocalSystemMessage(text) AppendEntry { @@ -96,10 +84,9 @@ end ------------------------------------------------------------------------------- -- Slash commands ---- (Re-)registers every built-in chat command with the registry. `Register` ---- overwrites, so calling this repeatedly is safe and cheap — we do so on ---- every slash-entry path so hot-reloading `ChatCommandRegistry.lua` (which ---- resets its internal tables) doesn't leave us with an empty registry. +--- (Re-)registers every built-in chat command with the registry. Idempotent +--- and called on every slash-entry path so a hot-reload of the registry +--- doesn't leave us with an empty command set. function RegisterBuiltinCommands() local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") @@ -131,16 +118,7 @@ end ------------------------------------------------------------------------------- -- Address book --- --- These helpers resolve engine-level routing info (session-client indices, --- army-data lookups) without depending on the legacy `chat.lua`. They are --- the only place in the refactored chat system that touches --- `GetSessionClients` / `GetArmiesTable`. - ---- Observer-mode branch of `FindClients`: every connected observer client, ---- plus any disconnected-but-recognised human player, is included. The ---- per-client inner loop tries to find a matching army by nickname — if ---- none matches, the client is treated as an observer and included. + ---@param armiesTable table ---@return number[] local function FindClientsAsObserver(armiesTable) @@ -165,10 +143,6 @@ local function FindClientsAsObserver(armiesTable) return result end ---- In-play branch of `FindClients`: gathers the `authorizedCommandSources` ---- of the target army (private-message case) or every focus-army ally ---- (`allies`-broadcast case), then returns the clients whose sources ---- intersect that set. ---@param armiesTable table ---@param focus number ---@param armyID? number @@ -203,18 +177,14 @@ local function FindClientsAsPlayer(armiesTable, focus, armyID) return result end ---- Resolves the session-client indices for a given chat target. Mirrors the ---- legacy `chat.lua` behavior: +--- Resolves the session-client indices for a given chat target. --- --- * Observing (focus == -1): every connected observer client, plus any ---- disconnected-but-recognised human player, is included. ---- * Playing with an `armyID`: the clients authorised for that specific army ---- — used for private messages. ---- * Playing with no `armyID`: the clients authorised for any of the focus ---- army's allies — used for `allies` broadcasts. ---- ---- Exported so other UI modules (notify, score, painting canvas) can migrate ---- away from `/lua/ui/game/chat.lua`'s copy. +--- disconnected-but-recognised human player. +--- * Playing with an `armyID`: clients authorised for that specific army +--- (private messages). +--- * Playing with no `armyID`: clients authorised for any focus-army ally +--- (`allies` broadcasts). ---@param armyID? number ---@return number[] function FindClients(armyID) @@ -225,9 +195,8 @@ function FindClients(armyID) return FindClientsAsPlayer(t.armiesTable, t.focusArmy, armyID) end ---- Looks up army data by army index (number) or nickname (string). Returns ---- the entry from `armiesTable` or nil if no match; for nickname lookups the ---- returned table has `ArmyID` set to the matching index. +--- Looks up army data by army index (number) or nickname (string). For +--- nickname lookups the returned table has `ArmyID` set to the matching index. ---@param army number | string ---@return table | nil local function GetArmyData(army) @@ -244,19 +213,10 @@ local function GetArmyData(army) end end --- Recipient-label / chat-line-prefix descriptors. Lives in `ChatUtils` so --- the controller, the edit interface, and any future view can share one --- copy of the strings — see `ChatUtils.ToStrings` for the table shape. local ToStrings = ChatUtils.ToStrings ------------------------------------------------------------------------------- -- Chat line construction --- --- Receive and echo share the same "package sender + message into a --- `UIChatEntry` and push it onto history" work. The only thing they --- disagree on is how the name prefix reads and whose army data they pull --- from — so that's where they diverge; everything else goes through --- `AppendChatLine`. --- Builds a `UIChatEntry` from a sender's army data + message metadata and --- appends it to the model history. Fields with natural defaults (colour, @@ -266,21 +226,13 @@ local ToStrings = ChatUtils.ToStrings local function AppendChatLine(args) local armyData = args.ArmyData or {} -- Observers have no `faction`; fall through to the tail icon in - -- `ChatLineInterface.FactionIcons` (observer). Real factions are 0..N-1 - -- in engine data; the view expects 1-based indices. + -- `ChatLineInterface.FactionIcons`. Engine factions are 0..N-1; the view + -- expects 1-based indices. local faction = not args.IsObserver and armyData.faction or nil - -- Pick the palette key for the body text: - -- * Camera-link messages (the sender attached a `Camera` snapshot or - -- a sim-side `Location` hint) always use the link palette so the - -- "click to jump" affordance is visually consistent regardless of - -- channel — matches the legacy override at chat.legacy.lua:443-446. - -- * Observer broadcasts also use the link palette so observer chatter - -- stands out from player traffic. - -- * Everyone else inherits the channel descriptor's `colorkey`. - -- Falls back to `'priv_color'` for unrecognised recipients (matches - -- the `ToStrings.private` descriptor fallback used elsewhere in - -- `OnReceive`). + -- Camera-link messages and observer broadcasts both use the link palette; + -- everyone else inherits the channel descriptor's `colorkey`. Unrecognised + -- recipients fall back to `priv_color` via the `ToStrings.private` entry. local colorKey if args.Camera or args.Location then colorKey = ChatConfigModel.KeyLinkColor @@ -314,39 +266,26 @@ end ---@param sender string ---@param msg ChatPayload function OnReceive(sender, msg) - -- Coerce sender to a non-empty string so the formatting concatenations - -- below can't blow up on a number, table, or nil. The guard is wider - -- than `or "nil sender"` because that left non-string truthy values - -- (e.g. a number sender from a misbehaving caller) flowing through. if type(sender) ~= 'string' or sender == '' then sender = 'nil sender' end - -- Pure-shape validation: every type / length / payload-shape rule - -- lives in `ChatPayload.IsValidPayload` (shared with the sim relay) so - -- the dispatch logic below stays focused on routing and rendering. The - -- receive path is reachable from any `gamemain.RegisterChatFunc` - -- caller, including external mods, so the shape can't be trusted. - -- Sender / observer-mode consistency stays here in `OnReceive` since - -- those rules need session context (army data, focus army, replay - -- state) the shared validator can't see. + -- Shape validation lives in `ChatPayload.IsValidPayload` (shared with the + -- sim relay). The receive path is reachable from external mods, so the + -- payload can't be trusted. Sender / observer consistency below needs + -- session context the shared validator can't see. if not ChatPayload.IsValidPayload(msg) then return end - -- LOCF-style format-on-receive. When the sender ships `Args` alongside - -- the message text, the text is treated as a `string.format` template - -- (typically a `` tag with `%s` / `%d` placeholders) and - -- formatted UI-side so the result respects the viewer's locale. The - -- formatted text replaces `msg.text` for every downstream consumer - -- (length-cap notwithstanding — the cap was already applied to the - -- pre-format template, so `LOCF` can legitimately exceed it; that's - -- by design for system events where the args are trusted sim values). + -- LOCF-style format-on-receive: when the sender ships `Args`, treat + -- `msg.text` as a `string.format` template (typically a `` tag) + -- so the result respects the viewer's locale. The cap was applied to the + -- pre-format template, so `LOCF` can legitimately exceed it. if msg.Args then msg.text = LOCF(msg.text, unpack(msg.Args)) end - -- Notify routing: the Notify subsystem tags messages with `to='notify'` - -- and owns the display decision. Only fall through to rendering a chat - -- line if Notify declines (returns false). + -- Notify owns the display decision for `to='notify'`; only fall through + -- to rendering a chat line if it returns false. if msg.to == ChatModel.RecipientNotify and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then return end @@ -356,14 +295,10 @@ function OnReceive(sender, msg) return end - -- Observer-flag consistency: `msg.Observer` is set on the send side - -- only when the sender's `GetFocusArmy()` is -1, which means they - -- have no army entry in the session. A peer that ships - -- `Observer = true` while also resolving to a real army is malformed - -- — drop the message entirely. The two states are mutually exclusive - -- on a well-formed sender, so the inconsistency implies tampering or - -- a bug; "fixing" it by stripping the flag would let manipulated - -- traffic still render, just under a different label. + -- `msg.Observer` is only set when the sender has no army entry. A peer + -- claiming Observer while resolving to a real army is malformed — drop + -- the message entirely rather than stripping the flag, which would let + -- manipulated traffic render under a different label. if msg.Observer and armyData then return end local to = msg.to @@ -393,17 +328,11 @@ function OnReceive(sender, msg) end --- Handler for the `Sync.ChatMessages` category, populated by the sim-side ---- `SendChatMessage` callback. Each entry in `msgs` is a message table the ---- sim has stamped with a trusted `From` army index. ---- ---- In live play the same message also arrives via `SessionSendChatMessage` ---- (handled by `OnReceive`) — whichever path lands first seeds the entry's ---- `Id`, and this handler skips any message whose id is already there. ---- Sim-originated messages have no `SessionSendChatMessage` counterpart, so ---- they flow straight through. ---- ---- Replays are the case where `SessionSendChatMessage` never fires: this ---- handler is the *only* source of chat in a replay. +--- `SendChatMessage` callback. In live play the same message also arrives +--- via `SessionSendChatMessage` (`OnReceive`); whichever path lands first +--- seeds the entry's `Id` and this handler skips duplicates by id. In a +--- replay this is the *only* source of chat — `SessionSendChatMessage` +--- never fires. ---@param msgs ChatPayload[] function OnSyncChatMessages(msgs) if type(msgs) ~= 'table' then return end @@ -427,14 +356,9 @@ end ------------------------------------------------------------------------------- -- Echoing (local synthesis for outgoing privates) -- --- The engine only routes a private message to its target, so the sender --- would otherwise never see their own whispers. `OnEcho` synthesises a --- "To :" line from the send-side data directly — no round-trip --- through a fake `msg.echo` field, no pretending the message was received --- from the network. - ---- Appends a locally-echoed line for a private message the local player ---- just sent. Called only from `Send`; not registered with gamemain. +-- The engine doesn't bounce private messages back to the sender, so we +-- synthesise a "To :" line locally instead. + ---@param senderData table # local player's army data ---@param recipientData table # target of the private message ---@param msg ChatPayload # outgoing message (uses `text`, `to`, `camera`) @@ -456,10 +380,8 @@ end --- Sends a chat message to the current recipient. Dispatches slash commands, --- drops all-whitespace bodies, short-circuits taunts, then routes the ---- payload to the engine based on the recipient and whether the local player ---- is observing. When `attachCamera` is true, snapshots the current world ---- camera and ships it on the message so recipients can jump to the view ---- by clicking the line. +--- payload to the engine. When `attachCamera` is true, snapshots the current +--- world camera so recipients can click the line to jump to the view. ---@param text string ---@param attachCamera? boolean function Send(text, attachCamera) @@ -474,12 +396,9 @@ function Send(text, attachCamera) AppendLocalSystemMessage(err) return end - -- Lone '/' or a slash-prefixed body with no matching command falls - -- through to the normal send path. + -- Lone '/' or unknown command falls through to the normal send path. end - -- Drop all-whitespace bodies. `string.find` with `%s+` returns the first - -- run of whitespace; if it spans the entire text there's nothing to send. local wsStart, wsEnd = string.find(text, "%s+") if wsStart == 1 and wsEnd == string.len(text) then return end @@ -496,34 +415,26 @@ function Send(text, attachCamera) text = text, } - -- Observers can't target a private recipient; silently drop (old - -- chat.lua did the same — the command simply had no effect). Bail - -- before stamping an id or firing sim callbacks so we don't leak - -- a message the engine would have refused to deliver anyway. + -- Observers can't target a private recipient. Bail before stamping an id + -- or firing sim callbacks for a message the engine would refuse anyway. if focusArmy == -1 and type(recipient) == 'number' then return end -- Flag observer broadcasts so receivers render "to observers:". Both - -- delivery paths (engine-routed and sim-routed) need to see this bit, - -- so set it before either fires. + -- delivery paths need to see this, so set it before either fires. if focusArmy == -1 then msg.Observer = true end if attachCamera then msg.camera = GetCamera('WorldCamera'):SaveSettings() end - -- Stamp an id used by `OnSyncChatMessages` to dedupe between the live - -- `SessionSendChatMessage` path and the sim-routed `Sync.ChatMessages` - -- path. The `seen` set spans the whole history, so the id must survive - -- table-address recycling — the tick suffix means a collision needs - -- both a recycled address *and* the same tick. + -- Stamp an id for `OnSyncChatMessages` to dedupe the live and sim-routed + -- delivery paths. Tick suffix guards against table-address recycling. msg.Id = string.format("%d %s", GameTick(), tostring(msg)) -- Replay-parser backwards compat: external replay tools scrape chat out - -- of recorded `GiveResourcesToPlayer` callback args. We fire one zero- - -- resource callback per outgoing message so they keep working, regardless - -- of who the real recipient is. `From`/`To` are both the focus army so - -- the sim-side ally/self-transfer guard short-circuits without doing - -- anything. Observers skip it — no army to ship. + -- of recorded `GiveResourcesToPlayer` callback args. Fire one zero- + -- resource callback per outgoing message so they keep working. Observers + -- skip it — no army to ship. if focusArmy ~= -1 then local senderData = GetArmyData(focusArmy) SimCallback({ @@ -536,11 +447,9 @@ function Send(text, attachCamera) }, false) end - -- Sim-routed path: hand the message to the sim, which validates and - -- re-broadcasts it via `Sync.ChatMessages` to every connected UI. In live - -- play this runs alongside `SessionSendChatMessage` and our id-based - -- dedupe keeps it from double-posting; in replays it is the *only* path - -- the viewer sees, which is exactly what we want. + -- Sim-routed path: the sim re-broadcasts via `Sync.ChatMessages`. In + -- live play it runs alongside `SessionSendChatMessage` and id-based + -- dedupe prevents double-posting; in replays it is the *only* path. SimCallback({ Func = 'SendChatMessage', Args = { Msg = msg } }, false) if recipient == ChatModel.RecipientAllies then @@ -548,8 +457,7 @@ function Send(text, attachCamera) elseif type(recipient) == 'number' then SessionSendChatMessage(FindClients(recipient), msg) - -- The engine does not bounce private messages back to the sender; - -- locally synthesise a line so the sender sees what they just wrote. + -- Engine doesn't bounce private messages back to the sender. local senderData = GetArmyData(focusArmy) local targetData = GetArmyData(recipient) if senderData and targetData then @@ -568,9 +476,9 @@ end -- Engine hotkey entry point --- Opens the chat window with the recipient forced to `allies` or `all` ---- based on the `send_type` preference and the Shift modifier. The engine ---- calls this via a top-level `ActivateChat` shim in `gamemain.lua` when ---- the user presses Enter outside the edit box. +--- based on `send_type` and the Shift modifier. The engine calls this via +--- a top-level `ActivateChat` shim in `gamemain.lua` when the user presses +--- Enter outside the edit box. --- --- Truth table (`send_type` reads as "default to allies"): --- * `send_type=false`, no Shift → `all` @@ -578,22 +486,16 @@ end --- * `send_type=true`, no Shift → `allies` --- * `send_type=true`, Shift → `all` --- ---- If the current recipient is already a specific army ID (mid-private ---- message), it is left alone — Shift only switches between the two ---- broadcast channels. +--- A specific-army recipient (mid-private) is left alone. ---@param modifiers? table # engine-supplied modifier state ({Shift, Ctrl, ...}) function ActivateChat(modifiers) local model = ChatModel.GetSingleton() local wasVisible = model.WindowVisible() - -- Toggle first. On open this runs `ApplyDefaultRecipient`, which picks - -- a recipient from `send_type` alone — it doesn't see modifiers. On - -- close it just flips visibility and we leave the recipient alone. import("/lua/ui/game/chat/ChatInterface.lua").Toggle() - -- Layer the Shift modifier on top of the default. Must happen AFTER - -- the toggle above — writing to `Recipient` before `ToggleWindow` runs - -- gets clobbered by its own `ApplyDefaultRecipient` call. + -- Layer Shift on top of the default. Must run AFTER the toggle — + -- writing `Recipient` first gets clobbered by `ApplyDefaultRecipient`. if not wasVisible and type(model.Recipient()) ~= 'number' then local sendType = ChatConfigModel.GetOptions().send_type or false local shift = modifiers and modifiers.Shift or false @@ -608,42 +510,28 @@ end ------------------------------------------------------------------------------- -- Lifecycle ---- One-shot initialisation: registers the receive handler with gamemain and ---- populates the slash-command registry with the built-ins. Called from ---- `gamemain.lua` during UI setup — kept out of module-load so mods can hook ---- the controller (replacing `Init`, `OnReceive`, or `RegisterBuiltinCommands`) ---- before any wiring happens. ---- ---- `RegisterChatFunc` keys by identifier and overwrites, so calling `Init` ---- more than once simply replaces the previous handler — no duplicate ---- dispatches, safe under hot reload. +--- Registers the receive handler with gamemain, populates the slash-command +--- registry, and mounts the chat tree. Called from `gamemain.lua` during UI +--- setup — kept out of module-load so mods can override the controller +--- before any wiring happens. Idempotent: `RegisterChatFunc` overwrites, +--- so re-running `Init` just rebinds the handlers. function Init() import("/lua/ui/game/gamemain.lua").RegisterChatFunc(OnReceive, 'Chat') AddOnSyncHashedCallback(OnSyncChatMessages, 'ChatMessages', 'Chat') RegisterBuiltinCommands() - -- Build the chat tree eagerly so the sibling feed view is mounted in - -- time to surface messages that arrive before the user has ever opened - -- the dialog. The window itself starts hidden (`model.WindowVisible` - -- defaults to false), so nothing renders until visibility flips — but - -- the feed's history observer is now subscribed and ready. + -- Build the chat tree eagerly so the sibling feed is mounted in time to + -- surface messages that arrive before the user opens the dialog. import("/lua/ui/game/chat/ChatInterface.lua").EnsureInstance() end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: re-runs `Init()` on the freshly imported module so ---- `gamemain.chatFuncs['Chat']` rebinds to the NEW `OnReceive` closure and ---- `RegisterBuiltinCommands` repopulates the registry. Without this, edits ---- to this file leave the old function registered and the new command set ---- empty — sending continues to "work" but receives keep flowing through ---- stale code, and slash commands stop dispatching. ---- ---- The short delay + re-import gives any cascading reloads (command files, ---- ChatModel, etc.) time to settle before we wire things up — calling ---- `newModule.Init()` synchronously can capture stale references partway ---- through the reload pipeline. +--- Hot-reload hook: re-runs `Init()` on the new module so the gamemain +--- registration rebinds to the fresh `OnReceive` closure and the registry +--- repopulates. Without this, edits leave stale code receiving messages. +--- The fork-with-delay lets cascading reloads settle first. function __moduleinfo.OnReload(newModule) ForkThread(function() WaitFrames(1) @@ -651,6 +539,8 @@ function __moduleinfo.OnReload(newModule) end) end +--- Hot-reload hook: re-imports this module after a couple of frames so +--- cascading reloads can settle first. function __moduleinfo.OnDirty() ForkThread( function() diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 29254815044..787b3922d6c 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -20,15 +20,8 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false ---- Cap on the command-history ring (newest at the tail). Older entries ---- are dropped when the buffer overflows. 32 is comfortably more than the ---- handful a typical session generates while staying small enough that ---- linear walks stay free. local MaxCommandHistorySize = 32 ------------------------------------------------------------------------------- @@ -36,6 +29,7 @@ local MaxCommandHistorySize = 32 -- box. Pressing Enter dispatches the text to the controller. Clicking the -- chat-bubble button or the label opens the recipient picker (ChatListInterface). +--- Chat input area: edit box, recipient label, recipient picker, camera toggle, command hint, recall ring. ---@class UIChatEditInterface : Group ---@field Trash TrashBag # owns every derived subscription-LazyVar ---@field ChatBubble Button @@ -57,9 +51,6 @@ ChatEditInterface = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent, "ChatEditInterface") - -- Single trash bag for everything we allocate that needs explicit - -- destruction — currently just the derived observer LazyVars. - -- Emptied in `OnDestroy`. self.Trash = TrashBag() self.Completion = nil @@ -79,16 +70,12 @@ ChatEditInterface = ClassUI(Group) { self.RecipientLabel = UIUtil.CreateText(self, "To All:", 14, 'Arial') self.RecipientLabel:SetDropShadow(true) - -- Clicking the label also opens the recipient picker. self.RecipientLabel.HandleEvent = function(_, event) if event.Type == 'ButtonPress' then self:ToggleList() end end - -- Camera-attach toggle. When checked, the next Send call snapshots - -- the world camera and ships it on the message; recipients can click - -- the resulting cam-icon on their chat line to jump to the view. self.CamCheckbox = Checkbox(self, UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_up.dds'), UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_down.dds'), @@ -100,11 +87,9 @@ ChatEditInterface = ClassUI(Group) { self.EditBox = Edit(self) - -- Placeholder bounds so that `SetupEditStd` below, which internally - -- calls `SetFont` and reads the control's Left/Right, can evaluate - -- the layout without tripping the default circular Left/Right/Width - -- chain set up by `Control.ResetLayout`. `__post_init` replaces these - -- with the real layout. + -- `SetupEditStd` below reads the control's bounds before + -- `__post_init` runs, so seed placeholder values to avoid tripping + -- the default circular Left/Right/Width chain. Layouter(self.EditBox) :Left(0) :Top(0) @@ -119,11 +104,8 @@ ChatEditInterface = ClassUI(Group) { self.EditBox:ShowBackground(false) self.EditBox:SetText('') - -- Pressing Enter on an empty edit box closes the window — matches - -- the legacy `chat.lua` shortcut where Enter serves as both "send" - -- and "dismiss" depending on whether there's anything to send. - -- Successful sends are appended to the command-history ring so - -- Up / Down can recall them when the hint isn't open. + -- Enter on an empty box closes the window; otherwise sends and + -- pushes onto the command-history ring for Up/Down recall. self.EditBox.OnEnterPressed = function(_, text) ChatController.NotifyActivity() if text and text ~= '' then @@ -135,9 +117,8 @@ ChatEditInterface = ClassUI(Group) { self:CloseCommandHint() end - -- Drive the command-hint popup from the edit-box contents, and drop - -- any in-flight Tab-completion cycle whenever the text changes from - -- something other than our own `ApplyCompletion` call. + -- Drop any in-flight Tab-completion cycle whenever the text changes + -- from something other than our own `ApplyCompletion`. self.EditBox.OnTextChanged = function(_, newText, _) ChatController.NotifyActivity() self:RefreshCommandHint(newText or '') @@ -146,11 +127,8 @@ ChatEditInterface = ClassUI(Group) { end end - -- Tab runs completion (commands when text starts with '/' and the - -- caret is in the first token, player nicknames otherwise). Repeat - -- presses cycle through candidates; any other keystroke resets the - -- cycle via `OnTextChanged`. `OnCharPressed` fires before insertion, - -- so the `>=` beep catches the keystroke the cap is about to reject. + -- `OnCharPressed` fires before insertion, so `>=` catches the + -- keystroke the cap is about to reject. self.EditBox.OnCharPressed = function(edit, charcode) if charcode == UIUtil.VK_TAB then self:HandleTabCompletion() @@ -175,21 +153,14 @@ ChatEditInterface = ClassUI(Group) { return true end - -- Page Up / Page Down scroll the chat feed. Three modes per key: - -- * no modifier → 10 rows (page-ish) - -- * Shift → 1 row (fine grain) - -- * Ctrl → jump to the extreme; `Ctrl+PgDn` while already - -- at the bottom collapses the window. - -- Matches the legacy chat.lua page-key binding so muscle memory - -- carries over, with `Ctrl` covering the jump-to-extreme case that - -- Home / End would normally serve — those are consumed by the Edit - -- control for caret navigation before they reach this handler, so - -- `OnNonTextKeyPressed` never sees them. - -- Up / Down cycle the command-hint selection while the hint is open. - -- Lazy import of ChatInterface avoids the import cycle: ChatInterface - -- imports this module at load time, so the reverse edge has to defer. - ---@param keycode number # OS-level VK_* code; compare against `UIUtil.VK_*` - ---@param event KeyEvent # full input-event payload; modifiers live at `event.Modifiers` + -- Page Up/Down: no mod = 10 rows, Shift = 1 row, Ctrl = jump to + -- the extreme (Ctrl+PgDn at bottom collapses the window). Home/End + -- are consumed by Edit for caret nav before they reach here. + -- Up/Down cycle the command-hint when open, otherwise walk + -- command history. Lazy import of ChatInterface breaks an import + -- cycle. + ---@param keycode number # OS-level VK_* code + ---@param event KeyEvent self.EditBox.OnNonTextKeyPressed = function(_, keycode, event) ChatController.NotifyActivity() local chatInterface = import("/lua/ui/game/chat/ChatInterface.lua") @@ -209,8 +180,6 @@ ChatEditInterface = ClassUI(Group) { chatInterface.ScrollLines(step) end elseif keycode == UIUtil.VK_UP then - -- Hint open → cycle the selection; closed → walk back - -- through the command-history ring, oldest first. if self.ChatCommandHintInterface then self.ChatCommandHintInterface:SelectNext() else @@ -225,9 +194,6 @@ ChatEditInterface = ClassUI(Group) { end end - -- Keep the label in sync with the model. `LazyVarDerive` gives us a - -- fresh per-subscriber LazyVar so we don't stomp any other observer - -- of `model.Recipient` (see the chat CLAUDE.md for the pattern). local model = ChatModel.GetSingleton() self.RecipientObserver = self.Trash:Add(LazyVarDerive(model.Recipient, function(lv) self:RefreshRecipient(lv()) @@ -247,26 +213,21 @@ ChatEditInterface = ClassUI(Group) { :AtVerticalCenterIn(self) :End() - -- Camera-attach toggle pinned to the right edge so the edit box can - -- claim the remaining width. Layouter(self.CamCheckbox) :AtRightIn(self, 12) :AtVerticalCenterIn(self, -2) :End() - -- Width must be re-derived from the now-anchored Left/Right. - -- Without this it stays pinned at the literal `:Width(200)` placeholder - -- set in `__init` (needed there so `SetupEditStd` can read the layout - -- without tripping the default circular `Width = Right - Left` chain), - -- and the visible typing area gets capped at 200 px regardless of - -- where `Right` actually anchors — so the text box visibly fails to - -- extend out toward the camera checkbox at higher UI scales or wider - -- windows. + -- `ResetWidth` drops the `:Width(200)` placeholder set in `__init` + -- (needed there so `SetupEditStd` could read the layout without + -- tripping the default circular Width chain). Without this reset, + -- the typing area stays capped at 200 px regardless of where Right + -- anchors. Layouter(self.EditBox) :AnchorToRight(self.RecipientLabel, 4) :AnchorToLeft(self.CamCheckbox, 4) :AtVerticalCenterIn(self) - :ResetWidth() -- drop the `:Width(200)` from `__init` + :ResetWidth() :Height(function() return self.EditBox:GetFontHeight() end) :End() @@ -278,11 +239,9 @@ ChatEditInterface = ClassUI(Group) { end end, - --- Entry point for the Tab key. When the command hint is open, Tab - --- commits the currently-selected command into the edit box (mirroring - --- a click on the hint row). Otherwise it runs the in-box completion - --- cycle for nicknames. Plays the error cue when there is nothing to - --- complete so the user isn't left wondering whether the key was handled. + --- Tab key. With the hint open, commits the selected command; + --- otherwise runs the in-box nickname completion cycle. Plays the + --- error cue when there is nothing to complete. ---@param self UIChatEditInterface HandleTabCompletion = function(self) if self.ChatCommandHintInterface then @@ -313,10 +272,9 @@ ChatEditInterface = ClassUI(Group) { self:ApplyCompletion() end, - --- Writes the current candidate into the edit box at the recorded anchor, - --- overwriting the consumed word. `SuppressCompletionReset` guards the - --- `OnTextChanged` branch that would otherwise clear the cycle state as - --- a side-effect of our own edit. + --- Writes the current candidate at the recorded anchor. + --- `SuppressCompletionReset` keeps the `OnTextChanged` branch from + --- clearing the cycle state as a side-effect of our own edit. ---@param self UIChatEditInterface ApplyCompletion = function(self) if not self.Completion then return end @@ -338,17 +296,16 @@ ChatEditInterface = ClassUI(Group) { self.EditBox:SetCaretPosition(c.Anchor + replacementLen) self.SuppressCompletionReset = false - -- Advance the consumed span to match what we just wrote so the next - -- cycle overwrites exactly this candidate, not the original word. + -- Advance the consumed span so the next cycle overwrites this + -- candidate, not the original word. c.Consume = replacementLen end, --------------------------------------------------------------------------- -- Command history recall - --- Appends a successfully-sent message to the command-history ring and - --- resets any active recall walk so the next Up press starts at the - --- newest entry. Trims the ring to `MaxCommandHistorySize`. + --- Pushes a sent message onto the recall ring, dropping the oldest if the + --- ring is full. Resets any in-progress recall walk. ---@param self UIChatEditInterface ---@param text string PushHistory = function(self, text) @@ -359,9 +316,8 @@ ChatEditInterface = ClassUI(Group) { self.RecallEntry = nil end, - --- Walks back toward older entries. Empty history is a no-op; the first - --- press lands on the newest entry, subsequent presses move one step - --- earlier each time and clamp at the oldest. + --- Walks toward older entries; first press lands on the newest, then + --- moves one step earlier per press and clamps at the oldest. ---@param self UIChatEditInterface RecallPrevious = function(self) local count = table.getn(self.CommandHistory) @@ -374,12 +330,8 @@ ChatEditInterface = ClassUI(Group) { self:ApplyRecall() end, - --- Walks forward toward newer entries. After the newest, `RecallEntry` - --- resets to nil so the next Down press blanks the edit (matching the - --- legacy "step past the end clears the line" feel). Empty history - --- with no active recall is a no-op; with no active recall but a - --- non-empty history, blanks the edit so users have a quick "wipe what - --- I'm typing" gesture. + --- Walks toward newer entries. Past the newest, `RecallEntry` resets + --- to nil and the next Down blanks the edit ("wipe what I'm typing"). ---@param self UIChatEditInterface RecallNext = function(self) local count = table.getn(self.CommandHistory) @@ -396,9 +348,8 @@ ChatEditInterface = ClassUI(Group) { end, --- Writes the entry at `RecallEntry` into the edit box and parks the - --- caret at the end. No-op if `RecallEntry` doesn't reference a real - --- entry — guards against being called between a destructive history - --- mutation and the next nav keystroke. + --- caret at the end. Guarded against `RecallEntry` going stale between + --- a destructive history mutation and the next nav keystroke. ---@param self UIChatEditInterface ApplyRecall = function(self) local entry = self.CommandHistory[self.RecallEntry or 0] @@ -407,11 +358,8 @@ ChatEditInterface = ClassUI(Group) { self.EditBox:SetCaretPosition(STR_Utf8Len(entry)) end, - --- Shows or hides the command hint based on the current edit-box text. - --- Only opens when the text transitions to exactly `/` — so closing the - --- hint via Escape leaves it closed while the user keeps typing past the - --- slash. An already-open hint keeps refreshing as long as text starts - --- with `/`. + --- Only opens when the text transitions to exactly `/` — so closing + --- the hint via Escape leaves it closed while the user keeps typing. ---@param self UIChatEditInterface ---@param text string RefreshCommandHint = function(self, text) @@ -427,13 +375,12 @@ ChatEditInterface = ClassUI(Group) { end end, - --- Creates the hint popup and anchors it directly above the edit box. + --- Mounts the slash-command hint popup above the edit box. No-op if open. ---@param self UIChatEditInterface OpenCommandHint = function(self) if self.ChatCommandHintInterface then return end - -- Ensure the built-ins exist before the hint queries the registry; - -- otherwise we'd only see the footer fallback on the first open. + -- Ensure the built-ins exist before the hint queries the registry. ChatController.RegisterBuiltinCommands() local hint = ChatCommandHintInterface(self, self.EditBox) @@ -446,8 +393,7 @@ ChatEditInterface = ClassUI(Group) { end) end, - --- Tears down the hint popup if it exists. Called when the user sends a - --- message, clears the prefix, or otherwise leaves command-entry mode. + --- Tears down the slash-command hint popup if it is open. ---@param self UIChatEditInterface CloseCommandHint = function(self) if not self.ChatCommandHintInterface then return end @@ -456,7 +402,7 @@ ChatEditInterface = ClassUI(Group) { hint:Destroy() end, - --- Opens the recipient picker popup, or closes it if it is already open. + --- Opens or closes the recipient picker popup, returning focus to the edit box. ---@param self UIChatEditInterface ToggleList = function(self) if self.ChatListInterface then @@ -467,8 +413,6 @@ ChatEditInterface = ClassUI(Group) { else local list = ChatListInterface(self) self.ChatListInterface = list - -- Position the popup above-left of the chat-bubble button. - -- Depth is handled by the list itself (see ChatListInterface.__init). LayoutHelpers.Above(list, self.ChatBubble, 15) LayoutHelpers.AtLeftIn(list, self.ChatBubble, 15) list:SetOnClosed(function() @@ -478,10 +422,7 @@ ChatEditInterface = ClassUI(Group) { end end, - --- Updates the label from the current recipient value. Strings come - --- from `ChatUtils.ToStrings` so the label respects the viewer's locale - --- and stays in lockstep with the chat-line prefixes rendered by - --- `ChatController`. + --- Updates the recipient label to match the current send target. ---@param self UIChatEditInterface ---@param recipient UIChatRecipient RefreshRecipient = function(self, recipient) @@ -496,20 +437,19 @@ ChatEditInterface = ClassUI(Group) { end end, - --- Moves keyboard focus into the edit box. + --- Gives keyboard focus to the edit box. ---@param self UIChatEditInterface AcquireFocus = function(self) self.EditBox:AcquireFocus() end, - --- Moves keyboard focus out of the edit box. + --- Releases keyboard focus from the edit box. ---@param self UIChatEditInterface AbandonFocus = function(self) self.EditBox:AbandonFocus() end, - --- Empties our trash bag so every derived observer we allocated is - --- destroyed — no `OnDirty` can fire into a torn-down `self`. + --- Destroys derived observers so dangling OnDirty callbacks don't fire into a dead self. ---@param self UIChatEditInterface OnDestroy = function(self) self.Trash:Destroy() diff --git a/lua/ui/game/chat/ChatFactionBadge.lua b/lua/ui/game/chat/ChatFactionBadge.lua index 9cbbbf48b4c..4ad91936ee3 100644 --- a/lua/ui/game/chat/ChatFactionBadge.lua +++ b/lua/ui/game/chat/ChatFactionBadge.lua @@ -8,13 +8,12 @@ local Bitmap = import("/lua/maui/bitmap.lua").Bitmap local ObserverIcon = '/widgets/faction-icons-alpha_bmp/observer_ico.dds' ------------------------------------------------------------------------------- --- A small badge showing a player's faction icon over their team colour, used --- to identify players in the chat recipient picker and anywhere else in the --- chat UI that wants to surface who a message is from or going to. Matches --- the visual style used by the score panel (see `score.lua`). Consumers can --- override the default 14x14 size via `LayoutHelpers.SetDimensions` or a --- Layouter chain, and update the contents via `SetFaction` / `SetColor`. +-- Faction icon over team-colour tile, used in the recipient picker and +-- elsewhere in the chat UI. Default 14x14; consumers can override via +-- `LayoutHelpers.SetDimensions` or Layouter and update via +-- `SetFaction` / `SetColor`. +--- Faction icon over a team-colour tile; reused in the recipient picker and on every chat row. ---@class ChatFactionBadge : Group ---@field Color Bitmap # team-colour tile behind the icon ---@field Icon Bitmap # faction icon on top of the colour tile @@ -35,7 +34,6 @@ ChatFactionBadge = ClassUI(Group) { self.Icon:DisableHitTest() self:SetFaction(factionIndex) - -- Default square size; consumers can override via Layouter / SetDimensions. LayoutHelpers.SetDimensions(self, 14, 14) end, @@ -45,13 +43,11 @@ ChatFactionBadge = ClassUI(Group) { LayoutHelpers.FillParent(self.Color, self) LayoutHelpers.FillParent(self.Icon, self) - -- Icon renders on top of the colour tile; the tile shows through the - -- icon's transparent pixels. LayoutHelpers.DepthOverParent(self.Color, self, 1) LayoutHelpers.DepthOverParent(self.Icon, self, 2) end, - --- Updates the faction icon. Pass `nil` to show the observer icon. + --- `nil` factionIndex shows the observer icon. ---@param self ChatFactionBadge ---@param factionIndex? number 0-based faction index SetFaction = function(self, factionIndex) @@ -62,7 +58,7 @@ ChatFactionBadge = ClassUI(Group) { end end, - --- Updates the team colour tile. + --- Updates the team-colour tile behind the icon. ---@param self ChatFactionBadge ---@param color string ARGB hex, e.g. 'ffff4242' SetColor = function(self, color) diff --git a/lua/ui/game/chat/ChatFeedInterface.lua b/lua/ui/game/chat/ChatFeedInterface.lua index aa7b886126c..be776299215 100644 --- a/lua/ui/game/chat/ChatFeedInterface.lua +++ b/lua/ui/game/chat/ChatFeedInterface.lua @@ -14,47 +14,33 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false ---- Cap on how many feed rows are visible at once. Older rows above this ---- are dropped immediately when a new row pushes in — feed mode is for ---- glanceable awareness, not full scrollback. local MaxFeedRows = 8 ---- Length of the alpha fade-out near the end of a row's lifetime, in ---- seconds. Capped to half the configured `fade_time` so very short ---- timeouts still get a visible fade rather than a hard pop. +--- Capped to half `fade_time` so very short timeouts still fade rather +--- than pop. local FadeOutDuration = 2 ---- Base alpha (0..1) of the per-row readability strip when the ---- `feed_background` option is on. Multiplied per-frame by `win_alpha` ---- and the row's fade progress, so the BG dims with the window opacity ---- and disappears together with the line as the row ages out. +--- Base alpha for the readability strip when `feed_background` is on; +--- multiplied per-frame by `win_alpha` and the row's fade progress. local FeedBackgroundAlpha = 0.5 ------------------------------------------------------------------------------- --- A separate "feed" view of the chat history that surfaces messages while --- the main chat window is hidden. Mounted as a sibling of the chat window --- (so the window's `Show`/`Hide` cascade can't reach us), but pinned to the --- window's line area via LazyVar bindings — drag/resize the chat window and --- the feed tracks for free. --- --- The feed is fully model-driven: --- * `ChatModel.History` — incoming entries are appended as feed rows. --- * `ChatModel.WindowVisible` — feed visible iff window hidden + we have rows. --- Each row carries its own age timer ticked by `OnFrame`; rows past --- `fade_time` destroy themselves. Pin (chrome-side toggle that suspends --- the per-row fade) is the remaining piece that hasn't been wired yet. +-- Feed view of the chat history shown while the main chat window is hidden. +-- Mounted as a sibling of the chat window (so its `Show`/`Hide` cascade +-- can't reach us), but pinned to the window's line area via LazyVars. +-- Each row carries its own age timer; rows past `fade_time` destroy +-- themselves. +--- One feed entry: a wrapped line, its readability strip, the source entry, and an independent age timer. ---@class UIChatFeedRow ---@field Line UIChatLineInterface # exactly one wrapped chunk: header on the entry's first row, continuation on the rest ---@field BG Bitmap # solid-colour readability strip behind `Line`; only paints when `feed_background` is on ---@field Entry UIChatEntry # the source message this line belongs to ---@field Time number # seconds since this row was added; each row ages and expires independently +--- Sibling-of-the-window feed shown while the chat window is hidden; lines fade and self-destruct on age. ---@class UIChatFeedInterface : Group ---@field Trash TrashBag # owns every subscription-LazyVar we create ---@field Window UIChatInterface | nil # chat window we anchor to; nil for standalone debug @@ -78,17 +64,14 @@ ChatFeedInterface = ClassUI(Group) { local model = ChatModel.GetSingleton() - -- Seed the high-water mark to whatever's already in history so the - -- initial fire of `HistoryObserver` doesn't replay every existing - -- entry as a fresh feed line. + -- Seed so the initial `HistoryObserver` fire doesn't replay every + -- existing entry as a fresh feed line. self.LastHistoryLength = table.getn(model.History()) - -- Window visibility flips us in / out of feed mode. Opening the - -- window throws away every active feed row — anything the user - -- wanted to read is now in the main view, and a stale fade - -- countdown lingering across an open/close cycle would just clutter - -- the feed with content the user already saw. `UpdateVisibility` - -- then hides us (rows == 0) and stops the frame ticker. + -- Opening the window discards active feed rows: anything worth + -- reading is now in the main view, and a stale fade countdown + -- across an open/close cycle would clutter content the user + -- already saw. self.WindowVisibleObserver = self.Trash:Add( LazyVarDerive(model.WindowVisible, function(lv) if lv() then @@ -98,10 +81,8 @@ ChatFeedInterface = ClassUI(Group) { end) ) - -- New entries → push to the feed only while the window is hidden. - -- Entries received with the window open are the user's to read in - -- the main view; we still bump `LastHistoryLength` either way so - -- they aren't replayed when the window later closes. + -- Push to feed only while the window is hidden; bump + -- `LastHistoryLength` either way so we don't replay later. self.HistoryObserver = self.Trash:Add( LazyVarDerive(model.History, function(lv) self:OnHistoryChanged(lv()) @@ -114,10 +95,8 @@ ChatFeedInterface = ClassUI(Group) { ---@param window UIChatInterface | nil __post_init = function(self, parent, window) if self.Window then - -- One-way LazyVar bind to the chat window's line area. Drag / - -- resize the chat window with the feed visible (during a - -- transition, etc.) and the feed tracks for free; no observer - -- glue, no model write — the dependency graph does it. + -- One-way LazyVar bind to the chat window's line area; drag / + -- resize tracks for free through the dependency graph. ---@diagnostic disable-next-line: param-type-mismatch Layouter(self) :Left(self.Window.ChatLinesInterface.Left) @@ -126,8 +105,7 @@ ChatFeedInterface = ClassUI(Group) { :Bottom(self.Window.ChatLinesInterface.Bottom) :End() else - -- Standalone debug fallback: anchor near the bottom-left of the - -- frame so `Toggle()` from a dev hotkey still shows somewhere. + -- Standalone debug fallback for dev-hotkey `Toggle()`. Layouter(self) :AtLeftBottomIn(parent, 8, 60) :Width(420) @@ -135,8 +113,6 @@ ChatFeedInterface = ClassUI(Group) { :End() end - -- Start hidden — `UpdateVisibility` reveals us when both conditions - -- (window hidden + rows > 0) are met. self:Hide() self:UpdateVisibility() @@ -152,12 +128,7 @@ ChatFeedInterface = ClassUI(Group) { -- History handling --------------------------------------------------------------------------- - --- Called whenever `ChatModel.History` fires dirty. Pushes entries that - --- arrived since the last call onto the feed — but only while the chat - --- window is hidden. Entries received while the window is open are the - --- user's to read in the main view, not surfaced again on next close. - --- We still bump `LastHistoryLength` either way so we never replay - --- already-seen entries when the window later closes. + --- Reacts to history mutations: feeds in new entries while the chat window is hidden. ---@param self UIChatFeedInterface ---@param history UIChatEntry[] OnHistoryChanged = function(self, history) @@ -170,18 +141,15 @@ ChatFeedInterface = ClassUI(Group) { self.LastHistoryLength = newCount end, - --- Appends one feed row per wrapped chunk in `entry`. Each row carries - --- its own `Time`, so capping and expiry act on individual lines - --- rather than entry-blocks — when the cap kicks in mid-stream, only - --- the single oldest row drops out instead of the entire block of - --- chunks belonging to one wrapped entry. + --- Appends one feed row per wrapped chunk. Per-row `Time` means + --- capping drops only the single oldest row, not an entry's whole + --- block of continuations. --- - --- We force the wrap before reading `entry.WrappedText`. Both views - --- observe the same `model.History` LazyVar, but `used_by` iteration - --- order is unspecified — if we fire before the chat-lines observer - --- the cache is empty and we'd degenerate to a single-line fallback. - --- We borrow the chat panel's measure-line because it shares our row - --- width exactly (LazyVar bind), so the wrap is valid here. + --- Forces the wrap before reading `entry.WrappedText` because both + --- views observe `model.History` and `used_by` iteration order is + --- unspecified — if we fire before the chat-lines observer the cache + --- is empty. We borrow the chat panel's measure-line because it + --- shares our row width by LazyVar bind. ---@param self UIChatFeedInterface ---@param entry UIChatEntry AppendRow = function(self, entry) @@ -199,8 +167,6 @@ ChatFeedInterface = ClassUI(Group) { local fontSize = ChatConfigModel.GetOptions().font_size or 14 for i, chunk in ipairs(wrapped) do - -- Per-chunk cap: a wrapped message pushes one row in for one - -- row out, keeping the visible total at exactly `MaxFeedRows`. if table.getn(self.Rows) >= MaxFeedRows then self:RemoveOldest() end @@ -212,20 +178,15 @@ ChatFeedInterface = ClassUI(Group) { else line:SetContinuation(entry, chunk) end - -- After the header/continuation pass, because `SetHeader` toggles - -- `CamIcon:EnableHitTest()` based on whether the entry has a - -- camera/location attachment — calling `DisableInteraction` last - -- guarantees nothing on the row can swallow a click or wheel - -- event meant for the worldview underneath. + -- `SetHeader` calls `EnableHitTest` on the cam icon when the + -- entry has a camera/location; disable hit-test last so + -- nothing on the row swallows clicks meant for worldview. line:DisableHitTest(true) line:SetAlpha(1.0, true) - -- Readability strip behind the row. Solid-black at full alpha; - -- per-frame `SetAlpha` modulates the actual opacity by the - -- window's `win_alpha`, the row's fade progress, and the - -- `feed_background` toggle (off → alpha 0). Lives on the feed - -- group (not the line) so we can drive its alpha independently - -- and skip the line's text/icon depth ordering. + -- Readability strip behind the row. Lives on the feed group + -- (not the line) so we can drive its alpha independently of + -- the line's text/icon depth ordering. local bg = Bitmap(self) bg:SetSolidColor('ff000000') bg:DisableHitTest() @@ -239,11 +200,9 @@ ChatFeedInterface = ClassUI(Group) { self:UpdateVisibility() end, - --- Pins each row from the bottom up. The bottom-most row anchors to - --- `AtBottomIn(self)`; every other row stacks `Above` the row that - --- comes after it in `Rows`. Because `AppendRow` inserts an entry's - --- chunks in reading order (header first, continuations after), the - --- header still sits at the top of its block and continuations below. + --- Pins each row from the bottom up. Header rows naturally end up at + --- the top of their wrapped block because AppendRow inserts in + --- reading order. ---@param self UIChatFeedInterface LayoutRows = function(self) local count = table.getn(self.Rows) @@ -265,11 +224,7 @@ ChatFeedInterface = ClassUI(Group) { end end, - --- Removes the single oldest row from the head of `Rows`. With each - --- row tracking its own `Time`, capping no longer cascades through a - --- wrapped entry's chunks — a header at the head of the queue gets - --- popped on its own, and its continuations stay until they age out - --- on their own timers. + --- Drops the oldest row to make room for a new one. ---@param self UIChatFeedInterface RemoveOldest = function(self) local oldest = self.Rows[1] @@ -280,8 +235,7 @@ ChatFeedInterface = ClassUI(Group) { end end, - --- Tears down every active row. Called when the user opens the chat - --- window (non-persist semantics) and from `OnDestroy`. + --- Destroys every active feed row. ---@param self UIChatFeedInterface ClearAll = function(self) for _, row in ipairs(self.Rows) do @@ -295,10 +249,8 @@ ChatFeedInterface = ClassUI(Group) { -- Visibility / lifecycle --------------------------------------------------------------------------- - --- Computes whether we should currently be on screen and ticking. - --- Feed visible iff: chat window is hidden AND we have at least one - --- active row. `SetNeedsFrameUpdate` toggles in lockstep so we don't - --- waste frame ticks while idle. + --- Visible iff window hidden AND we have at least one row. + --- `SetNeedsFrameUpdate` toggles in lockstep so we don't tick idle. ---@param self UIChatFeedInterface UpdateVisibility = function(self) local windowVisible = ChatModel.GetSingleton().WindowVisible() @@ -311,19 +263,12 @@ ChatFeedInterface = ClassUI(Group) { end end, - --- Per-frame timer pass. Walks each row, advances its `Time`, applies - --- alpha (per-row fade only for the line so the text stays crisp and - --- readable regardless of the window's opacity setting; window- - --- opacity × per-row fade × base intensity for the BG strip so the - --- backdrop dims with the user's preference), and destroys the row - --- once past `fade_time`. Each row ages independently — wrapped - --- entries arrive at the same instant so their chunks usually expire - --- together by virtue of starting from the same `Time = 0`, but the - --- cap or a future selective drop can take individual rows without - --- disturbing siblings. Re-evaluates visibility so the feed self- - --- hides when the last row expires. + --- Per-frame: ages each row, fades the line text (full per-row fade + --- only — text stays crisp regardless of `win_alpha`) and the BG + --- strip (modulated by `win_alpha` × fade × base intensity), and + --- destroys rows past `fade_time`. ---@param self UIChatFeedInterface - ---@param delta number # seconds since the last frame + ---@param delta number OnFrame = function(self, delta) local options = ChatConfigModel.GetOptions() local fadeTime = options.fade_time or 15 @@ -354,8 +299,7 @@ ChatFeedInterface = ClassUI(Group) { self:UpdateVisibility() end, - --- Empties our trash bag (destroying every derived observer) and - --- destroys any remaining feed rows. + --- Destroys every row plus the derived observers. ---@param self UIChatFeedInterface OnDestroy = function(self) self:ClearAll() @@ -366,8 +310,8 @@ ChatFeedInterface = ClassUI(Group) { ------------------------------------------------------------------------------- --#region Debugging ---- Owned and rebuilt by `ChatInterface`; touching the chat module after a ---- save here triggers the full chat-tree rebuild that picks up our changes. +--- Owned by `ChatInterface`; re-importing the chat module triggers the +--- full chat-tree rebuild. function __moduleinfo.OnDirty() import(__moduleinfo.name) end diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index e3f1742c345..ed16462a51d 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -18,16 +18,11 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false ---- Skin textures for the chat window frame. `SkinnableFile` returns a ---- callable that resolves the path against the current skin every time ---- it's read — so the border bitmaps automatically pick up the user's ---- skin choice when bound through MAUI's LazyVar machinery, instead of ---- being frozen at module-load time the way `UIFile` would freeze them. +--- Skin textures for the chat window frame. `SkinnableFile` resolves +--- against the current skin on each read, so the bitmaps follow skin +--- changes — unlike `UIFile`, which freezes the path at module-load time. local WindowTextures = { tl = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ul.dds'), tr = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ur.dds'), @@ -41,15 +36,11 @@ local WindowTextures = { borderColor = 'ff415055', } ---- Corner grip textures for the four resize handles sticking out of the ---- window corners. Each handle carries `up` / `over` / `down` states that ---- the `RolloverHandler` swaps through during hover-and-resize. ---- `SkinnableFile` again so the grips follow the active skin. +--- Corner grip textures for the four resize handles. Each handle carries +--- `up`/`over`/`down` states the `RolloverHandler` swaps through. --- --- The concatenated path strings widen to `string` rather than the ---- language server's `FileName` alias, which `SkinnableFile`'s parameter ---- annotation requires; suppress the resulting noise rather than littering ---- each line with a cast. +--- language server's `FileName` alias that `SkinnableFile` annotates. ---@diagnostic disable: param-type-mismatch local function DragHandleTextures(corner) return { @@ -60,25 +51,16 @@ local function DragHandleTextures(corner) end ---@diagnostic enable: param-type-mismatch ---- Default window rect, kept as a module local so `ResetPosition` can ---- restore it after the user has moved the window around. local DefaultRect = { Left = 8, Top = 460, Right = 430, Bottom = 720 } ------------------------------------------------------------------------------- -- The main chat window: a draggable, resizable frame hosting a -- `ChatLinesInterface` (line pool + scrollbar) and a `ChatEditInterface` --- (input area). --- --- The window owns three concerns; the rest is delegated: --- --- 1. Window chrome — drag handles, reset-position button, close / --- config buttons, resize bookkeeping. --- 2. Visibility — observes `model.WindowVisible` to show/hide. --- 3. Window-level options — `win_alpha` (cascades to descendants). --- --- Pool sizing, text wrapping, scrolling, filtering, and the per-row click --- forwarding all live on `ChatLinesInterface` — see that file. +-- (input area). The window owns chrome, visibility, and window-level +-- options (`win_alpha`); pool sizing, wrapping, scrolling, and filtering +-- live on `ChatLinesInterface`. +--- Main chat window: chrome, drag/resize handles, idle-fade timer, sibling feed; hosts lines + edit panels. ---@class UIChatInterface : Window ---@field Trash TrashBag # owns every subscription-LazyVar we create ---@field ChatLinesInterface UIChatLinesInterface # the wrapped panel containing line rows + scrollbar @@ -105,27 +87,17 @@ local ChatInterface = ClassUI(Window) { self:SetupDragHandles() self:SetupResetPositionButton() - -- Single trash bag for everything we allocate that needs explicit - -- destruction — currently just the derived observer LazyVars. - -- Emptied in `OnDestroy`. self.Trash = TrashBag() - -- The lines panel and edit area. Both are laid out in `__post_init` - -- once the client area has a real size to anchor against. self.ChatLinesInterface = ChatLinesInterface(self) self.ChatEditInterface = ChatEditInterface(self) - -- Feed view: a sibling control on the same parent frame so our own - -- `Show`/`Hide` cascade can't reach it. Pinned via LazyVars to our - -- line-area rect, so dragging or resizing the window carries the - -- feed along automatically. Destroyed in our `OnDestroy`. + -- Feed view: sibling on the parent frame so our `Show`/`Hide` + -- cascade can't reach it. Pinned to our line-area rect via LazyVars + -- so drag / resize carries it along. self.ChatFeedInterface = ChatFeedInterface(parent, self) - -- Override the lines panel's name-click hook to set the chat - -- recipient and re-focus the edit box. `OnCameraClicked` keeps the - -- panel's default behaviour (jump the world camera). Ignore clicks - -- on your own name — whispering yourself is pointless and the - -- picker would still route it as a private message. + -- Whispering yourself is pointless; ignore clicks on your own name. self.ChatLinesInterface.OnNameClicked = function(entry) if entry.ArmyID and entry.ArmyID ~= GetFocusArmy() then ChatController.SetRecipient(entry.ArmyID) @@ -133,17 +105,11 @@ local ChatInterface = ClassUI(Window) { end end - -- Reactive subscriptions use `LazyVarDerive` so each observer is a - -- fresh LazyVar that reads from an upstream model field — setting - -- our handler can never stomp another subscriber's (see the chat - -- CLAUDE.md for the pattern). local model = ChatModel.GetSingleton() - -- Window visibility → show / hide the frame, gate the idle timer. - -- `SetNeedsFrameUpdate(true)` is what makes `OnFrame` actually fire; - -- toggling it with visibility avoids ticking while hidden. Showing - -- the window stamps `LastActivity` so the user gets a fresh full - -- `fade_time` window before auto-close kicks in. + -- `SetNeedsFrameUpdate` toggles in lockstep with visibility so we + -- don't tick while hidden. Showing stamps `LastActivity` so the + -- user gets a full `fade_time` window before auto-close fires. self.WindowVisibleObserver = self.Trash:Add( LazyVarDerive( model.WindowVisible, @@ -162,12 +128,9 @@ local ChatInterface = ClassUI(Window) { ) ) - -- Title-bar button tooltips. The pin tooltip swaps between - -- `chat_pin` (autohide enabled, click to disable) and `chat_pinned` - -- (autohide disabled, click to enable) reactively from the model so - -- the wording matches the next click's effect. The `_closeBtn` / - -- `_configBtn` / `_pinBtn` fields are owned by `Window` but not in - -- its declared class fields, so the language server can't see them. + -- Pin tooltip wording swaps reactively so it matches the next + -- click's effect. `_closeBtn` / `_configBtn` / `_pinBtn` are owned + -- by `Window` but not in its declared class fields. ---@diagnostic disable: undefined-field Tooltip.AddButtonTooltip(self._closeBtn, 'chat_close') Tooltip.AddButtonTooltip(self._configBtn, 'chat_config') @@ -182,10 +145,9 @@ local ChatInterface = ClassUI(Window) { ---@diagnostic enable: undefined-field end, - --- Creates the four corner resize grips, wires the window's - --- `RolloverHandler` to swap their textures on hover / press, and lays - --- them out overhanging the window corners. Hit-test is disabled on the - --- grips so resize events still reach the Window's own resize bitmaps. + --- Creates the four corner resize grips and wires `RolloverHandler` to + --- swap their textures on hover/press. Grips disable hit-test so + --- resize events still reach the Window's own resize bitmaps. ---@param self UIChatInterface SetupDragHandles = function(self) self.DragTL = Bitmap(self) @@ -198,10 +160,9 @@ local ChatInterface = ClassUI(Window) { self.DragBL.textures = DragHandleTextures('ll') self.DragBR.textures = DragHandleTextures('lr') - -- Seed each grip with its `up` skinnable texture rather than a frozen - -- `UIFile` path. Otherwise the bitmaps display whichever skin was - -- active at module-load time (typically UEF) until the first - -- hover-exit hands `SetTexture` the live skinnable value. + -- Seed with the skinnable texture, not a frozen `UIFile` path — + -- otherwise the bitmaps stay on the module-load skin until the + -- first hover-exit hands `SetTexture` the live value. for _, grip in { self.DragTL, self.DragTR, self.DragBL, self.DragBR } do grip:DisableHitTest() grip:SetTexture(grip.textures.up) @@ -212,9 +173,7 @@ local ChatInterface = ClassUI(Window) { Layouter(self.DragBL):AtLeftBottomIn(self, -26, -8):Over(self, 5):End() Layouter(self.DragBR):AtRightBottomIn(self, -22, -8):Over(self, 5):End() - -- Each `controlID` the Window delivers maps to the grip(s) that - -- visually represent that edge: side edges light both adjacent - -- corners. + -- Side edges light both adjacent corners. self.DragHandleControlMap = { tl = { self.DragTL }, tr = { self.DragTR }, @@ -226,20 +185,17 @@ local ChatInterface = ClassUI(Window) { bm = { self.DragBL, self.DragBR }, } - -- Window calls the instance field `self.RolloverHandler(control, ...)` - -- as a plain function (no method syntax) — install a thin forwarder - -- here that binds `self` and dispatches to `OnRollover`. The class - -- method deliberately uses a different name: sharing `RolloverHandler` - -- would let the instance field shadow the class method, so - -- `self:RolloverHandler(...)` from within the forwarder would recurse. + -- Window calls `self.RolloverHandler(control, ...)` as a plain + -- function (no method syntax). The class method is named differently + -- (`OnRollover`) — sharing the name would shadow the class method + -- and recurse. self.RolloverHandler = function(_, event, xControl, yControl, cursor, controlID) self:OnRollover(event, xControl, yControl, cursor, controlID) end end, - --- Handles a rollover / press event delivered through the Window's - --- resize bitmaps (tl / tm / tr / ml / mr / bl / bm / br). Lights the - --- matching corner grip(s) and hands off to `StartSizing` on press. + --- Handles a rollover/press from the Window's resize bitmaps. Lights + --- the matching corner grip(s) and hands off to `StartSizing` on press. ---@param self UIChatInterface ---@param event KeyEvent ---@param xControl? LazyVar # Left or Right LazyVar to drive on drag @@ -268,9 +224,8 @@ local ChatInterface = ClassUI(Window) { end end, - --- Creates the reset-position button on the title strip (immediately to - --- the left of the Window's built-in `_configBtn`). Clicking it snaps - --- every rect edge back to `DefaultRect` and persists the location. + --- Creates the reset-position button left of `_configBtn`. Clicking + --- snaps every rect edge to `DefaultRect` and persists the location. ---@param self UIChatInterface SetupResetPositionButton = function(self) self.ResetPositionBtn = Button(self, @@ -301,8 +256,6 @@ local ChatInterface = ClassUI(Window) { __post_init = function(self, parent) local client = self:GetClientGroup() - -- Full width, flush with the bottom of the client area. The edit - -- group derives its own height (see ChatEditInterface.__post_init). Layouter(self.ChatEditInterface) :AtLeftIn(self) :AtRightIn(self) @@ -311,10 +264,6 @@ local ChatInterface = ClassUI(Window) { :Over(client) :End() - -- The lines panel fills the rest of the client area above the edit - -- box. The scrollbar is its own concern — `ChatLinesInterface` - -- reserves the space inside its right edge for the scrollbar - -- widget, so the parent only has to allocate a single rect. local paddingHorizontal = 8 local paddingVertical = 2 Layouter(self.ChatLinesInterface) @@ -324,20 +273,14 @@ local ChatInterface = ClassUI(Window) { :AnchorToTop(self.ChatEditInterface, 4) :End() - -- Now that the lines panel has a real rect, let it build its pool - -- and wire its options observer (the initial fire reads the laid- - -- out `Pool.Height()`). + -- Build the pool now that we have a real rect — `Initialize` reads + -- `Pool.Height()` for fixed-count sizing. self.ChatLinesInterface:Initialize() - -- Committed chat options → window-level concerns only. Pool sizing, - -- font, and filter changes are owned by the lines panel; we just - -- handle `win_alpha` here. `SetAlpha(_, true)` cascades so chrome, - -- edit, and scrollbar all dim uniformly. The chat-line *text* is - -- then forced back to full opacity by re-cascading from `Pool` - -- (which only contains the line rows) — the scrollbar is a sibling - -- of `Pool` on `ChatLinesInterface`, not a child, so this reset - -- doesn't touch it. Net effect: text stays crisp at low alpha, - -- everything else still fades. + -- Window-level options only (`win_alpha`). `SetAlpha(_, true)` + -- cascades to chrome / edit / scrollbar; re-cascading 1.0 from + -- `Pool` keeps the line text crisp. `Pool` doesn't contain the + -- scrollbar (it's a sibling), so the reset stays scoped. self.OptionsObserver = self.Trash:Add( LazyVarDerive( ChatConfigModel.GetSingleton().Committed, @@ -360,17 +303,11 @@ local ChatInterface = ClassUI(Window) { -- Idle / fade timer --------------------------------------------------------------------------- - --- Engine-driven frame tick. Only fires while `SetNeedsFrameUpdate(true)` - --- is set; the visibility observer toggles that with the window so we - --- don't tick while hidden. The timer is fully model-driven: any caller - --- that wants to count as activity calls `ChatController.NotifyActivity()` - --- to stamp `model.LastActivity`. Once the elapsed time since that stamp - --- crosses `fade_time`, ask the controller to close — closing flips - --- `model.WindowVisible`, which in turn disables further frame ticks. - --- Pinning the title-bar checkbox short-circuits the check entirely so - --- the user can keep the window up through long stretches of silence. + --- Idle-fade timer. Only fires while `SetNeedsFrameUpdate(true)` is + --- set; the visibility observer toggles that with the window. Pinning + --- short-circuits the check. ---@param self UIChatInterface - ---@param delta number # seconds since the last frame, unused (we read absolute time) + ---@param delta number # unused — we read absolute time OnFrame = function(self, delta) local model = ChatModel.GetSingleton() if model.Pinned() then return end @@ -381,9 +318,8 @@ local ChatInterface = ClassUI(Window) { end end, - --- Engine-invoked when the user toggles the title-bar pin checkbox. - --- Forwards to the controller, which writes `model.Pinned`. Refocuses - --- the edit box because clicking the checkbox steals focus. + --- Title-bar pin checkbox. Refocuses the edit box because clicking + --- the checkbox steals focus. ---@param self UIChatInterface ---@param checked boolean OnPinCheck = function(self, checked) @@ -395,17 +331,15 @@ local ChatInterface = ClassUI(Window) { -- Window event hooks --------------------------------------------------------------------------- - --- Fired continuously during a resize drag. Keep it cheap: just resize - --- the pool and re-render against existing wraps. + --- Per-frame during a resize drag. Resizes the pool only — rewrap + --- happens once on `OnResizeSet`. OnResize = function(self, width, height, firstFrame) ChatController.NotifyActivity() self.ChatLinesInterface:OnResizeLive() end, - --- Fired when a resize drag ends. Rewrapping is expensive, so it only - --- happens here rather than on every drag frame. Also snaps the corner - --- grips back to their `up` texture — the RolloverHandler leaves them - --- on `down` when StartSizing took over. + --- Resize finished. Snaps grips back to `up` — `StartSizing` takes + --- over from RolloverHandler so they'd otherwise stay on `down`. OnResizeSet = function(self) ChatController.NotifyActivity() self.ChatLinesInterface:OnResizeFinished() @@ -415,45 +349,37 @@ local ChatInterface = ClassUI(Window) { self.DragBR:SetTexture(self.DragBR.textures.up) end, - --- Engine-invoked continuously while the user drags the window by its - --- title bar. Mirrors `OnResize`: a long drag must not let the auto-close - --- timer expire mid-move, so every frame counts as activity. The engine - --- passes `(x, y, firstFrame)` after `self`, but we don't need them — - --- Lua silently drops trailing args. + --- Per-frame during a title-bar drag. Stamps activity so a long drag + --- can't trip the idle auto-close. OnMove = function(self) ChatController.NotifyActivity() end, - --- Engine-invoked when the user finishes dragging the window. The drag - --- handler steals focus mid-move, so re-acquire it so the user can keep - --- typing without a second click on the edit box. + --- Drag finished. Re-acquires edit-box focus that the drag handler stole. OnMoveSet = function(self) ChatController.NotifyActivity() self.ChatEditInterface:AcquireFocus() end, - --- Mouse wheel over the window scrolls the chat. `rotation` is in wheel - --- units (usually ±120 per notch); one notch ≈ one line. + --- `rotation` is in wheel units (usually ±120 per notch). OnMouseWheel = function(self, rotation) ChatController.NotifyActivity() self.ChatLinesInterface:ScrollLines(nil, -math.floor(rotation / 100)) end, - --- Engine-invoked when the user clicks the close button on the window frame. + --- Title-bar close button. Routes through the controller so the model is + --- the source of truth for visibility. OnClose = function(self) ChatController.CloseWindow() end, - --- Engine-invoked when the user clicks the config button on the window - --- frame. Opens (or closes, if already open) the chat options dialog. + --- Title-bar config button. Toggles the chat options dialog. OnConfigClick = function(self) import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() end, - --- Tears down the sibling feed view (it lives outside our control tree - --- so `Hide`/`Destroy` cascades don't reach it) and empties our trash - --- bag — destroying every derived observer so no `OnDirty` can fire - --- into a torn-down `self`. + --- Tears down the sibling feed (it lives outside our control tree, so + --- a Destroy cascade doesn't reach it) and empties the trash bag. OnDestroy = function(self) if self.ChatFeedInterface then self.ChatFeedInterface:Destroy() @@ -466,75 +392,64 @@ local ChatInterface = ClassUI(Window) { ------------------------------------------------------------------------------- -- Module-level singleton and standalone entry points. +--- Singleton handle; nil until `EnsureInstance` builds the window for the first time. ---@type UIChatInterface | nil local Instance = nil ---- Builds the chat window (and its sibling feed view) if they don't ---- already exist. Doesn't change visibility — `model.WindowVisible` ---- starts `false`, so the chat stays hidden until something flips it. ---- +--- Builds the chat window and its sibling feed if they don't already +--- exist. Doesn't change visibility — `model.WindowVisible` starts false. --- `ChatController.Init` calls this at game start so the feed is alive ---- in time to surface messages that arrive before the user first opens ---- the chat dialog. Open / Toggle also call it as a safety net for ---- entry points that bypass `Init` (mods, debug helpers). +--- before the user opens the dialog. function EnsureInstance() if not Instance then Instance = ChatInterface(GetFrame(0)) end end ---- Shows the chat window, creating it on first call. +--- Standalone entry point: ensures the window exists and shows it. function Open() EnsureInstance() ChatController.OpenWindow() end ---- Hides the chat window (the instance is kept around). +--- Standalone entry point: hides the chat window if it exists. function Close() ChatController.CloseWindow() end ---- Toggles the chat window, creating it on first call. +--- Standalone entry point: ensures the window exists and flips its visibility. function Toggle() EnsureInstance() ChatController.ToggleWindow() end ---- Scrolls the chat feed by `delta` rows (negative = toward older messages). ---- No-op if the window has never been opened. ----@param delta number +--- Scrolls the line pool by `delta` rows; no-op if the window hasn't been built yet. +---@param delta number # negative = toward older messages function ScrollLines(delta) if Instance then Instance.ChatLinesInterface:ScrollLines(nil, delta) end end ---- Scrolls the chat feed by `delta` pages (negative = toward older messages). ---- No-op if the window has never been opened. ----@param delta number +--- Scrolls the line pool by `delta` pages; no-op if the window hasn't been built yet. +---@param delta number # negative = toward older messages function ScrollPages(delta) if Instance then Instance.ChatLinesInterface:ScrollPages(nil, delta) end end ---- Snaps the chat feed to the oldest visible entry. No-op if the window ---- has never been opened. Not bound to a default key — the Edit control ---- consumes Home for caret navigation before `OnNonTextKeyPressed` fires ---- — but exposed for keymap entries (`UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").ScrollToTop()`) ---- and for mods that want a programmatic jump-to-top. +--- Jumps to the oldest visible entry. Not bound to a default key because +--- Edit consumes Home for caret nav before `OnNonTextKeyPressed` fires; +--- exposed for keymap entries and mods. function ScrollToTop() if Instance then Instance.ChatLinesInterface:ScrollSetTop(nil, 1) end end ---- Two-stage "jump to bottom" handler. If the chat is already pinned to ---- the newest entry, collapses the window — same intent as the legacy ---- "press End again to dismiss" feel without sneaking in a separate ---- toggle. Otherwise snaps to the bottom. No-op if the window has never ---- been opened. Not bound to a default key (see `ScrollToTop` for the ---- reason); exposed for keymap entries and mods. +--- Two-stage jump: snaps to bottom, or closes the window if already there. +--- Mirrors the legacy "press End again to dismiss" feel. function ScrollToBottomOrClose() if not Instance then return end local lines = Instance.ChatLinesInterface @@ -545,10 +460,8 @@ function ScrollToBottomOrClose() end end ---- Opens the chat window (creating it on first call) and scrolls the feed ---- by `delta` rows. Entry point for the global PgUp / PgDn key bindings — ---- so pressing PgUp with the window hidden both reveals it and starts ---- scrolling toward older messages. +--- Entry point for global PgUp / PgDn bindings: opens the window if needed +--- and scrolls in one step. ---@param delta number function OpenAndScrollLines(delta) Open() @@ -558,17 +471,15 @@ end ------------------------------------------------------------------------------- --#region Debugging ---- Called by the module manager when this module is reloaded. +--- Hot-reload hook: reopens the window on the freshly loaded module. ---@param newModule any function __moduleinfo.OnReload(newModule) newModule.Open() end ---- Called by the module manager when this module becomes dirty. +--- Hot-reload hook: tears down the old instance and re-imports this module. 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 diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index f725a92a5c1..bd1c47edc86 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -12,19 +12,14 @@ local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") local Layouter = LayoutHelpers.ReusedLayoutFor ---- Body-text colour used when an entry has neither a `BodyColor` override ---- nor a `ColorKey` palette lookup that resolves. Matches the legacy chat ---- panel's previous hardcoded body colour so unrecognised / pre-palette ---- entries still render close to their old appearance. +--- Fallback body-text colour for entries without a `BodyColor` or a +--- resolvable `ColorKey`. Matches the legacy hardcoded body colour. local DefaultBodyColor = 'ffc2f6ff' ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false --- Collect faction icons up-front; append an observer icon as the final entry --- so non-player senders can be represented too. +-- Faction icons with the observer icon appended as a tail for non-player +-- senders. local FactionIcons = {} for _, data in Factions do table.insert(FactionIcons, data.Icon) @@ -33,12 +28,8 @@ table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') local CamIconTexture = '/game/camera-btn/pinned_btn_up.dds' ---- Resolves the body-text colour for `entry`. Priority: ---- 1. `entry.BodyColor` — explicit override (system / synthetic lines). ---- 2. `entry.ColorKey` — palette lookup against `ChatConfigModel.GetOptions()`. ---- 3. `DefaultBodyColor` — fallback for entries from before the palette was wired. ---- Lives at module scope so both `SetHeader` and `SetContinuation` can call it ---- without each having to repeat the lookup. +--- Body-text colour for `entry`. Priority: `BodyColor` override, then +--- `ColorKey` palette lookup, then `DefaultBodyColor`. ---@param entry UIChatEntry ---@return string local function ResolveBodyColor(entry) @@ -54,12 +45,8 @@ end ------------------------------------------------------------------------------- -- A single chat row: team-coloured faction icon, sender name and message text. --- --- The semi-transparent "feed mode" background (shown when the window chrome --- is hidden) will be added back together with the feed-mode implementation — --- having it here while `line:Show()` cascades to children caused it to double --- up over the chat-window background. +--- One row of the chat-line pool: faction badge, clickable name, clickable text. Pooled and reused. ---@class UIChatLineInterface : Group ---@field TeamColor Bitmap ---@field FactionIcon Bitmap @@ -84,18 +71,17 @@ ChatLineInterface = ClassUI(Group) { self.Name = UIUtil.CreateText(self, '', 14, 'Arial Bold') self.Name:SetColor('ffffffff') self.Name:SetDropShadow(true) - -- Empty-name continuation lines have zero width here, so the hit - -- rect collapses with them — no need to gate dispatch on row role. + -- Continuation lines set Name to '' so the hit rect collapses with + -- it — no need to gate dispatch on row role. self.Name.HandleEvent = function(_, event) if event.Type == 'ButtonPress' and self.Entry then self:OnNameClicked(self.Entry) end end - -- Camera-link icon. Kept "invisible" when unused by clearing to a - -- transparent solid colour and disabling hit-test — calling `Hide()` - -- here would be undone when the window's `Show()` cascades to - -- descendants (same reason `FactionIcon` cycles via SolidColor). + -- Camera-link icon. Hidden via transparent SolidColor + disabled + -- hit-test rather than `Hide()` — the window's `Show()` cascade + -- would otherwise undo `Hide()` (same reason FactionIcon does it). self.CamIcon = Bitmap(self) self.CamIcon:SetSolidColor('00000000') self.CamIcon:DisableHitTest() @@ -119,14 +105,12 @@ ChatLineInterface = ClassUI(Group) { ---@param self UIChatLineInterface ---@param parent Control __post_init = function(self, parent) - -- `Layouter:Height(number)` would auto-scale a literal, but the - -- closures below need to track upstream LazyVars reactively, and - -- raw constants inside a SetFunction body don't get scaled. Pre-scale - -- the 2px row padding once so it follows the user's UI scale. + -- Raw constants in SetFunction bodies don't auto-scale (only + -- Layouter `:Height(number)` does); pre-scale once. local twoPxScaled = LayoutHelpers.ScaleNumber(2) - -- Derive the row's height from the name font so pool sizing and - -- scroll positions scale automatically with `ChatOptions.font_size`. + -- Derive row height from the name font so pool sizing scales + -- automatically with `ChatOptions.font_size`. Layouter(self) :Height(function() return self.Name.Height() + twoPxScaled end) :End() @@ -146,8 +130,7 @@ ChatLineInterface = ClassUI(Group) { :Over(self, 10) :End() - -- Cam icon sits between the name and text on header rows. Fixed - -- 20x16 footprint matching the legacy `pinned_btn_up.dds` art. + -- 20x16 footprint matches the `pinned_btn_up.dds` art. Layouter(self.CamIcon) :RightOf(self.Name, 4) :AtVerticalCenterIn(self.TeamColor) @@ -156,8 +139,7 @@ ChatLineInterface = ClassUI(Group) { :Over(self, 10) :End() - -- Text Left jumps over the icon when present; SetHeader rebinds this - -- when the entry's camera state changes. + -- SetHeader rebinds Text.Left when the entry's camera state changes. Layouter(self.Text) :Left(function() return self.Name.Right() + twoPxScaled end) :Right(self.Right) @@ -173,9 +155,7 @@ ChatLineInterface = ClassUI(Group) { end end, - --- Populates the row as the FIRST wrapped line of an entry: shows the - --- team-colour square, faction icon, the name prefix, and the first - --- wrapped chunk of message text. + --- Populates the row as the FIRST wrapped line of an entry. ---@param self UIChatLineInterface ---@param entry UIChatEntry ---@param wrappedText string # the first wrapped chunk of `entry.Text` @@ -186,11 +166,8 @@ ChatLineInterface = ClassUI(Group) { self.Text:SetColor(ResolveBodyColor(entry)) self.TeamColor:SetSolidColor(entry.Color or '00000000') - -- Grey the sender label on our own outgoing messages so the user - -- can pick out their own lines at a glance. Re-applied on every - -- `SetHeader` because pool slots get reused across entries from - -- different armies — the previous occupant's enable/disable state - -- would otherwise stick around. + -- Grey our own outgoing names. Re-applied every SetHeader because + -- pool slots get reused across entries from different armies. if entry.ArmyID == GetFocusArmy() then self.Name:Disable() else @@ -200,12 +177,10 @@ ChatLineInterface = ClassUI(Group) { local iconIndex = entry.Faction or table.getn(FactionIcons) self.FactionIcon:SetTexture(UIUtil.UIFile(FactionIcons[iconIndex])) - -- Camera affordance: switch between textured (hit-testable) and - -- transparent SolidColor (inert) rather than Show/Hide, so the - -- window-wide `Show()` cascade can't reveal stale icons. Re-applying - -- `RightOf` replaces the previous Left binding (no leak). Shown for - -- both full `Camera` snapshots (player attached their view) and - -- `Location` hints (AI tagged a point or region). + -- SolidColor swap rather than Show/Hide so the window's Show() + -- cascade can't reveal stale icons. Re-applying `RightOf` replaces + -- the previous Left binding. Shown for both `Camera` snapshots and + -- `Location` hints. if entry.Camera or entry.Location then self.CamIcon:SetTexture(UIUtil.UIFile(CamIconTexture)) self.CamIcon:EnableHitTest() @@ -217,14 +192,10 @@ ChatLineInterface = ClassUI(Group) { end end, - --- Populates the row as a CONTINUATION of a wrapped entry: the name slot - --- and team-colour square stay empty, only the wrapped text is shown. - --- The text control remains anchored to `Name.Right + 2`; with an empty - --- name that resolves to the left of the row, so continuation lines - --- naturally line up under the first wrapped chunk. - --- - --- The entry is still tracked so body clicks on wrapped lines dispatch - --- against the same message the header belongs to. + --- Populates the row as a CONTINUATION of a wrapped entry. Name and + --- team-colour stay empty; Text anchors to `Name.Right + 2`, which with + --- an empty name resolves to the row's left edge. Tracks the entry so + --- body clicks on wrapped lines still dispatch against the right message. ---@param self UIChatLineInterface ---@param entry UIChatEntry ---@param wrappedText string @@ -240,7 +211,7 @@ ChatLineInterface = ClassUI(Group) { LayoutHelpers.RightOf(self.Text, self.Name, 2) end, - --- Clears all content so the row can stand empty. + --- Resets the row to its empty state, ready for the next pool reuse. ---@param self UIChatLineInterface Clear = function(self) self.Entry = nil @@ -253,31 +224,24 @@ ChatLineInterface = ClassUI(Group) { LayoutHelpers.RightOf(self.Text, self.Name, 2) end, - --- Overridable: fires on a click on the sender name. Continuation - --- lines have an empty name control so the hit rect collapses — this - --- only runs on header rows in practice. Default is a no-op; replace - --- the field on an instance to subscribe. + --- Overridable; default no-op. Continuation rows have empty Name so + --- their hit rect collapses — only header rows fire in practice. ---@param self UIChatLineInterface ---@param entry UIChatEntry OnNameClicked = function(self, entry) end, - --- Overridable: fires on a click on the message body. Runs for both - --- header and continuation rows — they share the same entry — so a - --- click anywhere on a wrapped message resolves to the right sender. - --- Default is a no-op; replace the field on an instance to subscribe. + --- Overridable; default no-op. Fires for both header and continuation + --- rows — they share the entry so the click resolves to the right sender. ---@param self UIChatLineInterface ---@param entry UIChatEntry OnBodyClicked = function(self, entry) end, - --- Overridable: fires on a click on the camera icon. Only header rows - --- show the icon (continuation rows hide it), so this only runs there. - --- Default is a no-op; replace the field on an instance to subscribe. + --- Overridable; default no-op. Only header rows show the icon. ---@param self UIChatLineInterface ---@param entry UIChatEntry OnCameraClicked = function(self, entry) end, - --- Updates the font size for both name and body text. The row's `Height` - --- LazyVar is derived from `Name.Height`, so the row resizes automatically. + --- Updates the name and body fonts. Row height tracks the name font. ---@param self UIChatLineInterface ---@param size number # point size SetFontSize = function(self, size) diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index d7178da7b23..ab8b13fa3f4 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -16,43 +16,20 @@ local LazyVarDerive = import("/lua/lazyvar.lua").Derive local Layouter = LayoutHelpers.ReusedLayoutFor ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false -- Reserve space on the right of the wrapper for the scrollbar widget. --- Anything wider than the scrollbar bitmap (~17px) works; 20px gives a tiny --- breathing margin between the line text and the scrollbar. local ScrollbarReserve = 32 ------------------------------------------------------------------------------- -- A self-contained chat-lines panel: outer wrapper, inner pool of line rows, --- and the vertical scrollbar — packaged so a parent can drop it in and size --- it as one unit. Lifted out of `ChatInterface.lua` so the chat window only --- has to lay this out alongside the edit area. --- --- This class owns four related concerns that used to live on the window: --- --- 1. Pool sizing (`RebuildPool`) — line count follows --- the container height. --- 2. Text wrapping (`WrapEntry` / `RewrapAll`) — wraps message text to --- the current row width; --- results cached on the --- entry itself. --- 3. Scroll container (`GetScrollValues`, …) — virtual size = total --- wrapped-line count --- across *valid* entries. --- 4. Visibility mapping (`CalcVisible`) — projects the scroll --- position onto the pool. --- --- Filtering currently gates on `ChatConfigModel.GetOptions().muted`; --- camera-link filtering is still TODO. +-- and the vertical scrollbar. -- -- Click hooks (`OnNameClicked`, `OnCameraClicked`) are overridable instance --- fields with sensible defaults — name click is a no-op (window-level --- concern), camera click jumps the world camera to the entry's hint. +-- fields. Default `OnNameClicked` is a no-op (window-level concern); default +-- `OnCameraClicked` jumps the world camera to the entry's hint. +--- Wrapped chat panel: line-row pool, scrollbar, history/options observers. Owns wrap and scroll state. ---@class UIChatLinesInterface : Group ---@field Trash TrashBag # owns every subscription-LazyVar we create ---@field Pool Group # inner group hosting the line rows @@ -79,23 +56,16 @@ ChatLinesInterface = ClassUI(Group) { self.ScrollTop = 1 self.VirtualSize = 0 - -- Pool holds the actual line rows. Sized in `__post_init` to stop - -- short of our right edge so the scrollbar (anchored to Pool's - -- right) sits inside our footprint. self.Pool = Group(self, "ChatLinesPool") - -- Forward the scrollable interface from the Pool (which the - -- scrollbar binds to via `Scrollbar:SetScrollable`) up to `self`, - -- where the state lives. + -- `Scrollbar:SetScrollable` binds to Pool, but the state lives on + -- self — forward each method up. self.Pool.GetScrollValues = function(_, axis) return self:GetScrollValues(axis) end self.Pool.ScrollLines = function(_, axis, delta) self:ScrollLines(axis, delta) end self.Pool.ScrollPages = function(_, axis, delta) self:ScrollPages(axis, delta) end self.Pool.ScrollSetTop = function(_, axis, top) self:ScrollSetTop(axis, top) end self.Pool.IsScrollable = function(_, axis) return self:IsScrollable(axis) end - -- Default click hooks. Replaced on the instance by callers that - -- want different behaviour (e.g. the chat window re-points - -- `OnNameClicked` to set the recipient + re-focus the edit box). self.OnNameClicked = function(entry) end self.OnCameraClicked = function(entry) local cam = GetCamera('WorldCamera') @@ -112,18 +82,12 @@ ChatLinesInterface = ClassUI(Group) { end end - -- Built once per panel so every pool line can point its - -- `OnNameClicked` / `OnCameraClicked` at the same reference — pool - -- growth never allocates a per-row closure. Each forwarder reads - -- `self.OnNameClicked` (etc.) on every invocation, so callers can - -- replace the public hook at any time without re-wiring the rows. + -- Built once so pool growth never allocates a per-row closure. + -- Each forwarder reads `self.OnNameClicked` on every call, so + -- replacing the hook later doesn't require re-wiring the rows. self.LineNameClicked = function(_, entry) self.OnNameClicked(entry) end self.LineCameraClicked = function(_, entry) self.OnCameraClicked(entry) end - -- History → wrap new entries, refresh size, stick to bottom. The - -- initial firing happens before `__post_init` so the wrap call has - -- no pool to measure against; that's fine — `RewrapAll` runs once - -- the pool exists. local model = ChatModel.GetSingleton() self.HistoryObserver = self.Trash:Add( LazyVarDerive( @@ -134,31 +98,20 @@ ChatLinesInterface = ClassUI(Group) { ) ) - -- `OptionsObserver` is wired in `__post_init`, not here — its - -- initial fire triggers `ApplyOptions → RebuildPool`, which reads - -- `self.ChatLineInterfaces[1].Height()` and so requires the pool layout to - -- already be in place. + -- `OptionsObserver` is wired in `Initialize`, not here — its + -- initial fire calls `ApplyOptions → RebuildPool`, which reads + -- `Pool.Height()` and so requires layout to be in place. end, ---@param self UIChatLinesInterface ---@param parent Control __post_init = function(self, parent) - -- Pool fills the wrapper but stops `ScrollbarReserve` short of the - -- right edge so the scrollbar (which the engine anchors to Pool's - -- right) lands inside our footprint. These bindings are reactive — - -- they don't evaluate to concrete pixels until the parent has laid - -- us out, which is why pool / wrap / scroll work happens in - -- `Initialize` below rather than here. Layouter(self.Pool) :AtLeftTopIn(self) :AtRightIn(self, ScrollbarReserve) :AtBottomIn(self) :End() - -- `CreateVertScrollbarFor` calls `Scrollbar:SetScrollable(attachto)` - -- so the scrollable methods have to live on `Pool` — see the - -- forwarding stubs in `__init`. Anchoring the scrollbar is also - -- reactive (it tracks `Pool.Right`), so this is safe pre-layout. self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Pool) self.Scrollbar:SetParent(self) @@ -170,12 +123,10 @@ ChatLinesInterface = ClassUI(Group) { end end, - --- Called by the parent once it has laid out the lines panel. The - --- initial `RebuildPool` reads `Pool.Height()` — which evaluates to - --- zero until our outer rect is bound to something concrete — so the - --- pool / rewrap / scroll work has to wait until the parent positions - --- us. Wiring `OptionsObserver` here defers its initial fire (which - --- calls `ApplyOptions → RebuildPool`) for the same reason. + --- Called by the parent once it has laid out the lines panel. + --- `RebuildPool` reads `Pool.Height()`, which is zero until our outer + --- rect is bound, so pool / rewrap / scroll work has to wait until + --- the parent positions us. ---@param self UIChatLinesInterface Initialize = function(self) self:RebuildPool() @@ -194,24 +145,18 @@ ChatLinesInterface = ClassUI(Group) { -- Pool sizing --------------------------------------------------------------------------- - --- Rebuilds the line pool to fit the current Pool height. Lines stack - --- bottom-up: `ChatLineInterfaces[1]` pins to the pool's bottom and - --- holds the newest visible message; subsequent slots stack above. When - --- there are fewer messages than slots, the empty (and `Hide()`-flagged) - --- slots sit at the top of the pool, so the chat reads bottom-anchored - --- like Discord / Slack — and matches the feed's stacking direction so - --- close ↔ open transitions look continuous. Safe to call repeatedly; - --- callers are expected to follow up with `CalcVisible` (and `RewrapAll` - --- on a true resize). + --- Rebuilds the line pool to fit Pool height. Lines stack bottom-up: + --- `ChatLineInterfaces[1]` pins to the pool's bottom and holds the + --- newest visible message. Empty slots sit at the top so the feed + --- reads bottom-anchored. Safe to call repeatedly; callers follow up + --- with `CalcVisible` (and `RewrapAll` on a true resize). ---@param self UIChatLinesInterface RebuildPool = function(self) local pool = self.Pool - -- Read the live size straight from the config model so the pool - -- always tracks the current option without a cached copy on `self`. local fontSize = ChatConfigModel.GetOptions().font_size or 14 - -- Need one line to establish the row height. The row's `Height` is - -- a lazy function of the name-text font (see `ChatLineInterface`). + -- Need one line to establish the row height (a lazy function of + -- the name-text font in `ChatLineInterface`). if not self.ChatLineInterfaces[1] then self.ChatLineInterfaces[1] = ChatLineInterface(pool) self.ChatLineInterfaces[1]:SetFontSize(fontSize) @@ -224,12 +169,11 @@ ChatLinesInterface = ClassUI(Group) { end local rowHeight = self.ChatLineInterfaces[1].Height() - if rowHeight < 1 then rowHeight = 18 end -- safety fallback + if rowHeight < 1 then rowHeight = 18 end local neededLines = math.max(1, math.floor(pool.Height() / rowHeight)) local currentCount = table.getn(self.ChatLineInterfaces) - -- Grow: stack each new row above the previous one. for i = currentCount + 1, neededLines do self.ChatLineInterfaces[i] = ChatLineInterface(pool) self.ChatLineInterfaces[i]:SetFontSize(fontSize) @@ -242,7 +186,6 @@ ChatLinesInterface = ClassUI(Group) { :End() end - -- Shrink: destroy the surplus tail. for i = currentCount, neededLines + 1, -1 do self.ChatLineInterfaces[i]:Destroy() self.ChatLineInterfaces[i] = nil @@ -253,12 +196,8 @@ ChatLinesInterface = ClassUI(Group) { -- Options application --------------------------------------------------------------------------- - --- Applies a `UIChatOptions` snapshot. Handles `font_size` and the - --- `muted` filter today; future options that affect line rendering - --- (colours, link visibility) extend this method. - --- - --- Window-level options (`win_alpha`, default recipient, …) are the - --- parent's responsibility — we deliberately don't touch them here. + --- Applies a `UIChatOptions` snapshot. Window-level options + --- (`win_alpha`, default recipient, …) are the parent's responsibility. ---@param self UIChatLinesInterface ---@param options UIChatOptions ApplyOptions = function(self, options) @@ -268,13 +207,10 @@ ChatLinesInterface = ClassUI(Group) { line:SetFontSize(size) end -- Row height tracks the font, so the pool may need resizing; - -- wrap widths depend on font metrics, so rewrap all entries. + -- wrap widths depend on font metrics, so rewrap. self:RebuildPool() self:RewrapAll() - -- Filter-affecting options (muted, links) may have changed too. - -- Recompute what's visible so entries newly excluded by - -- `IsValidEntry` drop out of the feed immediately. self:RefreshVirtualSize() self:RecomputeScrollTopForPoolChange(oldPoolSize) self:CalcVisible() @@ -284,19 +220,14 @@ ChatLinesInterface = ClassUI(Group) { -- Text wrapping --------------------------------------------------------------------------- - --- Wraps a single entry's text to fit the current row width, caching - --- the result on the entry itself. Delegates to `ChatUtils.WrapEntry` - --- so the feed view can wrap the same entries with the same logic - --- (and same width — both panels share row metrics) without reaching - --- back into us. + --- Wraps an entry's text using the first pool line as the measurement source. ---@param self UIChatLinesInterface ---@param entry UIChatEntry WrapEntry = function(self, entry) ChatUtils.WrapEntry(entry, self.ChatLineInterfaces[1]) end, - --- Re-wraps every entry in the history. Used on resize (width change) - --- and on option changes that affect the measuring font. + --- Re-wraps every history entry. Called after a font, width, or font-metric change. ---@param self UIChatLinesInterface RewrapAll = function(self) local history = ChatModel.GetSingleton().History() @@ -310,15 +241,10 @@ ChatLinesInterface = ClassUI(Group) { -- Filtering --------------------------------------------------------------------------- - --- Whether an entry counts toward the virtual scroll size and should - --- appear in `CalcVisible`. Gates on: - --- * per-army mute map (`muted[ArmyID]` → drop) — per-game, set - --- via the config dialog or `/mute` / `/unmute`. - --- * `links` option (`Camera` or `Location` set + `links == false` - --- → drop) — mirrors the legacy filter at chat.legacy.lua:304-310. - --- Both `Camera` (full snapshot) and `Location` (sim-side point - --- or area hint) surface the camera-link affordance on the row, - --- so either field qualifies as a "link" message. + --- Whether an entry counts toward the virtual scroll size. Gates on + --- the per-army mute map and the `links` option (Camera or Location + --- both qualify as "link" messages — either surfaces the camera-link + --- affordance on the row). ---@param self UIChatLinesInterface ---@param entry UIChatEntry ---@return boolean @@ -338,7 +264,7 @@ ChatLinesInterface = ClassUI(Group) { -- Scroll container --------------------------------------------------------------------------- - --- Recomputes `VirtualSize` = total wrapped lines across all valid entries. + --- Recounts wrapped lines across non-filtered entries and stores the total in `VirtualSize`. ---@param self UIChatLinesInterface ---@param history? UIChatEntry[] RefreshVirtualSize = function(self, history) @@ -352,7 +278,7 @@ ChatLinesInterface = ClassUI(Group) { self.VirtualSize = size end, - --- Standard MAUI scrollable interface: returns (rangeMin, rangeMax, visibleMin, visibleMax). + --- Scrollbar contract: returns `(min, max, top, bottom)` of the visible range. ---@param self UIChatLinesInterface ---@param axis string # "Vert" or "Horz" GetScrollValues = function(self, axis) @@ -361,25 +287,25 @@ ChatLinesInterface = ClassUI(Group) { return 1, self.VirtualSize, top, math.min(top + poolSize, self.VirtualSize) end, - --- Scrolls by a number of rows (negative = toward older messages). + --- Scrolls by a line count (negative = older). ---@param self UIChatLinesInterface ---@param axis string - ---@param delta number + ---@param delta number # negative = toward older messages ScrollLines = function(self, axis, delta) self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) end, - --- Scrolls by a page (pool-size worth of rows). + --- Scrolls by `delta` pool-sized pages (negative = older). ---@param self UIChatLinesInterface ---@param axis string - ---@param delta number + ---@param delta number # in pool-size pages ScrollPages = function(self, axis, delta) self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * table.getn(self.ChatLineInterfaces)) end, - --- Jumps to an absolute virtual position, clamped to the valid range. - --- Name and signature match the engine's `ScrollSetTop(axis, top)` contract - --- so `Scrollbar:SetScrollable` can call it directly. + --- Jumps to an absolute virtual position, clamped. Signature matches + --- the engine's `ScrollSetTop(axis, top)` contract so the scrollbar + --- can call it directly. ---@param self UIChatLinesInterface ---@param axis string ---@param top number @@ -394,8 +320,7 @@ ChatLinesInterface = ClassUI(Group) { self:CalcVisible() end, - --- Standard MAUI scrollable interface: whether scrolling is possible on - --- the given axis. + --- Scrollbar contract: chat is always scrollable on the requested axis. ---@param self UIChatLinesInterface ---@param axis string ---@return boolean @@ -403,18 +328,11 @@ ChatLinesInterface = ClassUI(Group) { return true end, - --- Adjusts `ScrollTop` to compensate for a change in pool size (window - --- resize, font-size change). Keeps the bottom of the visible window — - --- the entry currently rendered at `pool[1]` — pinned across the change: - --- when the pool grows, the new slots above reveal *older* entries - --- instead of staying blank, and an at-bottom view stays at the bottom. - --- Caller is responsible for following up with `CalcVisible`. - --- - --- Without this step, growing the pool past the previous `visibleBottom` - --- range leaves the new top slots stuck on the `currentVirtualPos < scrollTop` - --- branch in `CalcVisible` — they Clear+Hide instead of being filled with - --- older history. Scrolling later "fixes" it because `ScrollSetTop` writes - --- a fresh `ScrollTop` that lets `CalcVisible` walk further back. + --- Adjusts `ScrollTop` to keep the entry at `pool[1]` pinned across a + --- pool-size change. Without this, growing the pool past the previous + --- `visibleBottom` leaves the new top slots stuck on Clear+Hide + --- instead of revealing older history; the user has to scroll to + --- "fix" it. Caller follows up with `CalcVisible`. ---@param self UIChatLinesInterface ---@param oldPoolSize number # pool length before the resize / RebuildPool call RecomputeScrollTopForPoolChange = function(self, oldPoolSize) @@ -425,20 +343,16 @@ ChatLinesInterface = ClassUI(Group) { self.ScrollTop = math.max(1, math.min(newMaxTop, newScrollTop)) end, - --- Snaps to the bottom of the virtual list. + --- Scrolls to the newest entry and forces a render pass. ---@param self UIChatLinesInterface ScrollToBottom = function(self) self:ScrollSetTop(nil, self.VirtualSize) - -- ScrollSetTop short-circuits when the position doesn't change, but - -- the pool still needs a render pass after a rebuild / rewrap. + -- ScrollSetTop short-circuits when the position doesn't change, + -- but the pool still needs a render pass after rebuild / rewrap. self:CalcVisible() end, - --- True when `ScrollTop` is already pinned at the maximum legal value - --- — i.e. the newest entry is in the bottom-most pool slot and no - --- amount of "scroll down" would change anything. Useful for callers - --- that want a "if already at bottom, do something else" two-stage - --- behaviour (e.g. dismissing the window on a second jump-to-bottom). + --- Whether the newest entry is currently visible. ---@param self UIChatLinesInterface ---@return boolean IsAtBottom = function(self) @@ -452,12 +366,9 @@ ChatLinesInterface = ClassUI(Group) { --------------------------------------------------------------------------- --- Projects the visible virtual range onto the bottom-anchored line - --- pool. `ChatLineInterfaces[1]` (bottom of the pool) shows the newest - --- visible entry / wrapped chunk; subsequent slots walk back through - --- history toward older content, matching the `Above`-stacked layout - --- from `RebuildPool`. Skips filtered-out entries in either direction. - --- When fewer entries fit than the pool can hold, the surplus slots - --- (at the top of the pool) are cleared and hidden. + --- pool. `ChatLineInterfaces[1]` shows the newest visible chunk; + --- subsequent slots walk back through history. Surplus slots at the + --- top are cleared and hidden. ---@param self UIChatLinesInterface CalcVisible = function(self) if not self.ChatLineInterfaces[1] then return end @@ -467,15 +378,11 @@ ChatLinesInterface = ClassUI(Group) { local poolSize = table.getn(self.ChatLineInterfaces) local scrollTop = self.ScrollTop - -- The bottommost visible virtual position is the newest entry the - -- user can currently see; pool[1] (the bottom row) renders it. - -- `VirtualSize` reflects the post-filter count, so this stays - -- correct when muted senders are hidden mid-feed. + -- pool[1] (bottom row) renders `visibleBottom`. `VirtualSize` is + -- post-filter so this stays correct mid-feed. local visibleBottom = math.min(scrollTop + poolSize - 1, self.VirtualSize) - -- Walk forward through history to find the entry + wrappedIdx that - -- covers `visibleBottom`. Same scan as the legacy loop but anchored - -- to the bottom of the visible window instead of its top. + -- Walk forward to find the entry + wrappedIdx covering visibleBottom. local entryIdx = 1 local wrappedIdx = 1 local virtualPos = 0 @@ -499,9 +406,8 @@ ChatLinesInterface = ClassUI(Group) { end end - -- Fill the pool from bottom (poolIdx 1) upward. Each step decrements - -- the wrapped-line cursor; when a continuation chunk runs out, we - -- hop back to the previous valid entry (walking past filtered ones). + -- Fill the pool bottom-up; on continuation exhaustion, hop back to + -- the previous valid entry (skipping filtered ones). local currentVirtualPos = visibleBottom for poolIdx = 1, poolSize do local line = self.ChatLineInterfaces[poolIdx] @@ -545,9 +451,7 @@ ChatLinesInterface = ClassUI(Group) { -- Model reactions --------------------------------------------------------------------------- - --- Called whenever `model.History` fires dirty. Wraps entries we haven't - --- wrapped yet (new arrivals), refreshes the virtual size, and snaps to - --- the bottom so the new line is visible. + --- Wraps new arrivals, refreshes virtual size, snaps to bottom. ---@param self UIChatLinesInterface ---@param history UIChatEntry[] OnHistoryChanged = function(self, history) @@ -561,7 +465,6 @@ ChatLinesInterface = ClassUI(Group) { self:ScrollToBottom() end - -- make sure chat messages stay hidden if window is hidden local windowVisible = ChatModel.GetSingleton().WindowVisible() if not windowVisible then self:Hide() @@ -572,6 +475,7 @@ ChatLinesInterface = ClassUI(Group) { -- Resize hooks (driven by the parent window's resize events) --------------------------------------------------------------------------- + --- Per-frame during a window resize: rebuilds the pool and rewraps in real time. ---@param self UIChatLinesInterface OnResizeLive = function(self) local oldPoolSize = table.getn(self.ChatLineInterfaces) @@ -581,6 +485,7 @@ ChatLinesInterface = ClassUI(Group) { self:CalcVisible() end, + --- Final pass after a window resize: rebuilds the pool and rewraps once more. ---@param self UIChatLinesInterface OnResizeFinished = function(self) local oldPoolSize = table.getn(self.ChatLineInterfaces) @@ -590,8 +495,7 @@ ChatLinesInterface = ClassUI(Group) { self:CalcVisible() end, - --- Empties our trash bag so every derived observer we allocated is - --- destroyed — no `OnDirty` can fire into a torn-down `self`. + --- Destroys derived observers so dangling OnDirty callbacks don't fire into a dead self. ---@param self UIChatLinesInterface OnDestroy = function(self) self.Trash:Destroy() @@ -601,6 +505,7 @@ ChatLinesInterface = ClassUI(Group) { ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-imports this module on save. function __moduleinfo.OnDirty() import(__moduleinfo.name) end diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index c3b962bde69..07c9b8878e8 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -14,11 +14,9 @@ local ChatController = import("/lua/ui/game/chat/ChatController.lua") local Layouter = LayoutHelpers.ReusedLayoutFor ---- Flip to `true` to overlay a semi-transparent coloured bitmap over the ---- control so its bounds are visible at runtime. Each chat interface uses a ---- distinct colour so overlapping controls can be told apart at a glance. local Debug = false +--- One picker row: label, hover backdrop, optional faction badge for player rows, and the recipient it sets. ---@class UIChatListEntry ---@field Text Text ---@field BG Bitmap @@ -26,14 +24,11 @@ local Debug = false ---@field Target UIChatRecipient ------------------------------------------------------------------------------- --- A popup recipient picker. Lists "All", "Allies", and one entry per --- connected non-local human player (sourced from `GetSessionClients`, so --- bots and disconnected players are excluded). Player rows show a small --- faction + team-colour badge next to the name so the right recipient is --- easy to spot. Clicking an entry calls `ChatController.SetRecipient` and --- destroys the popup. Clicking anywhere outside also destroys the popup — --- every open of the list rebuilds from fresh session state. +-- Popup recipient picker. Lists "All", "Allies", and one entry per +-- connected non-local human player (`GetSessionClients` excludes bots and +-- disconnected players). Each open rebuilds from fresh session state. +--- Recipient-picker popup; rebuilt from session state on every open so disconnects drop out. ---@class UIChatListInterface : Group ---@field Entries UIChatListEntry[] ---@field LTBG Bitmap @@ -53,10 +48,8 @@ ChatListInterface = ClassUI(Group) { Group.__init(self, parent, "ChatListInterface") self:DisableHitTest() - -- Popups must sit above the chat window's inner content (line rows, - -- edit area) to receive hover and click events. A plain +1 offset - -- ties with the line rows, which default to ChatLinesInterface+1 — - -- matching the list's default depth. +100 gives unambiguous headroom. + -- +100 keeps us above the line rows' default ChatLinesInterface+1 + -- depth so hover and click events reach us first. LayoutHelpers.DepthOverParent(self, parent, 100) self.Entries = {} @@ -66,7 +59,6 @@ ChatListInterface = ClassUI(Group) { self:CreateBorder() - -- Close on any mouse click outside the popup. local function onOutsideClick() self:Destroy() end UIMain.AddOnMouseClickedFunc(onOutsideClick) @@ -80,12 +72,9 @@ ChatListInterface = ClassUI(Group) { end end, - --- Builds the list of selectable targets: All, Allies, then one entry - --- per connected human player. `GetSessionClients` naturally excludes - --- bots (they are not session clients); we additionally skip the local - --- client (you can't privately message yourself) and any disconnected - --- player. A client's army is found by matching nickname — the target - --- stays an army ID so the send path continues to work unchanged. + --- Targets: All, Allies, then one entry per connected non-local human + --- player. Client matched to army by nickname; target stays an army ID + --- so the send path is unchanged. ---@param self UIChatListInterface ---@return table[] BuildTargetDefs = function(self) @@ -114,9 +103,7 @@ ChatListInterface = ClassUI(Group) { return defs end, - --- Creates a single row: text, highlight bitmap, optional faction badge, - --- and a hover/click handler that dispatches to `ChatController` and - --- closes the popup. Player rows carry a badge; All / Allies rows don't. + --- Builds one row from a target def, including the faction badge for player rows. ---@param self UIChatListInterface ---@param def table ---@return UIChatListEntry @@ -135,7 +122,6 @@ ChatListInterface = ClassUI(Group) { entry.Badge = ChatFactionBadge(self, def.Faction, def.Color) end - -- Capture target in a local so each entry closes over its own value. local target = def.Target entry.BG.HandleEvent = function(bg, event) ChatController.NotifyActivity() @@ -152,8 +138,7 @@ ChatListInterface = ClassUI(Group) { return entry end, - --- Creates the eight decorative border bitmaps that hug the outside of - --- the popup. Layout is applied in `LayoutBorder` from `__post_init`. + --- Eight decorative border bitmaps. Layout applied in `LayoutBorder`. ---@param self UIChatListInterface CreateBorder = function(self) local function makeBitmap(file) @@ -175,7 +160,6 @@ ChatListInterface = ClassUI(Group) { ---@param self UIChatListInterface ---@param parent Control __post_init = function(self, parent) - -- Size self to fit the widest row and the stacked heights. local maxWidth = 0 local totalHeight = 0 for _, entry in ipairs(self.Entries) do @@ -189,12 +173,9 @@ ChatListInterface = ClassUI(Group) { :Height(totalHeight) :End() - -- Left indent reserves room for the faction badge on player rows - -- and keeps All / Allies text aligned with the player names. + -- Left indent reserves room for the faction badge on player rows. local textIndent = 20 - -- Stack entries bottom-up: first at the bottom, each subsequent - -- entry above the previous. for i, entry in ipairs(self.Entries) do local below = i > 1 and self.Entries[i - 1] or nil self:LayoutEntry(entry, below, textIndent) @@ -210,9 +191,7 @@ ChatListInterface = ClassUI(Group) { end end, - --- Lays out one row: the text anchored above `below` (or at the bottom - --- if `below` is nil), an optional faction badge in the indent column, - --- and a highlight bitmap whose bounds track the text row. + --- Lays out one row above the previous one (or pinned to the bottom for the first). ---@param self UIChatListInterface ---@param entry UIChatListEntry ---@param below UIChatListEntry | nil @@ -232,8 +211,6 @@ ChatListInterface = ClassUI(Group) { :End() end - -- Badge (player rows only) sits in the reserved indent, centred - -- vertically on the text row. if entry.Badge then Layouter(entry.Badge) :AtLeftIn(self, 3) @@ -242,11 +219,9 @@ ChatListInterface = ClassUI(Group) { :End() end - -- The highlight bar spans the full row (including the badge area) - -- and sits behind everything in the depth order. Direct LazyVar - -- `:SetFunction` calls match the original `chat.lua` pattern and - -- avoid Layouter's reused-state quirks. The pixel offsets need - -- explicit scaling — the closures bypass Layouter's auto-scale. + -- Direct `:SetFunction` calls bypass Layouter's reused-state + -- pool and skip its auto-scale, so pixel offsets need + -- `ScaleNumber` by hand. local text = entry.Text local bgInsetLeft = LayoutHelpers.ScaleNumber(6) local bgInsetWidth = LayoutHelpers.ScaleNumber(8) @@ -260,7 +235,7 @@ ChatListInterface = ClassUI(Group) { ---@diagnostic enable: undefined-field end, - --- Pins the eight decorative border bitmaps to the outside of self. + --- Pins the eight border bitmaps around our rect. ---@param self UIChatListInterface LayoutBorder = function(self) Layouter(self.LTBG):Right(self.Left):Bottom(self.Top):End() @@ -273,7 +248,7 @@ ChatListInterface = ClassUI(Group) { Layouter(self.BBG):Left(self.Left):Right(self.Right):Top(self.Bottom):End() end, - --- Registers a callback that fires when the popup closes for any reason. + --- Registers a callback to fire when this popup is destroyed (e.g. on outside click). ---@param self UIChatListInterface ---@param callback function SetOnClosed = function(self, callback) diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index fd83b0bf449..2edd9f13461 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -4,33 +4,29 @@ local Create = import("/lua/lazyvar.lua").Create ------------------------------------------------------------------------------- -- Recipient constants, exported so the rest of the system never hardcodes them. ---- Broadcast to every connected client. RecipientAll = 'all' ---- Broadcast to allied players (or all observers when observing). RecipientAllies = 'allies' ---- UI subsystem channel — used by the Notify system (and any future ---- internal chat producer) to flag traffic that isn't user-driven. ---- Not part of `UIChatRecipient` because users can't send to this ---- channel; it appears only on incoming messages. +--- UI subsystem channel for the Notify system. Receive-only — not part of +--- `UIChatRecipient` because users can't send to this channel. RecipientNotify = 'notify' +--- Send target: a known channel constant, or an army ID for a private whisper. ---@alias UIChatRecipient 'all' | 'allies' | number # number = army ID for a private message ------------------------------------------------------------------------------- -- History entry. ---- Location hint carried by a message: either a single point or a bounding ---- rectangle in world space. Sim-originated senders (AI brains, future ---- system messages) populate this instead of `Camera` — the UI translates ---- it to an appropriate camera move on click (`Camera:MoveTo` for a point, ---- `Camera:MoveToRegion` for an area) so the viewer's pitch/heading is not ---- forced to match the sender's. +--- Location hint carried by a sim-originated message (AI brains, system +--- messages). The UI translates this to a camera move on click without +--- forcing the viewer's pitch/heading to match the sender's — unlike +--- `Camera`, which restores a full snapshot. ---@class UIChatEntryLocation ---@field Position? Vector # world-space focus point ---@field Area? Rectangle # world-space rectangle to frame +--- One row in the append-only history log; covers both live receives and replay/sim deliveries. ---@class UIChatEntry ---@field Name string # formatted prefix, e.g. "Sender to allies:" ---@field Text string # raw message body @@ -45,9 +41,7 @@ RecipientNotify = 'notify' ---@field Id? string # near-unique sender-stamped id (`tostring(msg)`); used to dedupe the `Sync.ChatMessages` replay/sim path against the live `SessionSendChatMessage` path ---@field WrappedText? string[] # view-side cache: text wrapped to the current row width (populated by ChatInterface) -------------------------------------------------------------------------------- --- Model. - +--- Reactive chat-state singleton — the single source of truth shared by every chat view. ---@class UIChatModel ---@field History LazyVar # append-only message log (set a new table ref to trigger dirty) ---@field Recipient LazyVar # current send target @@ -55,10 +49,11 @@ RecipientNotify = 'notify' ---@field LastActivity LazyVar # `GetSystemTimeSeconds()` of the most recent user / receive activity; observed by the chat window's idle / fade timer ---@field Pinned LazyVar # title-bar pin checkbox; while true the chat window's idle auto-close is suspended +--- Singleton handle; nil until `SetupSingleton` (or `GetSingleton`) builds the model. ---@type UIChatModel | nil local ModelInstance = nil ---- Creates and initializes the model singleton. +--- Allocates a fresh model singleton, replacing any existing instance. ---@return UIChatModel function SetupSingleton() ModelInstance = { @@ -71,7 +66,7 @@ function SetupSingleton() return ModelInstance end ---- Returns the model singleton, creating it if it does not exist yet. +--- Returns the model singleton, creating it on first access. ---@return UIChatModel function GetSingleton() if not ModelInstance then @@ -83,6 +78,8 @@ end ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: rebuilds the singleton on the new module and copies +--- the current LazyVar values across so observers don't see a state reset. ---@param newModule any function __moduleinfo.OnReload(newModule) if ModelInstance then @@ -95,6 +92,7 @@ function __moduleinfo.OnReload(newModule) end end +--- Hot-reload hook: re-imports this module after a couple of frames. function __moduleinfo.OnDirty() ForkThread( function() diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua index 9aed101c15e..829f7a54a34 100644 --- a/lua/ui/game/chat/ChatUtils.lua +++ b/lua/ui/game/chat/ChatUtils.lua @@ -3,29 +3,17 @@ local MauiWrapText = import("/lua/maui/text.lua").WrapText local ChatPayload = import("/lua/shared/ChatPayload.lua") ------------------------------------------------------------------------------- --- Shared, view-agnostic helpers for the chat tree. Each function operates --- on a `UIChatEntry` (or related primitive) and stays free of any one --- view's internal layout — anything that both `ChatLinesInterface` and --- `ChatFeedInterface` (or future views) can reuse without coupling them --- to each other lives here. +-- Shared, view-agnostic helpers for the chat tree. ---- Re-export of the chat-message length cap; the single source of truth ---- lives in `/lua/shared/ChatPayload.lua` so the sim relay and the UI ---- receive path can't drift on the bound. Call sites that already ---- reference `ChatUtils.MaxMessageLength` (the edit box's `SetMaxChars`, ---- the receive validator) keep working without learning a new path. +--- Re-export of the chat-message length cap. Source of truth is +--- `/lua/shared/ChatPayload.lua` so the sim relay and the UI can't drift. MaxMessageLength = ChatPayload.MaxMessageLength ---- Recipient-label / chat-line-prefix descriptors. Conceptually a label ---- table — the keys are not recipient constants but localization ---- categories, even when the string happens to coincide with a recipient ---- value (`all`/`allies`/`notify`). The receiver indexes by `msg.to` and ---- falls back to `private` for whispers; the edit interface and config ---- dialog look up `to` for the generic "To :" prefix. Loc keys ---- mirror the legacy `chat.lua` table so the rendered prefix reads ---- identically. Each entry carries a `text` (lowercase, e.g. ---- `"to all:"`), a `caps` (titlecase, e.g. `"To All:"`), and a ---- `colorkey` resolved against the palette at render time. +--- Recipient-label / chat-line-prefix descriptors. Keys are localization +--- categories, not recipient constants — receiver indexes by `msg.to` and +--- falls back to `private` for whispers. Each entry has a `text` +--- (lowercase), a `caps` (titlecase), and a `colorkey` resolved at render +--- time. ToStrings = { all = { text = 'to all:', caps = 'To All:', colorkey = 'all_color' }, allies = { text = 'to allies:', caps = 'To Allies:', colorkey = 'allies_color' }, @@ -34,12 +22,9 @@ ToStrings = { to = { text = 'to', caps = 'To', colorkey = 'all_color' }, } ---- 8-colour swatch palette indexed by `ChatConfigModel` colour keys ---- (`all_color`, `allies_color`, `priv_color`, `link_color`, ---- `notify_color`). The config dialog renders these as `BitmapCombo` ---- choices; `ChatLineInterface.SetHeader` looks them up at render time ---- via `entry.ColorKey` so palette changes take effect on the next ---- `CalcVisible` pass without a full rebuild. +--- 8-colour swatch palette indexed by `ChatConfigModel` colour keys. +--- Looked up at render time via `entry.ColorKey`, so palette changes +--- take effect on the next `CalcVisible` pass without a rebuild. ColorPalette = { 'ffffffff', -- 1: white 'ffff4242', -- 2: red @@ -51,20 +36,11 @@ ColorPalette = { 'ffff9f42', -- 8: orange } ---- Wraps an entry's body text against `measureLine`'s row width and stores ---- the result as `entry.WrappedText`. The first wrapped chunk reserves ---- horizontal space for the entry's name prefix (so the body starts ---- after `Name.Right + 4`); subsequent chunks span the full body width ---- starting from the team-colour column. ---- ---- Always overwrites `entry.WrappedText` — callers gate (`if not ---- entry.WrappedText then ... end`) for the cache-hit path; resize and ---- font-size changes call this directly to force a fresh wrap. ---- ---- With `measureLine == nil` we degrade to a single-chunk wrap that just ---- hands back the raw text; lets callers without a measurement source ---- (an empty pool, a standalone-launched debug feed) still produce ---- something renderable instead of crashing on the missing controls. +--- Wraps an entry's body text and caches it as `entry.WrappedText`. The +--- first chunk reserves space for the name prefix; subsequent chunks span +--- the full body width. Always overwrites — callers gate on the cache. +--- `measureLine == nil` degrades to a single-chunk wrap so callers without +--- a measurement source still get something renderable. ---@param entry UIChatEntry ---@param measureLine UIChatLineInterface | nil function WrapEntry(entry, measureLine) @@ -95,6 +71,7 @@ end ------------------------------------------------------------------------------- --#region Debugging +--- Hot-reload hook: re-imports this module on save. function __moduleinfo.OnDirty() import(__moduleinfo.name) end From f54c64f8c1950cb35246b4afaae4347a86f2db59 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 22:11:46 +0200 Subject: [PATCH 118/130] Add support for ctrl + click to copy to clipboard --- lua/ui/controls/floattext.lua | 95 +++++++++++++++++++++++++ lua/ui/game/chat/ChatLineInterface.lua | 15 ++-- lua/ui/game/chat/ChatLinesInterface.lua | 51 +++++++++---- 3 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 lua/ui/controls/floattext.lua diff --git a/lua/ui/controls/floattext.lua b/lua/ui/controls/floattext.lua new file mode 100644 index 00000000000..f3817af91ee --- /dev/null +++ b/lua/ui/controls/floattext.lua @@ -0,0 +1,95 @@ + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local UIUtil = import("/lua/ui/uiutil.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- Padding (unscaled) between the inner text and the background's edges. +local PaddingX = 8 +local PaddingY = 2 + +------------------------------------------------------------------------------- +-- A short-lived overlay text + background that floats vertically while +-- fading, then destroys itself. Useful for ephemeral feedback like +-- "Copied to clipboard!" toasts. The caller anchors `Left`/`Top`; the +-- Group sizes itself to fit its inner text. + +--- Animation parameters for `FloatText:Float`. All fields optional. +---@class UIFloatTextOptions +---@field Distance? number # vertical pixels to travel (positive = up); default 30 +---@field Duration? number # animation length in seconds; default 1.2 + +--- An auto-destroying floating text-with-background control. +---@class UIFloatText : Group +---@field Background Bitmap +---@field Text Text +FloatText = ClassUI(Group) { + + ---@param self UIFloatText + ---@param parent Control + ---@param text string + ---@param fontSize? number # default 14 + ---@param font? string # default `UIUtil.bodyFont` + ---@param color? string # ARGB hex; default 'ffffffff' + ---@param backgroundColor? string # ARGB hex; default '80000000' (semi-transparent black) + __init = function(self, parent, text, fontSize, font, color, backgroundColor) + Group.__init(self, parent, "FloatText") + + self.Background = Bitmap(self) + self.Background:SetSolidColor(backgroundColor or '80000000') + self.Background:DisableHitTest() + + self.Text = UIUtil.CreateText(self, text, fontSize or 14, font or UIUtil.bodyFont) + self.Text:SetColor(color or 'ffffffff') + self.Text:SetDropShadow(true) + self.Text:DisableHitTest() + + self:DisableHitTest() + end, + + ---@param self UIFloatText + __post_init = function(self) + -- Auto-fit the Text to its rendered string so the parent Group + -- can wrap it tightly. `TextAdvance` / `FontAscent` / `FontDescent` + -- are LazyVars exposed by the engine's `moho.text_methods`. + self.Text.Width:Set(function() return self.Text.TextAdvance() end) + self.Text.Height:Set(function() + return self.Text.FontAscent() + self.Text.FontDescent() + end) + + Layouter(self.Text):AtLeftTopIn(self, PaddingX, PaddingY):End() + Layouter(self.Background):Fill(self):End() + + local scaledPadX2 = LayoutHelpers.ScaleNumber(PaddingX * 2) + local scaledPadY2 = LayoutHelpers.ScaleNumber(PaddingY * 2) + self.Width:Set(function() return self.Text.Width() + scaledPadX2 end) + self.Height:Set(function() return self.Text.Height() + scaledPadY2 end) + end, + + --- Starts the float-up + fade-out animation. The control moves + --- `distance` pixels upward over `duration` seconds, fading from full + --- opacity to zero, then destroys itself. The alpha cascade applies + --- to the background bitmap and the text via `SetAlpha(_, true)`. + ---@param self UIFloatText + ---@param opts? UIFloatTextOptions + Float = function(self, opts) + opts = opts or {} + local duration = opts.Duration or 1.2 + local scaledDistance = LayoutHelpers.ScaleNumber(opts.Distance or 30) + local startTop = self.Top() + local startTime = GetSystemTimeSeconds() + + self:SetNeedsFrameUpdate(true) + self.OnFrame = function(control) + local t = (GetSystemTimeSeconds() - startTime) / duration + if t >= 1 then + control:Destroy() + return + end + control.Top:Set(startTop - scaledDistance * t) + control:SetAlpha(1 - t, true) + end + end, +} diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index bd1c47edc86..ed48ad620f5 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -75,7 +75,7 @@ ChatLineInterface = ClassUI(Group) { -- it — no need to gate dispatch on row role. self.Name.HandleEvent = function(_, event) if event.Type == 'ButtonPress' and self.Entry then - self:OnNameClicked(self.Entry) + self:OnNameClicked(self.Entry, event) end end @@ -87,7 +87,7 @@ ChatLineInterface = ClassUI(Group) { self.CamIcon:DisableHitTest() self.CamIcon.HandleEvent = function(_, event) if event.Type == 'ButtonPress' and self.Entry then - self:OnCameraClicked(self.Entry) + self:OnCameraClicked(self.Entry, event) end end @@ -97,7 +97,7 @@ ChatLineInterface = ClassUI(Group) { self.Text:SetClipToWidth(true) self.Text.HandleEvent = function(_, event) if event.Type == 'ButtonPress' and self.Entry then - self:OnBodyClicked(self.Entry) + self:OnBodyClicked(self.Entry, event) end end end, @@ -228,18 +228,21 @@ ChatLineInterface = ClassUI(Group) { --- their hit rect collapses — only header rows fire in practice. ---@param self UIChatLineInterface ---@param entry UIChatEntry - OnNameClicked = function(self, entry) end, + ---@param event KeyEvent + OnNameClicked = function(self, entry, event) end, --- Overridable; default no-op. Fires for both header and continuation --- rows — they share the entry so the click resolves to the right sender. ---@param self UIChatLineInterface ---@param entry UIChatEntry - OnBodyClicked = function(self, entry) end, + ---@param event KeyEvent + OnBodyClicked = function(self, entry, event) end, --- Overridable; default no-op. Only header rows show the icon. ---@param self UIChatLineInterface ---@param entry UIChatEntry - OnCameraClicked = function(self, entry) end, + ---@param event KeyEvent + OnCameraClicked = function(self, entry, event) end, --- Updates the name and body fonts. Row height tracks the name font. ---@param self UIChatLineInterface diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index ab8b13fa3f4..d7e0e94ddaf 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -4,6 +4,7 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local FloatText = import("/lua/ui/controls/floattext.lua").FloatText local ChatLineInterface = import("/lua/ui/game/chat/ChatLineInterface.lua").ChatLineInterface @@ -25,9 +26,11 @@ local ScrollbarReserve = 32 -- A self-contained chat-lines panel: outer wrapper, inner pool of line rows, -- and the vertical scrollbar. -- --- Click hooks (`OnNameClicked`, `OnCameraClicked`) are overridable instance --- fields. Default `OnNameClicked` is a no-op (window-level concern); default --- `OnCameraClicked` jumps the world camera to the entry's hint. +-- Click hooks (`OnNameClicked`, `OnBodyClicked`, `OnCameraClicked`) are +-- overridable instance fields. Default `OnNameClicked` is a no-op +-- (window-level concern); default `OnBodyClicked` copies the entry text to +-- the clipboard on Ctrl+click; default `OnCameraClicked` jumps the world +-- camera to the entry's hint. --- Wrapped chat panel: line-row pool, scrollbar, history/options observers. Owns wrap and scroll state. ---@class UIChatLinesInterface : Group @@ -39,10 +42,12 @@ local ScrollbarReserve = 32 ---@field VirtualSize number # total wrapped lines across valid entries ---@field HistoryObserver LazyVar ---@field OptionsObserver LazyVar ----@field LineNameClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared row-name click handler; captures `self` so pool lines don't allocate per-row closures ----@field LineCameraClicked fun(line: UIChatLineInterface, entry: UIChatEntry) # shared cam-icon click handler; captures `self` for the same reason ----@field OnNameClicked fun(entry: UIChatEntry) # overridable: replace to react to a sender-name click ----@field OnCameraClicked fun(entry: UIChatEntry) # overridable: replace to override camera-link behaviour +---@field LineNameClicked fun(line: UIChatLineInterface, entry: UIChatEntry, event: KeyEvent) # shared row-name click handler; captures `self` so pool lines don't allocate per-row closures +---@field LineBodyClicked fun(line: UIChatLineInterface, entry: UIChatEntry, event: KeyEvent) # shared body click handler; captures `self` for the same reason +---@field LineCameraClicked fun(line: UIChatLineInterface, entry: UIChatEntry, event: KeyEvent) # shared cam-icon click handler; captures `self` for the same reason +---@field OnNameClicked fun(entry: UIChatEntry, event: KeyEvent) # overridable: replace to react to a sender-name click +---@field OnBodyClicked fun(entry: UIChatEntry, event: KeyEvent) # overridable: replace to react to a body click (default copies on Ctrl+click) +---@field OnCameraClicked fun(entry: UIChatEntry, event: KeyEvent) # overridable: replace to override camera-link behaviour ---@field DebugBG? Bitmap # semi-transparent overlay shown when `Debug` is true ChatLinesInterface = ClassUI(Group) { @@ -66,8 +71,27 @@ ChatLinesInterface = ClassUI(Group) { self.Pool.ScrollSetTop = function(_, axis, top) self:ScrollSetTop(axis, top) end self.Pool.IsScrollable = function(_, axis) return self:IsScrollable(axis) end - self.OnNameClicked = function(entry) end - self.OnCameraClicked = function(entry) + self.OnNameClicked = function(entry, event) end + self.OnBodyClicked = function(entry, event) + if event.Modifiers and event.Modifiers.Ctrl then + if CopyToClipboard(entry.Text or '') then + -- Parent to the engine frame so the `event.MouseX/Y` + -- screen coords map straight to Left/Top without going + -- through the Layouter's `pixelScaleFactor` scaling. + -- `event.MouseX/Y` carry the actual click position; + -- `GetMouseScreenPos()` would freeze at the last + -- pre-UI-occlusion position. + local mouseX, mouseY = event.MouseX, event.MouseY + local toast = FloatText(GetFrame(0), "Copied to clipboard!") + -- Center horizontally on the cursor; the Width LazyVar + -- settles after the inner Text is measured. + toast.Left:SetFunction(function() return mouseX - toast.Width() / 2 end) + toast.Top:Set(mouseY - LayoutHelpers.ScaleNumber(30)) + toast:Float() + end + end + end + self.OnCameraClicked = function(entry, event) local cam = GetCamera('WorldCamera') if entry.Location then if entry.Location.Area then @@ -83,10 +107,11 @@ ChatLinesInterface = ClassUI(Group) { end -- Built once so pool growth never allocates a per-row closure. - -- Each forwarder reads `self.OnNameClicked` on every call, so + -- Each forwarder reads `self.OnXxxClicked` on every call, so -- replacing the hook later doesn't require re-wiring the rows. - self.LineNameClicked = function(_, entry) self.OnNameClicked(entry) end - self.LineCameraClicked = function(_, entry) self.OnCameraClicked(entry) end + self.LineNameClicked = function(_, entry, event) self.OnNameClicked(entry, event) end + self.LineBodyClicked = function(_, entry, event) self.OnBodyClicked(entry, event) end + self.LineCameraClicked = function(_, entry, event) self.OnCameraClicked(entry, event) end local model = ChatModel.GetSingleton() self.HistoryObserver = self.Trash:Add( @@ -161,6 +186,7 @@ ChatLinesInterface = ClassUI(Group) { self.ChatLineInterfaces[1] = ChatLineInterface(pool) self.ChatLineInterfaces[1]:SetFontSize(fontSize) self.ChatLineInterfaces[1].OnNameClicked = self.LineNameClicked + self.ChatLineInterfaces[1].OnBodyClicked = self.LineBodyClicked self.ChatLineInterfaces[1].OnCameraClicked = self.LineCameraClicked Layouter(self.ChatLineInterfaces[1]) :AtLeftBottomIn(pool) @@ -178,6 +204,7 @@ ChatLinesInterface = ClassUI(Group) { self.ChatLineInterfaces[i] = ChatLineInterface(pool) self.ChatLineInterfaces[i]:SetFontSize(fontSize) self.ChatLineInterfaces[i].OnNameClicked = self.LineNameClicked + self.ChatLineInterfaces[i].OnBodyClicked = self.LineBodyClicked self.ChatLineInterfaces[i].OnCameraClicked = self.LineCameraClicked Layouter(self.ChatLineInterfaces[i]) :Above(self.ChatLineInterfaces[i - 1]) From 9002d911ef330cb0b208d1f299db797800228373 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sun, 26 Apr 2026 22:19:29 +0200 Subject: [PATCH 119/130] Use square root for fade out --- lua/ui/controls/floattext.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lua/ui/controls/floattext.lua b/lua/ui/controls/floattext.lua index f3817af91ee..3b25694b964 100644 --- a/lua/ui/controls/floattext.lua +++ b/lua/ui/controls/floattext.lua @@ -47,6 +47,12 @@ FloatText = ClassUI(Group) { self.Text:DisableHitTest() self:DisableHitTest() + + -- Pin above everything else currently on the frame so the toast + -- isn't occluded by windows / popups / map dialogs that sit at a + -- higher depth than the toast's own parent. Same trick as the + -- chat config dialog uses on Open. + self.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) end, ---@param self UIFloatText @@ -89,7 +95,7 @@ FloatText = ClassUI(Group) { return end control.Top:Set(startTop - scaledDistance * t) - control:SetAlpha(1 - t, true) + control:SetAlpha(math.sqrt(1 - t), true) end end, } From 60bd41d5ccb1ec86de42aec8accec33f490d2009 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:07:59 +0200 Subject: [PATCH 120/130] Rename the chat brain component --- lua/ChatUtils.lua | 2 +- lua/aibrain.lua | 6 ++--- .../{chat.lua => ChatBrainComponent.lua} | 26 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) rename lua/aibrains/components/{chat.lua => ChatBrainComponent.lua} (89%) diff --git a/lua/ChatUtils.lua b/lua/ChatUtils.lua index 0a28151c913..27435816d2b 100644 --- a/lua/ChatUtils.lua +++ b/lua/ChatUtils.lua @@ -46,7 +46,7 @@ end --- Writes `msg` onto `Sync.ChatMessages` only if the local client is a --- legitimate recipient. Shared entry point for every sim-originated --- chat emitter (the `SendChatMessage` callback for UI-sent messages, the ---- `AIBrainChatComponent` for AI-emitted lines, and any future sim system +--- `AIChatBrainComponent` for AI-emitted lines, and any future sim system --- that wants to drop a line into the chat feed) so the recipient policy --- is enforced sim-side in exactly one place. ---@param msg ChatPayload diff --git a/lua/aibrain.lua b/lua/aibrain.lua index cf82c69743d..f4e4543692b 100644 --- a/lua/aibrain.lua +++ b/lua/aibrain.lua @@ -23,7 +23,7 @@ local FactoryManagerBrainComponent = import("/lua/aibrains/components/factoryman local JammerManagerBrainComponent = import("/lua/aibrains/components/jammermanagerbraincomponent.lua").JammerManagerBrainComponent local StatManagerBrainComponent = import("/lua/aibrains/components/statmanagerbraincomponent.lua").StatManagerBrainComponent local EnergyManagerBrainComponent = import("/lua/aibrains/components/energymanagerbraincomponent.lua").EnergyManagerBrainComponent -local AIBrainChatComponent = import("/lua/aibrains/components/chat.lua").AIBrainChatComponent +local AIChatBrainComponent = import("/lua/aibrains/components/ChatBrainComponent.lua").AIChatBrainComponent ---@class TriggerSpec ---@field Callback function @@ -50,7 +50,7 @@ local BrainGetUnitsAroundPoint = moho.aibrain_methods.GetUnitsAroundPoint local BrainGetListOfUnits = moho.aibrain_methods.GetListOfUnits local CategoriesDummyUnit = categories.DUMMYUNIT ----@class AIBrain: FactoryManagerBrainComponent, StatManagerBrainComponent, JammerManagerBrainComponent, EnergyManagerBrainComponent, StorageManagerBrainComponent, AIBrainChatComponent, moho.aibrain_methods +---@class AIBrain: FactoryManagerBrainComponent, StatManagerBrainComponent, JammerManagerBrainComponent, EnergyManagerBrainComponent, StorageManagerBrainComponent, AIChatBrainComponent, moho.aibrain_methods ---@field AI boolean ---@field Name string # Army name ---@field Nickname string # Player / AI / character name @@ -67,7 +67,7 @@ local CategoriesDummyUnit = categories.DUMMYUNIT ---@field LastUnitKilledBy Army # Which army last killed one of our units. Used for transfering to killer in other victory conditions. ---@field Army integer # Cached `GetArmyIndex` engine call AIBrain = Class(FactoryManagerBrainComponent, StatManagerBrainComponent, JammerManagerBrainComponent, - EnergyManagerBrainComponent, StorageManagerBrainComponent, AIBrainChatComponent, moho.aibrain_methods) { + EnergyManagerBrainComponent, StorageManagerBrainComponent, AIChatBrainComponent, moho.aibrain_methods) { Status = 'InProgress', diff --git a/lua/aibrains/components/chat.lua b/lua/aibrains/components/ChatBrainComponent.lua similarity index 89% rename from lua/aibrains/components/chat.lua rename to lua/aibrains/components/ChatBrainComponent.lua index 55536e876ca..a65ab6818e4 100644 --- a/lua/aibrains/components/chat.lua +++ b/lua/aibrains/components/ChatBrainComponent.lua @@ -23,28 +23,28 @@ local ChatUtils = import("/lua/chatutils.lua") --- point (viewer's pitch/heading/zoom preserved) or `MoveToRegion` for an --- area (framing computed automatically). Only one of the two is used; if --- both are present `Area` wins. ----@class AIBrainChatLocation +---@class AIChatLocation ---@field Position? Vector # world-space focus point ---@field Area? Rectangle # world-space rectangle to frame ----@class AIBrainChatComponent -AIBrainChatComponent = ClassSimple { +---@class AIChatBrainComponent +AIChatBrainComponent = ClassSimple { --- Broadcasts a message to every connected UI as an "all" chat line. - ---@param self AIBrainChatComponent + ---@param self AIChatBrainComponent ---@param text string ---@param args? any[] # optional `string.format` arguments; UI applies `LOCF(text, unpack(args))` on receive - ---@param location? AIBrainChatLocation + ---@param location? AIChatLocation SendChatToAll = function(self, text, args, location) self:SendChatTo('all', text, args, location) end, --- Broadcasts a message to the AI's allies. `Sync.ChatMessages` reaches --- every UI, so the non-ally filter is applied client-side on display. - ---@param self AIBrainChatComponent + ---@param self AIChatBrainComponent ---@param text string ---@param args? any[] - ---@param location? AIBrainChatLocation + ---@param location? AIChatLocation SendChatToAllies = function(self, text, args, location) self:SendChatTo('allies', text, args, location) end, @@ -52,11 +52,11 @@ AIBrainChatComponent = ClassSimple { --- Whispers a message to a specific army. No ally constraint — the AI is --- trusted sim code and may legitimately taunt an enemy or message a --- neutral party. - ---@param self AIBrainChatComponent + ---@param self AIChatBrainComponent ---@param army integer ---@param text string ---@param args? any[] - ---@param location? AIBrainChatLocation + ---@param location? AIChatLocation SendChatToPlayer = function(self, army, text, args, location) self:SendChatTo(army, text, args, location) end, @@ -66,10 +66,10 @@ AIBrainChatComponent = ClassSimple { --- that should only reach the army the event happened to (resource --- gifts received, ACU under attack, etc.) — `IsLocalRecipient` --- ensures only that army's UI renders the line. - ---@param self AIBrainChatComponent | AIBrain + ---@param self AIChatBrainComponent | AIBrain ---@param text string ---@param args? any[] - ---@param location? AIBrainChatLocation + ---@param location? AIChatLocation SendChatToSelf = function(self, text, args, location) self:SendChatTo(self:GetArmyIndex(), text, args, location) end, @@ -93,11 +93,11 @@ AIBrainChatComponent = ClassSimple { --- the UI as `entry.Location` — the click handler in `ChatInterface` --- translates it to a `MoveTo`/`MoveToRegion` call at click time, so --- there is no need to synthesise a camera snapshot sim-side. - ---@param self AIBrainChatComponent | AIBrain + ---@param self AIChatBrainComponent | AIBrain ---@param to AIBrainChatRecipient ---@param text string ---@param args? any[] - ---@param location? AIBrainChatLocation + ---@param location? AIChatLocation SendChatTo = function(self, to, text, args, location) if type(text) ~= 'string' or text == '' then return end From b65678e8f8e730170c50c8a1e1da594ca9dfe75d Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:09:17 +0200 Subject: [PATCH 121/130] Remove legacy hook that isn't used at this stage --- lua/ChatUtils.lua | 15 --------------- lua/SimUtils.lua | 1 - 2 files changed, 16 deletions(-) diff --git a/lua/ChatUtils.lua b/lua/ChatUtils.lua index 27435816d2b..5a6bd7375c8 100644 --- a/lua/ChatUtils.lua +++ b/lua/ChatUtils.lua @@ -57,21 +57,6 @@ function RelayChatMessage(msg) end end ---- Legacy replay hook kept for external callers that may still reference it. ---- The refactored chat path no longer uses this — chat is now relayed through ---- `Sync.ChatMessages` (see `SendChatMessage`) and external replay parsers ---- read the `Sender`/`Msg` fields off the recorded `GiveResourcesToPlayer` ---- callback args, which the UI sender emits once per outgoing message. ----@param data {Sender: integer, Msg: string} -function SendChatToReplay(data) - if data.Sender and data.Msg then - if not Sync.UnitData.Chat then - Sync.UnitData.Chat = {} - end - table.insert(Sync.UnitData.Chat, { sender = data.Sender, msg = data.Msg }) - end -end - --- Relays a chat message from a UI client back to every UI client via --- `Sync.ChatMessages`. The sender field is taken from the command source --- and written into `Msg.From` so clients can't spoof the originating army. diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua index efeb57e0ca8..75aadc2c6f1 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -1506,7 +1506,6 @@ function SetOfferDraw(data) end -- Chat-relay helpers moved to `/lua/ChatUtils.lua`: --- * `SendChatToReplay` — legacy `Sync.UnitData.Chat` writer, kept for mods. -- * `SendChatMessage` — trusted sim relay that feeds `Sync.ChatMessages`. ---@param data {From: Army, To: Army, Mass: number, Energy: number, Sender?: string, Msg?: table} From af0c9ee50dd91692be1129a74c27df6ccafa93b7 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:11:44 +0200 Subject: [PATCH 122/130] Compact comments of chat payload --- lua/shared/ChatPayload.lua | 67 +++++++++++++------------------------- 1 file changed, 23 insertions(+), 44 deletions(-) diff --git a/lua/shared/ChatPayload.lua b/lua/shared/ChatPayload.lua index 711f05b2fa9..41e7a793854 100644 --- a/lua/shared/ChatPayload.lua +++ b/lua/shared/ChatPayload.lua @@ -1,11 +1,9 @@ --- Pure, side-agnostic shape validation for chat payloads. Loaded by both the --- sim relay (`/lua/ChatUtils.lua`) and the UI receive path --- (`/lua/ui/game/chat/ChatController.lua`) so the shape rules and the length --- cap can't drift between sender and receiver. --- --- Anything that needs session context (sender identity, focus army, ally --- relationships, replay state) belongs on the call site, not here. +-- Side-agnostic chat-payload validation, shared by the sim relay +-- (`/lua/ChatUtils.lua`) and the UI receive path +-- (`/lua/ui/game/chat/ChatController.lua`) so shape rules and the length +-- cap can't drift. Session-context checks (sender identity, focus army, +-- ally relationships, replay state) belong at the call sites. ---@alias ChatPayloadRecipient ---| 'all' # broadcast to every connected client @@ -13,13 +11,10 @@ ---| 'notify' # UI subsystem channel — internal traffic, not player chat ---| number # army ID for a private whisper ---- Wire-format chat payload — what travels through both ---- `SessionSendChatMessage` and the sim-routed `Sync.ChatMessages`. Defined ---- here so the sim relay (`/lua/ChatUtils.lua`) and the UI receive path ---- (`/lua/ui/game/chat/ChatController.lua`) validate against the same ---- contract. `From` is intentionally optional: originating clients leave it ---- blank and the sim relay overwrites it with the trusted command-source ---- army before broadcasting. +--- Wire-format chat payload — what travels through `SessionSendChatMessage` +--- and the sim-routed `Sync.ChatMessages`. `From` is filled by the sim relay +--- (originating clients leave it blank), so every consumer past +--- `RelayChatMessage` sees it set. ---@class ChatPayload ---@field Chat true # must be exactly `true` — gate flag for the chat handlers ---@field text string # UTF-8 message body, length capped at `MaxMessageLength` @@ -30,30 +25,18 @@ ---@field location? table # lightweight location hint — see `UIChatEntryLocation` for the inner shape ---@field Args? any[] # `LOCF`-style format args spread alongside `text` on render ---@field Id? string # sender-stamped near-unique id; dedupes the two delivery paths ----@field From number # sim-stamped trusted sender army index — written by the relay before broadcast, so every consumer past `RelayChatMessage` sees it set +---@field From number # sim-stamped trusted sender army index — written by the relay before broadcast --- Maximum UTF-8 character length for a chat message body. The UI edit box ---- enforces this on input via `Edit:SetMaxChars`; both the sim relay and ---- the UI receive path gate on the same bound so a peer that bypassed the ---- input cap can't push every client into laying out arbitrarily long ---- lines. +--- enforces this on input; the sim relay and the receive path gate on the +--- same bound so a peer that bypassed the input cap can't push the session +--- into laying out arbitrarily long lines. MaxMessageLength = 200 ---- Type guard for the `ChatPayload` shape. Returns `true` only when every ---- required field is present with the expected type and every optional ---- field, when present, has the engine-API shape it must have for ---- downstream rendering / camera-jump code to treat it safely. After a ---- `true` return, callers can narrow with `--[[@as ChatPayload]]`. ---- ---- Each rule is its own `return false` — malformed input is dropped, never ---- coerced or "repaired". A peer that ships an inconsistent shape is ---- either modded, buggy, or hostile; in any of those cases letting the ---- message through would let manipulated traffic render somewhere it ---- shouldn't. ---- ---- The recipient set permits `'notify'` (the UI subsystem channel). Sim ---- callers that don't relay `'notify'` traffic must reject it separately ---- at their call site. +--- Type guard for `ChatPayload`. Callers can narrow with +--- `--[[@as ChatPayload]]` after a `true` return. Permits `'notify'` +--- recipients; sim callers that don't relay notify traffic must reject it +--- separately. ---@param msg any ---@return boolean function IsValidPayload(msg) @@ -62,10 +45,9 @@ function IsValidPayload(msg) if type(msg.text) ~= 'string' or msg.text == '' then return false end if STR_Utf8Len(msg.text) > MaxMessageLength then return false end - -- Recipient must be one of the supported shapes. Without this guard, - -- a bare string like 'admin' or a non-string truthy value would fall - -- through to the UI's recipient-formatting fallback and let a peer - -- fake a "to you:" header on what is actually a broadcast. + -- Without this guard, a bare string like 'admin' would fall through to + -- the UI's recipient-formatting fallback and let a peer fake a "to you:" + -- header on what is actually a broadcast. if msg.to ~= 'all' and msg.to ~= 'allies' and msg.to ~= 'notify' @@ -73,14 +55,11 @@ function IsValidPayload(msg) return false end - -- Optional payloads consumed UI-side by `WorldCamera:RestoreSettings` - -- and the camera-link click handler must be tables; reject other - -- shapes here so malformed values don't crash those handlers on click. + -- Optional payloads must be tables — `WorldCamera:RestoreSettings` and + -- the camera-link click handler crash on non-table inputs. if msg.camera ~= nil and type(msg.camera) ~= 'table' then return false end if msg.location ~= nil and type(msg.location) ~= 'table' then return false end - - -- Optional `Args` payload used by `LOCF`-style format-on-receive lines. - if msg.Args ~= nil and type(msg.Args) ~= 'table' then return false end + if msg.Args ~= nil and type(msg.Args) ~= 'table' then return false end return true end From d841287def7a52aa34a6d61126e1800460d7dc90 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:12:35 +0200 Subject: [PATCH 123/130] Only share the resources that the recipient can receive --- lua/SimUtils.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua index 75aadc2c6f1..399ff1236d6 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -1544,8 +1544,8 @@ function GiveResourcesToPlayer(data) local massGiven = math.min(massTaken, massCapacity) local energyGiven = math.min(energyTaken, energyCapacity) - toBrain:GiveResource('MASS', massTaken) - toBrain:GiveResource('ENERGY', energyTaken) + toBrain:GiveResource('MASS', massGiven) + toBrain:GiveResource('ENERGY', energyGiven) -- Whisper from giver → receiver so the line reads with the giver's -- attribution. Three LOC keys rather than one templated string so each From 17bb872e9f971d7c0c15a9aac06fb5f85aff3663 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:14:38 +0200 Subject: [PATCH 124/130] Update description --- lua/ui/game/chat/commands/builtin/DebugDumpControls.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua index b933f06a76d..25947005051 100644 --- a/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua +++ b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua @@ -3,7 +3,7 @@ ---@type UIChatCommand Command = { Name = 'debug-dump-controls', - Description = 'Toggle the Alt+click army-switch debug shortcut.', + Description = 'Dump the UI control hierarchy under the cursor to the log.', ShouldRegister = function() return HasCommandLineArg('/debug') end, From 08b8f558e33be52539814f77bbe7f06f52892767 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:22:28 +0200 Subject: [PATCH 125/130] De-duplicate messages in `OnReceive` to prevent race conditions --- lua/ui/game/chat/ChatController.lua | 43 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 481cb444421..3cfc797b552 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -260,9 +260,24 @@ end ------------------------------------------------------------------------------- -- Receiving (network) +--- Returns true when `msg.Id` matches an entry already in history. The same +--- chat message arrives via both delivery paths in live play (engine +--- `SessionSendChatMessage` and sim `Sync.ChatMessages`), so whichever lands +--- first seeds the entry and the second is dropped here. +---@param msg ChatPayload +---@return boolean +local function IsDuplicateMessage(msg) + if not msg.Id then return false end + local history = ChatModel.GetSingleton().History() + for _, entry in history do + if entry.Id == msg.Id then return true end + end + return false +end + --- Handler registered with `gamemain.RegisterChatFunc`. Validates the ---- message, delegates Notify-subsystem messages, resolves the sender's army ---- data, and appends a chat line. +--- message, dedupes against history, delegates Notify-subsystem messages, +--- resolves the sender's army data, and appends a chat line. ---@param sender string ---@param msg ChatPayload function OnReceive(sender, msg) @@ -275,6 +290,7 @@ function OnReceive(sender, msg) -- payload can't be trusted. Sender / observer consistency below needs -- session context the shared validator can't see. if not ChatPayload.IsValidPayload(msg) then return end + if IsDuplicateMessage(msg) then return end -- LOCF-style format-on-receive: when the sender ships `Args`, treat -- `msg.text` as a `string.format` template (typically a `` tag) @@ -329,27 +345,16 @@ end --- Handler for the `Sync.ChatMessages` category, populated by the sim-side --- `SendChatMessage` callback. In live play the same message also arrives ---- via `SessionSendChatMessage` (`OnReceive`); whichever path lands first ---- seeds the entry's `Id` and this handler skips duplicates by id. In a ---- replay this is the *only* source of chat — `SessionSendChatMessage` ---- never fires. +--- via `SessionSendChatMessage` (`OnReceive`); `OnReceive` dedupes by +--- `Id` so this handler can fan out unconditionally. In a replay this is +--- the *only* source of chat — `SessionSendChatMessage` never fires. ---@param msgs ChatPayload[] function OnSyncChatMessages(msgs) if type(msgs) ~= 'table' then return end - - local history = ChatModel.GetSingleton().History() - local seen = {} - for _, entry in history do - if entry.Id then seen[entry.Id] = true end - end - for _, msg in msgs do - if not (msg.Id and seen[msg.Id]) then - local armyData = GetArmyData(msg.From) - local nickname = armyData and armyData.nickname or tostring(msg.From or 'Unknown') - OnReceive(nickname, msg) - if msg.Id then seen[msg.Id] = true end - end + local armyData = GetArmyData(msg.From) + local nickname = armyData and armyData.nickname or tostring(msg.From or 'Unknown') + OnReceive(nickname, msg) end end From 79071e9a49015fd2c7046ab1365820cb95a04b5f Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:27:12 +0200 Subject: [PATCH 126/130] Apply muting changes to both committed and pending config --- .../game/chat/config/ChatConfigController.lua | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/lua/ui/game/chat/config/ChatConfigController.lua b/lua/ui/game/chat/config/ChatConfigController.lua index eef2f7f86fc..98c8d319537 100644 --- a/lua/ui/game/chat/config/ChatConfigController.lua +++ b/lua/ui/game/chat/config/ChatConfigController.lua @@ -44,39 +44,46 @@ function SetOption(key, value) model.Pending:Set(draft) end ---- Setting `muted = false` clears the key so the table stays compact; ---- absent keys read as "not muted". ----@param armyID number ----@param muted boolean -function SetMuted(armyID, muted) - local model = Model() - local draft = table.copy(model.Pending()) - local map = table.copy(draft.muted or {}) +--- Returns a deep-enough copy of `options` with `muted[armyID]` flipped +--- to the requested state. `muted = false` clears the key so the table +--- stays compact (absent keys read as "not muted"). Always returns a +--- fresh `options` and a fresh `muted` map so the LazyVar dirty check +--- fires in the caller's `:Set`. +---@param options UIChatOptions +---@param armyID number +---@param muted boolean +---@return UIChatOptions +local function WithMuteChange(options, armyID, muted) + local copy = table.copy(options) + local map = table.copy(copy.muted or {}) if muted then map[armyID] = true else map[armyID] = nil end - draft.muted = map - model.Pending:Set(draft) + copy.muted = map + return copy +end + +--- Updates the dialog's draft `Pending` mute map. +---@param armyID number +---@param muted boolean +function SetMuted(armyID, muted) + local model = Model() + model.Pending:Set(WithMuteChange(model.Pending(), armyID, muted)) end ---- Writes directly to `Committed` so `/mute` and `/unmute` take effect ---- immediately. Pending is left alone — an open config dialog keeps its ---- draft, and the next open re-syncs Pending from Committed. +--- Writes a mute change to both `Committed` (so `/mute` and `/unmute` +--- take effect immediately) and `Pending` (so an open config dialog's +--- draft doesn't overwrite the live change on Apply). Only the entry +--- for `armyID` is touched, so other in-flight Pending edits are +--- preserved. ---@param armyID number ---@param muted boolean function SetMutedLive(armyID, muted) local model = Model() - local options = table.copy(model.Committed()) - local map = table.copy(options.muted or {}) - if muted then - map[armyID] = true - else - map[armyID] = nil - end - options.muted = map - model.Committed:Set(options) + model.Committed:Set(WithMuteChange(model.Committed(), armyID, muted)) + model.Pending:Set(WithMuteChange(model.Pending(), armyID, muted)) end ------------------------------------------------------------------------------- From 1c17c6f7f4deb1411300149da48215523d7f414c Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Mon, 27 Apr 2026 10:30:33 +0200 Subject: [PATCH 127/130] Clear out draft when closing chat config --- lua/ui/game/chat/config/ChatConfigInterface.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 902f043c454..43ca2968d01 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -414,8 +414,10 @@ local ChatConfigInterface = ClassUI(Window) { end end, - --- Title-bar close button. Closes the dialog without applying. + --- Title-bar close button. Mirrors the Cancel button: discards any + --- Pending draft (re-syncs from Committed) and tears down the dialog. OnClose = function(self) + ChatConfigController.Cancel() import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() end, @@ -434,7 +436,13 @@ local ChatConfigInterface = ClassUI(Window) { local Instance = nil --- Standalone entry point: shows the config dialog, building it on first open. +--- Always re-syncs `Pending` from `Committed` first so a reopen reflects the +--- current committed state (including any `SetMutedLive` writes that landed +--- while the dialog was hidden) instead of a stale draft from a previous +--- session. function Open() + local Controller = import("/lua/ui/game/chat/config/ChatConfigController.lua") + Controller.Cancel() if Instance then Instance:Show() return From b967a7a663f3b80a673856d53fe4f4f8c9541c80 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Tue, 28 Apr 2026 07:52:35 +0200 Subject: [PATCH 128/130] Attempt to guardrail the use of comments --- annotation.md | 59 ++++++++ lua/ui/game/chat/ChatCommandHintInterface.lua | 6 +- lua/ui/game/chat/ChatCompletion.lua | 17 ++- lua/ui/game/chat/ChatController.lua | 142 +++++++++--------- lua/ui/game/chat/ChatDebug.lua | 24 +-- lua/ui/game/chat/ChatEditInterface.lua | 15 +- lua/ui/game/chat/ChatFeedInterface.lua | 37 ++--- lua/ui/game/chat/ChatInterface.lua | 58 +++---- lua/ui/game/chat/ChatLineInterface.lua | 33 ++-- lua/ui/game/chat/ChatLinesInterface.lua | 68 +++++---- lua/ui/game/chat/ChatListInterface.lua | 7 +- lua/ui/game/chat/ChatModel.lua | 8 +- lua/ui/game/chat/ChatUtils.lua | 14 +- .../chat/commands/ChatCommandRegistry.lua | 46 +++--- .../game/chat/commands/ChatCommandTypes.lua | 10 +- lua/ui/game/chat/commands/builtin/All.lua | 2 +- lua/ui/game/chat/commands/builtin/Allies.lua | 2 +- lua/ui/game/chat/commands/builtin/Clear.lua | 2 +- .../commands/builtin/DebugDumpControls.lua | 2 +- .../game/chat/commands/builtin/DebugLog.lua | 2 +- .../chat/commands/builtin/DebugStatistics.lua | 2 +- .../game/chat/commands/builtin/Debugger.lua | 2 +- .../game/chat/commands/builtin/EndMission.lua | 2 +- .../chat/commands/builtin/GiftResources.lua | 4 +- .../game/chat/commands/builtin/GiftUnits.lua | 6 +- lua/ui/game/chat/commands/builtin/Help.lua | 2 +- lua/ui/game/chat/commands/builtin/Load.lua | 2 +- lua/ui/game/chat/commands/builtin/Mute.lua | 2 +- lua/ui/game/chat/commands/builtin/Pause.lua | 2 +- lua/ui/game/chat/commands/builtin/Recall.lua | 2 +- lua/ui/game/chat/commands/builtin/Restart.lua | 2 +- lua/ui/game/chat/commands/builtin/Resume.lua | 2 +- lua/ui/game/chat/commands/builtin/Save.lua | 2 +- lua/ui/game/chat/commands/builtin/Speed.lua | 2 +- lua/ui/game/chat/commands/builtin/Taunt.lua | 2 +- .../chat/commands/builtin/ToEngineers.lua | 2 +- lua/ui/game/chat/commands/builtin/ToTick.lua | 2 +- lua/ui/game/chat/commands/builtin/Unmute.lua | 2 +- lua/ui/game/chat/commands/builtin/Whisper.lua | 2 +- .../game/chat/config/ChatConfigInterface.lua | 21 +-- 40 files changed, 346 insertions(+), 273 deletions(-) diff --git a/annotation.md b/annotation.md index 4c1f107b86e..4cfc365c01c 100644 --- a/annotation.md +++ b/annotation.md @@ -19,6 +19,65 @@ And a few specifics: - A comment should not end with a dot (.), but it can be used between sentences - Varargs `...` should be documented as: `@param ... `, it shouldn't use `@vararg` +## Comment style + +Comments explain intent and engine behavior. Anything readable straight from the code does not need a comment. + +### Rules + +1. Every `@class`, `@type`, `@alias`, and other top-level annotation has a `---` description directly above it. +2. Every function has a `---` description. The description tells a consumer what the function does and anything they need to know before calling it. Implementation details and rationale go inside the function body, at the very top, before any logic. +3. Comments are succinct and declarative. Short sentences. State the fact, not the reasoning around the fact. +4. Do not use em-dashes (`—`) or similar joining punctuation. Split into separate sentences, or use a colon, a comma, or parentheses. + +### Why the function-level vs. body split + +The `---` block is what the language server shows on hover. It is the public face of the function and should read as a contract. Implementation rationale belongs with the implementation, where future maintainers will read it. + +### Good + +```lua +--- Returns the army index that authored the current command, or nil +--- outside a command-source context. +---@return integer? +function GetCurrentCommandSourceArmy() + -- The engine clears the source between command dispatches, so nil + -- is normal during idle ticks. + ... +end +``` + +```lua +--- Wire-format chat payload. Travels through both the live engine +--- broadcast and the sim-routed sync stream. +---@class ChatPayload +``` + +```lua +--- A known channel constant, or an army index for a private whisper. +---@alias UIChatRecipient 'all' | 'allies' | number +``` + +### Not good + +The rationale leaks into the consumer-facing doc, and an em-dash joins what should be two sentences: + +```lua +--- Returns the army index that authored the current command — the engine +--- clears the source between command dispatches so a nil is normal during +--- idle ticks. Returns nil if there is no source. +---@return integer? +function GetCurrentCommandSourceArmy() + ... +end +``` + +The class is annotated but has no description, so the hover tooltip is empty: + +```lua +---@class ChatPayload +``` + ## Examples In general, all annotated code in the repository is a good example. Some was written before these guidelines were written. Therefore we'll include some good examples for you to look at. diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua index 19d1de927e0..a5057daad9b 100644 --- a/lua/ui/game/chat/ChatCommandHintInterface.lua +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -45,7 +45,7 @@ end ------------------------------------------------------------------------------- -- Command-hint popup. Shows commands whose name or aliases prefix-match. --- Reuses a pool of row controls across refreshes — rows are +-- Reuses a pool of row controls across refreshes. Rows are -- shown/hidden and re-positioned via a per-row `ordinal` LazyVar. --- One pooled hint row; ordinal 0 means hidden, otherwise it's the row's position from the bottom. @@ -466,8 +466,8 @@ ChatCommandHintInterface = ClassUI(Group) { elseif cur < 1 or cur > n then self.Selected:Set(1) else - -- Ordinal unchanged but the target underneath isn't — - -- repaint so colours match the new row assignments. + -- Ordinal unchanged but the target underneath isn't. + -- Repaint so colours match the new row assignments. self:RepaintRows() end diff --git a/lua/ui/game/chat/ChatCompletion.lua b/lua/ui/game/chat/ChatCompletion.lua index 23c4f75222a..20d98dff463 100644 --- a/lua/ui/game/chat/ChatCompletion.lua +++ b/lua/ui/game/chat/ChatCompletion.lua @@ -35,7 +35,7 @@ local function LastSpaceBefore(text, caret) end --- Codepoint position of the next space at or after `caret + 1`, or ---- `textLen` if the word runs to end-of-text. +--- `textLen` if the word runs to end of text. ---@param text string ---@param caret number ---@param textLen number @@ -52,10 +52,10 @@ local function NextSpaceAfter(text, caret, textLen) end --- Non-civilian nicknames from the armies table, minus the local player. ---- `focusArmy` is 0 for observers, making the comparison a no-op — fine, ---- observers have no nickname to complete anyway. ---@return string[] local function CollectNicknames() + -- `focusArmy` is 0 for observers, making the comparison a no-op. + -- That is fine, observers have no nickname to complete anyway. local out = {} local armies = GetArmiesTable() if not armies or not armies.armiesTable then return out end @@ -76,12 +76,13 @@ local function StartsWithCI(s, prefix) end --- Returns a completion record for the caret position, or nil if nothing ---- matches. `Consume` covers the full word under the caret so mid-word ---- completion overwrites the tail too. +--- matches. ---@param text string ---@param caret number ---@return UIChatCompletion? function Compute(text, caret) + -- `Consume` covers the full word under the caret so mid-word + -- completion overwrites the tail too. if not text or text == '' then return nil end local textLen = STR_Utf8Len(text) @@ -92,7 +93,7 @@ function Compute(text, caret) local isCommand = (wordStart == 0) and (STR_Utf8SubString(text, 1, 1) == '/') -- Only append a trailing space when the completion is unambiguous AND - -- the word runs to end-of-text — otherwise we'd double up an existing + -- the word runs to end of text. Otherwise we'd double up an existing -- separator. local atEnd = wordEnd == textLen @@ -119,8 +120,8 @@ function Compute(text, caret) if prefix == '' then return nil end -- `@nick` shorthand: strip the `@` for matching but keep it in - -- candidates so `/whisper @Jip` still works. ChatCommandTypes - -- strips `@` symmetrically on the resolver side. + -- candidates so `/whisper @Jip` still works. ChatCommandTypes strips + -- `@` symmetrically on the resolver side. local atSign = '' local matchPrefix = prefix if string.sub(prefix, 1, 1) == '@' then diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua index 3cfc797b552..8358c663ddb 100644 --- a/lua/ui/game/chat/ChatController.lua +++ b/lua/ui/game/chat/ChatController.lua @@ -26,16 +26,13 @@ end ------------------------------------------------------------------------------- -- Activity heartbeat ---- Stamps `LastActivity` with the current system time. The chat window's ---- idle / fade timer subscribes to this; call from any UI surface that ---- counts as engagement. +--- Stamps `LastActivity` with the current system time. Call from any +--- UI surface that counts as engagement. function NotifyActivity() ChatModel.GetSingleton().LastActivity:Set(GetSystemTimeSeconds()) end ---- While pinned, `ChatInterface.OnFrame` skips the idle auto-close check. ---- Unpinning re-stamps `LastActivity` so the user gets a full `fade_time` ---- window instead of being auto-closed against the stale stamp. +--- Sets the pinned flag. ---@param pinned boolean function SetPinned(pinned) ChatModel.GetSingleton().Pinned:Set(pinned and true or false) @@ -84,9 +81,7 @@ end ------------------------------------------------------------------------------- -- Slash commands ---- (Re-)registers every built-in chat command with the registry. Idempotent ---- and called on every slash-entry path so a hot-reload of the registry ---- doesn't leave us with an empty command set. +--- (Re-)registers every built-in chat command with the registry. Idempotent. function RegisterBuiltinCommands() local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") @@ -143,6 +138,10 @@ local function FindClientsAsObserver(armiesTable) return result end +--- * Calling with an `armyID`: clients authorised for that specific army +--- (private messages). +--- * Calling with no `armyID`: clients authorised for any focus-army ally +--- (`allies` broadcasts). ---@param armiesTable table ---@param focus number ---@param armyID? number @@ -181,9 +180,9 @@ end --- --- * Observing (focus == -1): every connected observer client, plus any --- disconnected-but-recognised human player. ---- * Playing with an `armyID`: clients authorised for that specific army +--- * Calling with an `armyID`: clients authorised for that specific army --- (private messages). ---- * Playing with no `armyID`: clients authorised for any focus-army ally +--- * Calling with no `armyID`: clients authorised for any focus-army ally --- (`allies` broadcasts). ---@param armyID? number ---@return number[] @@ -225,14 +224,15 @@ local ToStrings = ChatUtils.ToStrings ---@param args { Name: string, Text?: string, ArmyData?: table, IsObserver?: boolean, Recipient: UIChatRecipient, Camera?: table, Location?: UIChatEntryLocation, Id?: string } local function AppendChatLine(args) local armyData = args.ArmyData or {} - -- Observers have no `faction`; fall through to the tail icon in - -- `ChatLineInterface.FactionIcons`. Engine factions are 0..N-1; the view - -- expects 1-based indices. + -- Observers have no `faction`, fall through to the tail icon in + -- `ChatLineInterface.FactionIcons`. Engine factions are 0..N-1. + -- The view expects 1-based indices. local faction = not args.IsObserver and armyData.faction or nil - -- Camera-link messages and observer broadcasts both use the link palette; - -- everyone else inherits the channel descriptor's `colorkey`. Unrecognised - -- recipients fall back to `priv_color` via the `ToStrings.private` entry. + -- Camera-link messages and observer broadcasts both use the link + -- palette. Everyone else inherits the channel descriptor's `colorkey`. + -- Unrecognised recipients fall back to `priv_color` via the + -- `ToStrings.private` entry. local colorKey if args.Camera or args.Location then colorKey = ChatConfigModel.KeyLinkColor @@ -285,23 +285,16 @@ function OnReceive(sender, msg) sender = 'nil sender' end - -- Shape validation lives in `ChatPayload.IsValidPayload` (shared with the - -- sim relay). The receive path is reachable from external mods, so the - -- payload can't be trusted. Sender / observer consistency below needs - -- session context the shared validator can't see. if not ChatPayload.IsValidPayload(msg) then return end if IsDuplicateMessage(msg) then return end - -- LOCF-style format-on-receive: when the sender ships `Args`, treat - -- `msg.text` as a `string.format` template (typically a `` tag) - -- so the result respects the viewer's locale. The cap was applied to the - -- pre-format template, so `LOCF` can legitimately exceed it. + -- only apply LOCf when Args are present, otherwise players can randomly send localized messages by including format specifiers in their text. if msg.Args then msg.text = LOCF(msg.text, unpack(msg.Args)) end - -- Notify owns the display decision for `to='notify'`; only fall through - -- to rendering a chat line if it returns false. + -- Notify owns the display decision for `to='notify'`. Only fall + -- through to rendering a chat line if it returns false. if msg.to == ChatModel.RecipientNotify and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then return end @@ -311,10 +304,10 @@ function OnReceive(sender, msg) return end - -- `msg.Observer` is only set when the sender has no army entry. A peer - -- claiming Observer while resolving to a real army is malformed — drop - -- the message entirely rather than stripping the flag, which would let - -- manipulated traffic render under a different label. + -- `msg.Observer` is only set when the sender has no army entry. A + -- peer claiming Observer while resolving to a real army is malformed. + -- Drop the message entirely rather than stripping the flag, which + -- would let manipulated traffic render under a different label. if msg.Observer and armyData then return end local to = msg.to @@ -323,8 +316,8 @@ function OnReceive(sender, msg) local name if type(to) == 'number' and SessionIsReplay() then - -- In a replay, private messages need the full routing so spectators - -- can attribute the conversation. + -- In a replay, private messages need the full routing so + -- spectators can attribute the conversation. name = string.format("%s %s %s:", sender, LOC(ToStrings.to.text), (GetArmyData(to) or {}).nickname or tostring(to)) else @@ -344,12 +337,14 @@ function OnReceive(sender, msg) end --- Handler for the `Sync.ChatMessages` category, populated by the sim-side ---- `SendChatMessage` callback. In live play the same message also arrives ---- via `SessionSendChatMessage` (`OnReceive`); `OnReceive` dedupes by ---- `Id` so this handler can fan out unconditionally. In a replay this is ---- the *only* source of chat — `SessionSendChatMessage` never fires. +--- `SendChatMessage` callback. ---@param msgs ChatPayload[] function OnSyncChatMessages(msgs) + -- In live play the same message also arrives via + -- `SessionSendChatMessage` (`OnReceive`). `OnReceive` dedupes by + -- `Id` so this handler can fan out unconditionally. In a replay + -- this is the *only* source of chat. `SessionSendChatMessage` never + -- fires. if type(msgs) ~= 'table' then return end for _, msg in msgs do local armyData = GetArmyData(msg.From) @@ -420,26 +415,29 @@ function Send(text, attachCamera) text = text, } - -- Observers can't target a private recipient. Bail before stamping an id - -- or firing sim callbacks for a message the engine would refuse anyway. + -- Observers can't target a private recipient. Bail before stamping + -- an id or firing sim callbacks for a message the engine would + -- refuse anyway. if focusArmy == -1 and type(recipient) == 'number' then return end - -- Flag observer broadcasts so receivers render "to observers:". Both - -- delivery paths need to see this, so set it before either fires. + -- Flag observer broadcasts so receivers render "to observers:". + -- Both delivery paths need to see this, so set it before either + -- fires. if focusArmy == -1 then msg.Observer = true end if attachCamera then msg.camera = GetCamera('WorldCamera'):SaveSettings() end - -- Stamp an id for `OnSyncChatMessages` to dedupe the live and sim-routed - -- delivery paths. Tick suffix guards against table-address recycling. + -- Stamp an id for `OnSyncChatMessages` to dedupe the live and + -- sim-routed delivery paths. Tick suffix guards against + -- table-address recycling. msg.Id = string.format("%d %s", GameTick(), tostring(msg)) - -- Replay-parser backwards compat: external replay tools scrape chat out - -- of recorded `GiveResourcesToPlayer` callback args. Fire one zero- - -- resource callback per outgoing message so they keep working. Observers - -- skip it — no army to ship. + -- Replay-parser backwards compat: external replay tools scrape chat + -- out of recorded `GiveResourcesToPlayer` callback args. Fire one + -- zero-resource callback per outgoing message so they keep working. + -- Observers skip it (no army to ship). if focusArmy ~= -1 then local senderData = GetArmyData(focusArmy) SimCallback({ @@ -452,9 +450,10 @@ function Send(text, attachCamera) }, false) end - -- Sim-routed path: the sim re-broadcasts via `Sync.ChatMessages`. In - -- live play it runs alongside `SessionSendChatMessage` and id-based - -- dedupe prevents double-posting; in replays it is the *only* path. + -- Sim-routed path: the sim re-broadcasts via `Sync.ChatMessages`. + -- In live play it runs alongside `SessionSendChatMessage` and + -- id-based dedupe prevents double-posting. In replays it is the + -- *only* path. SimCallback({ Func = 'SendChatMessage', Args = { Msg = msg } }, false) if recipient == ChatModel.RecipientAllies then @@ -481,26 +480,18 @@ end -- Engine hotkey entry point --- Opens the chat window with the recipient forced to `allies` or `all` ---- based on `send_type` and the Shift modifier. The engine calls this via ---- a top-level `ActivateChat` shim in `gamemain.lua` when the user presses ---- Enter outside the edit box. ---- ---- Truth table (`send_type` reads as "default to allies"): ---- * `send_type=false`, no Shift → `all` ---- * `send_type=false`, Shift → `allies` ---- * `send_type=true`, no Shift → `allies` ---- * `send_type=true`, Shift → `all` ---- ---- A specific-army recipient (mid-private) is left alone. ----@param modifiers? table # engine-supplied modifier state ({Shift, Ctrl, ...}) +--- based on `send_type` and the Shift modifier. A specific-army recipient +--- (mid-private) is left alone.---@param modifiers? table # engine-supplied modifier state ({Shift, Ctrl, ...}) function ActivateChat(modifiers) + -- The engine calls this via a top-level `ActivateChat` shim in + -- `gamemain.lua` when the user presses Enter outside the edit box. local model = ChatModel.GetSingleton() local wasVisible = model.WindowVisible() import("/lua/ui/game/chat/ChatInterface.lua").Toggle() - -- Layer Shift on top of the default. Must run AFTER the toggle — - -- writing `Recipient` first gets clobbered by `ApplyDefaultRecipient`. + -- Layer Shift on top of the default. Must run AFTER the toggle. + -- Writing `Recipient` first gets clobbered by `ApplyDefaultRecipient`. if not wasVisible and type(model.Recipient()) ~= 'number' then local sendType = ChatConfigModel.GetOptions().send_type or false local shift = modifiers and modifiers.Shift or false @@ -516,28 +507,31 @@ end -- Lifecycle --- Registers the receive handler with gamemain, populates the slash-command ---- registry, and mounts the chat tree. Called from `gamemain.lua` during UI ---- setup — kept out of module-load so mods can override the controller ---- before any wiring happens. Idempotent: `RegisterChatFunc` overwrites, ---- so re-running `Init` just rebinds the handlers. +--- registry, and mounts the chat tree. Idempotent. function Init() + -- Called from `gamemain.lua` during UI setup. Kept out of module-load + -- so mods can override the controller before any wiring happens. + -- `RegisterChatFunc` overwrites, so re-running `Init` just rebinds + -- the handlers. import("/lua/ui/game/gamemain.lua").RegisterChatFunc(OnReceive, 'Chat') AddOnSyncHashedCallback(OnSyncChatMessages, 'ChatMessages', 'Chat') RegisterBuiltinCommands() - -- Build the chat tree eagerly so the sibling feed is mounted in time to - -- surface messages that arrive before the user opens the dialog. + -- Build the chat tree eagerly so the sibling feed is mounted in + -- time to surface messages that arrive before the user opens the + -- dialog. import("/lua/ui/game/chat/ChatInterface.lua").EnsureInstance() end ------------------------------------------------------------------------------- --#region Debugging ---- Hot-reload hook: re-runs `Init()` on the new module so the gamemain ---- registration rebinds to the fresh `OnReceive` closure and the registry ---- repopulates. Without this, edits leave stale code receiving messages. ---- The fork-with-delay lets cascading reloads settle first. +--- Hot-reload hook: re-runs `Init()` on the new module. function __moduleinfo.OnReload(newModule) + -- The gamemain registration rebinds to the fresh `OnReceive` closure + -- and the registry repopulates. Without this, edits leave stale code + -- receiving messages. The fork-with-delay lets cascading reloads + -- settle first. ForkThread(function() WaitFrames(1) newModule.Init() diff --git a/lua/ui/game/chat/ChatDebug.lua b/lua/ui/game/chat/ChatDebug.lua index 3c5d6c35281..06c182ac5bb 100644 --- a/lua/ui/game/chat/ChatDebug.lua +++ b/lua/ui/game/chat/ChatDebug.lua @@ -11,15 +11,17 @@ local LongText = "The quick brown fox jumps over the lazy dog and then doubles back, " .. "dodges a passing T2 mobile artillery shell, ramps off a discarded " .. "engineer drone, and lands neatly on the scoreboard with a triumphant " .. - "bark — at which point the dog wakes up and demands to know who " .. + "bark, at which point the dog wakes up and demands to know who " .. "authorised the construction of the ramp in the first place." ---- Stamps the entry with the local focus army's metadata so the colour ---- and faction icon match a real outgoing message. Fresh `Id` so the ---- `OnSyncChatMessages` dedupe doesn't swallow it later. +--- Builds a synthetic `UIChatEntry` for debug injection, optionally +--- overriding fields from the defaults. ---@param overrides table # fields merged on top of the synth defaults ---@return UIChatEntry local function SynthEntry(overrides) + -- Stamps the entry with the local focus army's metadata so the colour + -- and faction icon match a real outgoing message. Fresh `Id` so the + -- `OnSyncChatMessages` dedupe doesn't swallow it later. local focus = GetFocusArmy() local armies = GetArmiesTable().armiesTable local data = (focus and focus > 0) and armies[focus] or {} @@ -68,15 +70,16 @@ function AppendShortMessage() ChatController.AppendEntry(SynthEntry({})) end ---- Body wraps onto several rows at every supported font size — exercises ---- the continuation-row layout. +--- Appends a long synthetic message that exercises the continuation-row layout. function AppendLongMessage() + -- Body wraps onto several rows at every supported font size. ChatController.AppendEntry(SynthEntry({ Text = LongText })) end ---- Ten entries in one batch — exercises pool sizing past the line cap and ---- snap-to-bottom on rapid arrivals. +--- Appends ten synthetic entries in one batch. function AppendBurst() + -- Exercises pool sizing past the line cap and snap-to-bottom on + -- rapid arrivals. for i = 1, 10 do ChatController.AppendEntry(SynthEntry({ Text = string.format('[debug] burst %d / 10', i), @@ -84,9 +87,10 @@ function AppendBurst() end end ---- Captures the camera focus at hotkey time so panning and clicking the ---- camera icon should bounce back to the original spot. +--- Appends a synthetic message tagged with the current camera focus. function AppendCameraMessage() + -- Captures the camera focus at hotkey time so panning and clicking + -- the camera icon should bounce back to the original spot. local cam = GetCamera('WorldCamera') local settings = cam:SaveSettings() ChatController.AppendEntry(SynthEntry({ diff --git a/lua/ui/game/chat/ChatEditInterface.lua b/lua/ui/game/chat/ChatEditInterface.lua index 787b3922d6c..6c447bf2f0b 100644 --- a/lua/ui/game/chat/ChatEditInterface.lua +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -88,7 +88,7 @@ ChatEditInterface = ClassUI(Group) { self.EditBox = Edit(self) -- `SetupEditStd` below reads the control's bounds before - -- `__post_init` runs, so seed placeholder values to avoid tripping + -- `__post_init` runs. Seed placeholder values to avoid tripping -- the default circular Left/Right/Width chain. Layouter(self.EditBox) :Left(0) @@ -127,7 +127,7 @@ ChatEditInterface = ClassUI(Group) { end end - -- `OnCharPressed` fires before insertion, so `>=` catches the + -- `OnCharPressed` fires before insertion. `>=` catches the -- keystroke the cap is about to reject. self.EditBox.OnCharPressed = function(edit, charcode) if charcode == UIUtil.VK_TAB then @@ -273,10 +273,10 @@ ChatEditInterface = ClassUI(Group) { end, --- Writes the current candidate at the recorded anchor. - --- `SuppressCompletionReset` keeps the `OnTextChanged` branch from - --- clearing the cycle state as a side-effect of our own edit. ---@param self UIChatEditInterface ApplyCompletion = function(self) + -- `SuppressCompletionReset` keeps the `OnTextChanged` branch from + -- clearing the cycle state as a side-effect of our own edit. if not self.Completion then return end local c = self.Completion --[[@as UIChatCompletion]] @@ -358,11 +358,12 @@ ChatEditInterface = ClassUI(Group) { self.EditBox:SetCaretPosition(STR_Utf8Len(entry)) end, - --- Only opens when the text transitions to exactly `/` — so closing - --- the hint via Escape leaves it closed while the user keeps typing. + --- Refreshes (or opens) the command-hint popup based on the current text. ---@param self UIChatEditInterface ---@param text string RefreshCommandHint = function(self, text) + -- Only opens when the text transitions to exactly `/`. Closing + -- the hint via Escape leaves it closed while the user keeps typing. if self.ChatCommandHintInterface then if string.sub(text, 1, 1) == '/' then self.ChatCommandHintInterface:Refresh(text) @@ -378,9 +379,9 @@ ChatEditInterface = ClassUI(Group) { --- Mounts the slash-command hint popup above the edit box. No-op if open. ---@param self UIChatEditInterface OpenCommandHint = function(self) + -- Ensure the built-ins exist before the hint queries the registry. if self.ChatCommandHintInterface then return end - -- Ensure the built-ins exist before the hint queries the registry. ChatController.RegisterBuiltinCommands() local hint = ChatCommandHintInterface(self, self.EditBox) diff --git a/lua/ui/game/chat/ChatFeedInterface.lua b/lua/ui/game/chat/ChatFeedInterface.lua index be776299215..9b1d029d5ec 100644 --- a/lua/ui/game/chat/ChatFeedInterface.lua +++ b/lua/ui/game/chat/ChatFeedInterface.lua @@ -141,18 +141,18 @@ ChatFeedInterface = ClassUI(Group) { self.LastHistoryLength = newCount end, - --- Appends one feed row per wrapped chunk. Per-row `Time` means - --- capping drops only the single oldest row, not an entry's whole - --- block of continuations. - --- - --- Forces the wrap before reading `entry.WrappedText` because both - --- views observe `model.History` and `used_by` iteration order is - --- unspecified — if we fire before the chat-lines observer the cache - --- is empty. We borrow the chat panel's measure-line because it - --- shares our row width by LazyVar bind. + --- Appends one feed row per wrapped chunk of the entry. ---@param self UIChatFeedInterface ---@param entry UIChatEntry AppendRow = function(self, entry) + -- Per-row `Time` means capping drops only the single oldest row, + -- not an entry's whole block of continuations. + -- + -- Forces the wrap before reading `entry.WrappedText` because both + -- views observe `model.History` and `used_by` iteration order is + -- unspecified. If we fire before the chat-lines observer the + -- cache is empty. We borrow the chat panel's measure-line because + -- it shares our row width by LazyVar bind. if not entry then return end if not entry.WrappedText and self.Window then @@ -200,11 +200,11 @@ ChatFeedInterface = ClassUI(Group) { self:UpdateVisibility() end, - --- Pins each row from the bottom up. Header rows naturally end up at - --- the top of their wrapped block because AppendRow inserts in - --- reading order. + --- Lays out feed rows pinned from the bottom up. ---@param self UIChatFeedInterface LayoutRows = function(self) + -- Header rows naturally end up at the top of their wrapped block + -- because AppendRow inserts in reading order. local count = table.getn(self.Rows) for i = count, 1, -1 do local row = self.Rows[i] @@ -249,10 +249,10 @@ ChatFeedInterface = ClassUI(Group) { -- Visibility / lifecycle --------------------------------------------------------------------------- - --- Visible iff window hidden AND we have at least one row. - --- `SetNeedsFrameUpdate` toggles in lockstep so we don't tick idle. + --- Updates visibility: visible iff the window is hidden AND we have at least one row. ---@param self UIChatFeedInterface UpdateVisibility = function(self) + -- `SetNeedsFrameUpdate` toggles in lockstep so we don't tick idle. local windowVisible = ChatModel.GetSingleton().WindowVisible() if not windowVisible and table.getn(self.Rows) > 0 then self:Show() @@ -263,13 +263,14 @@ ChatFeedInterface = ClassUI(Group) { end end, - --- Per-frame: ages each row, fades the line text (full per-row fade - --- only — text stays crisp regardless of `win_alpha`) and the BG - --- strip (modulated by `win_alpha` × fade × base intensity), and - --- destroys rows past `fade_time`. + --- Per-frame: ages each row, fades the line text and BG strip, + --- and destroys rows past `fade_time`. ---@param self UIChatFeedInterface ---@param delta number OnFrame = function(self, delta) + -- Line text fades on the per-row fade only (it stays crisp + -- regardless of `win_alpha`). BG strip alpha is modulated by + -- `win_alpha` * fade * base intensity. local options = ChatConfigModel.GetOptions() local fadeTime = options.fade_time or 15 local winAlpha = options.win_alpha or 1.0 diff --git a/lua/ui/game/chat/ChatInterface.lua b/lua/ui/game/chat/ChatInterface.lua index ed16462a51d..28b5e584bd6 100644 --- a/lua/ui/game/chat/ChatInterface.lua +++ b/lua/ui/game/chat/ChatInterface.lua @@ -22,7 +22,7 @@ local Debug = false --- Skin textures for the chat window frame. `SkinnableFile` resolves --- against the current skin on each read, so the bitmaps follow skin ---- changes — unlike `UIFile`, which freezes the path at module-load time. +--- changes (unlike `UIFile`, which freezes the path at module-load time). local WindowTextures = { tl = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ul.dds'), tr = UIUtil.SkinnableFile('/game/chat_brd/chat_brd_ur.dds'), @@ -129,8 +129,8 @@ local ChatInterface = ClassUI(Window) { ) -- Pin tooltip wording swaps reactively so it matches the next - -- click's effect. `_closeBtn` / `_configBtn` / `_pinBtn` are owned - -- by `Window` but not in its declared class fields. + -- click's effect. `_closeBtn` / `_configBtn` / `_pinBtn` are + -- owned by `Window` but not in its declared class fields. ---@diagnostic disable: undefined-field Tooltip.AddButtonTooltip(self._closeBtn, 'chat_close') Tooltip.AddButtonTooltip(self._configBtn, 'chat_config') @@ -160,8 +160,8 @@ local ChatInterface = ClassUI(Window) { self.DragBL.textures = DragHandleTextures('ll') self.DragBR.textures = DragHandleTextures('lr') - -- Seed with the skinnable texture, not a frozen `UIFile` path — - -- otherwise the bitmaps stay on the module-load skin until the + -- Seed with the skinnable texture, not a frozen `UIFile` path. + -- Otherwise the bitmaps stay on the module-load skin until the -- first hover-exit hands `SetTexture` the live value. for _, grip in { self.DragTL, self.DragTR, self.DragBL, self.DragBR } do grip:DisableHitTest() @@ -187,7 +187,7 @@ local ChatInterface = ClassUI(Window) { -- Window calls `self.RolloverHandler(control, ...)` as a plain -- function (no method syntax). The class method is named differently - -- (`OnRollover`) — sharing the name would shadow the class method + -- (`OnRollover`). Sharing the name would shadow the class method -- and recurse. self.RolloverHandler = function(_, event, xControl, yControl, cursor, controlID) self:OnRollover(event, xControl, yControl, cursor, controlID) @@ -273,12 +273,12 @@ local ChatInterface = ClassUI(Window) { :AnchorToTop(self.ChatEditInterface, 4) :End() - -- Build the pool now that we have a real rect — `Initialize` reads - -- `Pool.Height()` for fixed-count sizing. + -- Build the pool now that we have a real rect. `Initialize` + -- reads `Pool.Height()` for fixed-count sizing. self.ChatLinesInterface:Initialize() -- Window-level options only (`win_alpha`). `SetAlpha(_, true)` - -- cascades to chrome / edit / scrollbar; re-cascading 1.0 from + -- cascades to chrome / edit / scrollbar. Re-cascading 1.0 from -- `Pool` keeps the line text crisp. `Pool` doesn't contain the -- scrollbar (it's a sibling), so the reset stays scoped. self.OptionsObserver = self.Trash:Add( @@ -303,12 +303,13 @@ local ChatInterface = ClassUI(Window) { -- Idle / fade timer --------------------------------------------------------------------------- - --- Idle-fade timer. Only fires while `SetNeedsFrameUpdate(true)` is - --- set; the visibility observer toggles that with the window. Pinning - --- short-circuits the check. + --- Idle-fade timer. Closes the window once the user has been idle for + --- `fade_time` seconds. While `Pinned` is true the check is skipped. ---@param self UIChatInterface - ---@param delta number # unused — we read absolute time + ---@param delta number # unused, we read absolute time OnFrame = function(self, delta) + -- Only fires while `SetNeedsFrameUpdate(true)` is set. The + -- visibility observer toggles that with the window. local model = ChatModel.GetSingleton() if model.Pinned() then return end local fadeTime = ChatConfigModel.GetOptions().fade_time or 15 @@ -318,11 +319,12 @@ local ChatInterface = ClassUI(Window) { end end, - --- Title-bar pin checkbox. Refocuses the edit box because clicking - --- the checkbox steals focus. + --- Title-bar pin checkbox handler. ---@param self UIChatInterface ---@param checked boolean OnPinCheck = function(self, checked) + -- Refocuses the edit box because clicking the checkbox steals + -- focus. ChatController.SetPinned(checked) self.ChatEditInterface:AcquireFocus() end, @@ -331,16 +333,17 @@ local ChatInterface = ClassUI(Window) { -- Window event hooks --------------------------------------------------------------------------- - --- Per-frame during a resize drag. Resizes the pool only — rewrap - --- happens once on `OnResizeSet`. + --- Per-frame during a resize drag. OnResize = function(self, width, height, firstFrame) + -- Resizes the pool only. Rewrap happens once on `OnResizeSet`. ChatController.NotifyActivity() self.ChatLinesInterface:OnResizeLive() end, - --- Resize finished. Snaps grips back to `up` — `StartSizing` takes - --- over from RolloverHandler so they'd otherwise stay on `down`. + --- Resize finished. Snaps grips back to `up`. OnResizeSet = function(self) + -- `StartSizing` takes over from RolloverHandler so the grips + -- would otherwise stay on `down`. ChatController.NotifyActivity() self.ChatLinesInterface:OnResizeFinished() self.DragTL:SetTexture(self.DragTL.textures.up) @@ -349,9 +352,9 @@ local ChatInterface = ClassUI(Window) { self.DragBR:SetTexture(self.DragBR.textures.up) end, - --- Per-frame during a title-bar drag. Stamps activity so a long drag - --- can't trip the idle auto-close. + --- Per-frame during a title-bar drag. OnMove = function(self) + -- Stamps activity so a long drag can't trip the idle auto-close. ChatController.NotifyActivity() end, @@ -378,9 +381,10 @@ local ChatInterface = ClassUI(Window) { import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() end, - --- Tears down the sibling feed (it lives outside our control tree, so - --- a Destroy cascade doesn't reach it) and empties the trash bag. + --- Tears down the sibling feed and empties the trash bag. OnDestroy = function(self) + -- The feed lives outside our control tree, so a Destroy cascade + -- doesn't reach it. if self.ChatFeedInterface then self.ChatFeedInterface:Destroy() self.ChatFeedInterface = nil @@ -397,7 +401,7 @@ local ChatInterface = ClassUI(Window) { local Instance = nil --- Builds the chat window and its sibling feed if they don't already ---- exist. Doesn't change visibility — `model.WindowVisible` starts false. +--- exist. Does not change visibility (`model.WindowVisible` starts false). --- `ChatController.Init` calls this at game start so the feed is alive --- before the user opens the dialog. function EnsureInstance() @@ -439,10 +443,10 @@ function ScrollPages(delta) end end ---- Jumps to the oldest visible entry. Not bound to a default key because ---- Edit consumes Home for caret nav before `OnNonTextKeyPressed` fires; ---- exposed for keymap entries and mods. +--- Jumps to the oldest visible entry. Exposed for keymap entries and mods. function ScrollToTop() + -- Not bound to a default key because Edit consumes Home for caret + -- nav before `OnNonTextKeyPressed` fires. if Instance then Instance.ChatLinesInterface:ScrollSetTop(nil, 1) end diff --git a/lua/ui/game/chat/ChatLineInterface.lua b/lua/ui/game/chat/ChatLineInterface.lua index ed48ad620f5..88595c2e443 100644 --- a/lua/ui/game/chat/ChatLineInterface.lua +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -71,8 +71,8 @@ ChatLineInterface = ClassUI(Group) { self.Name = UIUtil.CreateText(self, '', 14, 'Arial Bold') self.Name:SetColor('ffffffff') self.Name:SetDropShadow(true) - -- Continuation lines set Name to '' so the hit rect collapses with - -- it — no need to gate dispatch on row role. + -- Continuation lines set Name to '' so the hit rect collapses + -- with it. No need to gate dispatch on row role. self.Name.HandleEvent = function(_, event) if event.Type == 'ButtonPress' and self.Entry then self:OnNameClicked(self.Entry, event) @@ -80,8 +80,9 @@ ChatLineInterface = ClassUI(Group) { end -- Camera-link icon. Hidden via transparent SolidColor + disabled - -- hit-test rather than `Hide()` — the window's `Show()` cascade - -- would otherwise undo `Hide()` (same reason FactionIcon does it). + -- hit-test rather than `Hide()`. The window's `Show()` cascade + -- would otherwise undo `Hide()` (same reason FactionIcon does + -- it). self.CamIcon = Bitmap(self) self.CamIcon:SetSolidColor('00000000') self.CamIcon:DisableHitTest() @@ -106,7 +107,7 @@ ChatLineInterface = ClassUI(Group) { ---@param parent Control __post_init = function(self, parent) -- Raw constants in SetFunction bodies don't auto-scale (only - -- Layouter `:Height(number)` does); pre-scale once. + -- Layouter `:Height(number)` does), so pre-scale once. local twoPxScaled = LayoutHelpers.ScaleNumber(2) -- Derive row height from the name font so pool sizing scales @@ -178,9 +179,9 @@ ChatLineInterface = ClassUI(Group) { self.FactionIcon:SetTexture(UIUtil.UIFile(FactionIcons[iconIndex])) -- SolidColor swap rather than Show/Hide so the window's Show() - -- cascade can't reveal stale icons. Re-applying `RightOf` replaces - -- the previous Left binding. Shown for both `Camera` snapshots and - -- `Location` hints. + -- cascade can't reveal stale icons. Re-applying `RightOf` + -- replaces the previous Left binding. Shown for both `Camera` + -- snapshots and `Location` hints. if entry.Camera or entry.Location then self.CamIcon:SetTexture(UIUtil.UIFile(CamIconTexture)) self.CamIcon:EnableHitTest() @@ -192,14 +193,15 @@ ChatLineInterface = ClassUI(Group) { end end, - --- Populates the row as a CONTINUATION of a wrapped entry. Name and - --- team-colour stay empty; Text anchors to `Name.Right + 2`, which with - --- an empty name resolves to the row's left edge. Tracks the entry so - --- body clicks on wrapped lines still dispatch against the right message. + --- Populates the row as a CONTINUATION of a wrapped entry. ---@param self UIChatLineInterface ---@param entry UIChatEntry ---@param wrappedText string SetContinuation = function(self, entry, wrappedText) + -- Name and team-colour stay empty. Text anchors to `Name.Right + -- + 2`, which with an empty name resolves to the row's left edge. + -- Tracks the entry so body clicks on wrapped lines still dispatch + -- against the right message. self.Entry = entry self.Name:SetText('') self.Text:SetText(wrappedText or '') @@ -224,15 +226,14 @@ ChatLineInterface = ClassUI(Group) { LayoutHelpers.RightOf(self.Text, self.Name, 2) end, - --- Overridable; default no-op. Continuation rows have empty Name so - --- their hit rect collapses — only header rows fire in practice. + --- Overridable; default no-op. Fires when the sender's name is clicked. ---@param self UIChatLineInterface ---@param entry UIChatEntry ---@param event KeyEvent OnNameClicked = function(self, entry, event) end, - --- Overridable; default no-op. Fires for both header and continuation - --- rows — they share the entry so the click resolves to the right sender. + --- Overridable; default no-op. Fires when the body text is clicked. + --- Both header and continuation rows fire (they share the entry). ---@param self UIChatLineInterface ---@param entry UIChatEntry ---@param event KeyEvent diff --git a/lua/ui/game/chat/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua index d7e0e94ddaf..a275e11041e 100644 --- a/lua/ui/game/chat/ChatLinesInterface.lua +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -63,8 +63,8 @@ ChatLinesInterface = ClassUI(Group) { self.Pool = Group(self, "ChatLinesPool") - -- `Scrollbar:SetScrollable` binds to Pool, but the state lives on - -- self — forward each method up. + -- `Scrollbar:SetScrollable` binds to Pool, but the state lives + -- on self. Forward each method up. self.Pool.GetScrollValues = function(_, axis) return self:GetScrollValues(axis) end self.Pool.ScrollLines = function(_, axis, delta) self:ScrollLines(axis, delta) end self.Pool.ScrollPages = function(_, axis, delta) self:ScrollPages(axis, delta) end @@ -78,7 +78,7 @@ ChatLinesInterface = ClassUI(Group) { -- Parent to the engine frame so the `event.MouseX/Y` -- screen coords map straight to Left/Top without going -- through the Layouter's `pixelScaleFactor` scaling. - -- `event.MouseX/Y` carry the actual click position; + -- `event.MouseX/Y` carry the actual click position. -- `GetMouseScreenPos()` would freeze at the last -- pre-UI-occlusion position. local mouseX, mouseY = event.MouseX, event.MouseY @@ -123,9 +123,9 @@ ChatLinesInterface = ClassUI(Group) { ) ) - -- `OptionsObserver` is wired in `Initialize`, not here — its - -- initial fire calls `ApplyOptions → RebuildPool`, which reads - -- `Pool.Height()` and so requires layout to be in place. + -- `OptionsObserver` is wired in `Initialize`, not here. Its + -- initial fire calls `ApplyOptions` then `RebuildPool`, which + -- reads `Pool.Height()` and so requires layout to be in place. end, ---@param self UIChatLinesInterface @@ -149,11 +149,13 @@ ChatLinesInterface = ClassUI(Group) { end, --- Called by the parent once it has laid out the lines panel. - --- `RebuildPool` reads `Pool.Height()`, which is zero until our outer - --- rect is bound, so pool / rewrap / scroll work has to wait until - --- the parent positions us. + --- Builds the pool, rewraps history, scrolls to the bottom, and + --- wires the options observer. ---@param self UIChatLinesInterface Initialize = function(self) + -- `RebuildPool` reads `Pool.Height()`, which is zero until our + -- outer rect is bound. Pool / rewrap / scroll work has to wait + -- until the parent positions us. self:RebuildPool() self:RewrapAll() self:ScrollToBottom() @@ -170,13 +172,13 @@ ChatLinesInterface = ClassUI(Group) { -- Pool sizing --------------------------------------------------------------------------- - --- Rebuilds the line pool to fit Pool height. Lines stack bottom-up: - --- `ChatLineInterfaces[1]` pins to the pool's bottom and holds the - --- newest visible message. Empty slots sit at the top so the feed - --- reads bottom-anchored. Safe to call repeatedly; callers follow up - --- with `CalcVisible` (and `RewrapAll` on a true resize). + --- Rebuilds the line pool to fit Pool height. Safe to call repeatedly. + --- Callers follow up with `CalcVisible` (and `RewrapAll` on a true resize). ---@param self UIChatLinesInterface RebuildPool = function(self) + -- Lines stack bottom-up: `ChatLineInterfaces[1]` pins to the + -- pool's bottom and holds the newest visible message. Empty + -- slots sit at the top so the feed reads bottom-anchored. local pool = self.Pool local fontSize = ChatConfigModel.GetOptions().font_size or 14 @@ -224,7 +226,7 @@ ChatLinesInterface = ClassUI(Group) { --------------------------------------------------------------------------- --- Applies a `UIChatOptions` snapshot. Window-level options - --- (`win_alpha`, default recipient, …) are the parent's responsibility. + --- (`win_alpha`, default recipient, ...) are the parent's responsibility. ---@param self UIChatLinesInterface ---@param options UIChatOptions ApplyOptions = function(self, options) @@ -233,8 +235,8 @@ ChatLinesInterface = ClassUI(Group) { for _, line in ipairs(self.ChatLineInterfaces) do line:SetFontSize(size) end - -- Row height tracks the font, so the pool may need resizing; - -- wrap widths depend on font metrics, so rewrap. + -- Row height tracks the font, so the pool may need resizing. + -- Wrap widths depend on font metrics, so rewrap. self:RebuildPool() self:RewrapAll() @@ -268,14 +270,14 @@ ChatLinesInterface = ClassUI(Group) { -- Filtering --------------------------------------------------------------------------- - --- Whether an entry counts toward the virtual scroll size. Gates on - --- the per-army mute map and the `links` option (Camera or Location - --- both qualify as "link" messages — either surfaces the camera-link - --- affordance on the row). + --- Whether an entry counts toward the virtual scroll size. ---@param self UIChatLinesInterface ---@param entry UIChatEntry ---@return boolean IsValidEntry = function(self, entry) + -- Gates on the per-army mute map and the `links` option. Camera + -- or Location both qualify as "link" messages: either surfaces + -- the camera-link affordance on the row. if entry == nil then return false end local options = ChatConfigModel.GetOptions() if options.muted and entry.ArmyID and options.muted[entry.ArmyID] then @@ -330,13 +332,13 @@ ChatLinesInterface = ClassUI(Group) { self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * table.getn(self.ChatLineInterfaces)) end, - --- Jumps to an absolute virtual position, clamped. Signature matches - --- the engine's `ScrollSetTop(axis, top)` contract so the scrollbar - --- can call it directly. + --- Jumps to an absolute virtual position, clamped. ---@param self UIChatLinesInterface ---@param axis string ---@param top number ScrollSetTop = function(self, axis, top) + -- Signature matches the engine's `ScrollSetTop(axis, top)` + -- contract so the scrollbar can call it directly. ChatController.NotifyActivity() top = math.floor(top or 1) local poolSize = table.getn(self.ChatLineInterfaces) @@ -355,14 +357,14 @@ ChatLinesInterface = ClassUI(Group) { return true end, - --- Adjusts `ScrollTop` to keep the entry at `pool[1]` pinned across a - --- pool-size change. Without this, growing the pool past the previous - --- `visibleBottom` leaves the new top slots stuck on Clear+Hide - --- instead of revealing older history; the user has to scroll to - --- "fix" it. Caller follows up with `CalcVisible`. + --- Adjusts `ScrollTop` to keep the entry at `pool[1]` pinned across + --- a pool-size change. Caller follows up with `CalcVisible`. ---@param self UIChatLinesInterface ---@param oldPoolSize number # pool length before the resize / RebuildPool call RecomputeScrollTopForPoolChange = function(self, oldPoolSize) + -- Without this, growing the pool past the previous `visibleBottom` + -- leaves the new top slots stuck on Clear+Hide instead of + -- revealing older history. The user has to scroll to "fix" it. local oldVisibleBottom = math.min(self.ScrollTop + oldPoolSize - 1, self.VirtualSize) local newPoolSize = table.getn(self.ChatLineInterfaces) local newMaxTop = math.max(1, self.VirtualSize - newPoolSize + 1) @@ -392,12 +394,12 @@ ChatLinesInterface = ClassUI(Group) { -- Visibility mapping --------------------------------------------------------------------------- - --- Projects the visible virtual range onto the bottom-anchored line - --- pool. `ChatLineInterfaces[1]` shows the newest visible chunk; - --- subsequent slots walk back through history. Surplus slots at the - --- top are cleared and hidden. + --- Projects the visible virtual range onto the bottom-anchored line pool. ---@param self UIChatLinesInterface CalcVisible = function(self) + -- `ChatLineInterfaces[1]` shows the newest visible chunk. + -- Subsequent slots walk back through history. Surplus slots at + -- the top are cleared and hidden. if not self.ChatLineInterfaces[1] then return end local history = ChatModel.GetSingleton().History() diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua index 07c9b8878e8..f9e4e3d1a8a 100644 --- a/lua/ui/game/chat/ChatListInterface.lua +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -72,12 +72,13 @@ ChatListInterface = ClassUI(Group) { end end, - --- Targets: All, Allies, then one entry per connected non-local human - --- player. Client matched to army by nickname; target stays an army ID - --- so the send path is unchanged. + --- Returns target defs: All, Allies, then one entry per connected + --- non-local human player. ---@param self UIChatListInterface ---@return table[] BuildTargetDefs = function(self) + -- Client matched to army by nickname. Target stays an army ID + -- so the send path is unchanged. local defs = { { Nickname = "All", Target = ChatModel.RecipientAll }, { Nickname = "Allies", Target = ChatModel.RecipientAllies }, diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua index 2edd9f13461..6cf74a90bfd 100644 --- a/lua/ui/game/chat/ChatModel.lua +++ b/lua/ui/game/chat/ChatModel.lua @@ -8,7 +8,7 @@ RecipientAll = 'all' RecipientAllies = 'allies' ---- UI subsystem channel for the Notify system. Receive-only — not part of +--- UI subsystem channel for the Notify system. Receive-only, not part of --- `UIChatRecipient` because users can't send to this channel. RecipientNotify = 'notify' @@ -20,8 +20,8 @@ RecipientNotify = 'notify' --- Location hint carried by a sim-originated message (AI brains, system --- messages). The UI translates this to a camera move on click without ---- forcing the viewer's pitch/heading to match the sender's — unlike ---- `Camera`, which restores a full snapshot. +--- forcing the viewer's pitch/heading to match the sender's (unlike +--- `Camera`, which restores a full snapshot). ---@class UIChatEntryLocation ---@field Position? Vector # world-space focus point ---@field Area? Rectangle # world-space rectangle to frame @@ -41,7 +41,7 @@ RecipientNotify = 'notify' ---@field Id? string # near-unique sender-stamped id (`tostring(msg)`); used to dedupe the `Sync.ChatMessages` replay/sim path against the live `SessionSendChatMessage` path ---@field WrappedText? string[] # view-side cache: text wrapped to the current row width (populated by ChatInterface) ---- Reactive chat-state singleton — the single source of truth shared by every chat view. +--- Reactive chat-state singleton: the single source of truth shared by every chat view. ---@class UIChatModel ---@field History LazyVar # append-only message log (set a new table ref to trigger dirty) ---@field Recipient LazyVar # current send target diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua index 829f7a54a34..e0bbbcec463 100644 --- a/lua/ui/game/chat/ChatUtils.lua +++ b/lua/ui/game/chat/ChatUtils.lua @@ -10,7 +10,7 @@ local ChatPayload = import("/lua/shared/ChatPayload.lua") MaxMessageLength = ChatPayload.MaxMessageLength --- Recipient-label / chat-line-prefix descriptors. Keys are localization ---- categories, not recipient constants — receiver indexes by `msg.to` and +--- categories, not recipient constants. Receiver indexes by `msg.to` and --- falls back to `private` for whispers. Each entry has a `text` --- (lowercase), a `caps` (titlecase), and a `colorkey` resolved at render --- time. @@ -36,14 +36,16 @@ ColorPalette = { 'ffff9f42', -- 8: orange } ---- Wraps an entry's body text and caches it as `entry.WrappedText`. The ---- first chunk reserves space for the name prefix; subsequent chunks span ---- the full body width. Always overwrites — callers gate on the cache. ---- `measureLine == nil` degrades to a single-chunk wrap so callers without ---- a measurement source still get something renderable. +--- Wraps `entry.Text` to the row width and caches it as `entry.WrappedText`. +--- Pass `measureLine = nil` to skip wrapping and store the raw text as a +--- single chunk. ---@param entry UIChatEntry ---@param measureLine UIChatLineInterface | nil function WrapEntry(entry, measureLine) + -- Always overwrites WrappedText, callers gate on the cache to avoid + -- a re-wrap. The first chunk reserves space for the name prefix so + -- the body starts after Name.Right + 4. Subsequent chunks span the + -- full body width. if not measureLine then entry.WrappedText = { entry.Text or '' } return diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua index 3212a5ee93a..96dbf37ae4c 100644 --- a/lua/ui/game/chat/commands/ChatCommandRegistry.lua +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -3,19 +3,19 @@ local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") ------------------------------------------------------------------------------- -- Registry + parser + dispatcher for chat slash-commands. See design.md. ---- One declared parameter slot in a command's signature; resolver is picked by `Type`. +--- One declared parameter slot in a command's signature. Resolver is picked by `Type`. ---@class UIChatCommandParam ---@field Name string ---@field Type UIChatCommandParamType ---@field Optional boolean? ---- Per-invocation context handed to `Accept` and `Execute`; holds model + controller + raw input. +--- Per-invocation context handed to `Accept` and `Execute`. Holds model + controller + raw input. ---@class UIChatCommandContext ---@field Model UIChatModel ---@field Controller table ---@field SourceText string ---- A registered slash-command — name, optional aliases/params/gates, and the dispatcher's hooks. +--- A registered slash-command: name, optional aliases/params/gates, and the dispatcher's hooks. ---@class UIChatCommand ---@field Name string ---@field Aliases? string[] @@ -29,7 +29,7 @@ local Types = import("/lua/ui/game/chat/commands/ChatCommandTypes.lua") ---@type table local Commands = {} ---- Lower-cased alias → canonical command name; merged into lookup so `/w` resolves to `/whisper`. +--- Lower-cased alias to canonical command name. Merged into lookup so `/w` resolves to `/whisper`. ---@type table local Aliases = {} @@ -50,11 +50,13 @@ function Unregister(name) Commands[key] = nil end ---- Overwrites any previous registration with the same canonical name; ---- aliases from the previous registration are cleared first. A ---- `ShouldRegister` returning false drops the command for this session. +--- Registers a command in the registry. ---@param cmd UIChatCommand function Register(cmd) + -- Overwrites any previous registration with the same canonical name. + -- Aliases from the previous registration are cleared first. A + -- `ShouldRegister` returning false drops the command for this + -- session. assert(cmd and cmd.Name, "Chat command requires a name.") assert(cmd.Execute, "Chat command requires an execute function.") @@ -79,11 +81,12 @@ function Register(cmd) end end ---- Loads a command file and registers its `Command` export inside ---- pcalls so one broken file can't take down the registration pass. +--- Loads a command file at `path` and registers its `Command` export. --- Every failure is logged and swallowed. ---@param path string function RegisterFromPath(path) + -- Wrapped in pcalls so one broken file can't take down the + -- registration pass. if not DiskGetFileInfo(path) then WARN(string.format("Chat command skipped: file not found '%s'.", tostring(path))) return @@ -178,7 +181,7 @@ end ------------------------------------------------------------------------------- -- Parsing ---- "whisper Jip hello" → "whisper", {"Jip", "hello"} +--- "whisper Jip hello" -> "whisper", {"Jip", "hello"} ---@param body string ---@return string?, string[] local function Tokenize(body) @@ -248,15 +251,14 @@ end --- Fall-through to legacy `RunChatCommand` for pre-MVC commands --- registered via Notify's `AddChatCommand` (`/enablenotify`, etc.). --- New commands should live under `commands/builtin/`. ---- ---- Args shape matches the legacy dispatcher: lowercased name in slot 1, ---- lowercased remaining tokens after. Wrapped in pcall for the same ---- reason as Accept/Execute — third-party commands throwing must not ---- leak up through the chat send path. ---@param name string # the slash-stripped command word, original case ---@param tokens string[] # remaining tokens (after the command word) ---@return boolean handled local function DispatchLegacy(name, tokens) + -- Args shape matches the legacy dispatcher: lowercased name in slot + -- 1, lowercased remaining tokens after. Wrapped in pcall for the + -- same reason as Accept/Execute. Third-party commands throwing must + -- not leak up through the chat send path. local args = { string.lower(name) } for _, tok in ipairs(tokens) do table.insert(args, string.lower(tok)) @@ -275,9 +277,9 @@ end --- Parses a chat line that starts with '/' and invokes the matching command. --- Return values: ---- (true, nil) → command ran (or was accept-rejected and already reported) ---- (false, errText) → slash-prefixed but failed; caller should surface errText ---- (false, nil) → lone '/' or whitespace; caller may treat as normal text +--- (true, nil) -> command ran (or was accept-rejected and already reported) +--- (false, errText) -> slash-prefixed but failed. Caller should surface errText. +--- (false, nil) -> lone '/' or whitespace. Caller may treat as normal text. ---@param text string ---@return boolean handled ---@return string? errorText @@ -314,13 +316,13 @@ function Dispatch(text) } if cmd.Accept then - -- Accept is user code; treat a throw as a soft failure so it + -- Accept is user code. Treat a throw as a soft failure so it -- doesn't propagate up through the edit-box event handler. local pcallOk, ok, reason = pcall(cmd.Accept, args, ctx) if not pcallOk then WARN(string.format("/%s: Accept threw (%s).", cmd.Name, tostring(ok))) return false, string.format( - "/%s: command errored while validating — see the log for details.", + "/%s: command errored while validating. See the log for details.", cmd.Name) end if not ok then @@ -329,12 +331,12 @@ function Dispatch(text) end -- Same pcall as Accept. Side effects before the throw aren't rolled - -- back; this just keeps the chat input usable. + -- back. This just keeps the chat input usable. local executeOk, err = pcall(cmd.Execute, args, ctx) if not executeOk then WARN(string.format("/%s: Execute threw (%s).", cmd.Name, tostring(err))) return false, string.format( - "/%s: command errored while running — see the log for details.", + "/%s: command errored while running. See the log for details.", cmd.Name) end return true, nil diff --git a/lua/ui/game/chat/commands/ChatCommandTypes.lua b/lua/ui/game/chat/commands/ChatCommandTypes.lua index 756adb21eee..599d2a0fd8b 100644 --- a/lua/ui/game/chat/commands/ChatCommandTypes.lua +++ b/lua/ui/game/chat/commands/ChatCommandTypes.lua @@ -37,14 +37,14 @@ local function ResolveArmy(token) return false, string.format("no player named '%s'.", token) end ---- Tag identifying which `Resolvers` entry parses a parameter token; one tag per supported type. +--- Tag identifying which `Resolvers` entry parses a parameter token. One tag per supported type. ---@alias UIChatCommandParamType 'Recipient' | 'Player' | 'Int' | 'String' | 'Rest' ---- Param-type → resolver table; each resolver returns `(true, value)` on success or `(false, errMsg)`. +--- Param-type to resolver table. Each resolver returns `(true, value)` on success or `(false, errMsg)`. ---@type table Resolvers = {} ---- "all", "allies"/"team", nickname, or army ID → `UIChatRecipient`. +--- Resolves "all", "allies"/"team", nickname, or army ID into a `UIChatRecipient`. Resolvers.Recipient = function(token) local lower = string.lower(token) if lower == 'all' then @@ -55,12 +55,12 @@ Resolvers.Recipient = function(token) return ResolveArmy(token) end ---- Nickname or army ID → numeric army ID. Rejects "all"/"allies". +--- Resolves a nickname or army ID into a numeric army ID. Rejects "all"/"allies". Resolvers.Player = function(token) return ResolveArmy(token) end ---- Parses a token as an integer; rejects fractional or non-numeric input. +--- Parses a token as an integer. Rejects fractional or non-numeric input. Resolvers.Int = function(token) local n = tonumber(token) if not n or math.floor(n) ~= n then diff --git a/lua/ui/game/chat/commands/builtin/All.lua b/lua/ui/game/chat/commands/builtin/All.lua index d746c088aa6..91f855f8cb3 100644 --- a/lua/ui/game/chat/commands/builtin/All.lua +++ b/lua/ui/game/chat/commands/builtin/All.lua @@ -1,7 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") ---- /all — switch send target to every player and observer. +--- /all: switch send target to every player and observer. ---@type UIChatCommand Command = { Name = 'all', diff --git a/lua/ui/game/chat/commands/builtin/Allies.lua b/lua/ui/game/chat/commands/builtin/Allies.lua index 48dbd61103e..b8f5109f281 100644 --- a/lua/ui/game/chat/commands/builtin/Allies.lua +++ b/lua/ui/game/chat/commands/builtin/Allies.lua @@ -1,7 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") ---- /allies — switch send target to allies only. +--- /allies: switch send target to allies only. ---@type UIChatCommand Command = { Name = 'allies', diff --git a/lua/ui/game/chat/commands/builtin/Clear.lua b/lua/ui/game/chat/commands/builtin/Clear.lua index c2aab4d3494..11846da4eda 100644 --- a/lua/ui/game/chat/commands/builtin/Clear.lua +++ b/lua/ui/game/chat/commands/builtin/Clear.lua @@ -1,7 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") ---- /clear — wipes local chat history. +--- /clear: wipes local chat history. ---@type UIChatCommand Command = { Name = 'clear', diff --git a/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua index 25947005051..07edb99f6cb 100644 --- a/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua +++ b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua @@ -1,5 +1,5 @@ ---- /debug-dump-controls — invoke `UI_DumpControlsUnderCursor`; only registered with `/debug`. +--- /debug-dump-controls: invoke `UI_DumpControlsUnderCursor`. Only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debug-dump-controls', diff --git a/lua/ui/game/chat/commands/builtin/DebugLog.lua b/lua/ui/game/chat/commands/builtin/DebugLog.lua index 41db20cae6b..7e155e2fe74 100644 --- a/lua/ui/game/chat/commands/builtin/DebugLog.lua +++ b/lua/ui/game/chat/commands/builtin/DebugLog.lua @@ -1,5 +1,5 @@ ---- /debug-log — toggle the log window; only registered with `/debug`. +--- /debug-log: toggle the log window. Only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debug-log', diff --git a/lua/ui/game/chat/commands/builtin/DebugStatistics.lua b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua index 7fa2370d163..076c8acfcc7 100644 --- a/lua/ui/game/chat/commands/builtin/DebugStatistics.lua +++ b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua @@ -1,5 +1,5 @@ ---- /debug-statistics — cycle the engine's `ShowStats` overlay; only registered with `/debug`. +--- /debug-statistics: cycle the engine's `ShowStats` overlay. Only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debug-statistics', diff --git a/lua/ui/game/chat/commands/builtin/Debugger.lua b/lua/ui/game/chat/commands/builtin/Debugger.lua index 3957b10e03d..6c64d04526a 100644 --- a/lua/ui/game/chat/commands/builtin/Debugger.lua +++ b/lua/ui/game/chat/commands/builtin/Debugger.lua @@ -1,5 +1,5 @@ ---- /debugger — open the Lua debugger; only registered with `/debug`. +--- /debugger: open the Lua debugger. Only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'debugger', diff --git a/lua/ui/game/chat/commands/builtin/EndMission.lua b/lua/ui/game/chat/commands/builtin/EndMission.lua index 94523eaaf61..71ec3cfe399 100644 --- a/lua/ui/game/chat/commands/builtin/EndMission.lua +++ b/lua/ui/game/chat/commands/builtin/EndMission.lua @@ -1,5 +1,5 @@ ---- /end-mission — forfeit the current session and open the score screen. +--- /end-mission: forfeit the current session and open the score screen. ---@type UIChatCommand Command = { Name = 'end-mission', diff --git a/lua/ui/game/chat/commands/builtin/GiftResources.lua b/lua/ui/game/chat/commands/builtin/GiftResources.lua index 5a7cc8a5973..9aae8d9d893 100644 --- a/lua/ui/game/chat/commands/builtin/GiftResources.lua +++ b/lua/ui/game/chat/commands/builtin/GiftResources.lua @@ -1,7 +1,7 @@ local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") ---- Normalises a resource-kind token to 'mass' or 'energy'; returns nil on no match. +--- Normalises a resource-kind token to 'mass' or 'energy'. Returns nil on no match. local function NormalizeType(token) local lower = string.lower(token or '') if lower == 'mass' or lower == 'm' then @@ -12,7 +12,7 @@ local function NormalizeType(token) return nil end ---- /gift-resources [target] — gift a fraction of mass or energy to an ally. +--- /gift-resources [target]: gift a fraction of mass or energy to an ally. ---@type UIChatCommand Command = { Name = 'gift-resources', diff --git a/lua/ui/game/chat/commands/builtin/GiftUnits.lua b/lua/ui/game/chat/commands/builtin/GiftUnits.lua index afaee6309ca..def8182c6c5 100644 --- a/lua/ui/game/chat/commands/builtin/GiftUnits.lua +++ b/lua/ui/game/chat/commands/builtin/GiftUnits.lua @@ -1,5 +1,5 @@ ---- /gift-units — transfer current selection to an ally; sim re-checks alliance and `ManualUnitShare`. +--- /gift-units : transfer current selection to an ally. Sim re-checks alliance and `ManualUnitShare`. ---@type UIChatCommand Command = { Name = 'gift-units', @@ -13,8 +13,8 @@ Command = { return false, "/gift-units: observers can't gift units." end - -- Fall back to the unit under the cursor. `armyIndex` is 0-based; - -- the armies table is 1-based. + -- Fall back to the unit under the cursor. `armyIndex` is + -- 0-based, the armies table is 1-based. if args.target == nil then local info = GetRolloverInfo() if not info or not info.armyIndex then diff --git a/lua/ui/game/chat/commands/builtin/Help.lua b/lua/ui/game/chat/commands/builtin/Help.lua index e3dc81e32f9..27c24f4e57b 100644 --- a/lua/ui/game/chat/commands/builtin/Help.lua +++ b/lua/ui/game/chat/commands/builtin/Help.lua @@ -1,7 +1,7 @@ local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") ---- /help — prints every registered command as a local system line. +--- /help: prints every registered command as a local system line. ---@type UIChatCommand Command = { Name = 'help', diff --git a/lua/ui/game/chat/commands/builtin/Load.lua b/lua/ui/game/chat/commands/builtin/Load.lua index 36e159155e2..b8cba86632a 100644 --- a/lua/ui/game/chat/commands/builtin/Load.lua +++ b/lua/ui/game/chat/commands/builtin/Load.lua @@ -1,7 +1,7 @@ local Prefs = import("/lua/user/prefs.lua") ---- /load [name] — load a save; default is the quick-save slot, matching `QuickSave`'s path. +--- /load [name]: load a save. Default is the quick-save slot, matching `QuickSave`'s path. ---@type UIChatCommand Command = { Name = 'load', diff --git a/lua/ui/game/chat/commands/builtin/Mute.lua b/lua/ui/game/chat/commands/builtin/Mute.lua index 57622ab3a4b..76a917a99c1 100644 --- a/lua/ui/game/chat/commands/builtin/Mute.lua +++ b/lua/ui/game/chat/commands/builtin/Mute.lua @@ -1,7 +1,7 @@ local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") ---- /mute — hide messages from a player for the rest of this game. +--- /mute : hide messages from a player for the rest of this game. ---@type UIChatCommand Command = { Name = 'mute', diff --git a/lua/ui/game/chat/commands/builtin/Pause.lua b/lua/ui/game/chat/commands/builtin/Pause.lua index 6ab707d1e16..aab8bf6ec24 100644 --- a/lua/ui/game/chat/commands/builtin/Pause.lua +++ b/lua/ui/game/chat/commands/builtin/Pause.lua @@ -1,5 +1,5 @@ ---- /pause — pause the local simulation; not registered in multiplayer (vote/request hotkey owns that). +--- /pause: pause the local simulation. Not registered in multiplayer (vote/request hotkey owns that). ---@type UIChatCommand Command = { Name = 'pause', diff --git a/lua/ui/game/chat/commands/builtin/Recall.lua b/lua/ui/game/chat/commands/builtin/Recall.lua index e498e995b6f..d2cdac21945 100644 --- a/lua/ui/game/chat/commands/builtin/Recall.lua +++ b/lua/ui/game/chat/commands/builtin/Recall.lua @@ -1,5 +1,5 @@ ---- /recall — vote yes on the team recall. Only "yes" is exposed; voting no stays in the diplomacy UI. +--- /recall: vote yes on the team recall. Only "yes" is exposed (voting no stays in the diplomacy UI). ---@type UIChatCommand Command = { Name = 'recall', diff --git a/lua/ui/game/chat/commands/builtin/Restart.lua b/lua/ui/game/chat/commands/builtin/Restart.lua index 62a71d92cd3..96d35896fcd 100644 --- a/lua/ui/game/chat/commands/builtin/Restart.lua +++ b/lua/ui/game/chat/commands/builtin/Restart.lua @@ -1,5 +1,5 @@ ---- /restart — restart the current session; skips the escape-menu's confirmation dialog. +--- /restart: restart the current session. Skips the escape-menu's confirmation dialog. ---@type UIChatCommand Command = { Name = 'restart', diff --git a/lua/ui/game/chat/commands/builtin/Resume.lua b/lua/ui/game/chat/commands/builtin/Resume.lua index 1f50a90c158..b8e35e5db69 100644 --- a/lua/ui/game/chat/commands/builtin/Resume.lua +++ b/lua/ui/game/chat/commands/builtin/Resume.lua @@ -1,5 +1,5 @@ ---- /resume — un-pause the local simulation; symmetric with `/pause`. +--- /resume: un-pause the local simulation. Symmetric with `/pause`. ---@type UIChatCommand Command = { Name = 'resume', diff --git a/lua/ui/game/chat/commands/builtin/Save.lua b/lua/ui/game/chat/commands/builtin/Save.lua index c2ef265bbd5..9588ab6631e 100644 --- a/lua/ui/game/chat/commands/builtin/Save.lua +++ b/lua/ui/game/chat/commands/builtin/Save.lua @@ -1,5 +1,5 @@ ---- /save [name] — quick-save; default name matches the quick-save hotkey so repeats overwrite the slot. +--- /save [name]: quick-save. Default name matches the quick-save hotkey so repeats overwrite the slot. ---@type UIChatCommand Command = { Name = 'save', diff --git a/lua/ui/game/chat/commands/builtin/Speed.lua b/lua/ui/game/chat/commands/builtin/Speed.lua index f1467ed9428..7f9457e56fd 100644 --- a/lua/ui/game/chat/commands/builtin/Speed.lua +++ b/lua/ui/game/chat/commands/builtin/Speed.lua @@ -1,5 +1,5 @@ ---- /speed — set sim speed via `WLD_GameSpeed`; not registered in multiplayer (host vote/request flow). +--- /speed : set sim speed via `WLD_GameSpeed`. Not registered in multiplayer (host vote/request flow). ---@type UIChatCommand Command = { Name = 'speed', diff --git a/lua/ui/game/chat/commands/builtin/Taunt.lua b/lua/ui/game/chat/commands/builtin/Taunt.lua index 340cb1be467..a6611cbe3b9 100644 --- a/lua/ui/game/chat/commands/builtin/Taunt.lua +++ b/lua/ui/game/chat/commands/builtin/Taunt.lua @@ -1,5 +1,5 @@ ---- /taunt — broadcast a numbered taunt; receivers silently ignore out-of-range indices. +--- /taunt : broadcast a numbered taunt. Receivers silently ignore out-of-range indices. ---@type UIChatCommand Command = { Name = 'taunt', diff --git a/lua/ui/game/chat/commands/builtin/ToEngineers.lua b/lua/ui/game/chat/commands/builtin/ToEngineers.lua index e7a98effcd0..84b548e17a6 100644 --- a/lua/ui/game/chat/commands/builtin/ToEngineers.lua +++ b/lua/ui/game/chat/commands/builtin/ToEngineers.lua @@ -1,5 +1,5 @@ ---- /to-engineers — narrow selection to engineers; errors rather than silently clearing on no-match. +--- /to-engineers: narrow selection to engineers. Errors rather than silently clearing on no-match. ---@type UIChatCommand Command = { Name = 'to-engineers', diff --git a/lua/ui/game/chat/commands/builtin/ToTick.lua b/lua/ui/game/chat/commands/builtin/ToTick.lua index 56737be5648..cc69c4d38dd 100644 --- a/lua/ui/game/chat/commands/builtin/ToTick.lua +++ b/lua/ui/game/chat/commands/builtin/ToTick.lua @@ -1,5 +1,5 @@ ---- /to-tick — fast-forward to `tick` and pause; only registered with `/debug`. +--- /to-tick : fast-forward to `tick` and pause. Only registered with `/debug`. ---@type UIChatCommand Command = { Name = 'to-tick', diff --git a/lua/ui/game/chat/commands/builtin/Unmute.lua b/lua/ui/game/chat/commands/builtin/Unmute.lua index 5f7a9ebe8e6..92eacb9d0db 100644 --- a/lua/ui/game/chat/commands/builtin/Unmute.lua +++ b/lua/ui/game/chat/commands/builtin/Unmute.lua @@ -1,7 +1,7 @@ local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") ---- /unmute — reverse of `/mute`; re-shows new arrivals and history that landed while muted. +--- /unmute : reverse of `/mute`. Re-shows new arrivals and history that landed while muted. ---@type UIChatCommand Command = { Name = 'unmute', diff --git a/lua/ui/game/chat/commands/builtin/Whisper.lua b/lua/ui/game/chat/commands/builtin/Whisper.lua index 3d1d6ab405a..f82bd7a1e52 100644 --- a/lua/ui/game/chat/commands/builtin/Whisper.lua +++ b/lua/ui/game/chat/commands/builtin/Whisper.lua @@ -1,5 +1,5 @@ ---- /whisper — private-message a specific player. +--- /whisper : private-message a specific player. ---@type UIChatCommand Command = { Name = 'whisper', diff --git a/lua/ui/game/chat/config/ChatConfigInterface.lua b/lua/ui/game/chat/config/ChatConfigInterface.lua index 43ca2968d01..80a7ea9e018 100644 --- a/lua/ui/game/chat/config/ChatConfigInterface.lua +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -18,7 +18,7 @@ local Layouter = LayoutHelpers.ReusedLayoutFor local Debug = false --- Generic `panel_brd_*` chrome rather than the chat window's bespoke ---- art — the two dialogs are different sizes. +--- art (the two dialogs are different sizes). ---@diagnostic disable: param-type-mismatch local WindowTextures = { tl = UIUtil.SkinnableFile('/game/panel/panel_brd_ul.dds'), @@ -34,7 +34,7 @@ local WindowTextures = { } ---@diagnostic enable: param-type-mismatch --- Same `chat_color` tooltip on every colour combo — the per-row label +-- Same `chat_color` tooltip on every colour combo. The per-row label -- already names the recipient, so the tooltip just explains the control. local ColorDefs = { { Key = ChatConfigModel.KeyAllColor, Text = "All", Tooltip = 'chat_color' }, @@ -235,7 +235,7 @@ local ChatConfigInterface = ClassUI(Window) { end -- ---- Decorative corner grips ---- - -- Pure decoration — `lockSize` is true on this window, so routing + -- Pure decoration. `lockSize` is true on this window, so routing -- clicks through them would only confuse the title-bar drag. self.DragTL = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) self.DragTR = Bitmap(self, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) @@ -367,7 +367,7 @@ local ChatConfigInterface = ClassUI(Window) { :AtVerticalCenterIn(self.BtnOk) :End() - -- Don't pin Width here — the drag handler's Right:Set(Left + Width) + -- Don't pin Width here. The drag handler's Right:Set(Left + Width) -- would snap to whatever Width got pinned to. Width stays driven -- by Left/Right from the default rect. local bottomPadScaled = LayoutHelpers.ScaleNumber(16) @@ -414,9 +414,10 @@ local ChatConfigInterface = ClassUI(Window) { end end, - --- Title-bar close button. Mirrors the Cancel button: discards any - --- Pending draft (re-syncs from Committed) and tears down the dialog. + --- Title-bar close button. Mirrors the Cancel button. OnClose = function(self) + -- Discards any Pending draft (re-syncs from Committed) and tears + -- down the dialog. ChatConfigController.Cancel() import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() end, @@ -436,11 +437,11 @@ local ChatConfigInterface = ClassUI(Window) { local Instance = nil --- Standalone entry point: shows the config dialog, building it on first open. ---- Always re-syncs `Pending` from `Committed` first so a reopen reflects the ---- current committed state (including any `SetMutedLive` writes that landed ---- while the dialog was hidden) instead of a stale draft from a previous ---- session. function Open() + -- Always re-syncs `Pending` from `Committed` first so a reopen + -- reflects the current committed state (including any `SetMutedLive` + -- writes that landed while the dialog was hidden) instead of a stale + -- draft from a previous session. local Controller = import("/lua/ui/game/chat/config/ChatConfigController.lua") Controller.Cancel() if Instance then From c7a8eaf297d4ca429742fd0c2d85d9703ef75011 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Tue, 28 Apr 2026 07:57:58 +0200 Subject: [PATCH 129/130] Improve comment about console output ending up as a chat message --- lua/ui/game/gamemain.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/ui/game/gamemain.lua b/lua/ui/game/gamemain.lua index 68310342df1..238471cf65b 100644 --- a/lua/ui/game/gamemain.lua +++ b/lua/ui/game/gamemain.lua @@ -1071,8 +1071,7 @@ end ---@param sender string # username ---@param data table function ReceiveChat(sender, data) - - -- early exit for console output + -- console output ends up as a chat message, hence we early exit here if data.ConsoleOutput then print(LOCF("%s %s", sender, data.ConsoleOutput)) return From 87d3f5b399911d5fb05d71f11023e8684a078824 Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Tue, 28 Apr 2026 08:31:10 +0200 Subject: [PATCH 130/130] Remove the legacy chat --- lua/ui/game/chat.legacy.lua | 1565 ----------------------------------- 1 file changed, 1565 deletions(-) delete mode 100644 lua/ui/game/chat.legacy.lua diff --git a/lua/ui/game/chat.legacy.lua b/lua/ui/game/chat.legacy.lua deleted file mode 100644 index 2914fa82dfd..00000000000 --- a/lua/ui/game/chat.legacy.lua +++ /dev/null @@ -1,1565 +0,0 @@ -local UiUtilsS = import("/lua/uiutilssorian.lua") -local UIUtil = import("/lua/ui/uiutil.lua") -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local EffectHelpers = import("/lua/maui/effecthelpers.lua") -local Group = import("/lua/maui/group.lua").Group -local Checkbox = import("/lua/ui/controls/checkbox.lua").Checkbox -local Button = import("/lua/maui/button.lua").Button -local Text = import("/lua/maui/text.lua").Text -local Edit = import("/lua/maui/edit.lua").Edit -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local ItemList = import("/lua/maui/itemlist.lua").ItemList -local Window = import("/lua/maui/window.lua").Window -local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo -local IntegerSlider = import("/lua/maui/slider.lua").IntegerSlider -local Prefs = import("/lua/user/prefs.lua") -local Dragger = import("/lua/maui/dragger.lua").Dragger -local Tooltip = import("/lua/ui/game/tooltip.lua") -local UIMain = import("/lua/ui/uimain.lua") ---[[ LOC Strings -To %s: -Chat (%d - %d of %d lines) ---]] - -local AddUnicodeCharToEditText = import("/lua/utf.lua").AddUnicodeCharToEditText - -local CHAT_INACTIVITY_TIMEOUT = 15 -- in seconds -local savedParent = false -local chatHistory = {} - -local commandHistory = {} - -local ChatTo = import("/lua/lazyvar.lua").Create() - -local defOptions = { all_color = 1, - allies_color = 2, - priv_color = 3, - link_color = 4, - notify_color = 8, - font_size = 14, - fade_time = 15, - win_alpha = 1, - feed_background = false, - feed_persist = true} - -local ChatOptions = Prefs.GetFieldFromCurrentProfile("chatoptions") or {} -for option, value in defOptions do - if ChatOptions[option] == nil then - ChatOptions[option] = value - end -end - -GUI = import("/lua/ui/controls.lua").Get() -GUI.chatLines = GUI.chatLines or {} - -local FactionsIcon = {} -local Factions = import("/lua/factions.lua").Factions -for k, FactionData in Factions do - table.insert(FactionsIcon, FactionData.Icon) -end -table.insert(FactionsIcon, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') - - -local chatColors = {'ffffffff', 'ffff4242', 'ffefff42','ff4fff42', 'ff42fff8', 'ff424fff', 'ffff42eb', 'ffff9f42'} - -local ToStrings = { - to = {text = 'to', caps = 'To', colorkey = 'all_color'}, - allies = {text = 'to allies:', caps = 'To Allies:', colorkey = 'allies_color'}, - all = {text = 'to all:', caps = 'To All:', colorkey = 'all_color'}, - private = {text = 'to you:', caps = 'To You:', colorkey = 'priv_color'}, - notify = {text = 'to allies:', caps = 'To Allies:', colorkey = 'notify_color'}, -} - -function SetLayout() - import(UIUtil.GetLayoutFilename('chat')).SetLayout() -end - -function CreateChatBackground() - local location = {Top = function() return GetFrame(0).Bottom() - LayoutHelpers.ScaleNumber(393) end, - Left = function() return GetFrame(0).Left() + LayoutHelpers.ScaleNumber(8) end, - Right = function() return GetFrame(0).Left() + LayoutHelpers.ScaleNumber(430) end, - Bottom = function() return GetFrame(0).Bottom() - LayoutHelpers.ScaleNumber(238) end} - local bg = Window(GetFrame(0), '', nil, true, true, nil, nil, 'chat_window', location) - bg.Depth:Set(200) - - bg.DragTL = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) - bg.DragTR = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) - bg.DragBL = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) - bg.DragBR = Bitmap(bg, UIUtil.UIFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) - - local controlMap = { - tl = {bg.DragTL}, - tr = {bg.DragTR}, - bl = {bg.DragBL}, - br = {bg.DragBR}, - mr = {bg.DragBR,bg.DragTR}, - ml = {bg.DragBL,bg.DragTL}, - tm = {bg.DragTL,bg.DragTR}, - bm = {bg.DragBL,bg.DragBR}, - } - - bg.RolloverHandler = function(control, event, xControl, yControl, cursor, controlID) - if bg._lockSize then return end - local styles = import("/lua/maui/window.lua").styles - if not bg._sizeLock then - if event.Type == 'MouseEnter' then - if controlMap[controlID] then - for _, control in controlMap[controlID] do - control:SetTexture(control.textures.over) - end - end - GetCursor():SetTexture(styles.cursorFunc(cursor)) - elseif event.Type == 'MouseExit' then - if controlMap[controlID] then - for _, control in controlMap[controlID] do - control:SetTexture(control.textures.up) - end - end - GetCursor():Reset() - elseif event.Type == 'ButtonPress' then - if controlMap[controlID] then - for _, control in controlMap[controlID] do - control:SetTexture(control.textures.down) - end - end - bg.StartSizing(event, xControl, yControl) - bg._sizeLock = true - end - end - end - - bg.OnResizeSet = function(control) - bg.DragTL:SetTexture(bg.DragTL.textures.up) - bg.DragTR:SetTexture(bg.DragTR.textures.up) - bg.DragBL:SetTexture(bg.DragBL.textures.up) - bg.DragBR:SetTexture(bg.DragBR.textures.up) - end - - LayoutHelpers.AtLeftTopIn(bg.DragTL, bg, -26, -6) - bg.DragTL.Depth:Set(220) - bg.DragTL:DisableHitTest() - - LayoutHelpers.AtRightTopIn(bg.DragTR, bg, -22, -8) - bg.DragTR.Depth:Set(bg.DragTL.Depth) - bg.DragTR:DisableHitTest() - - LayoutHelpers.AtLeftBottomIn(bg.DragBL, bg, -26, -8) - bg.DragBL.Depth:Set(bg.DragTL.Depth) - bg.DragBL:DisableHitTest() - - LayoutHelpers.AtRightBottomIn(bg.DragBR, bg, -22, -8) - bg.DragBR.Depth:Set(bg.DragTL.Depth) - bg.DragBR:DisableHitTest() - - bg.ResetPositionBtn = Button(bg, - UIUtil.SkinnableFile('/game/menu-btns/default_btn_up.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_down.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_over.dds'), - UIUtil.SkinnableFile('/game/menu-btns/default_btn_dis.dds')) - LayoutHelpers.LeftOf(bg.ResetPositionBtn, bg._configBtn) - bg.ResetPositionBtn.Depth:Set(function() return bg.Depth() + 10 end) - bg.ResetPositionBtn.OnClick = function(self, modifiers) - for index, position in location do - local i = index - local pos = position - bg[i]:Set(pos) - end - CreateChatLines() - bg:SaveWindowLocation() - end - - Tooltip.AddButtonTooltip(bg.ResetPositionBtn, 'chat_reset') - - bg:SetMinimumResize(400, 160) - return bg -end - -function CreateChatLines() - local function CreateChatLine() - local line = Group(GUI.chatContainer) - - -- Draw the faction icon with a colour representing the team behind it. - line.teamColor = Bitmap(line) - line.teamColor:SetSolidColor('00000000') - line.teamColor.Height:Set(line.Height) - line.teamColor.Width:Set(line.Height) - LayoutHelpers.AtLeftTopIn(line.teamColor, line) - - line.factionIcon = Bitmap(line.teamColor) - line.factionIcon:SetSolidColor('00000000') - LayoutHelpers.FillParent(line.factionIcon, line.teamColor) - - -- Player name - line.name = UIUtil.CreateText(line, '', ChatOptions.font_size, "Arial Bold") - LayoutHelpers.CenteredRightOf(line.name, line.teamColor, 4) - line.name.Depth:Set(function() return line.Depth() + 10 end) - line.name:SetColor('ffffffff') - line.name:DisableHitTest() - line.name:SetDropShadow(true) - line.name.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - if line.chatID then - if GUI.bg:IsHidden() then GUI.bg:Show() end - ChatTo:Set(line.chatID) - if GUI.chatEdit.edit then - GUI.chatEdit.edit:AcquireFocus() - end - if GUI.chatEdit.private then - GUI.chatEdit.private:SetCheck(true) - end - end - end - end - - line.text = UIUtil.CreateText(line, '', ChatOptions.font_size, "Arial") - line.text.Depth:Set(function() return line.Depth() + 10 end) - line.text.Left:Set(function() return line.name.Right() + 2 end) - line.text.Right:Set(line.Right) - line.text:SetClipToWidth(true) - line.text:DisableHitTest() - line.text:SetColor('ffc2f6ff') - line.text:SetDropShadow(true) - LayoutHelpers.AtVerticalCenterIn(line.text, line.teamColor) - line.text.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - if line.cameraData then - GetCamera('WorldCamera'):RestoreSettings(line.cameraData) - end - end - end - - -- A background for the line that persists after the chat panel is closed (to help with - -- readability against the simulation) - line.lineStickybg = Bitmap(line) - line.lineStickybg:DisableHitTest() - line.lineStickybg:SetSolidColor('aa000000') - LayoutHelpers.FillParent(line.lineStickybg, line) - LayoutHelpers.DepthUnderParent(line.lineStickybg, line) - line.lineStickybg:Hide() - - return line - end - if GUI.chatContainer then - local curEntries = table.getsize(GUI.chatLines) - local neededEntries = math.floor(GUI.chatContainer.Height() / (GUI.chatLines[1].Height() + 0)) - if curEntries - neededEntries == 0 then - return - elseif curEntries - neededEntries < 0 then - for i = curEntries + 1, neededEntries do - local index = i - GUI.chatLines[index] = CreateChatLine() - LayoutHelpers.Below(GUI.chatLines[index], GUI.chatLines[index-1], 0) - GUI.chatLines[index].Height:Set(function() return GUI.chatLines[index].name.Height() + 2 end) - GUI.chatLines[index].Right:Set(GUI.chatContainer.Right) - end - elseif curEntries - neededEntries > 0 then - for i = neededEntries + 1, curEntries do - if GUI.chatLines[i] then - GUI.chatLines[i]:Destroy() - GUI.chatLines[i] = nil - end - end - end - else - local clientArea = GUI.bg:GetClientGroup() - GUI.chatContainer = Group(clientArea) - LayoutHelpers.AtLeftIn(GUI.chatContainer, clientArea, 10) - LayoutHelpers.AtTopIn(GUI.chatContainer, clientArea, 2) - LayoutHelpers.AtRightIn(GUI.chatContainer, clientArea, 38) - LayoutHelpers.AnchorToTop(GUI.chatContainer, GUI.chatEdit, 10) - - SetupChatScroll() - - if not GUI.chatLines[1] then - GUI.chatLines[1] = CreateChatLine() - LayoutHelpers.AtLeftTopIn(GUI.chatLines[1], GUI.chatContainer, 0, 0) - GUI.chatLines[1].Height:Set(function() return GUI.chatLines[1].name.Height() + 2 end) - GUI.chatLines[1].Right:Set(GUI.chatContainer.Right) - end - local index = 1 - while GUI.chatLines[index].Bottom() + GUI.chatLines[1].Height() < GUI.chatContainer.Bottom() do - index = index + 1 - if not GUI.chatLines[index] then - GUI.chatLines[index] = CreateChatLine() - LayoutHelpers.Below(GUI.chatLines[index], GUI.chatLines[index-1], 0) - GUI.chatLines[index].Height:Set(function() return GUI.chatLines[index].name.Height() + 2 end) - GUI.chatLines[index].Right:Set(GUI.chatContainer.Right) - end - end - end -end - -function OnNISBegin() - CloseChat() -end - -function SetupChatScroll() - GUI.chatContainer.top = 1 - GUI.chatContainer.scroll = UIUtil.CreateVertScrollbarFor(GUI.chatContainer) - - local numLines = function() return table.getsize(GUI.chatLines) end - GUI.chatContainer.prevtabsize = 0 - GUI.chatContainer.prevsize = 0 - - local function IsValidEntry(entryData) - if entryData.camera then - return ChatOptions.links and ChatOptions[entryData.armyID] - end - - return ChatOptions[entryData.armyID] - end - - local function DataSize() - if GUI.chatContainer.prevtabsize ~= table.getn(chatHistory) then - local size = 0 - for i, v in chatHistory do - if IsValidEntry(v) then - size = size + table.getn(v.wrappedtext) - end - end - GUI.chatContainer.prevtabsize = table.getn(chatHistory) - GUI.chatContainer.prevsize = size - end - return GUI.chatContainer.prevsize - end - - -- called when the scrollbar for the control requires data to size itself - -- GetScrollValues must return 4 values in this order: - -- rangeMin, rangeMax, visibleMin, visibleMax - -- aixs can be "Vert" or "Horz" - GUI.chatContainer.GetScrollValues = function(self, axis) - local size = DataSize() - --LOG(size, ":", self.top, ":", math.min(self.top + numLines(), size)) - return 1, size, self.top, math.min(self.top + numLines(), size) - end - - -- called when the scrollbar wants to scroll a specific number of lines (negative indicates scroll up) - GUI.chatContainer.ScrollLines = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta)) - end - - -- called when the scrollbar wants to scroll a specific number of pages (negative indicates scroll up) - GUI.chatContainer.ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta) * numLines()) - end - - -- called when the scrollbar wants to set a new visible top line - GUI.chatContainer.ScrollSetTop = function(self, axis, top) - top = math.floor(top) - if top == self.top then return end - local size = DataSize() - self.top = math.max(math.min(size - numLines()+1, top), 1) - self:CalcVisible() - end - - -- called to determine if the control is scrollable on a particular access. Must return true or false. - GUI.chatContainer.IsScrollable = function(self, axis) - return true - end - - GUI.chatContainer.ScrollToBottom = function(self) - --LOG(DataSize()) - GUI.chatContainer:ScrollSetTop(nil, DataSize()) - end - - -- determines what controls should be visible or not - GUI.chatContainer.CalcVisible = function(self) - GUI.bg.curTime = 0 - local index = 1 - local tempTop = self.top - local curEntry = 1 - local curTop = 1 - local tempsize = 0 - - if GUI.bg:IsHidden() then - tempTop = math.max(DataSize() - numLines()+1, 1) - end - - for i, v in chatHistory do - if IsValidEntry(v) then - if tempsize + table.getsize(v.wrappedtext) < tempTop then - tempsize = tempsize + table.getsize(v.wrappedtext) - else - curEntry = i - for h, x in v.wrappedtext do - if h + tempsize == tempTop then - curTop = h - break - end - end - break - end - end - end - while GUI.chatLines[index] do - local line = GUI.chatLines[index] - - if not chatHistory[curEntry].wrappedtext[curTop] then - if chatHistory[curEntry].new then chatHistory[curEntry].new = nil end - curTop = 1 - curEntry = curEntry + 1 - while chatHistory[curEntry] and not IsValidEntry(chatHistory[curEntry]) do - curEntry = curEntry + 1 - end - end - if chatHistory[curEntry] then - local Index = index - if curTop == 1 then - line.name:SetText(chatHistory[curEntry].name) - if chatHistory[curEntry].armyID == GetFocusArmy() then - line.name:Disable() - else - line.name:Enable() - end - line.text:SetText(chatHistory[curEntry].wrappedtext[curTop] or "") - line.teamColor:SetSolidColor(chatHistory[curEntry].color) - line.factionIcon:SetTexture(UIUtil.UIFile(FactionsIcon[chatHistory[curEntry].faction])) - line.IsTop = true - line.chatID = chatHistory[curEntry].armyID - if chatHistory[curEntry].camera and not line.camIcon then - line.camIcon = Bitmap(line.text, UIUtil.UIFile('/game/camera-btn/pinned_btn_up.dds')) - LayoutHelpers.SetDimensions(line.camIcon, 20, 16) - LayoutHelpers.AtVerticalCenterIn(line.camIcon, line.teamColor) - LayoutHelpers.RightOf(line.camIcon, line.name, 4) - LayoutHelpers.RightOf(line.text, line.camIcon, 4) - elseif not chatHistory[curEntry].camera and line.camIcon then - line.camIcon:Destroy() - line.camIcon = false - LayoutHelpers.RightOf(line.text, line.name, 2) - end - else - line.name:Disable() - line.name:SetText('') - line.text:SetText(chatHistory[curEntry].wrappedtext[curTop] or "") - line.teamColor:SetSolidColor('00000000') - line.factionIcon:SetSolidColor('00000000') - line.IsTop = false - if line.camIcon then - line.camIcon:Destroy() - line.camIcon = false - LayoutHelpers.RightOf(line.text, line.name, 2) - end - end - if chatHistory[curEntry].camera then - line.cameraData = chatHistory[curEntry].camera - line.text:Enable() - line.text:SetColor(chatColors[ChatOptions.link_color]) - else - line.text:Disable() - line.text:SetColor('ffc2f6ff') - line.text:SetColor(chatColors[ChatOptions[chatHistory[curEntry].tokey]]) - end - - line.EntryID = curEntry - - if GUI.bg:IsHidden() then - - line.curHistory = chatHistory[curEntry] - if line.curHistory.new or line.curHistory.time == nil then - line.curHistory.time = 0 - end - - if line.curHistory.time < ChatOptions.fade_time then - line:Show() - - UIUtil.setVisible(line.lineStickybg, ChatOptions.feed_background) - - if line.name:GetText() == '' then - line.teamColor:Hide() - end - if line.curHistory.wrappedtext[curTop+1] == nil then - line.OnFrame = function(self, delta) - self.curHistory.time = self.curHistory.time + delta - if self.curHistory.time > ChatOptions.fade_time then - if GUI.bg:IsHidden() then - self:Hide() - end - self:SetNeedsFrameUpdate(false) - end - end - -- Don't increment time on lines with wrapped text - else - line.OnFrame = function(self, delta) - if self.curHistory.time > ChatOptions.fade_time then - if GUI.bg:IsHidden() then - self:Hide() - end - self:SetNeedsFrameUpdate(false) - end - end - end - line:SetNeedsFrameUpdate(true) - end - - end - else - line.name:Disable() - line.name:SetText('') - line.text:SetText('') - line.teamColor:SetSolidColor('00000000') - end - line:SetAlpha(ChatOptions.win_alpha, true) - curTop = curTop + 1 - index = index + 1 - end - if chatHistory[curEntry].new then chatHistory[curEntry].new = nil end - end -end - -function FindClients(id) - local t = GetArmiesTable() - local focus = t.focusArmy - local result = {} - if focus == -1 then - for index,client in GetSessionClients() do - if not client.connected then - continue - end - local playerIsObserver = true - for id, player in GetArmiesTable().armiesTable do - if player.outOfGame and player.human and player.nickname == client.name then - table.insert(result, index) - playerIsObserver = false - break - elseif player.nickname == client.name then - playerIsObserver = false - break - end - end - if playerIsObserver then - table.insert(result, index) - end - end - else - local srcs = {} - for army,info in t.armiesTable do - if id then - if army == id then - for k,cmdsrc in info.authorizedCommandSources do - srcs[cmdsrc] = true - end - break - end - else - if IsAlly(focus, army) then - for k,cmdsrc in info.authorizedCommandSources do - srcs[cmdsrc] = true - end - end - end - end - for index,client in GetSessionClients() do - for k,cmdsrc in client.authorizedCommandSources do - if srcs[cmdsrc] then - table.insert(result, index) - break - end - end - end - end - return result -end - -local RunChatCommand = import("/lua/ui/notify/commands.lua").RunChatCommand -function CreateChatEdit() - local parent = GUI.bg:GetClientGroup() - local group = Group(parent) - - group.Bottom:Set(parent.Bottom) - group.Right:Set(parent.Right) - group.Left:Set(parent.Left) - group.Top:Set(function() return group.Bottom() - group.Height() end) - - local toText = UIUtil.CreateText(group, '', 14, 'Arial') - LayoutHelpers.AtBottomIn(toText, group, 1) - LayoutHelpers.AtLeftIn(toText, group, 35) - - ChatTo.OnDirty = function(self) - if ToStrings[self()] then - toText:SetText(LOC(ToStrings[self()].caps)) - else - toText:SetText(LOCF('%s %s:', ToStrings['to'].caps, GetArmyData(self()).nickname)) - end - end - - group.edit = Edit(group) - LayoutHelpers.AnchorToRight(group.edit, toText, 5) - LayoutHelpers.AtRightIn(group.edit, group, 38) - group.edit.Depth:Set(function() return GUI.bg:GetClientGroup().Depth() + 200 end) - LayoutHelpers.AtBottomIn(group.edit, group, 1) - group.edit.Height:Set(function() return group.edit:GetFontHeight() end) - UIUtil.SetupEditStd(group.edit, "ff00ff00", nil, "ffffffff", UIUtil.highlightColor, UIUtil.bodyFont, 14, 200) - group.edit:SetDropShadow(true) - group.edit:ShowBackground(false) - - group.edit:SetText('') - - group.Height:Set(function() return group.edit.Height() end) - - local function CreateTestBtn(text) - local btn = UIUtil.CreateCheckbox(group, '/dialogs/toggle_btn/toggle') - btn.Depth:Set(function() return group.Depth() + 10 end) - btn.OnClick = function(self, modifiers) - if self._checkState == "unchecked" then - self:ToggleCheck() - end - end - btn.txt = UIUtil.CreateText(btn, text, 12, UIUtil.bodyFont) - LayoutHelpers.AtCenterIn(btn.txt, btn) - btn.txt:SetColor('ffffffff') - btn.txt:DisableHitTest() - return btn - end - - group.camData = Checkbox(group, - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_up.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_down.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_over.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds'), - UIUtil.SkinnableFile('/game/camera-btn/pinned_btn_dis.dds')) - - LayoutHelpers.AtRightIn(group.camData, group, 5) - LayoutHelpers.AtVerticalCenterIn(group.camData, group.edit, -1) - - group.chatBubble = Button(group, - UIUtil.UIFile('/game/chat-box_btn/radio_btn_up.dds'), - UIUtil.UIFile('/game/chat-box_btn/radio_btn_down.dds'), - UIUtil.UIFile('/game/chat-box_btn/radio_btn_over.dds'), - UIUtil.UIFile('/game/chat-box_btn/radio_btn_dis.dds')) - group.chatBubble.OnClick = function(self, modifiers) - if not self.list then - self.list = CreateChatList(self) - LayoutHelpers.Above(self.list, self, 15) - LayoutHelpers.AtLeftIn(self.list, self, 15) - else - self.list:Destroy() - self.list = nil - end - end - - toText.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - group.chatBubble:OnClick(event.Modifiers) - end - end - - LayoutHelpers.AtLeftIn(group.chatBubble, group, 3) - LayoutHelpers.AtVerticalCenterIn(group.chatBubble, group.edit) - - group.edit.OnNonTextKeyPressed = function(self, charcode, event) - if AddUnicodeCharToEditText(self, charcode) then - return - end - GUI.bg.curTime = 0 - local function RecallCommand(entryNumber) - self:SetText(commandHistory[self.recallEntry].text) - if commandHistory[self.recallEntry].camera then - self.tempCam = commandHistory[self.recallEntry].camera - group.camData:Disable() - group.camData:SetCheck(true) - else - self.tempCam = nil - group.camData:Enable() - group.camData:SetCheck(false) - end - end - if charcode == UIUtil.VK_NEXT then - local mod = 10 - if event.Modifiers.Shift then - mod = 1 - end - ChatPageDown(mod) - return true - elseif charcode == UIUtil.VK_PRIOR then - local mod = 10 - if event.Modifiers.Shift then - mod = 1 - end - ChatPageUp(mod) - return true - elseif charcode == UIUtil.VK_UP then - if not table.empty(commandHistory) then - if self.recallEntry then - self.recallEntry = math.max(self.recallEntry-1, 1) - else - self.recallEntry = table.getsize(commandHistory) - end - RecallCommand(self.recallEntry) - end - elseif charcode == UIUtil.VK_DOWN then - if not table.empty(commandHistory) then - if self.recallEntry then - self.recallEntry = math.min(self.recallEntry+1, table.getsize(commandHistory)) - RecallCommand(self.recallEntry) - if self.recallEntry == table.getsize(commandHistory) then - self.recallEntry = nil - end - else - self:SetText('') - end - end - else - return true - end - end - - group.edit.OnCharPressed = function(self, charcode) - local charLim = self:GetMaxChars() - if charcode == 9 then - return true - end - GUI.bg.curTime = 0 - if STR_Utf8Len(self:GetText()) >= charLim then - local sound = Sound({Cue = 'UI_Menu_Error_01', Bank = 'Interface',}) - PlaySound(sound) - end - end - - group.edit.OnEnterPressed = function(self, text) - -- Analyse for any commands entered for Notify toggling - if string.len(text) > 1 and string.sub(text, 1, 1) == "/" then - local args = {} - - for word in string.gfind(string.sub(text, 2), "%S+") do - table.insert(args, string.lower(word)) - end - - -- We've done the command, exit without sending the message to other players - if RunChatCommand(args) then - return - end - end - - GUI.bg.curTime = 0 - if group.camData:IsDisabled() then - group.camData:Enable() - end - if text == "" then - ToggleChat() - else - local gnBegin, gnEnd = string.find(text, "%s+") - if gnBegin and (gnBegin == 1 and gnEnd == string.len(text)) then - return - end - if import("/lua/ui/game/taunt.lua").CheckForAndHandleTaunt(text) then - return - end - - msg = { to = ChatTo(), Chat = true } - if self.tempCam then - msg.camera = self.tempCam - elseif group.camData:IsChecked() then - msg.camera = GetCamera('WorldCamera'):SaveSettings() - end - msg.text = text - if ChatTo() == 'allies' then - if GetFocusArmy() ~= -1 then - SessionSendChatMessage(FindClients(), msg) - else - msg.Observer = true - SessionSendChatMessage(FindClients(), msg) - end - elseif type(ChatTo()) == 'number' then - if GetFocusArmy() ~= -1 then - SessionSendChatMessage(FindClients(ChatTo()), msg) - msg.echo = true - msg.from = GetArmyData(GetFocusArmy()).nickname - ReceiveChat(GetArmyData(ChatTo()).nickname, msg) - end - else - if GetFocusArmy() == -1 then - msg.Observer = true - SessionSendChatMessage(FindClients(), msg) - else - SessionSendChatMessage(msg) - end - end - table.insert(commandHistory, msg) - self.recallEntry = nil - self.tempCam = nil - end - end - - ChatTo:Set('all') - group.edit:AcquireFocus() - - return group -end - -function ChatPageUp(mod) - if GUI.bg:IsHidden() then - ForkThread(ToggleChat) - else - local newTop = GUI.chatContainer.top - mod - GUI.chatContainer:ScrollSetTop(nil, newTop) - end -end - -function ChatPageDown(mod) - local oldTop = GUI.chatContainer.top - local newTop = GUI.chatContainer.top + mod - GUI.chatContainer:ScrollSetTop(nil, newTop) - if GUI.bg:IsHidden() or oldTop == GUI.chatContainer.top then - ForkThread(ToggleChat) - end -end - -function ReceiveChat(sender, msg) - if not msg.ConsoleOutput then - SimCallback({Func="GiveResourcesToPlayer", Args={ From=GetFocusArmy(), To=GetFocusArmy(), Mass=0, Energy=0, Sender=sender, Msg=msg},} , true) - end - if not SessionIsReplay() then - ReceiveChatFromSim(sender, msg) - end - -end - -function ReceiveChatFromSim(sender, msg) - sender = sender or "nil sender" - if msg.ConsoleOutput then - print(LOCF("%s %s", sender, msg.ConsoleOutput)) - return - end - - if not msg.Chat then - return - end - - if msg.to == 'notify' and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then - return - end - - if type(msg) == 'string' then - msg = { text = msg } - elseif type(msg) ~= 'table' then - msg = { text = repr(msg) } - end - - local armyData = GetArmyData(sender) - if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then - return - end - - local towho = LOC(ToStrings[msg.to].text) or LOC(ToStrings['private'].text) - local tokey = ToStrings[msg.to].colorkey or ToStrings['private'].colorkey - if msg.Observer then - towho = LOC("to observers:") - tokey = "link_color" - if armyData.faction then - armyData.faction = table.getn(FactionsIcon) - 1 - end - end - - if type(msg.to) == 'number' and SessionIsReplay() then - towho = string.format("%s %s:", LOC(ToStrings.to.text), GetArmyData(msg.to).nickname) - end - local name = sender .. ' ' .. towho - - if msg.echo then - if msg.from and SessionIsReplay() then - name = string.format("%s %s %s:", msg.from, LOC(ToStrings.to.text), GetArmyData(msg.to).nickname) - else - name = string.format("%s %s:", LOC(ToStrings.to.caps), sender) - end - end - local tempText = WrapText({text = msg.text, name = name}) - -- if text wrap produces no lines (ie text is all white space) then add a blank line - if table.empty(tempText) then - tempText = {""} - end - local entry = { - name = name, - tokey = tokey, - color = (armyData.color or "ffffffff"), - armyID = (armyData.ArmyID or 1), - faction = (armyData.faction or (table.getn(FactionsIcon)-1))+1, - text = msg.text, - wrappedtext = tempText, - new = true, - camera = msg.camera - } - - table.insert(chatHistory, entry) - if ChatOptions[entry.armyID] then - if table.getsize(chatHistory) == 1 then - GUI.chatContainer:CalcVisible() - else - GUI.chatContainer:ScrollToBottom() - end - end -end - -function ToggleChat() - if GUI.bg:IsHidden() then - GUI.bg:Show() - GUI.chatEdit.edit:AcquireFocus() - if not GUI.bg.pinned then - GUI.bg:SetNeedsFrameUpdate(true) - GUI.bg.curTime = 0 - end - for i, v in GUI.chatLines do - v:SetNeedsFrameUpdate(false) - v:Show() - v.lineStickybg:Hide() - end - GUI.chatContainer:CalcVisible() - else - GUI.bg:Hide() - GUI.chatEdit.edit:AbandonFocus() - GUI.bg:SetNeedsFrameUpdate(false) - - if ChatOptions.feed_persist then - GUI.chatContainer:CalcVisible() - else - for i, v in GUI.chatLines do - if v.curHistory and v.curHistory.time ~= nil then - v.curHistory.time = ChatOptions.fade_time + 1 - end - end - end - end -end - -function ActivateChat(modifiers) - if type(ChatTo()) ~= 'number' then - if (not modifiers.Shift) == (ChatOptions['send_type'] or false) then - ChatTo:Set('allies') - else - ChatTo:Set('all') - end - end - ToggleChat() -end - ---------------------- ---Added for sorian ai ---------------------- - -function CreateChatList(parent) - local armies = GetArmiesTable() - local container = Group(GUI.chatEdit) - container:DisableHitTest() - container.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) - container.entries = {} - local function CreatePlayerEntry(data) - if not data.human and not data.civilian then - data.nickname = UiUtilsS.trim(string.gsub(data.nickname,'%b()', '')) - end - local text = UIUtil.CreateText(container, data.nickname, 12, "Arial") - text:SetColor('ffffffff') - text:DisableHitTest() - - text.BG = Bitmap(text) - text.BG:SetSolidColor('ff000000') - text.BG.Depth:Set(function() return text.Depth() - 1 end) - text.BG.Left:Set(function() return text.Left() - 6 end) - text.BG.Top:Set(function() return text.Top() - 1 end) - text.BG.Width:Set(function() return container.Width() + 8 end) - text.BG.Bottom:Set(function() return text.Bottom() + 1 end) - - text.BG.HandleEvent = function(self, event) - if event.Type == 'MouseEnter' then - self:SetSolidColor('ff666666') - elseif event.Type == 'MouseExit' then - self:SetSolidColor('ff000000') - elseif event.Type == 'ButtonPress' then - ChatTo:Set(data.armyID) - container:Destroy() - parent.list = nil - GUI.chatEdit.edit:Enable() - GUI.chatEdit.edit:AcquireFocus() - end - GUI.bg.curTime = 0 - end - return text - end - - local entries = { - {nickname = ToStrings.all.caps, armyID = 'all'}, - {nickname = ToStrings.allies.caps, armyID = 'allies'}, - } - - for armyID, armyData in armies.armiesTable do - if armyID ~= armies.focusArmy and not armyData.civilian then - table.insert(entries, {nickname = armyData.nickname, armyID = armyID}) - end - end - - local maxWidth = 0 - local height = 0 - for index, data in entries do - local i = index - table.insert(container.entries, CreatePlayerEntry(data)) - if container.entries[i].Width() > maxWidth then - maxWidth = container.entries[i].Width() + 8 - end - height = height + container.entries[i].Height() - if i > 1 then - LayoutHelpers.Above(container.entries[i], container.entries[i-1]) - else - LayoutHelpers.AtLeftIn(container.entries[i], container) - LayoutHelpers.AtBottomIn(container.entries[i], container) - end - end - container.Width:Set(maxWidth + 40) - container.Height:Set(height) - - container.LTBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ul.dds')) - container.LTBG:DisableHitTest() - container.LTBG.Right:Set(container.Left) - container.LTBG.Bottom:Set(container.Top) - - container.RTBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ur.dds')) - container.RTBG:DisableHitTest() - container.RTBG.Left:Set(container.Right) - container.RTBG.Bottom:Set(container.Top) - - container.RBBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lr.dds')) - container.RBBG:DisableHitTest() - container.RBBG.Left:Set(container.Right) - container.RBBG.Top:Set(container.Bottom) - - container.RLBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_ll.dds')) - container.RLBG:DisableHitTest() - container.RLBG.Right:Set(container.Left) - container.RLBG.Top:Set(container.Bottom) - - container.LBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_l.dds')) - container.LBG:DisableHitTest() - container.LBG.Right:Set(container.Left) - container.LBG.Top:Set(container.Top) - container.LBG.Bottom:Set(container.Bottom) - - container.RBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_vert_r.dds')) - container.RBG:DisableHitTest() - container.RBG.Left:Set(container.Right) - container.RBG.Top:Set(container.Top) - container.RBG.Bottom:Set(container.Bottom) - - container.TBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_horz_um.dds')) - container.TBG:DisableHitTest() - container.TBG.Left:Set(container.Left) - container.TBG.Right:Set(container.Right) - container.TBG.Bottom:Set(container.Top) - - container.BBG = Bitmap(container, UIUtil.UIFile('/game/chat_brd/drop-box_brd_lm.dds')) - container.BBG:DisableHitTest() - container.BBG.Left:Set(container.Left) - container.BBG.Right:Set(container.Right) - container.BBG.Top:Set(container.Bottom) - - function DestroySelf() - parent:OnClick() - end - - UIMain.AddOnMouseClickedFunc(DestroySelf) - - container.OnDestroy = function(self) - UIMain.RemoveOnMouseClickedFunc(DestroySelf) - end - - return container -end - -function SetupChatLayout(mapGroup) - savedParent = mapGroup - CreateChat() - import("/lua/ui/game/gamemain.lua").RegisterChatFunc(ReceiveChat, 'Chat') -end - ----@type table -local OnChatOptionsChangedCallbacks = {} - ---- Adds a callback and calls it with the current chat options. ----@param callback fun(chatOptions: table) ----@param id? string -function AddChatOptionSetCallback(callback, id) - local ok, msg = pcall(callback, ChatOptions) - if not ok then - WARN(string.format('Error with initial run of `ChatOptionSet` callback%s: %s' - , id and string.format(' (id "%s")', tostring(id)) or '' - , msg) - ) - return - end - if id then - OnChatOptionsChangedCallbacks[id] = callback - else - table.insert(OnChatOptionsChangedCallbacks, callback) - end -end - ---- Runs all callbacks using the current chat options. -local function DoChatOptionSetCallbacks() - for id, callback in OnChatOptionsChangedCallbacks do - local ok, msg = pcall(callback, ChatOptions) - if not ok then - WARN(string.format('Error running `ChatOptionSet` callback (id "%s"): %s', tostring(id), msg)) - OnChatOptionsChangedCallbacks[id] = nil - end - end -end - -function CreateChat() - if GUI.bg then - GUI.bg.OnClose() - end - GUI.bg = CreateChatBackground() - GUI.chatEdit = CreateChatEdit() - GUI.bg.OnResize = function(self, x, y, firstFrame) - if firstFrame then - self:SetNeedsFrameUpdate(false) - end - CreateChatLines() - GUI.chatContainer:CalcVisible() - end - GUI.bg.OnResizeSet = function(self) - if not self:IsPinned() then - self:SetNeedsFrameUpdate(true) - end - RewrapLog() - CreateChatLines() - GUI.chatContainer:CalcVisible() - GUI.chatEdit.edit:AcquireFocus() - end - GUI.bg.OnMove = function(self, x, y, firstFrame) - if firstFrame then - self:SetNeedsFrameUpdate(false) - end - end - GUI.bg.OnMoveSet = function(self) - GUI.chatEdit.edit:AcquireFocus() - if not self:IsPinned() then - self:SetNeedsFrameUpdate(true) - end - end - GUI.bg.OnMouseWheel = function(self, rotation) - local newTop = GUI.chatContainer.top - math.floor(rotation / 100) - GUI.chatContainer:ScrollSetTop(nil, newTop) - end - GUI.bg.OnClose = function(self) - ToggleChat() - end - GUI.bg.OnOptionsSet = function(self) - GUI.chatContainer:Destroy() - GUI.chatContainer = false - for i, v in GUI.chatLines do - v:Destroy() - end - GUI.bg:SetAlpha(ChatOptions.win_alpha, true) - GUI.chatLines = {} - CreateChatLines() - RewrapLog() - GUI.chatContainer:CalcVisible() - GUI.chatEdit.edit:AcquireFocus() - if not GUI.bg.pinned then - GUI.bg.curTime = 0 - GUI.bg:SetNeedsFrameUpdate(true) - end - DoChatOptionSetCallbacks() - end - GUI.bg.OnHideWindow = function(self, hidden) - if not hidden then - for i, v in GUI.chatLines do - v:SetNeedsFrameUpdate(false) - end - end - end - GUI.bg.curTime = 0 - GUI.bg.pinned = false - GUI.bg.OnFrame = function(self, delta) - self.curTime = self.curTime + delta - if self.curTime > ChatOptions.fade_time then - ToggleChat() - end - end - GUI.bg.OnPinCheck = function(self, checked) - GUI.bg.pinned = checked - GUI.bg:SetNeedsFrameUpdate(not checked) - GUI.bg.curTime = 0 - GUI.chatEdit.edit:AcquireFocus() - if checked then - Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pinned') - else - Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pin') - end - end - GUI.bg.OnConfigClick = function(self, checked) - if GUI.config then GUI.config:Destroy() GUI.config = false return end - CreateConfigWindow() - GUI.bg:SetNeedsFrameUpdate(false) - - end - for i, v in GetArmiesTable().armiesTable do - if not v.civilian then - ChatOptions[i] = true - end - end - GUI.bg:SetAlpha(ChatOptions.win_alpha, true) - Tooltip.AddButtonTooltip(GUI.bg._closeBtn, 'chat_close') - GUI.bg.OldHandleEvent = GUI.bg.HandleEvent - GUI.bg.HandleEvent = function(self, event) - if event.Type == "WheelRotation" and self:IsHidden() then - import("/lua/ui/game/worldview.lua").ForwardMouseWheelInput(event) - return true - else - return GUI.bg.OldHandleEvent(self, event) - end - end - - Tooltip.AddCheckboxTooltip(GUI.bg._pinBtn, 'chat_pin') - Tooltip.AddControlTooltip(GUI.bg._configBtn, 'chat_config') - Tooltip.AddControlTooltip(GUI.bg._closeBtn, 'chat_close') - Tooltip.AddCheckboxTooltip(GUI.chatEdit.camData, 'chat_camera') - - ChatOptions['links'] = ChatOptions.links or true - CreateChatLines() - RewrapLog() - GUI.chatContainer:CalcVisible() - ToggleChat() -end - -function RewrapLog() - local tempSize = 0 - for i, v in chatHistory do - v.wrappedtext = WrapText(v) - tempSize = tempSize + table.getsize(v.wrappedtext) - end - GUI.chatContainer.prevtabsize = 0 - GUI.chatContainer.prevsize = 0 - GUI.chatContainer:ScrollSetTop(nil, tempSize) -end - -function WrapText(data) - return import("/lua/maui/text.lua").WrapText(data.text, - function(line) - local firstLine = GUI.chatLines[1] - if line == 1 then - return firstLine.Right() - (firstLine.name.Left() + firstLine.name:GetStringAdvance(data.name) + 4) - else - return firstLine.Right() - (firstLine.name.Left() + 4) - end - end, - function(text) - return GUI.chatLines[1].text:GetStringAdvance(text) - end) -end - -function GetArmyData(army) - local armies = GetArmiesTable() - local result = nil - if type(army) == 'number' then - if armies.armiesTable[army] then - result = armies.armiesTable[army] - end - elseif type(army) == 'string' then - for i, v in armies.armiesTable do - if v.nickname == army then - result = v - result.ArmyID = i - break - end - end - end - return result -end - -function CloseChat() - if not GUI.bg:IsHidden() then - ToggleChat() - end - if GUI.config then - GUI.config:Destroy() - GUI.config = nil - end -end - -function CreateConfigWindow() - import("/lua/ui/game/multifunction.lua").CloseMapDialog() - local windowTextures = { - tl = UIUtil.SkinnableFile('/game/panel/panel_brd_ul.dds'), - tr = UIUtil.SkinnableFile('/game/panel/panel_brd_ur.dds'), - tm = UIUtil.SkinnableFile('/game/panel/panel_brd_horz_um.dds'), - ml = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_l.dds'), - m = UIUtil.SkinnableFile('/game/panel/panel_brd_m.dds'), - mr = UIUtil.SkinnableFile('/game/panel/panel_brd_vert_r.dds'), - bl = UIUtil.SkinnableFile('/game/panel/panel_brd_ll.dds'), - bm = UIUtil.SkinnableFile('/game/panel/panel_brd_lm.dds'), - br = UIUtil.SkinnableFile('/game/panel/panel_brd_lr.dds'), - borderColor = 'ff415055', - } - - local defPosition = Prefs.GetFieldFromCurrentProfile('chat_config') or nil - GUI.config = Window(GetFrame(0), 'Chat Options', nil, nil, nil, true, true, 'chat_config', defPosition, windowTextures) - GUI.config.Depth:Set(GetFrame(0):GetTopmostDepth() + 1) - Tooltip.AddButtonTooltip(GUI.config._closeBtn, 'chat_close') - LayoutHelpers.AnchorToBottom(GUI.config, GetFrame(0), -700) - LayoutHelpers.SetWidth(GUI.config, 300) - LayoutHelpers.AtHorizontalCenterIn(GUI.config, GetFrame(0)) - LayoutHelpers.ResetRight(GUI.config) - - GUI.config.DragTL = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ul_btn_up.dds')) - GUI.config.DragTR = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ur_btn_up.dds')) - GUI.config.DragBL = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-ll_btn_up.dds')) - GUI.config.DragBR = Bitmap(GUI.config, UIUtil.SkinnableFile('/game/drag-handle/drag-handle-lr_btn_up.dds')) - - LayoutHelpers.AtLeftTopIn(GUI.config.DragTL, GUI.config, -24, -8) - - LayoutHelpers.AtRightTopIn(GUI.config.DragTR, GUI.config, -22, -8) - - LayoutHelpers.AtLeftIn(GUI.config.DragBL, GUI.config, -24) - LayoutHelpers.AtBottomIn(GUI.config.DragBL, GUI.config, -8) - - LayoutHelpers.AtRightIn(GUI.config.DragBR, GUI.config, -22) - LayoutHelpers.AtBottomIn(GUI.config.DragBR, GUI.config, -8) - - GUI.config.DragTL.Depth:Set(function() return GUI.config.Depth() + 10 end) - GUI.config.DragTR.Depth:Set(GUI.config.DragTL.Depth) - GUI.config.DragBL.Depth:Set(GUI.config.DragTL.Depth) - GUI.config.DragBR.Depth:Set(GUI.config.DragTL.Depth) - - GUI.config.DragTL:DisableHitTest() - GUI.config.DragTR:DisableHitTest() - GUI.config.DragBL:DisableHitTest() - GUI.config.DragBR:DisableHitTest() - - GUI.config.OnClose = function(self) - GUI.config:Destroy() - GUI.config = false - end - - local options = { - filters = {{type = 'filter', name = 'Links', key = 'links', tooltip = 'chat_filter'}}, - winOptions = { - {type = 'color', name = '', key = 'all_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'allies_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'priv_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'link_color', tooltip = 'chat_color'}, - {type = 'color', name = '', key = 'notify_color', tooltip = 'chat_color'}, - {type = 'splitter'}, - {type = 'slider', name = 'Chat Font Size', key = 'font_size', tooltip = 'chat_fontsize', min = 12, max = 18, inc = 1}, - {type = 'slider', name = 'Window Fade Time', key = 'fade_time', tooltip = 'chat_fadetime', min = 5, max = 30, inc = 1}, - {type = 'slider', name = 'Window Alpha', key = 'win_alpha', tooltip = 'chat_alpha', min = 20, max = 100, inc = 1}, - {type = 'splitter'}, - {type = 'filter', name = 'Default recipient: allies', key = 'send_type', tooltip = 'chat_send_type'}, - {type = 'filter', name = 'Show Feed Background', key = 'feed_background', tooltip = 'chat_feed_background'}, - {type = 'filter', name = 'Persist Feed Timeout', key = 'feed_persist', tooltip = 'chat_feed_persist'}, - }, - } - - local optionGroup = Group(GUI.config:GetClientGroup()) - LayoutHelpers.FillParent(optionGroup, GUI.config:GetClientGroup()) - optionGroup.options = {} - local tempOptions = {} - - local function UpdateOption(key, value) - if key == 'win_alpha' then - value = value / 100 - end - tempOptions[key] = value - end - - local function CreateSplitter() - local splitter = Bitmap(optionGroup) - splitter:SetSolidColor('ff000000') - splitter.Left:Set(optionGroup.Left) - splitter.Right:Set(optionGroup.Right) - splitter.Height:Set(2) - return splitter - end - - local function CreateEntry(data) - local group = Group(optionGroup) - if data.type == 'filter' then - group.check = UIUtil.CreateCheckbox(group, '/dialogs/check-box_btn/', data.name, true) - LayoutHelpers.AtLeftTopIn(group.check, group) - group.check.key = data.key - group.Height:Set(group.check.Height) - group.Width:Set(function() return group.check.Width() end) - group.check.OnCheck = function(self, checked) - UpdateOption(self.key, checked) - end - if ChatOptions[data.key] then - group.check:SetCheck(ChatOptions[data.key], true) - end - elseif data.type == 'color' then - group.name = UIUtil.CreateText(group, data.name, 14, "Arial") - local defValue = ChatOptions[data.key] or 1 - group.color = BitmapCombo(group, chatColors, defValue, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - LayoutHelpers.AtLeftTopIn(group.color, group) - LayoutHelpers.RightOf(group.name, group.color, 5) - LayoutHelpers.AtVerticalCenterIn(group.name, group.color) - LayoutHelpers.SetWidth(group.color, 55) - group.color.key = data.key - group.Height:Set(group.color.Height) - group.Width:Set(group.color.Width) - group.color.OnClick = function(self, index) - UpdateOption(self.key, index) - end - elseif data.type == 'slider' then - group.name = UIUtil.CreateText(group, data.name, 14, "Arial") - LayoutHelpers.AtLeftTopIn(group.name, group) - group.slider = IntegerSlider(group, false, - data.min, data.max, - data.inc, UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), - UIUtil.SkinnableFile('/dialogs/options-02/slider-back_bmp.dds')) - LayoutHelpers.Below(group.slider, group.name) - group.slider.key = data.key - group.Height:Set(function() return group.name.Height() + group.slider.Height() end) - group.slider.OnValueSet = function(self, newValue) - UpdateOption(self.key, newValue) - end - group.value = UIUtil.CreateText(group, '', 14, "Arial") - LayoutHelpers.RightOf(group.value, group.slider) - group.slider.OnValueChanged = function(self, newValue) - group.value:SetText(string.format('%3d', newValue)) - end - local defValue = ChatOptions[data.key] or 1 - if data.key == 'win_alpha' then - defValue = defValue * 100 - end - group.slider:SetValue(defValue) - LayoutHelpers.SetWidth(group, 200) - elseif data.type == 'splitter' then - group.split = CreateSplitter() - LayoutHelpers.AtTopIn(group.split, group) - group.Width:Set(group.split.Width) - group.Height:Set(group.split.Height) - end - if data.type ~= 'splitter' then - Tooltip.AddControlTooltip(group, data.tooltip or 'chat_filter') - end - return group - end - - local armyData = GetArmiesTable() - for i, v in armyData.armiesTable do - if not v.civilian then - table.insert(options.filters, {type = 'filter', name = v.nickname, key = i}) - end - end - - local filterTitle = UIUtil.CreateText(optionGroup, 'Message Filters', 14, "Arial Bold") - LayoutHelpers.AtLeftTopIn(filterTitle, optionGroup, 5, 5) - Tooltip.AddControlTooltip(filterTitle, 'chat_filter') - local index = 1 - for i, v in options.filters do - optionGroup.options[index] = CreateEntry(v) - optionGroup.options[index].Left:Set(filterTitle.Left) - optionGroup.options[index].Right:Set(optionGroup.Right) - if index == 1 then - LayoutHelpers.Below(optionGroup.options[index], filterTitle, 5) - else - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], -2) - end - index = index + 1 - end - local splitIndex = index - local splitter = CreateSplitter() - splitter.Top:Set(function() return optionGroup.options[splitIndex-1].Bottom() + 5 end) - - local WindowTitle = UIUtil.CreateText(optionGroup, 'Message Colors', 14, "Arial Bold") - LayoutHelpers.Below(WindowTitle, splitter, 5) - WindowTitle.Left:Set(filterTitle.Left) - Tooltip.AddControlTooltip(WindowTitle, 'chat_color') - - local firstOption = true - local optionIndex = 1 - for i, v in options.winOptions do - optionGroup.options[index] = CreateEntry(v) - optionGroup.options[index].Data = v - if firstOption then - LayoutHelpers.Below(optionGroup.options[index], WindowTitle, 5) - optionGroup.options[index].Right:Set(function() return filterTitle.Left() + (optionGroup.Width() / 2) end) - firstOption = false - elseif v.type == 'color' then - optionGroup.options[index].Right:Set(function() return filterTitle.Left() + (optionGroup.Width() / 2) end) - if math.mod(optionIndex, 2) == 1 then - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-2], 2) - else - LayoutHelpers.RightOf(optionGroup.options[index], optionGroup.options[index-1]) - end - elseif v.type == 'filter' then - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], 4) - LayoutHelpers.AtLeftIn(optionGroup.options[index], WindowTitle) - else - LayoutHelpers.Below(optionGroup.options[index], optionGroup.options[index-1], 4) - LayoutHelpers.AtHorizontalCenterIn(optionGroup.options[index], optionGroup) - end - optionIndex = optionIndex + 1 - index = index + 1 - end - - local applyBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(applyBtn, optionGroup.options[index-1], 4) - LayoutHelpers.AtLeftIn(applyBtn, optionGroup) - applyBtn.OnClick = function(self) - ChatOptions = table.merged(ChatOptions, tempOptions) - Prefs.SetToCurrentProfile("chatoptions", ChatOptions) - GUI.bg:OnOptionsSet() - end - - local resetBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(resetBtn, optionGroup.options[index-1], 4) - LayoutHelpers.AtRightIn(resetBtn, optionGroup) - LayoutHelpers.ResetLeft(resetBtn) - resetBtn.OnClick = function(self) - for option, value in defOptions do - for i, control in optionGroup.options do - if control.Data.key == option then - if control.Data.type == 'slider' then - if control.Data.key == 'win_alpha' then - value = value * 100 - end - control.slider:SetValue(value) - elseif control.Data.type == 'color' then - control.color:SetItem(value) - elseif control.Data.type == 'filter' then - control.check:SetCheck(value, true) - end - UpdateOption(option, value) - break - end - end - end - end - - local okBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(okBtn, resetBtn, 4) - LayoutHelpers.AtLeftIn(okBtn, optionGroup) - okBtn.OnClick = function(self) - ChatOptions = table.merged(ChatOptions, tempOptions) - Prefs.SetToCurrentProfile("chatoptions", ChatOptions) - GUI.bg:OnOptionsSet() - GUI.config:Destroy() - GUI.config = false - end - - local cancelBtn = UIUtil.CreateButtonStd(optionGroup, '/widgets02/small', '', 16) - LayoutHelpers.Below(cancelBtn, resetBtn, 4) - LayoutHelpers.AtRightIn(cancelBtn, optionGroup) - LayoutHelpers.ResetLeft(cancelBtn) - cancelBtn.OnClick = function(self) - GUI.config:Destroy() - GUI.config = false - end - - - GUI.config.Bottom:Set(function() return okBtn.Bottom() + 5 end) - if defPosition ~= nil then - GUI.config.Top:Set(defPosition.top) - GUI.config.Left:Set(defPosition.left) - else - GUI.config.Top:Set(function() return LayoutHelpers.ScaleNumber(90) end) - end - GUI.config:SetPositionLock(false) -- allow window to be draggable, didn't worked in Window() call -end - -function CloseChatConfig() - if GUI.config then - GUI.config:Destroy() - GUI.config = nil - end -end