fix(style): the hover fade ran at light speed, and stopped dead

Tester feedback via Flo, aimed at the sidebar tabs but true everywhere: 14/s in
means a full fade in seventy milliseconds, which is five frames -- fast enough
to read as a flicker rather than a fade. Halved to 6.5/s in and 4/s out, which
puts the rise around 150ms and the settle around 250ms, where UI fades usually
live.

And the curve was linear: one speed the whole way, then a dead stop. Consumers
now get a smoothstep over the linear state -- soft start, soft landing -- while
the state itself stays linear, because the evict rule and the advance step are
written against it. Every hover in the plugin inherits both changes through the
one Query call, so the sidebar, the popups, the ghost buttons and the message
rows all breathe at the same pace.

The timing test now guards the other direction: it fails if anyone drifts the
rate back toward light speed.
This commit is contained in:
2026-08-19 19:26:47 +02:00
parent 12a445e08f
commit eeffb5bc6b
2 changed files with 18 additions and 5 deletions
+4 -1
View File
@@ -61,7 +61,10 @@ internal static class HoverState
// OR, never assign: a second caller in the same frame that is not hovered // OR, never assign: a second caller in the same frame that is not hovered
// must not cancel the first one that is. // must not cancel the first one that is.
entry.Hovered |= hovered; entry.Hovered |= hovered;
return entry.Value;
// Callers get the eased value; the entry keeps the linear one, which is
// what the evict rule and the advance step are written against.
return HoverMath.Ease(entry.Value);
} }
// Never re-entrant: BeginFrame runs once from the draw thread, AdvanceForTest // Never re-entrant: BeginFrame runs once from the draw thread, AdvanceForTest
+14 -4
View File
@@ -1,12 +1,17 @@
namespace HellionChat.Util; namespace HellionChat.Util;
// State rules for the held hover value, split from HoverState so the build suite // 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): // can pin them without an ImGui frame. Slower out than in is what makes a fade
// slower out than in is what makes a fade read as deliberate rather than laggy. // read as deliberate rather than laggy (the asymmetry follows Lightless,
// Selune.cs:36-37).
//
// The rates started at 14/s in and 8/s out -- a full fade-in in seventy
// milliseconds, which tester feedback called light speed. Half that now: rise in
// about 150ms, settle in about 250ms, which is where UI fades usually live.
internal static class HoverMath internal static class HoverMath
{ {
internal const float FadeInPerSecond = 14f; internal const float FadeInPerSecond = 6.5f;
internal const float FadeOutPerSecond = 8f; internal const float FadeOutPerSecond = 4f;
// Below this an entry is indistinguishable from zero and can be dropped. // Below this an entry is indistinguishable from zero and can be dropped.
internal const float EvictBelow = 0.001f; internal const float EvictBelow = 0.001f;
@@ -20,4 +25,9 @@ internal static class HoverMath
); );
internal static bool ShouldEvict(float value, bool hovered) => !hovered && value <= EvictBelow; internal static bool ShouldEvict(float value, bool hovered) => !hovered && value <= EvictBelow;
// Smoothstep over the linear state. The state itself stays linear -- evict
// and threshold logic depend on it -- but what callers paint with starts
// gently and lands gently instead of moving at one speed and stopping dead.
internal static float Ease(float t) => t * t * (3f - 2f * t);
} }