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>
124 lines
5.1 KiB
C#
124 lines
5.1 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Phase J Tier 3: <see cref="ChatPanel.Render"/> must reserve footer
|
|
/// space for the separator + input field so the input stays anchored
|
|
/// at the bottom across window resizes (the user reported the input
|
|
/// disappearing when the window shrank). The pattern is the standard
|
|
/// ImGui chat-window layout: a scrollable child filling
|
|
/// <c>(0, -footerHeight)</c>, then the separator + input below it.
|
|
/// </summary>
|
|
public sealed class ChatPanelLayoutTests
|
|
{
|
|
private sealed class NoBus : ICommandBus
|
|
{
|
|
public void Publish<T>(T command) where T : notnull { /* no-op */ }
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_OrderIs_Begin_BeginChild_EndChild_Separator_InputTextSubmit_End()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("seed", chatType: 0);
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer();
|
|
|
|
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
|
|
|
|
var methods = renderer.Calls.Select(c => c.Method).ToList();
|
|
int beginIdx = methods.IndexOf("Begin");
|
|
int beginChildIdx = methods.IndexOf("BeginChild");
|
|
int endChildIdx = methods.IndexOf("EndChild");
|
|
// 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");
|
|
|
|
// All present
|
|
Assert.True(beginIdx >= 0, "Begin missing");
|
|
Assert.True(beginChildIdx >= 0, "BeginChild missing");
|
|
Assert.True(endChildIdx >= 0, "EndChild missing");
|
|
Assert.True(separatorIdx >= 0, "Separator missing");
|
|
Assert.True(inputSubmitIdx >= 0, "InputTextSubmit missing");
|
|
Assert.True(endIdx >= 0, "End missing");
|
|
|
|
// Order: Begin < BeginChild < EndChild < Separator < InputTextSubmit < End
|
|
Assert.True(beginIdx < beginChildIdx);
|
|
Assert.True(beginChildIdx < endChildIdx);
|
|
Assert.True(endChildIdx < separatorIdx);
|
|
Assert.True(separatorIdx < inputSubmitIdx);
|
|
Assert.True(inputSubmitIdx < endIdx);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_BeginChild_ReservesNegativeFooterFromFrameHeight()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer { FrameHeightWithSpacingValue = 24f };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
|
|
|
|
var beginChildCall = renderer.Calls.Single(c => c.Method == "BeginChild");
|
|
var size = (System.Numerics.Vector2)beginChildCall.Args[1]!;
|
|
// Width 0 = fill available; height < 0 = "fill minus this".
|
|
// Reserved height should equal FrameHeightWithSpacing + a small
|
|
// separator pad (~6f) so the input never visually clips the
|
|
// last chat line.
|
|
Assert.Equal(0f, size.X);
|
|
Assert.True(size.Y < 0, $"expected negative reserve, got {size.Y}");
|
|
Assert.True(size.Y <= -24f, $"expected at least -24f reserve, got {size.Y}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_NewEntries_ScrollsToBottom()
|
|
{
|
|
// First render establishes the baseline (no auto-scroll because
|
|
// _lastRenderedCount == lines.Count == 0). Then a second render
|
|
// after a new entry should fire SetScrollHereY(1.0f).
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer();
|
|
var ctx = new PanelContext(0.016f, new NoBus());
|
|
|
|
panel.Render(ctx, renderer);
|
|
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
|
|
|
|
// Append a new entry, render again — auto-scroll should fire.
|
|
log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false);
|
|
renderer.Calls.Clear();
|
|
panel.Render(ctx, renderer);
|
|
|
|
var scrollCall = renderer.Calls.Single(c => c.Method == "SetScrollHereY");
|
|
Assert.Equal(1.0f, (float)scrollCall.Args[0]!);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_NoNewEntries_DoesNotForceScroll()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("seed", chatType: 0);
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var renderer = new FakePanelRenderer();
|
|
var ctx = new PanelContext(0.016f, new NoBus());
|
|
|
|
// First render establishes count baseline (1 entry). The first
|
|
// render auto-scrolls because lines.Count (1) > _lastRenderedCount
|
|
// (0). Subsequent renders without new entries should NOT scroll.
|
|
panel.Render(ctx, renderer);
|
|
renderer.Calls.Clear();
|
|
panel.Render(ctx, renderer);
|
|
|
|
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
|
|
}
|
|
}
|