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>
319 lines
13 KiB
C#
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 <msg></c> or no prefix → Say (default)</item>
|
|
/// <item><c>/t</c> / <c>/tell <name> <msg></c> → whisper</item>
|
|
/// <item><c>/r</c> / <c>/reply <msg></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 <msg></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.";
|
|
}
|