acdream/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
Erik 4c75ced92b feat(ui): chat Copy mode — select + Ctrl+C any text in the chat tail
User reported wanting to mark text in-game and copy it out (item names,
coordinates, NPC dialogue, etc). ImGui doesn't natively let you select
across multiple TextColored widgets, but a read-only multi-line
InputText is fully click-drag selectable + Ctrl+C copyable. This
commit adds a "Copy mode" toggle to ChatPanel that swaps the chat
tail's render path between the colored-line view and a single
selectable text region.

New IPanelRenderer primitive:

  void TextMultilineReadOnly(string id, string content, Vector2 size);

ImGui maps this to InputTextMultiline with the ReadOnly flag — same
selection + Ctrl+C UX a user expects from any text-input widget.
FakePanelRenderer records the call for tests. The future D.2b
custom retail-look backend implements its own equivalent (likely
the same widget pattern with retail font/skin).

ChatPanel rendering:

  · A "Copy mode (select text to Ctrl+C)" Checkbox at the top of
    the panel toggles _copyMode.
  · Off (default) — current per-line render with colored combat
    entries. Visually unchanged from before.
  · On — the chat tail becomes a single TextMultilineReadOnly
    widget holding every visible line joined with newlines. Loses
    per-line color, gains arbitrary-span text selection.
  · Footer (separator + input field) renders identically in both
    modes so the user can still type while in copy mode.

Existing ChatPanelLayoutTests's footer-separator probe was using
IndexOf("Separator") — which now matches the new pre-tail separator
between the Checkbox and the chat tail. Switched to LastIndexOf
which still pins the footer separator (between EndChild and
InputTextSubmit). Behaviour and intent unchanged.

DisplaySettingsTests' With_expression test was still asserting the
old "1920x1080" Default.Resolution; updated to the new "1280x720"
that the previous wire-up commit introduced (the earlier commit
forgot this one).

dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:45:39 +02:00

319 lines
13 KiB
C#

using System.Linq;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>
/// The chat panel. Shows the tail of <see cref="ChatLog"/> + an input
/// field at the bottom that submits on Enter.
///
/// <para>
/// Phase I.4 added the input field and slash-command parsing. Supported
/// prefixes (alias-matched against the verb token, not by string-prefix
/// — so <c>/general</c> is NOT <c>/g</c>):
/// <list type="bullet">
/// <item><c>/say &lt;msg&gt;</c> or no prefix → Say (default)</item>
/// <item><c>/t</c> / <c>/tell &lt;name&gt; &lt;msg&gt;</c> → whisper</item>
/// <item><c>/r</c> / <c>/reply &lt;msg&gt;</c> → reply to most recent
/// INCOMING Tell (uses <see cref="ChatVM.LastIncomingTellSender"/>;
/// drops the message if no Tell has arrived yet)</item>
/// <item><c>/g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society,
/// /olthoi &lt;msg&gt;</c> → corresponding channel</item>
/// <item>unknown <c>/xyz hello</c> → Say with the literal text intact
/// (matches holtburger fall-through)</item>
/// </list>
/// </para>
///
/// <para>
/// Empty / whitespace-only / target-but-no-message inputs are silently
/// dropped — the input field clears and no command goes out.
/// </para>
/// </summary>
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));
}
/// <inheritdoc />
public string Id => "acdream.chat";
/// <inheritdoc />
public string Title => "Chat";
/// <inheritdoc />
public bool IsVisible { get; set; } = true;
/// <summary>
/// Phase K.2: request keyboard focus for the chat input on the
/// NEXT <see cref="Render"/>. One-shot — fires once and resets,
/// so callers (e.g. <c>GameWindow</c>'s Tab handler subscribing to
/// <c>ToggleChatEntry</c>) can drive it on a single key press
/// without trapping the user permanently in the input field.
/// </summary>
public void FocusInput() => _focusRequested = true;
/// <inheritdoc />
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();
}
/// <summary>
/// Phase I.7: per-severity color for combat-feedback chat lines.
/// Maps onto holtburger's <c>color_for_tags</c> at chat.rs:330-333
/// (info → yellowish, warning → red incoming, error → deep red).
/// </summary>
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),
};
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Recognised client-side commands:
/// <list type="bullet">
/// <item><c>/help</c>, <c>/?</c>, <c>/h</c> — 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.</item>
/// <item><c>/clear</c>, <c>/cls</c> — drain the chat log so the
/// panel starts empty.</item>
/// </list>
/// </remarks>
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;
}
/// <summary>Case-insensitive multi-string equality test.</summary>
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;
}
/// <summary>
/// Multi-line cheat-sheet text rendered by <c>/help</c>. ImGui's
/// <c>Text</c> path flows embedded newlines naturally so this lands
/// as one ChatLog entry that visually wraps to several lines.
/// </summary>
private static string BuildHelpText() =>
"Note: / and @ are equivalent prefixes.\n" +
"Chat: /say (default), /tell <name>, /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.";
}