using System.Linq; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; namespace AcDream.UI.Abstractions.Panels.Chat; /// /// The chat panel. Shows the tail of + an input /// field at the bottom that submits on Enter. /// /// /// Phase I.4 added the input field and slash-command parsing. Supported /// prefixes (alias-matched against the verb token, not by string-prefix /// — so /general is NOT /g): /// /// /say <msg> or no prefix → Say (default) /// /t / /tell <name> <msg> → whisper /// /r / /reply <msg> → reply to most recent /// INCOMING Tell (uses ; /// drops the message if no Tell has arrived yet) /// /g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society, /// /olthoi <msg> → corresponding channel /// unknown /xyz hello → Say with the literal text intact /// (matches holtburger fall-through) /// /// /// /// /// Empty / whitespace-only / target-but-no-message inputs are silently /// dropped — the input field clears and no command goes out. /// /// public sealed class ChatPanel : IPanel { private const int InputBufferMaxLen = 512; private readonly ChatVM _vm; private string _input = string.Empty; // Phase J Tier 3: tracks the chat-tail size between frames so we // can auto-scroll the scrollable child to the bottom on new // entries without yanking the user's manual scroll. private int _lastRenderedCount; // Phase K.2: one-shot focus request for the chat input. Set by // FocusInput() (driven by Tab → ToggleChatEntry); the next Render // call emits SetKeyboardFocusHere immediately before the input // field and clears the flag. Without the one-shot semantics, the // panel would steal focus on every frame and the user could never // click into another widget. private bool _focusRequested; // L.0 follow-up: "Copy mode" — when true, render the chat tail as // a read-only multi-line text widget the user can click+drag to // select + Ctrl+C to copy. Trades per-line color for selectability; // user toggles when they want to grab specific text out of the // log (item names, coordinates, NPC dialogue, etc). private bool _copyMode; public ChatPanel(ChatVM vm) { _vm = vm ?? throw new ArgumentNullException(nameof(vm)); } /// public string Id => "acdream.chat"; /// public string Title => "Chat"; /// public bool IsVisible { get; set; } = true; /// /// Phase K.2: request keyboard focus for the chat input on the /// NEXT . One-shot — fires once and resets, /// so callers (e.g. GameWindow's Tab handler subscribing to /// ToggleChatEntry) can drive it on a single key press /// without trapping the user permanently in the input field. /// public void FocusInput() => _focusRequested = true; /// public void Render(PanelContext ctx, IPanelRenderer renderer) { if (!renderer.Begin(Title)) { renderer.End(); return; } // L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the // chat tail rendering swaps to TextMultilineReadOnly so the // user can mark + Ctrl+C any text. Off (default) preserves the // colored per-line render with combat highlights. The checkbox // sits ABOVE the chat tail (not in the footer) so it's always // visible regardless of scroll position. bool copyMode = _copyMode; if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode)) _copyMode = copyMode; renderer.Separator(); // Phase J Tier 3: keep the input field at the bottom of the // window across resizes by reserving footer space and putting // the chat tail in a scrollable child that fills the rest. // The reserved footer holds: one Separator + one InputText. // FrameHeightWithSpacing covers the input; we add a small fudge // (~6px) for the separator above it. float footerHeight = renderer.FrameHeightWithSpacing() + 6f; // Phase I.7: pull the typed-line view so combat entries can // route through TextColored. Non-combat entries still take // the plain Text path (visually identical to the I.4 panel). var lines = _vm.RecentLinesDetailed(); if (_copyMode) { // Copy mode: one big read-only multiline text widget // holding every visible line, joined with newlines. Loses // per-line color but lets the user click+drag to select // arbitrary spans of text + Ctrl+C to copy. Sized to fill // the available space minus the footer. string joined = lines.Count == 0 ? "(no messages yet)" : string.Join("\n", lines.Select(l => l.Text)); renderer.TextMultilineReadOnly( "##chattailcopy", joined, new System.Numerics.Vector2(0f, -footerHeight)); } else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) { if (lines.Count == 0) { renderer.Text("(no messages yet)"); } else { for (int i = 0; i < lines.Count; i++) { var line = lines[i]; if (line.Kind == ChatKind.Combat && line.CombatKind is { } ck) { renderer.TextColored(ColorForCombat(ck), line.Text); } else { renderer.Text(line.Text); } } } // Auto-scroll to bottom only when a new line was appended // since the last render. Manual user scroll-up isn't fought // against; new messages will jump the view back down once // they arrive. if (lines.Count > _lastRenderedCount) { renderer.SetScrollHereY(1.0f); } _lastRenderedCount = lines.Count; } if (!_copyMode) renderer.EndChild(); // Phase I.4: input field. Backend implementation clears _input // on submit per the IPanelRenderer contract. renderer.Separator(); // Phase K.2: honor a pending FocusInput() request — emit // SetKeyboardFocusHere immediately before the input widget so // ImGui (or the future custom backend) applies it to that // field. One-shot: clear the flag after firing. if (_focusRequested) { renderer.SetKeyboardFocusHere(); _focusRequested = false; } if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { var trimmed = submitted.Trim(); // Phase J follow-up: client-side commands intercepted before // the server-bound parse path. Avoids the /help round-trip // that produced "Unknown command: help" duplicates from // ACE's command-error replies, AND gives users a discoverable // local cheat-sheet of acdream's own slash prefixes. if (TryHandleClientCommand(trimmed)) { _input = string.Empty; renderer.End(); return; } // Phase J Tier 4: any /-prefixed input that ISN'T one of our // known verbs gets a local "Unknown command" message instead // of being broadcast to the server as plain speech. The // user reported "/ls" / "/mp /path" leaking out as chat — // a / prefix is a command, never speech. (@-prefixed unknown // verbs still pass through to ACE because ACE's // CommandManager intercepts @ server-side and replies with // its own "Unknown command" / valid command output.) if (trimmed.Length > 0 && trimmed[0] == '/') { string verb = ChatInputParser.GetVerbToken(trimmed); if (!ChatInputParser.IsKnownVerb(verb)) { _vm.ShowSystemMessage( $"Unknown command: {verb}. Type /help for the list of supported commands."); _input = string.Empty; renderer.End(); return; } } var parsed = ChatInputParser.Parse( trimmed, ChatChannelKind.Say, _vm.LastIncomingTellSender, _vm.LastOutgoingTellTarget); if (parsed is { } p) { ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); } // Defensive: if the backend ever forgot to clear on submit, // do it here. Cheap; no harm if already empty. _input = string.Empty; } renderer.End(); } /// /// Phase I.7: per-severity color for combat-feedback chat lines. /// Maps onto holtburger's color_for_tags at chat.rs:330-333 /// (info → yellowish, warning → red incoming, error → deep red). /// public static Vector4 ColorForCombat(CombatLineKind kind) => kind switch { CombatLineKind.Info => new Vector4(1.0f, 1.0f, 0.6f, 1.0f), CombatLineKind.Warning => new Vector4(1.0f, 0.5f, 0.5f, 1.0f), CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f), _ => new Vector4(1f, 1f, 1f, 1f), }; /// /// Phase J follow-up: handle client-side slash commands before /// the parser passes anything to the server bus. Returns true /// when the input was consumed (and the caller should clear the /// buffer + skip the SendChatCmd path); false otherwise. /// /// /// Recognised client-side commands: /// /// /help, /?, /h — render the slash-prefix /// cheat-sheet locally. Avoids the server's "Unknown command" /// round-trip when the user just wants to know what they can /// type. /// /clear, /cls — drain the chat log so the /// panel starts empty. /// /// private bool TryHandleClientCommand(string trimmed) { if (trimmed.Length == 0) return false; // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence. if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) { _vm.ShowSystemMessage(BuildHelpText()); return true; } // /clear, /cls — also @clear, @cls. if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) { _vm.Clear(); return true; } // /framerate — also @framerate. Prints current FPS to chat. if (EqAny(trimmed, "/framerate", "@framerate")) { _vm.ShowFps(); return true; } // /loc — also @loc. Prints current player position to chat. // ACE has a server-side @loc too; client-side wins here // (instantaneous + uses our local interpolated position). if (EqAny(trimmed, "/loc", "@loc")) { _vm.ShowLocation(); return true; } return false; } /// Case-insensitive multi-string equality test. private static bool EqAny(string s, params string[] options) { for (int i = 0; i < options.Length; i++) if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; return false; } /// /// Multi-line cheat-sheet text rendered by /help. ImGui's /// Text path flows embedded newlines naturally so this lands /// as one ChatLog entry that visually wraps to several lines. /// private static string BuildHelpText() => "Note: / and @ are equivalent prefixes.\n" + "Chat: /say (default), /tell , /reply, /retell\n" + "Channels: /general /trade /fellowship /allegiance\n" + " /patron /vassals /monarch /covassals\n" + " /lfg /roleplay /society /olthoi\n" + "Client: /help (this) /clear /framerate /loc\n" + "Server: type @acehelp or @acecommands for ACE's full list."; }