fix(ui): chat input pinned to window bottom on resize via scrollable child

User reported the chat input field disappearing when the chat
window was resized smaller — older entries pushed it past the
visible area. Standard ImGui chat-window pattern fixes it: scrollable
nested region for the chat tail, fixed footer for the
separator + input field below it.

IPanelRenderer extensions (Phase J Tier 3):
- BeginChild(string id, Vector2 size, bool border = false) — opens
  a nested scrollable region. Size follows ImGui semantics:
  0 = fill available, negative = fill available minus this much.
- EndChild() — closes the nested region.
- FrameHeightWithSpacing() — single-line widget height incl. frame
  padding + item spacing. Lets panels compute footer reservations
  without hardcoding pixel constants.
- SetScrollHereY(float ratio) — forces scroll within current region;
  pass 1.0f to keep the latest line visible after new entries
  arrive.

ImGuiPanelRenderer impls. ImGui.NET's BeginChild signature changed
across versions (third arg moved from `bool border` to
`ImGuiChildFlags`); we cast a numeric literal (0x01 = Border bit)
to sidestep the rename. FrameHeightWithSpacing maps to
ImGui.GetFrameHeightWithSpacing(); SetScrollHereY to ImGui.SetScrollHereY.

ChatPanel restructured:
- Reserves footer height = FrameHeightWithSpacing() + 6f (small pad
  for the separator above the input).
- Wraps the chat tail in BeginChild("##chattail", (0, -footer))
  so the inner region scrolls independently of the window.
- Tracks _lastRenderedCount across frames and calls SetScrollHereY(1f)
  only when new entries appended — manual scroll-up isn't fought
  against; new messages jump the view back down only when they
  actually arrive.
- Header Separator removed (the BeginChild border is enough).

FakePanelRenderer extended with the four new methods + recording.
4 new tests in ChatPanelLayoutTests pin the layout invariants:
- Render order: Begin → BeginChild → ... → EndChild → Separator
  → InputTextSubmit → End.
- BeginChild size has X=0 + negative Y at least matching the
  injected FrameHeightWithSpacingValue.
- SetScrollHereY fires when entries grow.
- SetScrollHereY does NOT fire when entries don't grow.

Solution total: 1067 green (243 Core.Net + 164 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 21:44:10 +02:00
parent a316d6359c
commit a44488e277
5 changed files with 240 additions and 17 deletions

View file

@ -42,6 +42,12 @@ internal sealed class FakePanelRenderer : IPanelRenderer
/// <summary>Pre-set return for <see cref="BeginTable"/>.</summary>
public bool BeginTableNextReturn { get; set; } = true;
/// <summary>Pre-set return for <see cref="BeginChild"/>.</summary>
public bool BeginChildNextReturn { get; set; } = true;
/// <summary>Pre-set return value for <see cref="FrameHeightWithSpacing"/>.</summary>
public float FrameHeightWithSpacingValue { get; set; } = 24f;
/// <summary>Pre-set "submitted" string for the next <see cref="InputTextSubmit"/>; null = no submit this frame.</summary>
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<object?>()));
public float FrameHeightWithSpacing()
{
Calls.Add(("FrameHeightWithSpacing", Array.Empty<object?>()));
return FrameHeightWithSpacingValue;
}
public void SetScrollHereY(float ratio)
=> Calls.Add(("SetScrollHereY", new object?[] { ratio }));
}

View file

@ -0,0 +1,120 @@
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");
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");
}
}