fix(visual): time fade redraw window on the monotonic clock
Measure the redraw window from a monotonic base so wall-clock jumps after suspend cannot stop redraws mid-fade, and poke once more when the window closes so a starved ticker still wakes every output past the end.
- commit
- 2881c25e46760405a859bcab13982b3bc2fb68f9
- author
- brice · · 2026-10-01 15:21 UTC
- parent
- 8377bd2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +18 -10
M internal/adapters/visual/fade.go +16 -8
| @@ -24,9 +24,10 @@ type fadeCmd struct { | |||
| 24 | 24 | type fadeModel struct { | |
| 25 | 25 | cmds chan fadeCmd | |
| 26 | 26 | wake chan struct{} | |
| 27 | - | // tickUntil (UnixNano) keeps redraws coming until every surface has drawn | |
| 28 | - | // a frame after the fade ended. It must not depend on which surface drew | |
| 29 | - | // last: a wake redraws all surfaces, but each draws at its own instant. | |
| 27 | + | // tickUntil keeps redraws coming past the fade end, as nanoseconds since | |
| 28 | + | // base (monotonic, like fadeAlpha). It must not depend on which surface | |
| 29 | + | // drew last: a wake redraws all surfaces, but each draws at its own instant. | |
| 30 | + | base time.Time | |
| 30 | 31 | tickUntil atomic.Int64 | |
| 31 | 32 | // finish ends the run once a reveal completed; set by the runner before | |
| 32 | 33 | // the loop starts. | |
| @@ -40,7 +41,7 @@ type fadeModel struct { | |||
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | func newFadeModel() *fadeModel { | |
| 43 | - | return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1)} | |
| 44 | + | return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1), base: time.Now()} | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | // send queues a retarget (dropping the oldest when full) and requests a redraw. | |
| @@ -82,7 +83,7 @@ func fadeCSS(a float64) string { | |||
| 82 | 83 | ||
| 83 | 84 | // animatingAt reports whether the ticker must still request redraws. | |
| 84 | 85 | func (m *fadeModel) animatingAt(now time.Time) bool { | |
| 85 | - | return now.UnixNano() < m.tickUntil.Load() | |
| 86 | + | return int64(now.Sub(m.base)) < m.tickUntil.Load() | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | // fadeView applies queued retargets, then draws the overlay for this instant. | |
| @@ -105,8 +106,10 @@ func (m *fadeModel) step(now time.Time) (float64, bool) { | |||
| 105 | 106 | m.to = 1 | |
| 106 | 107 | } | |
| 107 | 108 | m.start, m.dur, m.started = now, c.dur, true | |
| 108 | - | // Two ticks past the end: every surface draws the final opacity. | |
| 109 | - | m.tickUntil.Store(now.Add(c.dur + 2*fadeTick).UnixNano()) | |
| 109 | + | // Any wake at or after the end marks every surface dirty until it | |
| 110 | + | // draws, so that draw shows the final opacity. The extra ticks are | |
| 111 | + | // margin for ticker jitter. | |
| 112 | + | m.tickUntil.Store(int64(now.Add(c.dur + 2*fadeTick).Sub(m.base))) | |
| 110 | 113 | continue | |
| 111 | 114 | default: | |
| 112 | 115 | } | |
| @@ -130,14 +133,19 @@ func (guiFader) RunFade(ctx context.Context, m *fadeModel) error { | |||
| 130 | 133 | go func() { | |
| 131 | 134 | t := time.NewTicker(fadeTick) | |
| 132 | 135 | defer t.Stop() | |
| 136 | + | was := false | |
| 133 | 137 | for { | |
| 134 | 138 | select { | |
| 135 | 139 | case <-ctx.Done(): | |
| 136 | 140 | return | |
| 137 | 141 | case now := <-t.C: | |
| 138 | - | if m.animatingAt(now) { | |
| 142 | + | // One more poke when animation stops: even a starved ticker | |
| 143 | + | // then wakes every surface at least once past the end. | |
| 144 | + | animating := m.animatingAt(now) | |
| 145 | + | if animating || was { | |
| 139 | 146 | m.poke() | |
| 140 | 147 | } | |
| 148 | + | was = animating | |
| 141 | 149 | } | |
| 142 | 150 | } | |
| 143 | 151 | }() | |
| @@ -24,9 +24,10 @@ type fadeCmd struct { | |||||
| 24 | type fadeModel struct { | 24 | type fadeModel struct { | ||
| 25 | cmds chan fadeCmd | 25 | cmds chan fadeCmd | ||
| 26 | wake chan struct{} | 26 | wake chan struct{} | ||
| 27 | - | // tickUntil (UnixNano) keeps redraws coming until every surface has drawn | 27 | + | // tickUntil keeps redraws coming past the fade end, as nanoseconds since |
| 28 | - | // a frame after the fade ended. It must not depend on which surface drew | 28 | + | // base (monotonic, like fadeAlpha). It must not depend on which surface |
| 29 | - | // last: a wake redraws all surfaces, but each draws at its own instant. | 29 | + | // drew last: a wake redraws all surfaces, but each draws at its own instant. |
| 30 | + | base time.Time | |||
| 30 | tickUntil atomic.Int64 | 31 | tickUntil atomic.Int64 | ||
| 31 | // finish ends the run once a reveal completed; set by the runner before | 32 | // finish ends the run once a reveal completed; set by the runner before | ||
| 32 | // the loop starts. | 33 | // the loop starts. | ||
| @@ -40,7 +41,7 @@ type fadeModel struct { | |||||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | func newFadeModel() *fadeModel { | 43 | func newFadeModel() *fadeModel { | ||
| 43 | - | return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1)} | 44 | + | return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1), base: time.Now()} |
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 46 | // send queues a retarget (dropping the oldest when full) and requests a redraw. | 47 | // send queues a retarget (dropping the oldest when full) and requests a redraw. | ||
| @@ -82,7 +83,7 @@ func fadeCSS(a float64) string { | |||||
| 82 | 83 | ||||
| 83 | // animatingAt reports whether the ticker must still request redraws. | 84 | // animatingAt reports whether the ticker must still request redraws. | ||
| 84 | func (m *fadeModel) animatingAt(now time.Time) bool { | 85 | func (m *fadeModel) animatingAt(now time.Time) bool { | ||
| 85 | - | return now.UnixNano() < m.tickUntil.Load() | 86 | + | return int64(now.Sub(m.base)) < m.tickUntil.Load() |
| 86 | } | 87 | } | ||
| 87 | 88 | ||||
| 88 | // fadeView applies queued retargets, then draws the overlay for this instant. | 89 | // fadeView applies queued retargets, then draws the overlay for this instant. | ||
| @@ -105,8 +106,10 @@ func (m *fadeModel) step(now time.Time) (float64, bool) { | |||||
| 105 | m.to = 1 | 106 | m.to = 1 | ||
| 106 | } | 107 | } | ||
| 107 | m.start, m.dur, m.started = now, c.dur, true | 108 | m.start, m.dur, m.started = now, c.dur, true | ||
| 108 | - | // Two ticks past the end: every surface draws the final opacity. | 109 | + | // Any wake at or after the end marks every surface dirty until it |
| 109 | - | m.tickUntil.Store(now.Add(c.dur + 2*fadeTick).UnixNano()) | 110 | + | // draws, so that draw shows the final opacity. The extra ticks are |
| 111 | + | // margin for ticker jitter. | |||
| 112 | + | m.tickUntil.Store(int64(now.Add(c.dur + 2*fadeTick).Sub(m.base))) | |||
| 110 | continue | 113 | continue | ||
| 111 | default: | 114 | default: | ||
| 112 | } | 115 | } | ||
| @@ -130,14 +133,19 @@ func (guiFader) RunFade(ctx context.Context, m *fadeModel) error { | |||||
| 130 | go func() { | 133 | go func() { | ||
| 131 | t := time.NewTicker(fadeTick) | 134 | t := time.NewTicker(fadeTick) | ||
| 132 | defer t.Stop() | 135 | defer t.Stop() | ||
| 136 | + | was := false | |||
| 133 | for { | 137 | for { | ||
| 134 | select { | 138 | select { | ||
| 135 | case <-ctx.Done(): | 139 | case <-ctx.Done(): | ||
| 136 | return | 140 | return | ||
| 137 | case now := <-t.C: | 141 | case now := <-t.C: | ||
| 138 | - | if m.animatingAt(now) { | 142 | + | // One more poke when animation stops: even a starved ticker |
| 143 | + | // then wakes every surface at least once past the end. | |||
| 144 | + | animating := m.animatingAt(now) | |||
| 145 | + | if animating || was { | |||
| 139 | m.poke() | 146 | m.poke() | ||
| 140 | } | 147 | } | ||
| 148 | + | was = animating | |||
| 141 | } | 149 | } | ||
| 142 | } | 150 | } | ||
| 143 | }() | 151 | }() | ||
M internal/adapters/visual/view_test.go +2 -2
| @@ -25,7 +25,7 @@ func TestFadeAlphaInterpolatesAndArrives(t *testing.T) { | |||
| 25 | 25 | func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | |
| 26 | 26 | m := newFadeModel() | |
| 27 | 27 | m.send(fadeCmd{black: true, dur: time.Second}) | |
| 28 | - | t0 := time.Unix(100, 0) | |
| 28 | + | t0 := m.base.Add(time.Minute) | |
| 29 | 29 | a, done := m.step(t0) | |
| 30 | 30 | require.Equal(t, 0.0, a) | |
| 31 | 31 | require.False(t, done) | |
| @@ -38,7 +38,7 @@ func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | |||
| 38 | 38 | require.Equal(t, 1.0, a) | |
| 39 | 39 | require.True(t, done) | |
| 40 | 40 | require.True(t, m.animatingAt(after), "surface A still needs a redraw") | |
| 41 | - | require.False(t, m.animatingAt(t0.Add(time.Second+2*fadeTick))) | |
| 41 | + | require.False(t, m.animatingAt(t0.Add(time.Hour))) | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | |
| @@ -25,7 +25,7 @@ func TestFadeAlphaInterpolatesAndArrives(t *testing.T) { | |||||
| 25 | func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | 25 | func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | ||
| 26 | m := newFadeModel() | 26 | m := newFadeModel() | ||
| 27 | m.send(fadeCmd{black: true, dur: time.Second}) | 27 | m.send(fadeCmd{black: true, dur: time.Second}) | ||
| 28 | - | t0 := time.Unix(100, 0) | 28 | + | t0 := m.base.Add(time.Minute) |
| 29 | a, done := m.step(t0) | 29 | a, done := m.step(t0) | ||
| 30 | require.Equal(t, 0.0, a) | 30 | require.Equal(t, 0.0, a) | ||
| 31 | require.False(t, done) | 31 | require.False(t, done) | ||
| @@ -38,7 +38,7 @@ func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | |||||
| 38 | require.Equal(t, 1.0, a) | 38 | require.Equal(t, 1.0, a) | ||
| 39 | require.True(t, done) | 39 | require.True(t, done) | ||
| 40 | require.True(t, m.animatingAt(after), "surface A still needs a redraw") | 40 | require.True(t, m.animatingAt(after), "surface A still needs a redraw") | ||
| 41 | - | require.False(t, m.animatingAt(t0.Add(time.Second+2*fadeTick))) | 41 | + | require.False(t, m.animatingAt(t0.Add(time.Hour))) |
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | 44 | func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | ||