diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 91cc42f..0c7c311 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -162,4 +162,40 @@ public interface IPanelRenderer /// Use for descriptions, error messages, anything multi-line. /// void TextWrapped(string text); + + // -- Phase J Tier 3 — scrollable child region for chat-style layouts -- + + /// + /// Open a scrollable nested region inside the current window. + /// follows ImGui semantics: 0 means "fill + /// available", a positive value is absolute pixels, a negative + /// value means "fill available minus this amount". Pass + /// (0, -footerHeight) to reserve space at the bottom for + /// fixed elements (separator + input field) so the inner content + /// scrolls but the footer stays put across window resizes. + /// + bool BeginChild(string id, Vector2 size, bool border = false); + + /// Close the most recent . + void EndChild(); + + /// + /// Approximate single-line widget height including ImGui's frame + /// padding + item spacing. Panels use this to compute footer + /// reservations for (e.g. one input + /// field + spacing). Backend-friendly because it returns a + /// scalar that the custom retail-look toolkit can also expose. + /// + float FrameHeightWithSpacing(); + + /// + /// Scroll the current scroll region so that the given fractional + /// vertical position is visible, where 0 = top and 1 = bottom. + /// Typical usage: call with 1.0f at the bottom of a + /// chat-tail render block to keep the latest line visible when + /// new entries arrive. No-op when no new content was emitted — + /// callers are expected to skip the call when they don't want + /// to force a scroll. + /// + void SetScrollHereY(float ratio); } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index b54621f..c60524b 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -37,6 +37,11 @@ public sealed class ChatPanel : IPanel 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; + public ChatPanel(ChatVM vm) { _vm = vm ?? throw new ArgumentNullException(nameof(vm)); @@ -60,33 +65,52 @@ public sealed class ChatPanel : IPanel return; } + // 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 (lines.Count == 0) + + if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) { - renderer.Text("(no messages yet)"); - } - else - { - // Header separator so the reader always sees the tail start; - // the IPanel contract promises pressing Begin opens the window - // at a stable anchor. - renderer.Separator(); - for (int i = 0; i < lines.Count; i++) + if (lines.Count == 0) { - var line = lines[i]; - if (line.Kind == ChatKind.Combat && line.CombatKind is { } ck) + renderer.Text("(no messages yet)"); + } + else + { + for (int i = 0; i < lines.Count; i++) { - renderer.TextColored(ColorForCombat(ck), line.Text); - } - else - { - renderer.Text(line.Text); + 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; } + 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 e39cc43..671e7b2 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -150,4 +150,24 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer /// public void TextWrapped(string text) => ImGuiNET.ImGui.TextWrapped(text); + + // -- Phase J Tier 3 — scrollable child for chat-style layouts --------- + + /// + public bool BeginChild(string id, Vector2 size, bool border = false) + // ImGuiChildFlags has changed names across ImGui.NET versions + // (Border vs Borders); 0x01 is the stable bit value for "draw + // a border". Casting from a numeric literal sidesteps the + // version-skew without requiring a hard reference to either + // enum spelling. + => ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0)); + + /// + public void EndChild() => ImGuiNET.ImGui.EndChild(); + + /// + public float FrameHeightWithSpacing() => ImGuiNET.ImGui.GetFrameHeightWithSpacing(); + + /// + public void SetScrollHereY(float ratio) => ImGuiNET.ImGui.SetScrollHereY(ratio); } diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs index 70fcfc3..5c67f6d 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -42,6 +42,12 @@ internal sealed class FakePanelRenderer : IPanelRenderer /// Pre-set return for . public bool BeginTableNextReturn { get; set; } = true; + /// Pre-set return for . + public bool BeginChildNextReturn { get; set; } = true; + + /// Pre-set return value for . + public float FrameHeightWithSpacingValue { get; set; } = 24f; + /// Pre-set "submitted" string for the next ; null = no submit this frame. public string? InputTextSubmitNextSubmitted { get; set; } public string? InputTextSubmitNextBufferAfter { get; set; } @@ -139,4 +145,21 @@ internal sealed class FakePanelRenderer : IPanelRenderer public void Dummy(Vector2 size) => Calls.Add(("Dummy", new object?[] { size })); public void TextWrapped(string text) => Calls.Add(("TextWrapped", new object?[] { text })); + + public bool BeginChild(string id, Vector2 size, bool border = false) + { + Calls.Add(("BeginChild", new object?[] { id, size, border })); + return BeginChildNextReturn; + } + + public void EndChild() => Calls.Add(("EndChild", Array.Empty())); + + public float FrameHeightWithSpacing() + { + Calls.Add(("FrameHeightWithSpacing", Array.Empty())); + return FrameHeightWithSpacingValue; + } + + public void SetScrollHereY(float ratio) + => Calls.Add(("SetScrollHereY", new object?[] { ratio })); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs new file mode 100644 index 0000000..9c2a04a --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs @@ -0,0 +1,120 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Phase J Tier 3: 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 +/// (0, -footerHeight), then the separator + input below it. +/// +public sealed class ChatPanelLayoutTests +{ + private sealed class NoBus : ICommandBus + { + public void Publish(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"); + int separatorIdx = methods.IndexOf("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"); + } +}