feat(style): hold hover state across frames instead of one-shot sweeps
DrawHoverSheen measured its own elapsed time against DateTime.UtcNow and gave up after 0.65s, so a row stopped reacting while the pointer was still on it. There was no held value to interpolate colours against. HoverState keeps one 0..1 intensity per element, rising at 14/s and falling at 8/s. Slower out than in is what makes the fade read as deliberate. Query and advance are separate on purpose. Several SelfTest steps call Sidebar.Draw against the live tab list, so the same element gets submitted up to 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 ORs the hover flag; BeginFrame does all the moving and the eviction. BeginFrame sits above the HideInLoadingScreens and New Game+ early returns, so a hidden main window still lets pop-out hovers fade out instead of freezing mid-blend. FrameLerp gains Ramp: Smooth approaches asymptotically and never arrives, so a value driven by it would never reach zero and never become evictable. ReduceMotion short-circuits before the map is touched, returning a hard 0 or 1. An infinite rate would produce NaN and poison the entry for the session.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<uint, Entry> Entries = [];
|
||||
private static readonly List<uint> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user