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 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. 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/annotation.md b/annotation.md index 7e6fd8f4a11..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. @@ -103,3 +162,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/engine/User/CMauiEdit.lua b/engine/User/CMauiEdit.lua index eff8cc9566a..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,34 +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 - ----@alias KeyEventType ----| "ButtonPress" ----| "ButtonDClick" ----| "ButtonRelease" ----| "MouseMotion" ----| "MouseEnter" ----| "MouseExit" ----| "WheelRotation" - ----@class KeyEvent ----@field Control Control ----@field KeyCode number # 0 = nothing, 1 = left click, 2 = middle click, 3 = right click ----@field Modifiers EventModifiers ----@field MouseX number ----@field MouseY number ----@field RawKeyCode number ----@field Type KeyEventType ----@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/loc/DE/strings_db.lua b/loc/DE/strings_db.lua index 598b7ac8459..8b4b754d6da 100644 --- a/loc/DE/strings_db.lua +++ b/loc/DE/strings_db.lua @@ -4205,6 +4205,16 @@ 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_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 4b31f5f3c3f..4ae7d5b822b 100644 --- a/loc/RU/strings_db.lua +++ b/loc/RU/strings_db.lua @@ -4339,6 +4339,16 @@ 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_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 a3e4e249ebf..c70b0dcf0bd 100644 --- a/loc/US/strings_db.lua +++ b/loc/US/strings_db.lua @@ -3741,6 +3741,13 @@ 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_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/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/ChatUtils.lua b/lua/ChatUtils.lua new file mode 100644 index 00000000000..5a6bd7375c8 --- /dev/null +++ b/lua/ChatUtils.lua @@ -0,0 +1,112 @@ + +-- 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") +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 +--- 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 ChatPayload +---@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 +--- `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 +function RelayChatMessage(msg) + if IsLocalRecipient(msg) then + Sync.ChatMessages = Sync.ChatMessages or {} + table.insert(Sync.ChatMessages, 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: ChatPayload} +function SendChatMessage(data) + if type(data) ~= 'table' then return end + local msg = data.Msg + + -- 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() + 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 --[[@as integer]]) then + return + end + + msg.From = from + + RelayChatMessage(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..399ff1236d6 100644 --- a/lua/SimUtils.lua +++ b/lua/SimUtils.lua @@ -752,7 +752,63 @@ 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. + 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 transferredUnits 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 = 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) + + -- 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 transferredUnits 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 @@ -1449,19 +1505,17 @@ 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`: +-- * `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 @@ -1480,8 +1534,42 @@ function GiveResourcesToPlayer(data) local massTaken = fromBrain:TakeResource('MASS', data.Mass * fromBrain:GetEconomyStored('MASS')) local energyTaken = fromBrain:TakeResource('ENERGY', data.Energy * fromBrain:GetEconomyStored('ENERGY')) - toBrain:GiveResource('MASS', massTaken) - toBrain:GiveResource('ENERGY', energyTaken) + -- `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', 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 + -- 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/aibrain.lua b/lua/aibrain.lua index c580e5f7752..f4e4543692b 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 AIChatBrainComponent = import("/lua/aibrains/components/ChatBrainComponent.lua").AIChatBrainComponent ---@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, AIChatBrainComponent, 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, AIChatBrainComponent, moho.aibrain_methods) { Status = 'InProgress', diff --git a/lua/aibrains/components/ChatBrainComponent.lua b/lua/aibrains/components/ChatBrainComponent.lua new file mode 100644 index 00000000000..a65ab6818e4 --- /dev/null +++ b/lua/aibrains/components/ChatBrainComponent.lua @@ -0,0 +1,116 @@ + +--*************************************************************************** +--** 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 AIChatLocation +---@field Position? Vector # world-space focus point +---@field Area? Rectangle # world-space rectangle to frame + +---@class AIChatBrainComponent +AIChatBrainComponent = ClassSimple { + + --- Broadcasts a message to every connected UI as an "all" chat line. + ---@param self AIChatBrainComponent + ---@param text string + ---@param args? any[] # optional `string.format` arguments; UI applies `LOCF(text, unpack(args))` on receive + ---@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 AIChatBrainComponent + ---@param text string + ---@param args? any[] + ---@param location? AIChatLocation + 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 + --- trusted sim code and may legitimately taunt an enemy or message a + --- neutral party. + ---@param self AIChatBrainComponent + ---@param army integer + ---@param text string + ---@param args? any[] + ---@param location? AIChatLocation + 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, 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 AIChatBrainComponent | AIBrain + ---@param text string + ---@param args? any[] + ---@param location? AIChatLocation + SendChatToSelf = function(self, text, args, location) + self:SendChatTo(self:GetArmyIndex(), text, args, 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`). + --- + --- `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 AIChatBrainComponent | AIBrain + ---@param to AIBrainChatRecipient + ---@param text string + ---@param args? any[] + ---@param location? AIChatLocation + 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, + } + msg.Id = tostring(msg) + + ChatUtils.RelayChatMessage(msg) + end, +} 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 77270012582..a8966005c9f 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', }, } diff --git a/lua/keymap/keydescriptions.lua b/lua/keymap/keydescriptions.lua index 7ff7890c52f..4f17b220170 100755 --- a/lua/keymap/keydescriptions.lua +++ b/lua/keymap/keydescriptions.lua @@ -198,6 +198,17 @@ keyDescriptions = { ['chat_line_up'] = 'Chat line up', ['chat_line_down'] = 'Chat line down', + ['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', ['switch_layout_up'] = 'Rotate layouts up', 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/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/maui/window.lua b/lua/maui/window.lua index 3ae46c59fdb..d4a47ac058a 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() @@ -476,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 diff --git a/lua/shared/ChatPayload.lua b/lua/shared/ChatPayload.lua new file mode 100644 index 00000000000..41e7a793854 --- /dev/null +++ b/lua/shared/ChatPayload.lua @@ -0,0 +1,65 @@ + +-- 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 +---| '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 `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` +---@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 + +--- Maximum UTF-8 character length for a chat message body. The UI edit box +--- 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 `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) + 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 + + -- 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' + and type(msg.to) ~= 'number' then + return false + end + + -- 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 + if msg.Args ~= nil and type(msg.Args) ~= 'table' then return false end + + return true +end 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/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. diff --git a/lua/ui/controls/floattext.lua b/lua/ui/controls/floattext.lua new file mode 100644 index 00000000000..3b25694b964 --- /dev/null +++ b/lua/ui/controls/floattext.lua @@ -0,0 +1,101 @@ + +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() + + -- 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 + __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(math.sqrt(1 - t), true) + end + end, +} diff --git a/lua/ui/game/chat.lua b/lua/ui/game/chat.lua index 2914fa82dfd..22ec2170289 100644 --- a/lua/ui/game/chat.lua +++ b/lua/ui/game/chat.lua @@ -1,1565 +1,166 @@ -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) +------------------------------------------------------------------------------- +-- 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) + ChatController.ActivateChat(modifiers) end -table.insert(FactionsIcon, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') +------------------------------------------------------------------------------- +-- Deprecated forwards -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() +--- @deprecated use [ChatController.OnReceive](chat/ChatController.lua) instead +function ReceiveChat(sender, msg) + _deprecate('ReceiveChat', 'ChatController.OnReceive') + ChatController.OnReceive(sender, msg) 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 +--- @deprecated use [ChatController.OnReceive](chat/ChatController.lua) instead +function ReceiveChatFromSim(sender, msg) + _deprecate('ReceiveChatFromSim', 'ChatController.OnReceive') + ChatController.OnReceive(sender, msg) 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 +--- @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() - 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 + _deprecate('OnNISBegin', 'no replacement (no longer required)') end +--- @deprecated use [ChatInterface.OpenAndScrollLines](chat/ChatInterface.lua) with a negative delta function ChatPageUp(mod) - if GUI.bg:IsHidden() then - ForkThread(ToggleChat) - else - local newTop = GUI.chatContainer.top - mod - GUI.chatContainer:ScrollSetTop(nil, newTop) - end + _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) - 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 + _deprecate('ChatPageDown', 'ChatInterface.OpenAndScrollLines(mod)') + ChatInterface.OpenAndScrollLines(mod or 10) 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) +--- @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 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 (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` function GetArmyData(army) + _deprecate('GetArmyData', 'GetArmiesTable().armiesTable[ArmyID]') local armies = GetArmiesTable() - local result = nil if type(army) == 'number' then - if armies.armiesTable[army] then - result = armies.armiesTable[army] - end + return armies.armiesTable[army] elseif type(army) == 'string' then for i, v in armies.armiesTable do if v.nickname == army then - result = v - result.ArmyID = i - break + v.ArmyID = i + return v 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'}, - }, - } +------------------------------------------------------------------------------- +-- 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 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 +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 -function CloseChatConfig() - if GUI.config then - GUI.config:Destroy() - GUI.config = nil - end -end +GUI = _stateProxy('GUI') +ChatLines = _stateProxy('ChatLines') diff --git a/lua/ui/game/chat/CLAUDE.md b/lua/ui/game/chat/CLAUDE.md new file mode 100644 index 00000000000..7bcdd0e88ec --- /dev/null +++ b/lua/ui/game/chat/CLAUDE.md @@ -0,0 +1,244 @@ +# Chat — Refactoring Guide + +This directory contains the refactored in-game chat. The goal is to replace the monolithic legacy `/lua/ui/game/chat.lua` with a clean MVC structure where the **model** is reactive (LazyVar-based), the **view** is dumb (reads from the model, never writes), and the **controller** is the only place that sends or receives messages. + +> **Read first:** [`/lua/ui/CLAUDE.md`](/lua/ui/CLAUDE.md) covers project-wide UI patterns — `__init` vs `__post_init`, LazyVars and `Derive`, `TrashBag`, layout, skinning, debug overlays, hot-reload. This doc is chat-specific and assumes those rules. Class field annotation conventions live in [`annotation.md`](annotation.md). + +--- + +## Architecture + +``` +Controller ──writes──► Model (LazyVars) ──OnDirty──► View + ▲ │ + └──────────────────── user input ──────────────────────┘ +``` + +- **Model** — flat sets of `LazyVar` instances in `ChatModel.lua` (chat state) and `config/ChatConfigModel.lua` (options). No UI, no networking. The single source of truth. +- **View** — a tree of `*Interface` Groups and Windows that subscribe to model LazyVars via `Derive`. Views never touch each other or write to the model. +- **Controller** — `ChatController.lua` (chat) and `config/ChatConfigController.lua` (options). The only files allowed to send network messages, register receive handlers, or write to the model. + +--- + +## File Map + +The chat tree splits one feature into many small files so each `*Interface` is responsible for a single concern. When adding a feature, this table tells you which file to open. + +### Top-level + +| File | Responsibility | +|------|----------------| +| [ChatModel.lua](ChatModel.lua) | `UIChatModel` singleton + `UIChatEntry` / `UIChatEntryLocation` shapes; recipient + history + window-visible + last-activity + pin LazyVars | +| [ChatController.lua](ChatController.lua) | Send / receive pipelines, slash-command dispatch, activity heartbeat, recipient routing — the only file allowed to call `SessionSendChatMessage` or write to the model | +| [ChatInterface.lua](ChatInterface.lua) | `UIChatInterface : Window` — main draggable, resizable chat window; owns drag handles, idle/fade `OnFrame` timer, `win_alpha` cascade, standalone-invocation entry points | +| [ChatLinesInterface.lua](ChatLinesInterface.lua) | `UIChatLinesInterface : Group` — line pool, scrollbar, wrap/rebuild on resize, observes `model.History` + `ChatConfigModel.Committed` | +| [ChatLineInterface.lua](ChatLineInterface.lua) | `UIChatLineInterface : Group` — single message row: faction badge, sender name (clickable), body text (clickable when camera/location-tagged) | +| [ChatEditInterface.lua](ChatEditInterface.lua) | `UIChatEditInterface : Group` — edit box, recipient label, recipient-picker dropdown, camera-attach checkbox, command-hint popup, command history ring, Tab completion | +| [ChatFeedInterface.lua](ChatFeedInterface.lua) | `UIChatFeedInterface : Group` — sibling feed shown while the window is closed; per-row age timer fades old lines | +| [ChatListInterface.lua](ChatListInterface.lua) | `UIChatListInterface : Group` — popup recipient picker (all / allies / per-player) | +| [ChatCommandHintInterface.lua](ChatCommandHintInterface.lua) | `UIChatCommandHintInterface : Group` — slash-command auto-suggest popup anchored to the edit box | +| [ChatFactionBadge.lua](ChatFactionBadge.lua) | `ChatFactionBadge : Group` — faction icon with tooltip; rendered on every line | +| [ChatCompletion.lua](ChatCompletion.lua) | Tab-completion cycle state (no UI; consumed by `ChatEditInterface`) | +| [ChatUtils.lua](ChatUtils.lua) | Module-level helpers (max message length, etc.) | +| [ChatDebug.lua](ChatDebug.lua) | Debug helpers — not part of the production tree | + +### `config/` + +| File | Responsibility | +|------|----------------| +| [config/ChatConfigModel.lua](config/ChatConfigModel.lua) | `UIChatConfigModel` singleton + `UIChatOptions` schema + slider ranges; `Committed` (active) and `Pending` (draft) options LazyVars | +| [config/ChatConfigController.lua](config/ChatConfigController.lua) | Apply / Reset / Cancel / SetOption — the only writer to `ChatConfigModel` | +| [config/ChatConfigInterface.lua](config/ChatConfigInterface.lua) | `UIChatConfigInterface : Window` — options dialog; observes `Pending` to sync controls | + +### `commands/` + +| File | Responsibility | +|------|----------------| +| [commands/ChatCommandRegistry.lua](commands/ChatCommandRegistry.lua) | Registry, tokenizer, dispatcher; legacy fallback to `/lua/ui/notify/commands.lua` | +| [commands/ChatCommandTypes.lua](commands/ChatCommandTypes.lua) | Parameter resolvers: `Recipient`, `Player`, `Int`, `String`, `Rest` | +| [commands/builtin/*.lua](commands/builtin/) | One file per built-in command (`/all`, `/allies`, `/whisper`, `/help`, …); each exports a `Command` table | +| [commands/design.md](commands/design.md) | Slash-command system design — read before adding a command | + +To add a slash command, follow the [`add-chat-command`](../../../../.claude/skills/add-chat-command/SKILL.md) skill. + +--- + +## Model + +### `UIChatModel` ([ChatModel.lua](ChatModel.lua)) + +```lua +---@class UIChatModel +---@field History LazyVar # append-only log; Set a new table ref to trigger dirty +---@field Recipient LazyVar # current send target +---@field WindowVisible LazyVar # whether the chat window is open +---@field LastActivity LazyVar # GetSystemTimeSeconds() of the most recent engagement; drives the fade timer +---@field Pinned LazyVar # title-bar pin checkbox; suspends auto-close while true +``` + +`UIChatRecipient` is `'all' | 'allies' | number` — the engine-level target. The two string constants are exported as `ChatModel.RecipientAll` / `ChatModel.RecipientAllies` so nothing hardcodes the strings. + +### `UIChatEntry` + +```lua +---@class UIChatEntry +---@field Name string # formatted prefix, e.g. "Sender to allies:" +---@field Text string # raw message body +---@field Color string # ARGB hex of the sender's team colour +---@field BodyColor? string # explicit body ARGB; bypasses palette lookup (system / synthetic lines) +---@field ColorKey? string # palette key resolved against `ChatConfigModel.GetOptions()` at render time +---@field ArmyID number # sender's army index +---@field Faction number # 1-based faction icon index +---@field Recipient UIChatRecipient # original target of the message +---@field Camera? table # SaveSettings snapshot when the sender attached their view +---@field Location? UIChatEntryLocation # lightweight {Position?, Area?} hint from sim-side senders +---@field Id? string # near-unique sender-stamped id; dedupes the Sync.ChatMessages path against SessionSendChatMessage +---@field WrappedText? string[] # view-side wrap cache; populated by ChatLinesInterface +``` + +Display-lifecycle state (per-row `time` / `visible` flags, fade alpha) lives on the **view**, not on entries. `WrappedText` is the one exception — the wrap cache attaches to the entry because it depends on the entry's text and the current row width, and avoids re-wrapping every frame. + +### `UIChatConfigModel` ([config/ChatConfigModel.lua](config/ChatConfigModel.lua)) + +```lua +---@class UIChatConfigModel +---@field Committed LazyVar # the active, persisted options observed by the chat tree +---@field Pending LazyVar # the draft being edited in the config dialog +``` + +The two LazyVars exist so the config dialog can preview changes (`Pending`) without affecting live UI (`Committed`) until the user clicks Apply. Views observing chat options always read `Committed`. + +`UIChatOptions` is a plain table; option keys are exported as module globals (`ChatConfigModel.KeyFontSize`, etc.) so call sites don't repeat magic strings. Slider bounds (`FontSizeRange`, `FadeTimeRange`, `WinAlphaSliderRange`) live in the same module. + +| Key | Default | Meaning | +|-----|---------|---------| +| `all_color` | 1 | Palette index (1–8) for "all" messages | +| `allies_color` | 2 | Palette index for ally messages | +| `priv_color` | 3 | Palette index for private messages | +| `link_color` | 4 | Palette index for camera/location-link messages and observer chatter | +| `notify_color` | 8 | Palette index for Notify subsystem messages | +| `font_size` | 14 | Chat font size (12–18) | +| `fade_time` | 15 | Seconds before idle window/feed auto-hides (5–30) | +| `win_alpha` | 1.0 | Window opacity (0.0–1.0; edited via 20–100% slider) | +| `feed_background` | false | Semi-transparent backdrop behind feed lines | +| `send_type` | false | Default recipient: false = all, true = allies | +| `links` | true | Show camera-link messages | +| `muted` | `{}` | Per-army mute filter (`armyID → true` when muted) | + +--- + +## Controller + +### Receiving + +``` +gamemain.ReceiveChat(sender, data) [engine callback] + └── chatFuncs['Chat'](sender, data) [registered by ChatController.Init] + └── ChatController.OnReceive(sender, msg) + ├── shape-validate the payload (drop malformed, modded, or hostile) + ├── route Notify subsystem messages through their handlers + └── ChatModel.AppendEntry(entry) # writes model.History + stamps LastActivity +``` + +`OnSyncChatMessages` is the parallel path for sim-originated and replay messages — it goes through the same `OnReceive` once it has unpacked the sync payload, so live and replay paths converge. + +### Sending + +``` +ChatController.Send(text, attachCamera?) + ├── slash-command check → ChatCommandRegistry.Dispatch + ├── taunt check → /lua/ui/notify/taunt + ├── package message {to, Chat, text, Camera?, Id, Sender} + ├── resolve clients → FindClients[AsObserver|AsPlayer] + └── SessionSendChatMessage(clients?, msg) + + SimCallback('SendChatMessage') for the sim/replay path + + locally echo private messages (engine doesn't bounce them back) +``` + +Every public function on `ChatController` either reads input, writes the model, or speaks to the engine — there is no UI-side state on the controller. Anything in `/lua/ui/game/chat` that wants to mutate chat state goes through one of these: + +| Function | What it does | +|----------|--------------| +| `OpenWindow` / `CloseWindow` / `ToggleWindow` | Flip `model.WindowVisible` | +| `NotifyActivity` | Stamp `model.LastActivity` — the activity heartbeat read by the fade timer | +| `SetPinned(bool)` | Flip `model.Pinned` (and re-stamp activity on unpin) | +| `SetRecipient(target)` | Write `model.Recipient` | +| `AppendEntry(entry)` | Append to `model.History` + stamp activity | +| `AppendLocalSystemMessage(text)` | Synthesize a local-only system line (used by command errors) | +| `Send(text, attachCamera?)` | Slash dispatch / taunt / network send pipeline | +| `ActivateChat(modifiers?)` | Engine hotkey entry: open window with default recipient layered with Shift | +| `RegisterBuiltinCommands` | Re-runs the registry population; idempotent and safe under hot reload | +| `Init` | Registers `OnReceive` with gamemain, populates the registry, ensures the chat tree is mounted | + +### Init + +`ChatController.Init` is called once from `gamemain.lua` during UI setup. Hot reload re-runs `Init` via the `__moduleinfo.OnReload` hook so the gamemain registration rebinds to the freshly imported `OnReceive` closure — without that, edits to the controller leave stale code receiving messages. + +--- + +## Views + +Every `*Interface` file follows the rules in [`/lua/ui/CLAUDE.md`](../../CLAUDE.md) — `__init` for state and children, `__post_init` for layout, observers via `Derive`, cleanup via `TrashBag`. The chat-specific bits are which model fields each interface observes and which controller calls it makes. + +| Interface | Observes | Calls into controller | +|-----------|----------|-----------------------| +| `UIChatInterface` ([ChatInterface.lua](ChatInterface.lua)) | `model.WindowVisible`, `model.Pinned`, `ChatConfigModel.Committed.win_alpha` | `CloseWindow`, `SetPinned`, `NotifyActivity` | +| `UIChatLinesInterface` ([ChatLinesInterface.lua](ChatLinesInterface.lua)) | `model.History`, `ChatConfigModel.Committed` (font, palette, mute, links) | (read-only; click forwarders handed in by parent) | +| `UIChatLineInterface` ([ChatLineInterface.lua](ChatLineInterface.lua)) | (per-row; populated by `ChatLinesInterface`) | row click → `SetRecipient`, camera click → `WorldCamera:RestoreSettings` | +| `UIChatEditInterface` ([ChatEditInterface.lua](ChatEditInterface.lua)) | `model.Recipient` | `Send`, `SetRecipient`, `NotifyActivity`, `CloseWindow` | +| `UIChatFeedInterface` ([ChatFeedInterface.lua](ChatFeedInterface.lua)) | `model.History`, `ChatConfigModel.Committed` (palette, fade, feed_background) | (read-only) | +| `UIChatListInterface` ([ChatListInterface.lua](ChatListInterface.lua)) | (driven by edit dropdown) | `SetRecipient` | +| `UIChatCommandHintInterface` ([ChatCommandHintInterface.lua](ChatCommandHintInterface.lua)) | (driven by edit text) | (no controller calls; hint UI only) | +| `UIChatConfigInterface` ([config/ChatConfigInterface.lua](config/ChatConfigInterface.lua)) | `ChatConfigModel.Pending` | `ChatConfigController.SetOption` / `Apply` / `Reset` / `Cancel` | + +### Imports vs callbacks + +Views import models and controllers directly at the top of the file rather than receiving them through constructors: + +```lua +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") +local ChatController = import("/lua/ui/game/chat/ChatController.lua") +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +``` + +This keeps dependencies visible at the top of the file and avoids the autolobby's "prop drilling" pattern, where state was threaded through every constructor and every change required touching the controller. The MVC discipline is preserved by convention: views still only **read** from the model and **call** the controller — they never write to the model directly. + +--- + +## UI Elements + +| Element | File | Parent | +|---------|------|--------| +| Chat window (title bar + drag handles) | [ChatInterface.lua](ChatInterface.lua) | `GetFrame(0)` | +| Line pool + scrollbar | [ChatLinesInterface.lua](ChatLinesInterface.lua) | chat window's client area | +| Single message row | [ChatLineInterface.lua](ChatLineInterface.lua) | line pool | +| Sibling feed (window-hidden mode) | [ChatFeedInterface.lua](ChatFeedInterface.lua) | `GetFrame(0)` (anchored to chat window's lines rect) | +| Edit box, recipient label, recipient picker, camera checkbox | [ChatEditInterface.lua](ChatEditInterface.lua) | chat window's client area | +| Recipient-picker popup | [ChatListInterface.lua](ChatListInterface.lua) | edit interface | +| Slash-command hint popup | [ChatCommandHintInterface.lua](ChatCommandHintInterface.lua) | edit interface | +| Faction icon (per row) | [ChatFactionBadge.lua](ChatFactionBadge.lua) | line interface | +| Options dialog | [config/ChatConfigInterface.lua](config/ChatConfigInterface.lua) | `GetFrame(0)` | + +--- + +## Standalone Invocation + +Every complete UI component in this system (chat window, options dialog, edit area) **must be callable directly from a hotkey** with no prior context. This serves two purposes: + +1. **Debugging** — any component can be opened in isolation without launching the full game flow. +2. **Separation of concerns** — if a component requires another component to exist before it can be opened, that is a design smell indicating hidden coupling. + +Each top-level view module exports module-level `Toggle()` / `Open()` / `Close()` and an `Instance` local. Bind `chat_toggle` and `chat_config` actions in `keyactions.lua` to `UI_Lua import("/lua/ui/game/chat/ChatInterface.lua").Toggle()` and the corresponding config call. The same `Toggle()` is also what the hot-reload `__moduleinfo.OnReload` block reopens after a save — see [`/lua/ui/CLAUDE.md § 7.2`](../../CLAUDE.md). + +> This convention is currently chat-specific but is a candidate to lift into [`/lua/ui/CLAUDE.md`](../../CLAUDE.md). Until it does, treat this as the reference for any other top-level UI module. + +--- + +## Don'ts + +- **Don't store UI references in the model.** The model must be constructable with no UI present (and is — see the model singleton's hot-reload hook, which rebuilds without touching the view tree). +- **Don't write to the model from a view.** Views call into the controller; the controller writes. +- **Don't call `SessionSendChatMessage` or `gamemain.RegisterChatFunc` from anywhere but `ChatController`.** Network and sim-side traffic is funnelled through that file precisely so legacy/notify/script paths don't fork. +- **Don't mutate `model.History` (or any LazyVar's table) in place.** Build a new table and `Set` it; otherwise dependents never go dirty. See [`/lua/ui/CLAUDE.md § 2 Reactivity rules`](../../CLAUDE.md). +- **Don't replicate the autolobby's drilling pattern.** State is on the model; views import and subscribe — no parent needs to push updates into children. +- **Don't add a slash command by editing the registry directly.** Drop a file in [`commands/builtin/`](commands/builtin/) and add one `Registry.RegisterFromPath` line in `ChatController.RegisterBuiltinCommands` — see the [`add-chat-command`](../../../../.claude/skills/add-chat-command/SKILL.md) skill. diff --git a/lua/ui/game/chat/ChatCommandHintInterface.lua b/lua/ui/game/chat/ChatCommandHintInterface.lua new file mode 100644 index 00000000000..a5057daad9b --- /dev/null +++ b/lua/ui/game/chat/ChatCommandHintInterface.lua @@ -0,0 +1,484 @@ + +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 Debug = false + +local RowFontSize = 12 +local RowFontName = 'Arial' +local HorizontalPadding = 12 +local VerticalPadding = 2 + +local MaxVisibleRows = 6 + +--- 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. +---@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. +-- 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 +---@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 + +--- 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) +---@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 +---@field RBBG Bitmap +---@field RLBG Bitmap +---@field LBG Bitmap +---@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 + ---@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) + self.Selected = Create(0) + self.Selected.OnDirty = function() self:RepaintRows() end + self.ScrollBottom = Create(1) + self.ScrollBottom.OnDirty = function() self:UpdateRowVisibility() end + + -- 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() + + -- `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 + self.RowHeight = Create(probe.Height() + LayoutHelpers.ScaleNumber(VerticalPadding)) + probe:Destroy() + + 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 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) + 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() + + local extraScaled = LayoutHelpers.ScaleNumber(HorizontalPadding * 2 + ScrollbarWidth) + Layouter(self) + :Width(function() return textWidth + extraScaled end) + :End() + + ---@diagnostic disable: undefined-field + self.Height:SetFunction(function() + local rows = math.min(self.VisibleCount(), MaxVisibleRows) + return rows * self.RowHeight() + end) + + 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) + + 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 + + -- Negative offset_right pulls the bar inside the popup bounds + -- instead of overlapping the right border art. + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self, -ScrollbarWidth) + + local function syncScrollbarVisibility() + if self.VisibleCount() > MaxVisibleRows then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + 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, + + --- Reusable row (text + highlight + hover handler). Layout deferred + --- to `GetOrCreateRow` / `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('00000000') + row.Hovered = false + + local owner = self + 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 + row.Hovered = true + paint() + elseif event.Type == 'MouseExit' then + row.Hovered = false + paint() + elseif event.Type == 'ButtonPress' then + if row.Target and owner.OnSelect then + owner.OnSelect(row.Target) + end + end + end + + return row + end, + + --- 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 + if row.Paint then row.Paint() end + end + end, + + --- Shows or hides each row based on the current scroll window. + ---@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 the visible window so `ordinal` is on screen. + ---@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 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) + ------------------------------------------------------------------------- + + --- Scrollbar contract: returns `(min, max, top, bottom)` in top-down coordinates. + ---@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, + + --- Scrolls by a line count. + ---@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, + + --- Scrolls by full visible-window pages. + ---@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, + + --- 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 + 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, + + --- 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, + + --- Wheel scroll handler. + ---@param self UIChatCommandHintInterface + ---@param rotation number + OnMouseWheel = function(self, rotation) + self:ScrollLines(nil, -math.floor(rotation / 100)) + end, + + --- Advances the selection one row down (wraps to the top). + ---@param self UIChatCommandHintInterface + SelectNext = function(self) + local n = self.VisibleCount() + if n <= 0 then return end + local cur = self.Selected() + local next = cur >= n and 1 or cur + 1 + self.Selected:Set(next) + self:EnsureOrdinalVisible(next) + end, + + --- Moves the selection one row up (wraps to the bottom). + ---@param self UIChatCommandHintInterface + SelectPrev = function(self) + local n = self.VisibleCount() + if n <= 0 then return end + local cur = self.Selected() + local prev = cur <= 1 and n or cur - 1 + self.Selected:Set(prev) + self:EnsureOrdinalVisible(prev) + end, + + --- Returns the currently highlighted command, or nil if none. + ---@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, + + --- Returns the row at `idx`, building one on demand the first time. + ---@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 + 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 + -- 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() + end) + ---@diagnostic enable: undefined-field + + self:LayoutRowBackground(row) + return row + end, + + --- 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) + ---@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() - 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, + + --- 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) + 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 + table.insert(matches, cmd) + 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.Ordinal:Set(i) + end + for i = table.getn(matches) + 1, table.getn(self.Rows) do + local row = self.Rows[i] + row.Target = nil + row.Ordinal:Set(0) + end + + self.VisibleCount:Set(table.getn(matches)) + + -- Reset scroll to bottom on every match-set change. + self.ScrollBottom:Set(1) + + -- Keep the previously-selected ordinal when possible. + 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 unchanged but the target underneath isn't. + -- Repaint so colours match the new row assignments. + self:RepaintRows() + end + + self:UpdateRowVisibility() + ---@diagnostic enable: undefined-field + end, + + --- 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) + self.OnSelect = callback + end, +} diff --git a/lua/ui/game/chat/ChatCompletion.lua b/lua/ui/game/chat/ChatCompletion.lua new file mode 100644 index 00000000000..20d98dff463 --- /dev/null +++ b/lua/ui/game/chat/ChatCompletion.lua @@ -0,0 +1,151 @@ + +local CommandRegistry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + +------------------------------------------------------------------------------- +-- Pure tab-completion for the chat edit box. +-- +-- Compute(text, caret) → UIChatCompletion | nil +-- +-- 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` +---@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) + +--- Codepoint of the last space at or before `caret`, or 0 if none. +---@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 + +--- 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 +---@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. +---@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 + 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 + +--- Returns a completion record for the caret position, or nil if nothing +--- 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) + 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. Otherwise we'd double up an existing + -- separator. + 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 + + -- `@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 + atSign = '@' + matchPrefix = string.sub(prefix, 2) + if matchPrefix == '' then return nil end + end + + local candidates = {} + for _, name in ipairs(CollectNicknames()) do + if StartsWithCI(name, matchPrefix) then + table.insert(candidates, atSign .. 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 diff --git a/lua/ui/game/chat/ChatController.lua b/lua/ui/game/chat/ChatController.lua new file mode 100644 index 00000000000..8358c663ddb --- /dev/null +++ b/lua/ui/game/chat/ChatController.lua @@ -0,0 +1,552 @@ + +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 + +--- Shows the chat window. +function OpenWindow() + ChatModel.GetSingleton().WindowVisible:Set(true) +end + +--- Hides the chat window. +function CloseWindow() + ChatModel.GetSingleton().WindowVisible:Set(false) +end + +--- Flips chat window visibility. +function ToggleWindow() + local lv = ChatModel.GetSingleton().WindowVisible + lv:Set(not lv()) +end + +------------------------------------------------------------------------------- +-- Activity heartbeat + +--- Stamps `LastActivity` with the current system time. Call from any +--- UI surface that counts as engagement. +function NotifyActivity() + ChatModel.GetSingleton().LastActivity:Set(GetSystemTimeSeconds()) +end + +--- Sets the pinned flag. +---@param pinned boolean +function SetPinned(pinned) + ChatModel.GetSingleton().Pinned:Set(pinned and true or false) + if not pinned then + NotifyActivity() + end +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 and stamps `LastActivity`. Used by +--- the receive path and 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) + NotifyActivity() +end + +--- 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 { + Name = "System:", + Text = text, + Color = 'ffff6666', + BodyColor = 'ffff6666', + ArmyID = 0, + Recipient = ChatModel.RecipientAll, + } +end + +------------------------------------------------------------------------------- +-- Slash commands + +--- (Re-)registers every built-in chat command with the registry. Idempotent. +function RegisterBuiltinCommands() + local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + + 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/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") + Registry.RegisterFromPath("/lua/ui/game/chat/commands/builtin/Help.lua") +end + +------------------------------------------------------------------------------- +-- Address book + +---@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 + +--- * 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 +---@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. +--- +--- * Observing (focus == -1): every connected observer client, plus any +--- disconnected-but-recognised human player. +--- * 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 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). 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 + +local ToStrings = ChatUtils.ToStrings + +------------------------------------------------------------------------------- +-- Chat line construction + +--- 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, 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. + 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. + local colorKey + 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 + 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, + Camera = args.Camera, + Location = args.Location, + Id = args.Id, + } +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, 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) + if type(sender) ~= 'string' or sender == '' then + sender = 'nil sender' + end + + if not ChatPayload.IsValidPayload(msg) then return end + if IsDuplicateMessage(msg) then return end + + -- 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. + if msg.to == ChatModel.RecipientNotify 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 + + -- `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 + 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, + Location = msg.location, + Id = msg.Id, + } +end + +--- Handler for the `Sync.ChatMessages` category, populated by the sim-side +--- `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) + local nickname = armyData and armyData.nickname or tostring(msg.From or 'Unknown') + OnReceive(nickname, msg) + end +end + +------------------------------------------------------------------------------- +-- Echoing (local synthesis for outgoing privates) +-- +-- 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`) +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, + Location = msg.location, + Id = msg.Id, + } +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. 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) + 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 + if err then + AppendLocalSystemMessage(err) + return + end + -- Lone '/' or unknown command falls through to the normal send path. + end + + 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 + + local recipient = ChatModel.GetSingleton().Recipient() + local focusArmy = GetFocusArmy() + local msg = { + to = recipient, + Chat = true, + Identifier = 'Chat', + 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. + 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. + 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. + 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). + 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: 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 + SessionSendChatMessage(FindClients(), msg) + elseif type(recipient) == 'number' then + SessionSendChatMessage(FindClients(recipient), msg) + + -- Engine doesn't bounce private messages back to the sender. + local senderData = GetArmyData(focusArmy) + local targetData = GetArmyData(recipient) + if senderData and targetData then + OnEcho(senderData, targetData, msg) + end + else + if focusArmy == -1 then + SessionSendChatMessage(FindClients(), msg) + else + SessionSendChatMessage(msg) + end + end +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. 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`. + 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 + if (not shift) == sendType then + model.Recipient:Set(ChatModel.RecipientAllies) + else + model.Recipient:Set(ChatModel.RecipientAll) + end + end +end + +------------------------------------------------------------------------------- +-- Lifecycle + +--- Registers the receive handler with gamemain, populates the slash-command +--- 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. + import("/lua/ui/game/chat/ChatInterface.lua").EnsureInstance() +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- 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() + 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() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatDebug.lua b/lua/ui/game/chat/ChatDebug.lua new file mode 100644 index 00000000000..06c182ac5bb --- /dev/null +++ b/lua/ui/game/chat/ChatDebug.lua @@ -0,0 +1,133 @@ + +------------------------------------------------------------------------------- +-- 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") + +--- 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 " .. + "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." + +--- 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 {} + 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 +------------------------------------------------------------------------------- + +--- Debug hotkey: flips the chat window's visibility. +function ToggleWindow() + import("/lua/ui/game/chat/ChatInterface.lua").Toggle() +end + +--- Debug hotkey: flips the chat options dialog's visibility. +function ToggleConfig() + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Toggle() +end + +------------------------------------------------------------------------------- +-- Synthetic message injection +------------------------------------------------------------------------------- + +--- Debug hotkey: appends a synthetic system message. +function AppendSystemMessage() + ChatController.AppendLocalSystemMessage( + '[debug] system message at ' .. tostring(GetSystemTimeSeconds()) + ) +end + +--- Debug hotkey: appends a one-line synthetic message from the local player. +function AppendShortMessage() + ChatController.AppendEntry(SynthEntry({})) +end + +--- 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 + +--- 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), + })) + end +end + +--- 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({ + Text = '[debug] click the camera icon to jump back here', + Location = { Position = settings.Focus }, + })) +end + +------------------------------------------------------------------------------- +-- Recipient state +------------------------------------------------------------------------------- + +--- Debug hotkey: forces the recipient back to "All". +function SetRecipientAll() + ChatController.SetRecipient(ChatModel.RecipientAll) +end + +--- Debug hotkey: forces the recipient back to "Allies". +function SetRecipientAllies() + ChatController.SetRecipient(ChatModel.RecipientAllies) +end + +------------------------------------------------------------------------------- +-- History reset +------------------------------------------------------------------------------- + +--- Debug hotkey: wipes the entire history log. +function ClearHistory() + ChatModel.GetSingleton().History:Set({}) +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module on save. +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..6c447bf2f0b --- /dev/null +++ b/lua/ui/game/chat/ChatEditInterface.lua @@ -0,0 +1,458 @@ + +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 +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") +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 + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Debug = false + +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 +-- 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 +---@field RecipientLabel Text +---@field EditBox Edit +---@field CamCheckbox Checkbox # toggle: attach world-camera state to the next message +---@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 +---@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) { + + ---@param self UIChatEditInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatEditInterface") + + self.Trash = TrashBag() + + 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'), + 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) + + self.RecipientLabel.HandleEvent = function(_, event) + if event.Type == 'ButtonPress' then + self:ToggleList() + end + end + + 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')) + Tooltip.AddCheckboxTooltip(self.CamCheckbox, 'chat_camera') + + self.EditBox = Edit(self) + + -- `SetupEditStd` below reads the control's bounds before + -- `__post_init` runs. Seed placeholder values to avoid tripping + -- the default circular Left/Right/Width chain. + Layouter(self.EditBox) + :Left(0) + :Top(0) + :Width(200) + :Height(20) + :End() + + UIUtil.SetupEditStd(self.EditBox, + "ff00ff00", nil, "ffffffff", + UIUtil.highlightColor, UIUtil.bodyFont, 14, ChatUtils.MaxMessageLength) + self.EditBox:SetDropShadow(true) + self.EditBox:ShowBackground(false) + self.EditBox:SetText('') + + -- 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 + ChatController.Send(text, self.CamCheckbox:IsChecked()) + self:PushHistory(text) + else + ChatController.CloseWindow() + end + self:CloseCommandHint() + end + + -- 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 '') + if not self.SuppressCompletionReset then + self.Completion = nil + end + end + + -- `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 + self:HandleTabCompletion() + 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) + if self.ChatCommandHintInterface 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 + + -- 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") + 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 + if ctrl then + chatInterface.ScrollToTop() + else + chatInterface.ScrollLines(-step) + end + elseif keycode == UIUtil.VK_NEXT then + if ctrl then + chatInterface.ScrollToBottomOrClose() + else + chatInterface.ScrollLines(step) + end + elseif keycode == UIUtil.VK_UP then + 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 + + local model = ChatModel.GetSingleton() + self.RecipientObserver = self.Trash:Add(LazyVarDerive(model.Recipient, function(lv) + self:RefreshRecipient(lv()) + end)) + end, + + ---@param self UIChatEditInterface + ---@param parent Control + __post_init = function(self, parent) + Layouter(self.ChatBubble) + :AtLeftIn(self, 6) + :AtVerticalCenterIn(self) + :End() + + Layouter(self.RecipientLabel) + :AnchorToRight(self.ChatBubble, 6) + :AtVerticalCenterIn(self) + :End() + + Layouter(self.CamCheckbox) + :AtRightIn(self, 12) + :AtVerticalCenterIn(self, -2) + :End() + + -- `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() + :Height(function() return self.EditBox:GetFontHeight() 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, + + --- 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 + local hint = self.ChatCommandHintInterface --[[@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 + 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 at the recorded anchor. + ---@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]] + + 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 so the next cycle overwrites this + -- candidate, not the original word. + c.Consume = replacementLen + end, + + --------------------------------------------------------------------------- + -- Command history recall + + --- 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) + table.insert(self.CommandHistory, text) + while table.getn(self.CommandHistory) > MaxCommandHistorySize do + table.remove(self.CommandHistory, 1) + end + self.RecallEntry = nil + end, + + --- 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) + 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 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) + 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. 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] + if not entry then return end + self.EditBox:SetText(entry) + self.EditBox:SetCaretPosition(STR_Utf8Len(entry)) + end, + + --- 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) + else + self:CloseCommandHint() + end + elseif text == '/' then + self:OpenCommandHint() + self.ChatCommandHintInterface:Refresh(text) + end + end, + + --- 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 + + ChatController.RegisterBuiltinCommands() + + local hint = ChatCommandHintInterface(self, self.EditBox) + self.ChatCommandHintInterface = hint + LayoutHelpers.Above(hint, self.EditBox, 14) + LayoutHelpers.AtLeftIn(hint, self.EditBox) + hint:SetOnSelect(function(cmd) + self.EditBox:SetText('/' .. cmd.Name .. ' ') + self:AcquireFocus() + end) + end, + + --- Tears down the slash-command hint popup if it is open. + ---@param self UIChatEditInterface + CloseCommandHint = function(self) + if not self.ChatCommandHintInterface then return end + local hint = self.ChatCommandHintInterface --[[@as UIChatCommandHintInterface]] + self.ChatCommandHintInterface = nil + hint:Destroy() + end, + + --- Opens or closes the recipient picker popup, returning focus to the edit box. + ---@param self UIChatEditInterface + ToggleList = function(self) + if self.ChatListInterface then + local list = self.ChatListInterface --[[@as UIChatListInterface]] + self.ChatListInterface = nil + list:Destroy() + self:AcquireFocus() + else + local list = ChatListInterface(self) + self.ChatListInterface = list + LayoutHelpers.Above(list, self.ChatBubble, 15) + LayoutHelpers.AtLeftIn(list, self.ChatBubble, 15) + list:SetOnClosed(function() + self.ChatListInterface = nil + self:AcquireFocus() + end) + end + end, + + --- Updates the recipient label to match the current send target. + ---@param self UIChatEditInterface + ---@param recipient UIChatRecipient + RefreshRecipient = function(self, recipient) + 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(string.format("%s %s:", LOC(ChatUtils.ToStrings.to.caps), name)) + end + end, + + --- Gives keyboard focus to the edit box. + ---@param self UIChatEditInterface + AcquireFocus = function(self) + self.EditBox:AcquireFocus() + end, + + --- Releases keyboard focus from the edit box. + ---@param self UIChatEditInterface + AbandonFocus = function(self) + self.EditBox:AbandonFocus() + end, + + --- Destroys derived observers so dangling OnDirty callbacks don't fire into a dead self. + ---@param self UIChatEditInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} diff --git a/lua/ui/game/chat/ChatFactionBadge.lua b/lua/ui/game/chat/ChatFactionBadge.lua new file mode 100644 index 00000000000..4ad91936ee3 --- /dev/null +++ b/lua/ui/game/chat/ChatFactionBadge.lua @@ -0,0 +1,67 @@ + +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' + +------------------------------------------------------------------------------- +-- 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 +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) + + 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) + + LayoutHelpers.DepthOverParent(self.Color, self, 1) + LayoutHelpers.DepthOverParent(self.Icon, self, 2) + end, + + --- `nil` factionIndex shows 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 behind the icon. + ---@param self ChatFactionBadge + ---@param color string ARGB hex, e.g. 'ffff4242' + SetColor = function(self, color) + self.Color:SetSolidColor(color or 'ffffffff') + end, +} diff --git a/lua/ui/game/chat/ChatFeedInterface.lua b/lua/ui/game/chat/ChatFeedInterface.lua new file mode 100644 index 00000000000..9b1d029d5ec --- /dev/null +++ b/lua/ui/game/chat/ChatFeedInterface.lua @@ -0,0 +1,320 @@ + +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 + +local Debug = false + +local MaxFeedRows = 8 + +--- Capped to half `fade_time` so very short timeouts still fade rather +--- than pop. +local FadeOutDuration = 2 + +--- 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 + +------------------------------------------------------------------------------- +-- 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 +---@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 so the initial `HistoryObserver` fire doesn't replay every + -- existing entry as a fresh feed line. + self.LastHistoryLength = table.getn(model.History()) + + -- 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 + self:ClearAll() + end + self:UpdateVisibility() + end) + ) + + -- 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()) + 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 tracks for free through the dependency graph. + ---@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 for dev-hotkey `Toggle()`. + Layouter(self) + :AtLeftBottomIn(parent, 8, 60) + :Width(420) + :Height(160) + :End() + end + + 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 + --------------------------------------------------------------------------- + + --- Reacts to history mutations: feeds in new entries while the chat window is hidden. + ---@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 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 + 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 + 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 + -- `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. 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() + 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() + self:UpdateVisibility() + end, + + --- 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] + 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, + + --- Drops the oldest row to make room for a new one. + ---@param self UIChatFeedInterface + RemoveOldest = function(self) + local oldest = self.Rows[1] + if oldest then + oldest.Line:Destroy() + oldest.BG:Destroy() + table.remove(self.Rows, 1) + end + end, + + --- Destroys every active feed row. + ---@param self UIChatFeedInterface + ClearAll = function(self) + for _, row in ipairs(self.Rows) do + row.Line:Destroy() + row.BG:Destroy() + end + self.Rows = {} + end, + + --------------------------------------------------------------------------- + -- Visibility / lifecycle + --------------------------------------------------------------------------- + + --- 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() + self:SetNeedsFrameUpdate(true) + else + self:Hide() + self:SetNeedsFrameUpdate(false) + end + end, + + --- 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 + 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 + local row = self.Rows[i] + 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 + 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 + + self:UpdateVisibility() + end, + + --- Destroys every row plus the derived observers. + ---@param self UIChatFeedInterface + OnDestroy = function(self) + self:ClearAll() + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +--- Owned by `ChatInterface`; re-importing the chat module triggers the +--- full chat-tree rebuild. +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..28b5e584bd6 --- /dev/null +++ b/lua/ui/game/chat/ChatInterface.lua @@ -0,0 +1,499 @@ +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 +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") +local ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +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). +local WindowTextures = { + 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. 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 that `SkinnableFile` annotates. +---@diagnostic disable: param-type-mismatch +local function DragHandleTextures(corner) + return { + 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 + +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 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 +---@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 +---@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 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 +local ChatInterface = ClassUI(Window) { + + ---@param self UIChatInterface + ---@param parent Control + __init = function(self, parent) + Window.__init(self, parent, "Chat dialog", false, true, true, false, false, "chat_window_v2", DefaultRect, WindowTextures) + self:SetMinimumResize(400, 160) + + self:SetupDragHandles() + self:SetupResetPositionButton() + + self.Trash = TrashBag() + + self.ChatLinesInterface = ChatLinesInterface(self) + self.ChatEditInterface = ChatEditInterface(self) + + -- 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) + + -- 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) + self.ChatEditInterface:AcquireFocus() + end + end + + local model = ChatModel.GetSingleton() + + -- `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, + function(lv) + if lv() then + self:Show() + self.ChatEditInterface:AcquireFocus() + ChatController.NotifyActivity() + self:SetNeedsFrameUpdate(true) + else + self:SetNeedsFrameUpdate(false) + self.ChatEditInterface:AbandonFocus() + self:Hide() + end + end + ) + ) + + -- 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') + 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 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) + 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 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) + 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() + + -- Side edges light both adjacent corners. + self.DragHandleControlMap = { + 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 }, + } + + -- 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 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 + ---@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, + + --- 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, + 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() + self:OnResizeSet() + end + + Layouter(self.ResetPositionBtn) + :LeftOf(self._configBtn) + :End() + + Tooltip.AddButtonTooltip(self.ResetPositionBtn, 'chat_reset') + end, + + ---@param self UIChatInterface + ---@param parent Control + __post_init = function(self, parent) + local client = self:GetClientGroup() + + Layouter(self.ChatEditInterface) + :AtLeftIn(self) + :AtRightIn(self) + :AtBottomIn(self, 6) + :Height(19) + :Over(client) + :End() + + local paddingHorizontal = 8 + local paddingVertical = 2 + Layouter(self.ChatLinesInterface) + :AtTopIn(client, paddingVertical) + :AtLeftIn(client, paddingHorizontal) + :AtRightIn(client, paddingHorizontal) + :AnchorToTop(self.ChatEditInterface, 4) + :End() + + -- 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 + -- `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, + function(lv) + self:SetAlpha(lv().win_alpha or 1.0, true) + self.ChatLinesInterface.Pool:SetAlpha(1.0, true) + 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, + + --------------------------------------------------------------------------- + -- Idle / fade timer + --------------------------------------------------------------------------- + + --- 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 + 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 + local elapsed = GetSystemTimeSeconds() - model.LastActivity() + if elapsed >= fadeTime then + ChatController.CloseWindow() + end + end, + + --- 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, + + --------------------------------------------------------------------------- + -- Window event hooks + --------------------------------------------------------------------------- + + --- 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`. + 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) + self.DragTR:SetTexture(self.DragTR.textures.up) + self.DragBL:SetTexture(self.DragBL.textures.up) + self.DragBR:SetTexture(self.DragBR.textures.up) + end, + + --- 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, + + --- Drag finished. Re-acquires edit-box focus that the drag handler stole. + OnMoveSet = function(self) + ChatController.NotifyActivity() + self.ChatEditInterface:AcquireFocus() + end, + + --- `rotation` is in wheel units (usually ±120 per notch). + OnMouseWheel = function(self, rotation) + ChatController.NotifyActivity() + self.ChatLinesInterface:ScrollLines(nil, -math.floor(rotation / 100)) + end, + + --- Title-bar close button. Routes through the controller so the model is + --- the source of truth for visibility. + OnClose = function(self) + ChatController.CloseWindow() + end, + + --- 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 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 + end + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +-- 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 if they don't already +--- 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() + if not Instance then + Instance = ChatInterface(GetFrame(0)) + end +end + +--- Standalone entry point: ensures the window exists and shows it. +function Open() + EnsureInstance() + ChatController.OpenWindow() +end + +--- Standalone entry point: hides the chat window if it exists. +function Close() + ChatController.CloseWindow() +end + +--- Standalone entry point: ensures the window exists and flips its visibility. +function Toggle() + EnsureInstance() + ChatController.ToggleWindow() +end + +--- 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 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 + +--- 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 +end + +--- 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 + if lines:IsAtBottom() then + ChatController.CloseWindow() + else + lines:ScrollToBottom() + end +end + +--- Entry point for global PgUp / PgDn bindings: opens the window if needed +--- and scrolls in one step. +---@param delta number +function OpenAndScrollLines(delta) + Open() + ScrollLines(delta) +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: reopens the window on the freshly loaded module. +---@param newModule any +function __moduleinfo.OnReload(newModule) + newModule.Open() +end + +--- Hot-reload hook: tears down the old instance and re-imports this module. +function __moduleinfo.OnDirty() + if Instance then + Instance:Destroy() + Instance = nil + end + + 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 new file mode 100644 index 00000000000..88595c2e443 --- /dev/null +++ b/lua/ui/game/chat/ChatLineInterface.lua @@ -0,0 +1,255 @@ + +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 ChatConfigModel = import("/lua/ui/game/chat/config/ChatConfigModel.lua") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +--- Fallback body-text colour for entries without a `BodyColor` or a +--- resolvable `ColorKey`. Matches the legacy hardcoded body colour. +local DefaultBodyColor = 'ffc2f6ff' + +local Debug = false + +-- 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) +end +table.insert(FactionIcons, '/widgets/faction-icons-alpha_bmp/observer_ico.dds') + +local CamIconTexture = '/game/camera-btn/pinned_btn_up.dds' + +--- Body-text colour for `entry`. Priority: `BodyColor` override, then +--- `ColorKey` palette lookup, then `DefaultBodyColor`. +---@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. + +--- 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 +---@field Name Text +---@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 + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatLineInterface") + + 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) + -- 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) + end + 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). + 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, event) + end + end + + self.Text = UIUtil.CreateText(self, '', 14, 'Arial') + self.Text:SetColor('ffc2f6ff') + self.Text:SetDropShadow(true) + self.Text:SetClipToWidth(true) + self.Text.HandleEvent = function(_, event) + if event.Type == 'ButtonPress' and self.Entry then + self:OnBodyClicked(self.Entry, event) + end + end + end, + + ---@param self UIChatLineInterface + ---@param parent Control + __post_init = function(self, parent) + -- Raw constants in SetFunction bodies don't auto-scale (only + -- Layouter `:Height(number)` does), so pre-scale once. + local twoPxScaled = LayoutHelpers.ScaleNumber(2) + + -- 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() + + 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() + + -- 20x16 footprint matches the `pinned_btn_up.dds` art. + Layouter(self.CamIcon) + :RightOf(self.Name, 4) + :AtVerticalCenterIn(self.TeamColor) + :Width(20) + :Height(16) + :Over(self, 10) + :End() + + -- 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) + :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. + ---@param self UIChatLineInterface + ---@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.Text:SetColor(ResolveBodyColor(entry)) + self.TeamColor:SetSolidColor(entry.Color or '00000000') + + -- 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 + self.Name:Enable() + end + + local iconIndex = entry.Faction or table.getn(FactionIcons) + 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. + if entry.Camera or entry.Location 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. + ---@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 '') + self.Text:SetColor(ResolveBodyColor(entry)) + self.TeamColor:SetSolidColor('00000000') + self.FactionIcon:SetSolidColor('00000000') + self.CamIcon:SetSolidColor('00000000') + self.CamIcon:DisableHitTest() + LayoutHelpers.RightOf(self.Text, self.Name, 2) + end, + + --- Resets the row to its empty state, ready for the next pool reuse. + ---@param self UIChatLineInterface + Clear = function(self) + self.Entry = nil + self.Name:SetText('') + 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; 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 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 + OnBodyClicked = function(self, entry, event) end, + + --- Overridable; default no-op. Only header rows show the icon. + ---@param self UIChatLineInterface + ---@param entry UIChatEntry + ---@param event KeyEvent + OnCameraClicked = function(self, entry, event) end, + + --- Updates the name and body fonts. Row height tracks the name font. + ---@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/ChatLinesInterface.lua b/lua/ui/game/chat/ChatLinesInterface.lua new file mode 100644 index 00000000000..a275e11041e --- /dev/null +++ b/lua/ui/game/chat/ChatLinesInterface.lua @@ -0,0 +1,542 @@ + +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 FloatText = import("/lua/ui/controls/floattext.lua").FloatText + +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 ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Debug = false + +-- Reserve space on the right of the wrapper for the scrollbar widget. +local ScrollbarReserve = 32 + +------------------------------------------------------------------------------- +-- A self-contained chat-lines panel: outer wrapper, inner pool of line rows, +-- and the vertical scrollbar. +-- +-- 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 +---@field Trash TrashBag # owns every subscription-LazyVar we create +---@field Pool Group # inner group hosting the line rows +---@field Scrollbar Scrollbar +---@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 +---@field OptionsObserver LazyVar +---@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) { + + ---@param self UIChatLinesInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatLinesInterface") + + self.Trash = TrashBag() + self.ChatLineInterfaces = {} + self.ScrollTop = 1 + self.VirtualSize = 0 + + self.Pool = Group(self, "ChatLinesPool") + + -- `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 + + 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 + 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 so pool growth never allocates a per-row closure. + -- Each forwarder reads `self.OnXxxClicked` on every call, so + -- replacing the hook later doesn't require re-wiring the rows. + 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( + LazyVarDerive( + model.History, + function(lv) + self:OnHistoryChanged(lv()) + end + ) + ) + + -- `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 + ---@param parent Control + __post_init = function(self, parent) + Layouter(self.Pool) + :AtLeftTopIn(self) + :AtRightIn(self, ScrollbarReserve) + :AtBottomIn(self) + :End() + + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Pool) + self.Scrollbar:SetParent(self) + + 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. + --- 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() + + self.OptionsObserver = self.Trash:Add( + LazyVarDerive( + ChatConfigModel.GetSingleton().Committed, + function(lv) self:ApplyOptions(lv()) end + ) + ) + end, + + --------------------------------------------------------------------------- + -- Pool sizing + --------------------------------------------------------------------------- + + --- 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 + + -- 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) + self.ChatLineInterfaces[1].OnNameClicked = self.LineNameClicked + self.ChatLineInterfaces[1].OnBodyClicked = self.LineBodyClicked + self.ChatLineInterfaces[1].OnCameraClicked = self.LineCameraClicked + Layouter(self.ChatLineInterfaces[1]) + :AtLeftBottomIn(pool) + :Right(pool.Right) + :End() + end + + local rowHeight = self.ChatLineInterfaces[1].Height() + if rowHeight < 1 then rowHeight = 18 end + + local neededLines = math.max(1, math.floor(pool.Height() / rowHeight)) + local currentCount = table.getn(self.ChatLineInterfaces) + + 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].OnBodyClicked = self.LineBodyClicked + self.ChatLineInterfaces[i].OnCameraClicked = self.LineCameraClicked + Layouter(self.ChatLineInterfaces[i]) + :Above(self.ChatLineInterfaces[i - 1]) + :AtLeftIn(pool) + :Right(pool.Right) + :End() + end + + for i = currentCount, neededLines + 1, -1 do + self.ChatLineInterfaces[i]:Destroy() + self.ChatLineInterfaces[i] = nil + end + end, + + --------------------------------------------------------------------------- + -- Options application + --------------------------------------------------------------------------- + + --- 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) + local oldPoolSize = table.getn(self.ChatLineInterfaces) + local size = options.font_size or 14 + 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. + self:RebuildPool() + self:RewrapAll() + + self:RefreshVirtualSize() + self:RecomputeScrollTopForPoolChange(oldPoolSize) + self:CalcVisible() + end, + + --------------------------------------------------------------------------- + -- Text wrapping + --------------------------------------------------------------------------- + + --- 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 history entry. Called after a font, width, or font-metric change. + ---@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. + ---@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 + return false + end + if (entry.Camera or entry.Location) and options.links == false then + return false + end + return true + end, + + --------------------------------------------------------------------------- + -- Scroll container + --------------------------------------------------------------------------- + + --- Recounts wrapped lines across non-filtered entries and stores the total in `VirtualSize`. + ---@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, + + --- Scrollbar contract: returns `(min, max, top, bottom)` of the visible range. + ---@param self UIChatLinesInterface + ---@param axis string # "Vert" or "Horz" + GetScrollValues = function(self, axis) + local poolSize = table.getn(self.ChatLineInterfaces) + local top = self.ScrollTop + return 1, self.VirtualSize, top, math.min(top + poolSize, self.VirtualSize) + end, + + --- Scrolls by a line count (negative = older). + ---@param self UIChatLinesInterface + ---@param axis string + ---@param delta number # negative = toward older messages + ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) + end, + + --- Scrolls by `delta` pool-sized pages (negative = older). + ---@param self UIChatLinesInterface + ---@param axis string + ---@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. + ---@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) + 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, + + --- Scrollbar contract: chat is always scrollable on the requested axis. + ---@param self UIChatLinesInterface + ---@param axis string + ---@return boolean + IsScrollable = function(self, axis) + return true + end, + + --- 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) + local newScrollTop = math.max(1, oldVisibleBottom - newPoolSize + 1) + self.ScrollTop = math.max(1, math.min(newMaxTop, newScrollTop)) + end, + + --- 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 rebuild / rewrap. + self:CalcVisible() + end, + + --- Whether the newest entry is currently visible. + ---@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 + --------------------------------------------------------------------------- + + --- 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() + local historyCount = table.getn(history) + local poolSize = table.getn(self.ChatLineInterfaces) + local scrollTop = self.ScrollTop + + -- 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 to find the entry + wrappedIdx covering visibleBottom. + 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 >= visibleBottom then + wrappedIdx = visibleBottom - 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 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] + local outOfRange = entryIdx < 1 + or entryIdx > historyCount + or currentVirtualPos < scrollTop + or currentVirtualPos < 1 + if outOfRange 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() + + currentVirtualPos = currentVirtualPos - 1 + if wrappedIdx > 1 then + wrappedIdx = wrappedIdx - 1 + else + 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 + end + end, + + --------------------------------------------------------------------------- + -- Model reactions + --------------------------------------------------------------------------- + + --- Wraps new arrivals, refreshes virtual size, snaps to bottom. + ---@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.ChatLineInterfaces[1] then + self:ScrollToBottom() + end + + local windowVisible = ChatModel.GetSingleton().WindowVisible() + if not windowVisible then + self:Hide() + end + end, + + --------------------------------------------------------------------------- + -- 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) + self:RebuildPool() + self:RewrapAll() + self:RecomputeScrollTopForPoolChange(oldPoolSize) + 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) + self:RebuildPool() + self:RewrapAll() + self:RecomputeScrollTopForPoolChange(oldPoolSize) + self:CalcVisible() + end, + + --- Destroys derived observers so dangling OnDirty callbacks don't fire into a dead self. + ---@param self UIChatLinesInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module on save. +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatListInterface.lua b/lua/ui/game/chat/ChatListInterface.lua new file mode 100644 index 00000000000..f9e4e3d1a8a --- /dev/null +++ b/lua/ui/game/chat/ChatListInterface.lua @@ -0,0 +1,258 @@ + +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 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") +local ChatController = import("/lua/ui/game/chat/ChatController.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +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 +---@field Badge? ChatFactionBadge # only present on player entries +---@field Target UIChatRecipient + +------------------------------------------------------------------------------- +-- 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 +---@field RTBG Bitmap +---@field RBBG Bitmap +---@field RLBG Bitmap +---@field LBG Bitmap +---@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 + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "ChatListInterface") + self:DisableHitTest() + + -- +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 = {} + for _, def in ipairs(self:BuildTargetDefs()) do + table.insert(self.Entries, self:CreateEntry(def)) + end + + self:CreateBorder() + + 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, + + --- 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 }, + } + + 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, + Faction = armyData.faction, + Color = armyData.color, + }) + break + end + end + end + end + + return defs + end, + + --- Builds one row from a target def, including the faction badge for player rows. + ---@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() + + entry.BG = Bitmap(entry.Text) + entry.BG:SetSolidColor('ff000000') + + if def.Color then + entry.Badge = ChatFactionBadge(self, def.Faction, def.Color) + end + + 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 + bg:SetSolidColor('ff000000') + elseif event.Type == 'ButtonPress' then + ChatController.SetRecipient(target) + self:Destroy() + end + end + + return entry + end, + + --- Eight decorative border bitmaps. Layout applied in `LayoutBorder`. + ---@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) + 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() + + -- Left indent reserves room for the faction badge on player rows. + local textIndent = 20 + + for i, entry in ipairs(self.Entries) do + local below = i > 1 and self.Entries[i - 1] or nil + self:LayoutEntry(entry, below, textIndent) + 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 above the previous one (or pinned to the bottom for the first). + ---@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 + + if entry.Badge then + Layouter(entry.Badge) + :AtLeftIn(self, 3) + :AtVerticalCenterIn(entry.Text) + :Over(self, 2) + :End() + end + + -- 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) + 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() - 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, + + --- Pins the eight border bitmaps around our rect. + ---@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() + 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 to fire when this popup is destroyed (e.g. on outside click). + ---@param self UIChatListInterface + ---@param callback function + SetOnClosed = function(self, callback) + self._OnClosed = callback + end, +} diff --git a/lua/ui/game/chat/ChatModel.lua b/lua/ui/game/chat/ChatModel.lua new file mode 100644 index 00000000000..6cf74a90bfd --- /dev/null +++ b/lua/ui/game/chat/ChatModel.lua @@ -0,0 +1,105 @@ + +local Create = import("/lua/lazyvar.lua").Create + +------------------------------------------------------------------------------- +-- Recipient constants, exported so the rest of the system never hardcodes them. + +RecipientAll = 'all' + +RecipientAllies = 'allies' + +--- 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 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 +---@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 +---@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) + +--- 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 +---@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 + +--- Singleton handle; nil until `SetupSingleton` (or `GetSingleton`) builds the model. +---@type UIChatModel | nil +local ModelInstance = nil + +--- Allocates a fresh model singleton, replacing any existing instance. +---@return UIChatModel +function SetupSingleton() + ModelInstance = { + History = Create({}), + Recipient = Create(RecipientAll), + WindowVisible = Create(false), + LastActivity = Create(GetSystemTimeSeconds()), + Pinned = Create(false), + } + return ModelInstance +end + +--- Returns the model singleton, creating it on first access. +---@return UIChatModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UIChatModel]] +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 + local handle = newModule.SetupSingleton() + handle.History:Set(ModelInstance.History()) + handle.Recipient:Set(ModelInstance.Recipient()) + handle.WindowVisible:Set(ModelInstance.WindowVisible()) + handle.LastActivity:Set(ModelInstance.LastActivity()) + handle.Pinned:Set(ModelInstance.Pinned()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/game/chat/ChatUtils.lua b/lua/ui/game/chat/ChatUtils.lua new file mode 100644 index 00000000000..e0bbbcec463 --- /dev/null +++ b/lua/ui/game/chat/ChatUtils.lua @@ -0,0 +1,81 @@ + +local MauiWrapText = import("/lua/maui/text.lua").WrapText +local ChatPayload = import("/lua/shared/ChatPayload.lua") + +------------------------------------------------------------------------------- +-- Shared, view-agnostic helpers for the chat tree. + +--- 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. 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' }, + 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. +--- 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 + 'ffefff42', -- 3: yellow + 'ff4fff42', -- 4: green + 'ff42fff8', -- 5: cyan + 'ff424fff', -- 6: blue + 'ffff42eb', -- 7: magenta + 'ffff9f42', -- 8: orange +} + +--- 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 + 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 + +--- Hot-reload hook: re-imports this module on save. +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/chat/LAYOUT.md b/lua/ui/game/chat/LAYOUT.md new file mode 100644 index 00000000000..2887e9ecdd5 --- /dev/null +++ b/lua/ui/game/chat/LAYOUT.md @@ -0,0 +1,246 @@ +# Chat layout — component tree and scaling + +Anchors-and-dependencies map of the chat UI tree, used to reason about UI-scale behaviour. Read alongside [CLAUDE.md](CLAUDE.md) for the chat MVC contract and [`/lua/ui/CLAUDE.md`](../../CLAUDE.md) §§ 4 (Layout, UI scaling) and 5 (Skinning) for the project-wide rules. + +Notation throughout: + +- **(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`). + +--- + +## 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. +├── _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 + 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 - ScrollbarReserve(S) + │ │ │ 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(Pool) + │ anchored to Pool's right edge + │ + └── Edit (ChatEditInterface) + │ 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) + │ AtLeftIn(self, 6(S)) + │ AtVerticalCenterIn(self) + │ + ├── RecipientLabel (S font, ≈ font_h) + │ AnchorToRight(ChatBubble, 6(S)) + │ AtVerticalCenterIn(self) + │ + ├── EditBox (S font) + │ AnchorToRight(RecipientLabel, 4(S)) + │ AnchorToLeft(CamCheckbox, 4(S)) + │ AtVerticalCenterIn(self) + │ Height = GetFontHeight()(S) + │ + ├── CamCheckbox (F ≈ 24×24 bitmap) + │ AtRightIn(self, 12(S)) + │ AtVerticalCenterIn(self, −2) ← 2-pixel upward nudge + │ + ├── ChatListInterface (popup, child of self, on demand) + │ Above(ChatBubble, 15(S)) + │ AtLeftIn(ChatBubble, 15(S)) + │ + └── 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) + +``` +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) +│ ← shown when entry.Camera **or** entry.Location is set +│ +└── Text (S) Left = Name.Right + 2(S) + (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) + +``` +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) +│ └── 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 | +|--------------------|--------------------------|--------------------------------------------------| +| `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 | | +| `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`. | +| 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. | + +--- + +## Edit row at scale + +`font_h ≈ 17 / 25 / 33` (S) · `bitmap_h ≈ 24` (F) · `Edit.Height = 19` (S, fixed in parent) + +| 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 | + +`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 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) | +| 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) | diff --git a/lua/ui/game/chat/commands/ChatCommandRegistry.lua b/lua/ui/game/chat/commands/ChatCommandRegistry.lua new file mode 100644 index 00000000000..96dbf37ae4c --- /dev/null +++ b/lua/ui/game/chat/commands/ChatCommandRegistry.lua @@ -0,0 +1,343 @@ +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`. +---@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[] +---@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) + +--- Registered commands by lower-cased canonical name. +---@type table +local Commands = {} + +--- Lower-cased alias to canonical command name. Merged into lookup so `/w` resolves to `/whisper`. +---@type table +local Aliases = {} + +------------------------------------------------------------------------------- +-- Registration + +--- Removes a command and its aliases from the registry. +---@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 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.") + + if cmd.ShouldRegister and not cmd.ShouldRegister() then + return + end + + 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 + +--- 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 + 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 + +--- Canonical entries only. +---@return UIChatCommand[] +function GetAll() + local result = {} + for _, cmd in Commands do + table.insert(result, cmd) + end + return result +end + +--- Returns the command matching `name` (canonical 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 + +--- Commands whose canonical name or any alias begins with `prefix` +--- (case-insensitive, deduped, 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 + +--- "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 + +---@param cmd UIChatCommand +---@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 + + 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 + +--- Fall-through to legacy `RunChatCommand` for pre-MVC commands +--- registered via Notify's `AddChatCommand` (`/enablenotify`, etc.). +--- New commands should live under `commands/builtin/`. +---@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)) + 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) +--- (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 + + 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 + if DispatchLegacy(name, tokens) then + return true, nil + end + return false, string.format("Invalid command: /%s. Type /help for a list.", name) + end + + local args, parseErr = ParseArgs(cmd, tokens) + if not args 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 + -- 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.", + cmd.Name) + end + if not ok then + return false, reason or string.format("/%s: command rejected.", cmd.Name) + end + end + + -- 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))) + 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/ChatCommandTypes.lua b/lua/ui/game/chat/commands/ChatCommandTypes.lua new file mode 100644 index 00000000000..599d2a0fd8b --- /dev/null +++ b/lua/ui/game/chat/commands/ChatCommandTypes.lua @@ -0,0 +1,75 @@ + +------------------------------------------------------------------------------- +-- 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 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 +local function ResolveArmy(token) + local armies = GetArmiesTable() + if not armies or not armies.armiesTable then + return false, "no army table available." + end + + 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] + 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 + +--- Tag identifying which `Resolvers` entry parses a parameter token. One tag per supported type. +---@alias UIChatCommandParamType 'Recipient' | 'Player' | 'Int' | 'String' | 'Rest' + +--- Param-type to resolver table. Each resolver returns `(true, value)` on success or `(false, errMsg)`. +---@type table +Resolvers = {} + +--- Resolves "all", "allies"/"team", nickname, or army ID into a `UIChatRecipient`. +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 + +--- 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. +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 + +--- 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 new file mode 100644 index 00000000000..91f855f8cb3 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/All.lua @@ -0,0 +1,31 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +--- /all: switch 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 + +--- 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 new file mode 100644 index 00000000000..b8f5109f281 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Allies.lua @@ -0,0 +1,32 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +--- /allies: switch 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 + +--- 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 new file mode 100644 index 00000000000..11846da4eda --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Clear.lua @@ -0,0 +1,31 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +--- /clear: wipes local chat history. +---@type UIChatCommand +Command = { + Name = 'clear', + Description = 'Clear the local chat history.', + Execute = function() + 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 new file mode 100644 index 00000000000..07edb99f6cb --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/DebugDumpControls.lua @@ -0,0 +1,32 @@ + +--- /debug-dump-controls: invoke `UI_DumpControlsUnderCursor`. Only registered with `/debug`. +---@type UIChatCommand +Command = { + Name = 'debug-dump-controls', + Description = 'Dump the UI control hierarchy under the cursor to the log.', + ShouldRegister = function() + return HasCommandLineArg('/debug') + end, + Execute = function() + ConExecute('UI_DumpControlsUnderCursor') + 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/DebugLog.lua b/lua/ui/game/chat/commands/builtin/DebugLog.lua new file mode 100644 index 00000000000..7e155e2fe74 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/DebugLog.lua @@ -0,0 +1,32 @@ + +--- /debug-log: toggle the log window. Only registered 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 + +--- 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/DebugStatistics.lua b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua new file mode 100644 index 00000000000..076c8acfcc7 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/DebugStatistics.lua @@ -0,0 +1,32 @@ + +--- /debug-statistics: cycle the engine's `ShowStats` overlay. Only registered 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 + +--- 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/Debugger.lua b/lua/ui/game/chat/commands/builtin/Debugger.lua new file mode 100644 index 00000000000..6c64d04526a --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Debugger.lua @@ -0,0 +1,32 @@ + +--- /debugger: open the Lua debugger. Only registered 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 + +--- 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/EndMission.lua b/lua/ui/game/chat/commands/builtin/EndMission.lua new file mode 100644 index 00000000000..71ec3cfe399 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/EndMission.lua @@ -0,0 +1,32 @@ + +--- /end-mission: forfeit the current session and open the score screen. +---@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 + +--- 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/GiftResources.lua b/lua/ui/game/chat/commands/builtin/GiftResources.lua new file mode 100644 index 00000000000..9aae8d9d893 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/GiftResources.lua @@ -0,0 +1,89 @@ + +local ChatModel = import("/lua/ui/game/chat/ChatModel.lua") + +--- 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 + return 'mass' + elseif lower == 'energy' or lower == 'e' then + return 'energy' + end + return nil +end + +--- /gift-resources [target]: gift a fraction of mass or energy to an ally. +---@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 + +--- 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 new file mode 100644 index 00000000000..def8182c6c5 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/GiftUnits.lua @@ -0,0 +1,70 @@ + +--- /gift-units : transfer current selection to an ally. Sim re-checks alliance and `ManualUnitShare`. +---@type UIChatCommand +Command = { + 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 }, + }, + Accept = function(args) + local focusArmy = GetFocusArmy() + if focusArmy == -1 then + 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. + if args.target == nil then + local info = GetRolloverInfo() + if not info or not info.armyIndex then + 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-units: can't gift to yourself." + end + if not IsAlly(focusArmy, args.target) then + 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-units: no units selected." + end + if table.getn(selection) == 1 and EntityCategoryContains(categories.COMMAND, selection[1]) then + return false, "/gift-units: can't gift your ACU." + end + + return true + end, + Execute = function(args) + -- `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 new file mode 100644 index 00000000000..27c24f4e57b --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Help.lua @@ -0,0 +1,52 @@ + +local Registry = import("/lua/ui/game/chat/commands/ChatCommandRegistry.lua") + +--- /help: 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 + +--- 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 new file mode 100644 index 00000000000..b8cba86632a --- /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. Default is the quick-save slot, matching `QuickSave`'s path. +---@type UIChatCommand +Command = { + Name = 'load', + Description = 'Load a saved game by name (defaults to the quick-save slot).', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + 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 + +--- 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/Mute.lua b/lua/ui/game/chat/commands/builtin/Mute.lua new file mode 100644 index 00000000000..76a917a99c1 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Mute.lua @@ -0,0 +1,41 @@ + +local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") + +--- /mute : hide messages from a player for the rest of this game. +---@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 + +--- 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 new file mode 100644 index 00000000000..aab8bf6ec24 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Pause.lua @@ -0,0 +1,32 @@ + +--- /pause: pause the local simulation. Not registered in multiplayer (vote/request hotkey owns that). +---@type UIChatCommand +Command = { + Name = 'pause', + Description = 'Pause the simulation.', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + Execute = function() + SessionRequestPause() + 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/Recall.lua b/lua/ui/game/chat/commands/builtin/Recall.lua new file mode 100644 index 00000000000..d2cdac21945 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Recall.lua @@ -0,0 +1,38 @@ + +--- /recall: vote yes on the team recall. Only "yes" is exposed (voting no stays in the diplomacy UI). +---@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 + +--- 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 new file mode 100644 index 00000000000..96d35896fcd --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Restart.lua @@ -0,0 +1,32 @@ + +--- /restart: restart the current session. Skips the escape-menu's confirmation dialog. +---@type UIChatCommand +Command = { + Name = 'restart', + Description = 'Restart the current mission (single-player only).', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + Execute = function() + RestartSession() + 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/Resume.lua b/lua/ui/game/chat/commands/builtin/Resume.lua new file mode 100644 index 00000000000..b8e35e5db69 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Resume.lua @@ -0,0 +1,32 @@ + +--- /resume: un-pause the local simulation. Symmetric with `/pause`. +---@type UIChatCommand +Command = { + Name = 'resume', + Description = 'Resume the simulation.', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + Execute = function() + SessionResume() + 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/Save.lua b/lua/ui/game/chat/commands/builtin/Save.lua new file mode 100644 index 00000000000..9588ab6631e --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Save.lua @@ -0,0 +1,36 @@ + +--- /save [name]: quick-save. Default name matches the quick-save hotkey so repeats overwrite the slot. +---@type UIChatCommand +Command = { + Name = 'save', + Description = 'Quick-save the current session (optional name).', + ShouldRegister = function() + return not SessionIsMultiplayer() + end, + 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 + +--- 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/Speed.lua b/lua/ui/game/chat/commands/builtin/Speed.lua new file mode 100644 index 00000000000..7f9457e56fd --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Speed.lua @@ -0,0 +1,35 @@ + +--- /speed : set sim speed via `WLD_GameSpeed`. Not registered in multiplayer (host vote/request flow). +---@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 + +--- 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/Taunt.lua b/lua/ui/game/chat/commands/builtin/Taunt.lua new file mode 100644 index 00000000000..a6611cbe3b9 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Taunt.lua @@ -0,0 +1,41 @@ + +--- /taunt : broadcast a numbered taunt. Receivers silently ignore out-of-range indices. +---@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 + +--- 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/ToEngineers.lua b/lua/ui/game/chat/commands/builtin/ToEngineers.lua new file mode 100644 index 00000000000..84b548e17a6 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/ToEngineers.lua @@ -0,0 +1,41 @@ + +--- /to-engineers: narrow selection to engineers. Errors rather than silently clearing on no-match. +---@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 + +--- 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 new file mode 100644 index 00000000000..cc69c4d38dd --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/ToTick.lua @@ -0,0 +1,57 @@ + +--- /to-tick : fast-forward to `tick` and pause. Only registered with `/debug`. +---@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") + + ForkThread( + function() + while GetGameTick() < args.tick - 5 do + WaitFrames(1) + end + ConExecute("wld_RunWithTheWind 0") + SessionRequestPause() + 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/Unmute.lua b/lua/ui/game/chat/commands/builtin/Unmute.lua new file mode 100644 index 00000000000..92eacb9d0db --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Unmute.lua @@ -0,0 +1,34 @@ + +local ChatConfigController = import("/lua/ui/game/chat/config/ChatConfigController.lua") + +--- /unmute : reverse of `/mute`. Re-shows new arrivals and history that landed while 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 + +--- 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 new file mode 100644 index 00000000000..f82bd7a1e52 --- /dev/null +++ b/lua/ui/game/chat/commands/builtin/Whisper.lua @@ -0,0 +1,40 @@ + +--- /whisper : private-message a specific player. +---@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 + +--- 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/config/ChatConfigController.lua b/lua/ui/game/chat/config/ChatConfigController.lua new file mode 100644 index 00000000000..98c8d319537 --- /dev/null +++ b/lua/ui/game/chat/config/ChatConfigController.lua @@ -0,0 +1,97 @@ + +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 + +--- 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()) + model.Committed:Set(options) + + local persisted = table.copy(options) + persisted.muted = nil + Prefs.SetToCurrentProfile( + import("/lua/ui/game/chat/config/ChatConfigModel.lua").GetProfileKey(), + persisted + ) +end + +--- 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 the draft and re-syncs Pending from Committed. +function Cancel() + local model = Model() + model.Pending:Set(table.copy(model.Committed())) +end + +--- 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 + +--- 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 + 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 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() + model.Committed:Set(WithMuteChange(model.Committed(), armyID, muted)) + model.Pending:Set(WithMuteChange(model.Pending(), armyID, muted)) +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module on save. +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..80a7ea9e018 --- /dev/null +++ b/lua/ui/game/chat/config/ChatConfigInterface.lua @@ -0,0 +1,503 @@ +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 +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") +local ChatUtils = import("/lua/ui/game/chat/ChatUtils.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +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). +---@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 + +-- 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' }, + { 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", 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' }, +} + +------------------------------------------------------------------------------- +-- 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 +---@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 LabelMuted Text +---@field MuteRows UIChatConfigMuteRow[] +---@field BtnApply Button +---@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) { + + ---@param self UIChatConfigInterface + ---@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 = 500, Bottom = 640, + }, WindowTextures) + + self.Trash = TrashBag() + + 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, ChatUtils.ColorPalette, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01"), + Key = def.Key, + } + local key = def.Key + row.Combo.OnClick = function(_, index) + ChatConfigController.SetOption(key, index) + end + -- 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 + 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, + ChatConfigModel.FontSizeRange.Min, + ChatConfigModel.FontSizeRange.Max, + ChatConfigModel.FontSizeRange.Inc, + unpack(sliderBitmaps)) + self.SliderFontSize.OnValueSet = function(_, value) + ChatConfigController.SetOption(ChatConfigModel.KeyFontSize, value) + end + 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, + ChatConfigModel.FadeTimeRange.Min, + ChatConfigModel.FadeTimeRange.Max, + ChatConfigModel.FadeTimeRange.Inc, + unpack(sliderBitmaps)) + self.SliderFadeTime.OnValueSet = function(_, value) + ChatConfigController.SetOption(ChatConfigModel.KeyFadeTime, value) + end + 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, + ChatConfigModel.WinAlphaSliderRange.Min, + ChatConfigModel.WinAlphaSliderRange.Max, + ChatConfigModel.WinAlphaSliderRange.Inc, + unpack(sliderBitmaps)) + self.SliderWinAlpha.OnValueSet = function(_, value) + ChatConfigController.SetOption(ChatConfigModel.KeyWinAlpha, value / 100) + end + 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) + + 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) + ChatConfigController.SetOption(key, checked) + end + Tooltip.AddCheckboxTooltip(cb, def.Tooltip) + self.Checkboxes[i] = cb + end + + -- ---- Muted players ---- + -- One checkbox per non-civilian army other than the local player. + -- Captured at dialog-open; closing and reopening rebuilds 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 ---- + -- 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() + 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 + + self.BtnOk = UIUtil.CreateButtonStd(client, '/widgets02/small', "OK", 14) + self.BtnOk.OnClick = function() + 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() + ChatConfigController.Cancel() + import("/lua/ui/game/chat/config/ChatConfigInterface.lua").Close() + end + + -- ---- Decorative corner grips ---- + -- 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')) + 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 controls when pending options change ---- + local model = ChatConfigModel.GetSingleton() + self.PendingObserver = self.Trash:Add( + LazyVarDerive( + model.Pending, + function(pendingLazy) + local pending = pendingLazy() + self:RefreshFromOptions(pending) + end + ) + ) + end, + + ---@param self UIChatConfigInterface + ---@param parent Control + __post_init = function(self, parent) + local client = self:GetClientGroup() + local pad = 8 + + Layouter(self.LabelColors) + :AtLeftTopIn(client, pad, pad) + :End() + + ---@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 + + 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() + + Layouter(self.LabelBehavior) + :Below(self.SliderWinAlpha, 12) + :AtLeftIn(client, pad) + :End() + + prev = self.LabelBehavior + for _, cb in ipairs(self.Checkboxes) do + Layouter(cb) + :Below(prev, 6) + :AtLeftIn(client, pad) + :End() + prev = cb + end + + 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 + + -- 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() + + -- 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) + + 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') + self.DebugBG:DisableHitTest() + Layouter(self.DebugBG):Fill(self):Over(self, 100):End() + end + end, + + --- Syncs every control to the supplied options snapshot. Driven by the Pending observer. + ---@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 defaults[row.Key]) + end + + 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 + local value = options[def.Key] + if value == nil then + value = defaults[def.Key] + 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, + + --- 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, + + --- Destroys derived observers so dangling OnDirty callbacks don't fire into a dead self. + ---@param self UIChatConfigInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +-- 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 + +--- Standalone entry point: shows the config dialog, building it on first open. +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 + Instance:Show() + return + end + + -- 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 + +--- Standalone entry point: tears down the dialog if it exists. +function Close() + if Instance then + Instance:Destroy() + Instance = nil + end +end + +--- Standalone entry point: flips visibility, building the dialog if needed. +function Toggle() + if Instance then + Close() + else + Open() + end +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- 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 + newModule.Open() + end +end + +--- Hot-reload hook: tears down the old instance and re-imports this module. +function __moduleinfo.OnDirty() + if Instance then + Instance:Destroy() + Instance = nil + end + + ForkThread( + function() + WaitFrames(2) + 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..79fb4aaee2b --- /dev/null +++ b/lua/ui/game/chat/config/ChatConfigModel.lua @@ -0,0 +1,146 @@ + +local Prefs = import("/lua/user/prefs.lua") +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 +---@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 send_type boolean # false = all, true = allies +---@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, + 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, + send_type = false, + links = true, + muted = {}, +} + + +------------------------------------------------------------------------------- +-- Option keys, exported so call sites address fields without magic strings. + +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' +KeySendType = 'send_type' +KeyLinks = 'links' +KeyMuted = 'muted' + +------------------------------------------------------------------------------- +-- 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 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 + +--- 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 {} + local committed = table.merged(DefaultOptions, saved) + committed.muted = {} + + ModelInstance = { + Committed = Create(committed), + Pending = Create(table.copy(committed)), + } + + return ModelInstance +end + +--- Returns the model singleton, creating it on first access. +---@return UIChatConfigModel +function GetSingleton() + return ModelInstance or SetupSingleton() +end + +--- 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 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 +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- 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 + local handle = newModule.SetupSingleton() + handle.Committed:Set(table.copy(ModelInstance.Committed())) + handle.Pending:Set(table.copy(ModelInstance.Pending())) + end +end + +--- Hot-reload hook: re-imports this module on save. +function __moduleinfo.OnDirty() + import(__moduleinfo.name) +end + +--#endregion diff --git a/lua/ui/game/gamemain.lua b/lua/ui/game/gamemain.lua index 84bb0c26fee..238471cf65b 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() @@ -298,8 +297,8 @@ 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) if import("/lua/ui/campaign/campaignmanager.lua").campaignMode then @@ -735,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 @@ -941,7 +941,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() @@ -1071,6 +1071,12 @@ end ---@param sender string # username ---@param data table function ReceiveChat(sender, data) + -- 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 + end + if data.Identifier then -- we highly encourage to use the 'Identifier' field to quickly identify the correct function @@ -1167,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/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/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 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.", 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") 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