perf(card): variable-height clipper + layout-fingerprint cache invalidation + clip-plan self-test

This commit is contained in:
2026-06-16 20:09:45 +02:00
parent 1d69d0cc30
commit 93f4fbba72
3 changed files with 233 additions and 23 deletions
+133 -23
View File
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Linq;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility;
@@ -7,11 +8,10 @@ using HellionChat.Util;
namespace HellionChat.Ui.Components;
// Virtualised message list. Compact mode reuses ImGuiListClipper because
// rows have a constant line height; card mode falls back to a linear
// render with a per-message height cache and an IsItemVisible skip path
// so off-screen rows place a Dummy of the cached height rather than
// running the full render again.
// Virtualised message list. Compact mode uses ImGuiListClipper (constant row
// height); card mode rows wrap to arbitrary heights, so it runs its own
// prefix-sum clipper (CardClipPlanner) over a per-message height cache, dropped
// whenever the layout fingerprint (font/density/name-mode/width) changes.
internal sealed class MessageList
{
private const float CompactRowHeight = 18f;
@@ -27,6 +27,18 @@ internal sealed class MessageList
private bool _scrolledUp;
private bool _scrollToBottomRequested;
// B2: the height cache is only valid while these inputs are unchanged.
// FontManager's own fingerprint covers font sizes only, not density / the two
// name-display modes / width — a stale height would misplace the clipper dummies.
private (
float Global,
float Symbols,
bool Compact,
int NameForm,
int WorldSuffix,
float Width
) _lastLayoutFingerprint;
// §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle.
// Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist.
internal void AttachPayloadHandler(PayloadHandler handler)
@@ -54,6 +66,55 @@ internal sealed class MessageList
// post-snap reset can be asserted; without it only the OR branch is testable.
internal void RequestScrollToBottomForSelfTest() => _scrollToBottomRequested = true;
// SelfTest hook (B2): runs the real planner against a caller fixture so the
// step asserts the plan without a live scroll child (GetScrollY is garbage headless).
internal CardClipPlan PlanCardClipForSelfTest(
IReadOnlyList<float> heights,
float scrollY,
float viewportHeight
) => CardClipPlanner.Plan(heights, scrollY, viewportHeight);
// SelfTest hook (B2): drives the live invalidation, returns the tab's remaining
// cached-height count so the step can assert the drop.
internal int RunHeightCacheInvalidationForSelfTest(Tab tab, float contentWidth)
{
InvalidateHeightCacheIfLayoutChanged(tab, contentWidth);
using var messages = tab.Messages.GetReadOnly(3);
return messages.Count(m => m.Height.ContainsKey(tab.Identifier));
}
// Width is passed in (ContentRegionAvail is only valid inside the draw child);
// enum modes widened to int so the tuple stays comparable.
private (float, float, bool, int, int, float) BuildLayoutFingerprint(float contentWidth)
{
var (global, symbols) = _fonts.EffectiveFontFingerprint();
return (
global,
symbols,
Plugin.Config.UseCompactDensity,
(int)Plugin.Config.NameFormMode,
(int)Plugin.Config.WorldSuffixMode,
contentWidth
);
}
// Drop the tab's cached heights when the layout fingerprint changed — one
// tuple compare per frame, a clear only on a real settings/resize change.
private void InvalidateHeightCacheIfLayoutChanged(Tab tab, float contentWidth)
{
var fingerprint = BuildLayoutFingerprint(contentWidth);
if (fingerprint.Equals(_lastLayoutFingerprint))
return;
_lastLayoutFingerprint = fingerprint;
using var messages = tab.Messages.GetReadOnly(3);
foreach (var msg in messages)
{
msg.Height.Remove(tab.Identifier);
msg.IsVisible.Remove(tab.Identifier);
}
}
public void Draw(Tab tab)
{
if (!_fonts.FontsReady)
@@ -65,9 +126,15 @@ internal sealed class MessageList
// No own ImRaii.Child here — MainWindow already wraps the message
// area in one. Nesting would give the window two stacked scrolls
// and a runaway content-height computation.
using var messages = tab.Messages.GetReadOnly(3);
var compact = Plugin.Config.UseCompactDensity;
// B2: drop stale cached heights before the snapshot draw (card path only —
// compact rows are constant height). Width read here while it is valid.
if (!compact)
InvalidateHeightCacheIfLayoutChanged(tab, ImGui.GetContentRegionAvail().X);
using var messages = tab.Messages.GetReadOnly(3);
// Track whether the user was pinned to the bottom before this frame
// so newly arriving rows do not yank them up. The check runs against
// the parent child's scroll state, which is the one MainWindow owns.
@@ -192,29 +259,72 @@ internal sealed class MessageList
private void DrawCard(Tab tab, IReadOnlyList<Message> messages)
{
var tabId = tab.Identifier;
for (var i = 0; i < messages.Count; i++)
var count = messages.Count;
if (count == 0)
return;
// A row with no cached height yet can't be planned (first frame / post-
// invalidation), so draw everything once to fill the cache, plan next frame.
var heights = new float[count];
var allCached = true;
for (var i = 0; i < count; i++)
{
if (messages[i].Height.TryGetValue(tabId, out var cached) && cached is float h)
heights[i] = h;
else
allCached = false;
}
if (!allCached)
{
DrawCardLinearAndMeasure(tabId, messages);
return;
}
var plan = CardClipPlanner.Plan(heights, ImGui.GetScrollY(), ImGui.GetWindowSize().Y);
// Cached heights already include one trailing ItemSpacing.y; a dummy adds its
// own, which would shove the first visible row down one spacing (jitter).
// Subtract one spacing per dummy so offset AND total content height stay exact.
var spacingY = ImGui.GetStyle().ItemSpacing.Y;
float CompensatedDummy(float planned) => Math.Max(0f, planned - spacingY);
if (plan.FirstVisible < 0)
{
// Scrolled into a gap: one full-height dummy keeps the scrollbar honest.
var gap = CompensatedDummy(plan.LeadDummyHeight + plan.EndDummyHeight);
ImGui.Dummy(new Vector2(10f, gap));
return;
}
if (plan.LeadDummyHeight > 0f)
ImGui.Dummy(new Vector2(10f, CompensatedDummy(plan.LeadDummyHeight)));
for (var i = plan.FirstVisible; i <= plan.LastVisible; i++)
{
var msg = messages[i];
// Cached row: place a Dummy of the known height and skip the
// full render path if the row is off-screen. Mirrors the
// v1.5.6 Card-Mode pattern in ChatLogWindow.DrawMessages.
msg.Height.TryGetValue(tabId, out var cachedHeight);
if (cachedHeight is float h)
{
var beforeDummy = ImGui.GetCursorPos();
ImGui.Dummy(new Vector2(10f, h));
var visible = ImGui.IsItemVisible();
msg.IsVisible[tabId] = visible;
if (!visible)
continue;
ImGui.SetCursorPos(beforeDummy);
}
var before = ImGui.GetCursorPosY();
DrawCardRow(msg);
var after = ImGui.GetCursorPosY();
msg.Height[tabId] = after - before;
msg.IsVisible[tabId] = true;
}
if (plan.EndDummyHeight > 0f)
ImGui.Dummy(new Vector2(10f, CompensatedDummy(plan.EndDummyHeight)));
}
// First-frame / post-invalidation fallback: draw + measure every row into the
// cache so the next frame can take the planned path.
private void DrawCardLinearAndMeasure(Guid tabId, IReadOnlyList<Message> messages)
{
foreach (var msg in messages)
{
var before = ImGui.GetCursorPosY();
DrawCardRow(msg);
var after = ImGui.GetCursorPosY();
msg.Height[tabId] = after - before;
msg.IsVisible[tabId] = ImGui.IsItemVisible();
}
}