diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index fda84d3..97929c6 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -1059,6 +1059,11 @@ public sealed class Plugin : IAsyncDalamudPlugin Config.WindowOpacity ); + // Advance every held hover value once, before any window draws. Sits + // above the early returns below so a hidden main window still lets + // pop-out hovers fade instead of freezing mid-blend. + Ui.StyleEngine.HoverState.BeginFrame(); + if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas]) { TypingIpc.Update(); diff --git a/HellionChat/Ui/StyleEngine/HoverState.cs b/HellionChat/Ui/StyleEngine/HoverState.cs new file mode 100644 index 0000000..f3d194e --- /dev/null +++ b/HellionChat/Ui/StyleEngine/HoverState.cs @@ -0,0 +1,78 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// Held hover intensity per element, 0..1. Replaces the one-shot sheen timer, +// which stopped after 0.65s while the pointer was still on the row. +// +// Query and advance are deliberately separate. Several SelfTest steps call +// Sidebar.Draw against the live tab list, so the same element can be submitted +// three times in one frame -- twice from a window the mouse is not over. If the +// query advanced the value, the last caller would win and the fade would run +// backwards. Query only marks; BeginFrame does all the moving. Pattern anchor: +// LightlessSync Selune.cs:106-146. +internal static class HoverState +{ + private sealed class Entry + { + internal float Value; + internal bool Hovered; + } + + private static readonly Dictionary Entries = []; + private static readonly List Evicted = []; + + internal static int TrackedCount => Entries.Count; + + // Once per frame from Plugin.Draw, before any window renders. + internal static void BeginFrame() => Advance(ImGui.GetIO().DeltaTime); + + // SelfTest seam: the alloc step submits many elements inside a single frame, + // so it needs to step the clock without waiting for real frames. + internal static void AdvanceForTest(float deltaTime) => Advance(deltaTime); + + internal static void Reset() + { + Entries.Clear(); + Evicted.Clear(); + } + + internal static float Query(uint id, bool hovered) + { + // ReduceMotion short-circuits before touching the map: no entry, no fade, + // nothing to evict. Returning a hard 0/1 is also NaN-safe, which setting + // an infinite rate would not be. + if (Plugin.Config.ReduceMotion) + return hovered ? 1f : 0f; + + if (!Entries.TryGetValue(id, out var entry)) + { + entry = new Entry(); + Entries[id] = entry; + } + + // OR, never assign: a second caller in the same frame that is not hovered + // must not cancel the first one that is. + entry.Hovered |= hovered; + return entry.Value; + } + + private static void Advance(float deltaTime) + { + if (deltaTime <= 0f) + return; + + Evicted.Clear(); + foreach (var (id, entry) in Entries) + { + entry.Value = HoverMath.Step(entry.Value, entry.Hovered, deltaTime); + if (HoverMath.ShouldEvict(entry.Value, entry.Hovered)) + Evicted.Add(id); + entry.Hovered = false; + } + + foreach (var id in Evicted) + Entries.Remove(id); + } +} diff --git a/HellionChat/Util/FrameLerp.cs b/HellionChat/Util/FrameLerp.cs index 2593f6f..42c673e 100644 --- a/HellionChat/Util/FrameLerp.cs +++ b/HellionChat/Util/FrameLerp.cs @@ -14,4 +14,12 @@ internal static class FrameLerp var factor = Math.Min(1f, speed * deltaTime); return current + (target - current) * factor; } + + // Linear ramp at `speed` units per second, clamped at the target. Smooth() + // approaches asymptotically and never actually arrives, so a hover value + // driven by it would never reach zero and never become evictable. + public static float Ramp(float current, float target, float speed, float deltaTime) => + target > current + ? MathF.Min(target, current + speed * deltaTime) + : MathF.Max(target, current - speed * deltaTime); } diff --git a/HellionChat/Util/HoverMath.cs b/HellionChat/Util/HoverMath.cs new file mode 100644 index 0000000..7a2d624 --- /dev/null +++ b/HellionChat/Util/HoverMath.cs @@ -0,0 +1,23 @@ +namespace HellionChat.Util; + +// State rules for the held hover value, split from HoverState so the build suite +// can pin them without an ImGui frame. Rates follow Lightless (Selune.cs:36-37): +// slower out than in is what makes a fade read as deliberate rather than laggy. +internal static class HoverMath +{ + internal const float FadeInPerSecond = 14f; + internal const float FadeOutPerSecond = 8f; + + // Below this an entry is indistinguishable from zero and can be dropped. + internal const float EvictBelow = 0.001f; + + internal static float Step(float current, bool hovered, float deltaTime) => + FrameLerp.Ramp( + current, + hovered ? 1f : 0f, + hovered ? FadeInPerSecond : FadeOutPerSecond, + deltaTime + ); + + internal static bool ShouldEvict(float value, bool hovered) => !hovered && value <= EvictBelow; +}