From 4c75ced92bf6bf70e0d364e9aed53608f4bad1dd Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 21:45:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20chat=20Copy=20mode=20=E2=80=94=20se?= =?UTF-8?q?lect=20+=20Ctrl+C=20any=20text=20in=20the=20chat=20tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.UI.Abstractions/IPanelRenderer.cs | 17 +++++++++ .../Panels/Chat/ChatPanel.cs | 37 ++++++++++++++++++- src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 17 +++++++++ .../FakePanelRenderer.cs | 3 ++ .../Panels/Chat/ChatPanelLayoutTests.cs | 6 ++- .../Panels/Settings/DisplaySettingsTests.cs | 16 +++++--- 6 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 3656d75..70696a2 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -262,4 +262,21 @@ public interface IPanelRenderer /// Close the tab opened by . void EndTabItem(); + + /// + /// Render a read-only multi-line text region the user can + /// select with click+drag and copy with Ctrl+C. + /// Matches the typical "click into a textbox to grab text" UX — + /// chat panels, log viewers, etc. use this to make text + /// extractable without the user having to alt-tab + retype. + /// + /// + /// The widget is sized to ; pass + /// (0, 0) for "fill the current content region" semantics + /// (matches ImGui defaults). is the ImGui + /// stable identifier — typically "##chatcopy" or similar + /// hidden-label form. + /// + /// + void TextMultilineReadOnly(string id, string content, Vector2 size); } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index f3a4a07..28e035b 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; @@ -50,6 +51,13 @@ public sealed class ChatPanel : IPanel // 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)); @@ -82,6 +90,17 @@ public sealed class ChatPanel : IPanel 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. @@ -95,7 +114,21 @@ public sealed class ChatPanel : IPanel // the plain Text path (visually identical to the I.4 panel). var lines = _vm.RecentLinesDetailed(); - if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) + 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) { @@ -127,7 +160,7 @@ public sealed class ChatPanel : IPanel } _lastRenderedCount = lines.Count; } - renderer.EndChild(); + if (!_copyMode) renderer.EndChild(); // Phase I.4: input field. Backend implementation clears _input // on submit per the IPanelRenderer contract. diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index d5eb978..4396874 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -207,4 +207,21 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer /// public void EndTabItem() => ImGuiNET.ImGui.EndTabItem(); + + // -- Selectable / copyable text --------------------------------------- + + /// + public void TextMultilineReadOnly(string id, string content, Vector2 size) + { + // ImGui's InputTextMultiline takes a `ref string` even with the + // ReadOnly flag — we just hand it a local copy. maxLength caps + // what the user could type if ReadOnly were ever cleared; we + // size it to the current content (+1 for ImGui's internal NUL + // terminator in some bindings). Min of 1 keeps the empty case + // from confusing native bindings. + string buffer = content; + uint maxLen = (uint)System.Math.Max(content.Length + 1, 1); + ImGuiNET.ImGui.InputTextMultiline(id, ref buffer, maxLen, size, + ImGuiInputTextFlags.ReadOnly); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs index 3706188..9df8b12 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -231,4 +231,7 @@ internal sealed class FakePanelRenderer : IPanelRenderer } public void EndTabItem() => Calls.Add(("EndTabItem", Array.Empty())); + + public void TextMultilineReadOnly(string id, string content, Vector2 size) + => Calls.Add(("TextMultilineReadOnly", new object?[] { id, content, size })); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs index 9c2a04a..0e80233 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs @@ -33,7 +33,11 @@ public sealed class ChatPanelLayoutTests int beginIdx = methods.IndexOf("Begin"); int beginChildIdx = methods.IndexOf("BeginChild"); int endChildIdx = methods.IndexOf("EndChild"); - int separatorIdx = methods.IndexOf("Separator"); + // L.0 follow-up: Copy-mode toggle adds a Separator above the + // chat tail, so multiple Separators now exist. The footer + // separator (the one we care about for input layout) is the + // LAST one — between EndChild and the input field. + int separatorIdx = methods.LastIndexOf("Separator"); int inputSubmitIdx = methods.IndexOf("InputTextSubmit"); int endIdx = methods.IndexOf("End"); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs index 9e4e13d..90b1bb2 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -13,12 +13,16 @@ public sealed class DisplaySettingsTests [Fact] public void Default_values_match_pre_L0_runtime_state() { - // Defaults pinned to match the camera FovY (60° = π/3) and the - // pre-L.0 window options (VSync off, FPS in title bar). Opening - // Display + Save without touching anything must NOT change the - // user's visual experience. + // Defaults pinned to match the actual pre-L.0 startup state: + // · Resolution matches WindowOptions (1280×720 in GameWindow.Run) + // · FieldOfView matches camera FovY (60° = π/3) + // · VSync matches WindowOptions (false during dev) + // · ShowFps true preserves the perf string in the title bar + // Net effect: opening Display + Save with no edits is a visual + // no-op (no window resize, no camera FovY change, no title + // bar change). var d = DisplaySettings.Default; - Assert.Equal("1920x1080", d.Resolution); + Assert.Equal("1280x720", d.Resolution); Assert.False(d.Fullscreen); Assert.False(d.VSync); Assert.Equal(60f, d.FieldOfView); @@ -59,7 +63,7 @@ public sealed class DisplaySettingsTests var d = DisplaySettings.Default with { FieldOfView = 90f }; Assert.Equal(90f, d.FieldOfView); // Other fields untouched. - Assert.Equal("1920x1080", d.Resolution); + Assert.Equal("1280x720", d.Resolution); Assert.False(d.VSync); Assert.True(d.ShowFps); }