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>
This commit is contained in:
parent
fc1e1933aa
commit
4c75ced92b
6 changed files with 87 additions and 9 deletions
|
|
@ -262,4 +262,21 @@ public interface IPanelRenderer
|
||||||
|
|
||||||
/// <summary>Close the tab opened by <see cref="BeginTabItem"/>.</summary>
|
/// <summary>Close the tab opened by <see cref="BeginTabItem"/>.</summary>
|
||||||
void EndTabItem();
|
void EndTabItem();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render a read-only multi-line text region the user can
|
||||||
|
/// <b>select</b> with click+drag and copy with <c>Ctrl+C</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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The widget is sized to <paramref name="size"/>; pass
|
||||||
|
/// <c>(0, 0)</c> for "fill the current content region" semantics
|
||||||
|
/// (matches ImGui defaults). <paramref name="id"/> is the ImGui
|
||||||
|
/// stable identifier — typically <c>"##chatcopy"</c> or similar
|
||||||
|
/// hidden-label form.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
void TextMultilineReadOnly(string id, string content, Vector2 size);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Core.Chat;
|
using AcDream.Core.Chat;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
|
@ -50,6 +51,13 @@ public sealed class ChatPanel : IPanel
|
||||||
// click into another widget.
|
// click into another widget.
|
||||||
private bool _focusRequested;
|
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)
|
public ChatPanel(ChatVM vm)
|
||||||
{
|
{
|
||||||
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
|
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
|
||||||
|
|
@ -82,6 +90,17 @@ public sealed class ChatPanel : IPanel
|
||||||
return;
|
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
|
// Phase J Tier 3: keep the input field at the bottom of the
|
||||||
// window across resizes by reserving footer space and putting
|
// window across resizes by reserving footer space and putting
|
||||||
// the chat tail in a scrollable child that fills the rest.
|
// 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).
|
// the plain Text path (visually identical to the I.4 panel).
|
||||||
var lines = _vm.RecentLinesDetailed();
|
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)
|
if (lines.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -127,7 +160,7 @@ public sealed class ChatPanel : IPanel
|
||||||
}
|
}
|
||||||
_lastRenderedCount = lines.Count;
|
_lastRenderedCount = lines.Count;
|
||||||
}
|
}
|
||||||
renderer.EndChild();
|
if (!_copyMode) renderer.EndChild();
|
||||||
|
|
||||||
// Phase I.4: input field. Backend implementation clears _input
|
// Phase I.4: input field. Backend implementation clears _input
|
||||||
// on submit per the IPanelRenderer contract.
|
// on submit per the IPanelRenderer contract.
|
||||||
|
|
|
||||||
|
|
@ -207,4 +207,21 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
|
public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
|
||||||
|
|
||||||
|
// -- Selectable / copyable text ---------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -231,4 +231,7 @@ internal sealed class FakePanelRenderer : IPanelRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
public void EndTabItem() => Calls.Add(("EndTabItem", Array.Empty<object?>()));
|
public void EndTabItem() => Calls.Add(("EndTabItem", Array.Empty<object?>()));
|
||||||
|
|
||||||
|
public void TextMultilineReadOnly(string id, string content, Vector2 size)
|
||||||
|
=> Calls.Add(("TextMultilineReadOnly", new object?[] { id, content, size }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,11 @@ public sealed class ChatPanelLayoutTests
|
||||||
int beginIdx = methods.IndexOf("Begin");
|
int beginIdx = methods.IndexOf("Begin");
|
||||||
int beginChildIdx = methods.IndexOf("BeginChild");
|
int beginChildIdx = methods.IndexOf("BeginChild");
|
||||||
int endChildIdx = methods.IndexOf("EndChild");
|
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 inputSubmitIdx = methods.IndexOf("InputTextSubmit");
|
||||||
int endIdx = methods.IndexOf("End");
|
int endIdx = methods.IndexOf("End");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,16 @@ public sealed class DisplaySettingsTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Default_values_match_pre_L0_runtime_state()
|
public void Default_values_match_pre_L0_runtime_state()
|
||||||
{
|
{
|
||||||
// Defaults pinned to match the camera FovY (60° = π/3) and the
|
// Defaults pinned to match the actual pre-L.0 startup state:
|
||||||
// pre-L.0 window options (VSync off, FPS in title bar). Opening
|
// · Resolution matches WindowOptions (1280×720 in GameWindow.Run)
|
||||||
// Display + Save without touching anything must NOT change the
|
// · FieldOfView matches camera FovY (60° = π/3)
|
||||||
// user's visual experience.
|
// · 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;
|
var d = DisplaySettings.Default;
|
||||||
Assert.Equal("1920x1080", d.Resolution);
|
Assert.Equal("1280x720", d.Resolution);
|
||||||
Assert.False(d.Fullscreen);
|
Assert.False(d.Fullscreen);
|
||||||
Assert.False(d.VSync);
|
Assert.False(d.VSync);
|
||||||
Assert.Equal(60f, d.FieldOfView);
|
Assert.Equal(60f, d.FieldOfView);
|
||||||
|
|
@ -59,7 +63,7 @@ public sealed class DisplaySettingsTests
|
||||||
var d = DisplaySettings.Default with { FieldOfView = 90f };
|
var d = DisplaySettings.Default with { FieldOfView = 90f };
|
||||||
Assert.Equal(90f, d.FieldOfView);
|
Assert.Equal(90f, d.FieldOfView);
|
||||||
// Other fields untouched.
|
// Other fields untouched.
|
||||||
Assert.Equal("1920x1080", d.Resolution);
|
Assert.Equal("1280x720", d.Resolution);
|
||||||
Assert.False(d.VSync);
|
Assert.False(d.VSync);
|
||||||
Assert.True(d.ShowFps);
|
Assert.True(d.ShowFps);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue