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

@ -162,4 +162,40 @@ public interface IPanelRenderer
/// Use for descriptions, error messages, anything multi-line.
/// </summary>
void TextWrapped(string text);
// -- Phase J Tier 3 — scrollable child region for chat-style layouts --
/// <summary>
/// Open a scrollable nested region inside the current window.
/// <paramref name="size"/> follows ImGui semantics: 0 means "fill
/// available", a positive value is absolute pixels, a negative
/// value means "fill available minus this amount". Pass
/// <c>(0, -footerHeight)</c> 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.
/// </summary>
bool BeginChild(string id, Vector2 size, bool border = false);
/// <summary>Close the most recent <see cref="BeginChild"/>.</summary>
void EndChild();
/// <summary>
/// Approximate single-line widget height including ImGui's frame
/// padding + item spacing. Panels use this to compute footer
/// reservations for <see cref="BeginChild"/> (e.g. one input
/// field + spacing). Backend-friendly because it returns a
/// scalar that the custom retail-look toolkit can also expose.
/// </summary>
float FrameHeightWithSpacing();
/// <summary>
/// Scroll the current scroll region so that the given fractional
/// vertical position is visible, where 0 = top and 1 = bottom.
/// Typical usage: call with <c>1.0f</c> 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.
/// </summary>
void SetScrollHereY(float ratio);
}

View file

@ -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.

View file

@ -150,4 +150,24 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
/// <inheritdoc />
public void TextWrapped(string text) => ImGuiNET.ImGui.TextWrapped(text);
// -- Phase J Tier 3 — scrollable child for chat-style layouts ---------
/// <inheritdoc />
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));
/// <inheritdoc />
public void EndChild() => ImGuiNET.ImGui.EndChild();
/// <inheritdoc />
public float FrameHeightWithSpacing() => ImGuiNET.ImGui.GetFrameHeightWithSpacing();
/// <inheritdoc />
public void SetScrollHereY(float ratio) => ImGuiNET.ImGui.SetScrollHereY(ratio);
}