diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 80e55c7..b0499b1 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -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 _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) { diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs new file mode 100644 index 0000000..54d295d --- /dev/null +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -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; + } +}