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:
parent
a316d6359c
commit
a44488e277
5 changed files with 240 additions and 17 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue