fix(messages): invalidate the height cache when UI scale changes
The layout fingerprint tracked font size, density, both name modes and content width, but not ImGuiHelpers.GlobalScale. Scale feeds CalcWordWrapPositionA, so changing it rewraps every row while the cached heights stay put and the clipper dummies drift against the scrollbar. Two further problems came out of the same code: The fingerprint lived in a single field on MessageList while the cache it guards is per tab. Resizing in tab A marked the new value applied, so tab B kept measuring against the old width. It is now a gate per tab identifier. Acting on every fingerprint change is too eager. A window resize or a drag on the Dalamud UI-scale slider moves the value on every frame, and each change drops the cache and forces the linear measure path over the whole tab (up to Config.MaxLinesToRender rows). The gate now waits for the value to settle for 200ms, which turns a drag into one rebuild instead of one per frame. The settle logic sits in Util/LayoutFingerprint.cs as a plain value type so the build suite can pin it without standing up an ImGui frame.
This commit is contained in:
@@ -30,14 +30,9 @@ internal sealed class MessageList
|
||||
// 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;
|
||||
// Per tab, not per list: the old single field let a width change in tab A mark
|
||||
// itself applied, so tab B kept measuring against the previous width.
|
||||
private readonly Dictionary<Guid, LayoutFingerprintGate> _fingerprintGates = [];
|
||||
|
||||
// §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle.
|
||||
// Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist.
|
||||
@@ -84,29 +79,37 @@ internal sealed class MessageList
|
||||
}
|
||||
|
||||
// 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)
|
||||
// enum modes widened to int so the record stays comparable. UiScale is in here
|
||||
// because it feeds CalcWordWrapPositionA -- a scale change rewraps every row.
|
||||
private LayoutFingerprint BuildLayoutFingerprint(float contentWidth)
|
||||
{
|
||||
var (global, symbols) = _fonts.EffectiveFontFingerprint();
|
||||
return (
|
||||
return new LayoutFingerprint(
|
||||
global,
|
||||
symbols,
|
||||
Plugin.Config.UseCompactDensity,
|
||||
(int)Plugin.Config.NameFormMode,
|
||||
(int)Plugin.Config.WorldSuffixMode,
|
||||
contentWidth
|
||||
contentWidth,
|
||||
ImGuiHelpers.GlobalScale
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Drop the tab's cached heights once the layout fingerprint has settled — one
|
||||
// record compare per frame, a clear only after a real settings/resize change
|
||||
// stopped moving. The gate is what keeps a slider drag from rebuilding the
|
||||
// whole tab on every frame.
|
||||
private void InvalidateHeightCacheIfLayoutChanged(Tab tab, float contentWidth)
|
||||
{
|
||||
var fingerprint = BuildLayoutFingerprint(contentWidth);
|
||||
if (fingerprint.Equals(_lastLayoutFingerprint))
|
||||
if (!_fingerprintGates.TryGetValue(tab.Identifier, out var gate))
|
||||
{
|
||||
gate = new LayoutFingerprintGate();
|
||||
_fingerprintGates[tab.Identifier] = gate;
|
||||
}
|
||||
|
||||
if (!gate.ShouldInvalidate(BuildLayoutFingerprint(contentWidth), Environment.TickCount64))
|
||||
return;
|
||||
|
||||
_lastLayoutFingerprint = fingerprint;
|
||||
using var messages = tab.Messages.GetReadOnly(3);
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace HellionChat.Util;
|
||||
|
||||
// Layout inputs that make a tab's cached row heights stale. Kept as a plain
|
||||
// value type so the build suite can pin the settle logic without an ImGui frame.
|
||||
internal readonly record struct LayoutFingerprint(
|
||||
float FontGlobal,
|
||||
float FontSymbols,
|
||||
bool Compact,
|
||||
int NameForm,
|
||||
int WorldSuffix,
|
||||
float Width,
|
||||
float UiScale
|
||||
);
|
||||
|
||||
// Dragging a window edge or the Dalamud UI-scale slider moves the fingerprint on
|
||||
// every single frame. Acting on each one drops the height cache, which sends the
|
||||
// whole tab through the linear measure path (up to Config.MaxLinesToRender rows,
|
||||
// default 2500). Waiting for the value to settle turns that into one rebuild per
|
||||
// drag instead of one per frame.
|
||||
internal sealed class LayoutFingerprintGate
|
||||
{
|
||||
internal const long SettleMs = 200;
|
||||
|
||||
private LayoutFingerprint? _applied;
|
||||
private LayoutFingerprint _pending;
|
||||
private long _pendingSinceMs;
|
||||
|
||||
internal bool ShouldInvalidate(LayoutFingerprint current, long nowMs)
|
||||
{
|
||||
// First sight of this tab: nothing is cached yet, so there is nothing to
|
||||
// drop and nothing to wait for.
|
||||
if (_applied is null)
|
||||
{
|
||||
_applied = current;
|
||||
_pending = current;
|
||||
_pendingSinceMs = nowMs;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.Equals(_applied.Value))
|
||||
return false;
|
||||
|
||||
if (!current.Equals(_pending))
|
||||
{
|
||||
_pending = current;
|
||||
_pendingSinceMs = nowMs;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nowMs - _pendingSinceMs < SettleMs)
|
||||
return false;
|
||||
|
||||
_applied = current;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user