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