fix(visual): keep fade redraws until every output drew its end
All outputs share one fade model. The ticker stopped once any surface drew the final opacity, so an output drawing at a slower refresh could keep its last pre-end frame. Redraws now continue until two ticks past the fade end.
- commit
- 8377bd267bb4268f2988fe4ab8f595cdf6fe8e3a
- author
- brice · · 2026-10-01 15:17 UTC
- parent
- bbdfe3f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +49 -14
M internal/adapters/visual/fade.go +28 -14
| @@ -18,12 +18,16 @@ type fadeCmd struct { | |||
| 18 | 18 | dur time.Duration | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | // fadeModel is the overlay's animation state. The view (NeferGUI owner loop) | |
| 22 | - | // owns the fields below "owned"; other goroutines only use send. | |
| 21 | + | // fadeModel is the overlay's animation state, shared by the surfaces of every | |
| 22 | + | // output. The view (NeferGUI owner loop) owns the fields below "owned"; other | |
| 23 | + | // goroutines only use send and animatingAt. | |
| 23 | 24 | type fadeModel struct { | |
| 24 | - | cmds chan fadeCmd | |
| 25 | - | wake chan struct{} | |
| 26 | - | animating atomic.Bool | |
| 25 | + | cmds chan fadeCmd | |
| 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. | |
| 30 | + | tickUntil atomic.Int64 | |
| 27 | 31 | // finish ends the run once a reveal completed; set by the runner before | |
| 28 | 32 | // the loop starts. | |
| 29 | 33 | finish context.CancelFunc | |
| @@ -76,9 +80,22 @@ func fadeCSS(a float64) string { | |||
| 76 | 80 | return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100) | |
| 77 | 81 | } | |
| 78 | 82 | ||
| 83 | + | // animatingAt reports whether the ticker must still request redraws. | |
| 84 | + | func (m *fadeModel) animatingAt(now time.Time) bool { | |
| 85 | + | return now.UnixNano() < m.tickUntil.Load() | |
| 86 | + | } | |
| 87 | + | ||
| 79 | 88 | // fadeView applies queued retargets, then draws the overlay for this instant. | |
| 80 | 89 | func fadeView(f *nefergui.Frame, m *fadeModel) { | |
| 81 | - | now := time.Now() | |
| 90 | + | a, done := m.step(time.Now()) | |
| 91 | + | f.Root(nefergui.Inline(fadeCSS(a))) | |
| 92 | + | if done && m.started && m.to == 0 && m.finish != nil { | |
| 93 | + | m.finish() | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // step applies queued retargets and returns the opacity at now. | |
| 98 | + | func (m *fadeModel) step(now time.Time) (float64, bool) { | |
| 82 | 99 | for { | |
| 83 | 100 | select { | |
| 84 | 101 | case c := <-m.cmds: | |
| @@ -88,17 +105,14 @@ func fadeView(f *nefergui.Frame, m *fadeModel) { | |||
| 88 | 105 | m.to = 1 | |
| 89 | 106 | } | |
| 90 | 107 | 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()) | |
| 91 | 110 | continue | |
| 92 | 111 | default: | |
| 93 | 112 | } | |
| 94 | 113 | break | |
| 95 | 114 | } | |
| 96 | - | a, done := fadeAlpha(m.from, m.to, m.start, m.dur, now) | |
| 97 | - | m.animating.Store(!done) | |
| 98 | - | f.Root(nefergui.Inline(fadeCSS(a))) | |
| 99 | - | if done && m.started && m.to == 0 && m.finish != nil { | |
| 100 | - | m.finish() | |
| 101 | - | } | |
| 115 | + | return fadeAlpha(m.from, m.to, m.start, m.dur, now) | |
| 102 | 116 | } | |
| 103 | 117 | ||
| 104 | 118 | // fadeRunner runs the overlay until ctx ends or a reveal completes. | |
| @@ -120,8 +134,8 @@ func (guiFader) RunFade(ctx context.Context, m *fadeModel) error { | |||
| 120 | 134 | select { | |
| 121 | 135 | case <-ctx.Done(): | |
| 122 | 136 | return | |
| 123 | - | case <-t.C: | |
| 124 | - | if m.animating.Load() { | |
| 137 | + | case now := <-t.C: | |
| 138 | + | if m.animatingAt(now) { | |
| 125 | 139 | m.poke() | |
| 126 | 140 | } | |
| 127 | 141 | } | |
| @@ -18,12 +18,16 @@ type fadeCmd struct { | |||||
| 18 | dur time.Duration | 18 | dur time.Duration | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | // fadeModel is the overlay's animation state. The view (NeferGUI owner loop) | 21 | + | // fadeModel is the overlay's animation state, shared by the surfaces of every |
| 22 | - | // owns the fields below "owned"; other goroutines only use send. | 22 | + | // output. The view (NeferGUI owner loop) owns the fields below "owned"; other |
| 23 | + | // goroutines only use send and animatingAt. | |||
| 23 | type fadeModel struct { | 24 | type fadeModel struct { | ||
| 24 | - | cmds chan fadeCmd | 25 | + | cmds chan fadeCmd |
| 25 | - | wake chan struct{} | 26 | + | wake chan struct{} |
| 26 | - | animating atomic.Bool | 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. | |||
| 30 | + | tickUntil atomic.Int64 | |||
| 27 | // finish ends the run once a reveal completed; set by the runner before | 31 | // finish ends the run once a reveal completed; set by the runner before | ||
| 28 | // the loop starts. | 32 | // the loop starts. | ||
| 29 | finish context.CancelFunc | 33 | finish context.CancelFunc | ||
| @@ -76,9 +80,22 @@ func fadeCSS(a float64) string { | |||||
| 76 | return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100) | 80 | return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100) | ||
| 77 | } | 81 | } | ||
| 78 | 82 | ||||
| 83 | + | // animatingAt reports whether the ticker must still request redraws. | |||
| 84 | + | func (m *fadeModel) animatingAt(now time.Time) bool { | |||
| 85 | + | return now.UnixNano() < m.tickUntil.Load() | |||
| 86 | + | } | |||
| 87 | + | ||||
| 79 | // fadeView applies queued retargets, then draws the overlay for this instant. | 88 | // fadeView applies queued retargets, then draws the overlay for this instant. | ||
| 80 | func fadeView(f *nefergui.Frame, m *fadeModel) { | 89 | func fadeView(f *nefergui.Frame, m *fadeModel) { | ||
| 81 | - | now := time.Now() | 90 | + | a, done := m.step(time.Now()) |
| 91 | + | f.Root(nefergui.Inline(fadeCSS(a))) | |||
| 92 | + | if done && m.started && m.to == 0 && m.finish != nil { | |||
| 93 | + | m.finish() | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // step applies queued retargets and returns the opacity at now. | |||
| 98 | + | func (m *fadeModel) step(now time.Time) (float64, bool) { | |||
| 82 | for { | 99 | for { | ||
| 83 | select { | 100 | select { | ||
| 84 | case c := <-m.cmds: | 101 | case c := <-m.cmds: | ||
| @@ -88,17 +105,14 @@ func fadeView(f *nefergui.Frame, m *fadeModel) { | |||||
| 88 | m.to = 1 | 105 | m.to = 1 | ||
| 89 | } | 106 | } | ||
| 90 | m.start, m.dur, m.started = now, c.dur, true | 107 | 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()) | |||
| 91 | continue | 110 | continue | ||
| 92 | default: | 111 | default: | ||
| 93 | } | 112 | } | ||
| 94 | break | 113 | break | ||
| 95 | } | 114 | } | ||
| 96 | - | a, done := fadeAlpha(m.from, m.to, m.start, m.dur, now) | 115 | + | return fadeAlpha(m.from, m.to, m.start, m.dur, now) |
| 97 | - | m.animating.Store(!done) | |||
| 98 | - | f.Root(nefergui.Inline(fadeCSS(a))) | |||
| 99 | - | if done && m.started && m.to == 0 && m.finish != nil { | |||
| 100 | - | m.finish() | |||
| 101 | - | } | |||
| 102 | } | 116 | } | ||
| 103 | 117 | ||||
| 104 | // fadeRunner runs the overlay until ctx ends or a reveal completes. | 118 | // fadeRunner runs the overlay until ctx ends or a reveal completes. | ||
| @@ -120,8 +134,8 @@ func (guiFader) RunFade(ctx context.Context, m *fadeModel) error { | |||||
| 120 | select { | 134 | select { | ||
| 121 | case <-ctx.Done(): | 135 | case <-ctx.Done(): | ||
| 122 | return | 136 | return | ||
| 123 | - | case <-t.C: | 137 | + | case now := <-t.C: |
| 124 | - | if m.animating.Load() { | 138 | + | if m.animatingAt(now) { |
| 125 | m.poke() | 139 | m.poke() | ||
| 126 | } | 140 | } | ||
| 127 | } | 141 | } | ||
M internal/adapters/visual/view_test.go +21 -0
| @@ -20,6 +20,27 @@ func TestFadeAlphaInterpolatesAndArrives(t *testing.T) { | |||
| 20 | 20 | require.True(t, done) | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | + | // Surfaces share the model but draw at different instants: a surface that | |
| 24 | + | // drew just after the end must not stop redraws for one that drew just before. | |
| 25 | + | func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | |
| 26 | + | m := newFadeModel() | |
| 27 | + | m.send(fadeCmd{black: true, dur: time.Second}) | |
| 28 | + | t0 := time.Unix(100, 0) | |
| 29 | + | a, done := m.step(t0) | |
| 30 | + | require.Equal(t, 0.0, a) | |
| 31 | + | require.False(t, done) | |
| 32 | + | ||
| 33 | + | before := t0.Add(time.Second - time.Millisecond) | |
| 34 | + | after := t0.Add(time.Second + time.Millisecond) | |
| 35 | + | _, done = m.step(before) // surface A | |
| 36 | + | require.False(t, done) | |
| 37 | + | a, done = m.step(after) // surface B reaches the end | |
| 38 | + | require.Equal(t, 1.0, a) | |
| 39 | + | require.True(t, done) | |
| 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))) | |
| 42 | + | } | |
| 43 | + | ||
| 23 | 44 | func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | |
| 24 | 45 | l := fadeLayer() | |
| 25 | 46 | require.True(t, l.AllOutputs) | |
| @@ -20,6 +20,27 @@ func TestFadeAlphaInterpolatesAndArrives(t *testing.T) { | |||||
| 20 | require.True(t, done) | 20 | require.True(t, done) | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | + | // Surfaces share the model but draw at different instants: a surface that | |||
| 24 | + | // drew just after the end must not stop redraws for one that drew just before. | |||
| 25 | + | func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | |||
| 26 | + | m := newFadeModel() | |||
| 27 | + | m.send(fadeCmd{black: true, dur: time.Second}) | |||
| 28 | + | t0 := time.Unix(100, 0) | |||
| 29 | + | a, done := m.step(t0) | |||
| 30 | + | require.Equal(t, 0.0, a) | |||
| 31 | + | require.False(t, done) | |||
| 32 | + | ||||
| 33 | + | before := t0.Add(time.Second - time.Millisecond) | |||
| 34 | + | after := t0.Add(time.Second + time.Millisecond) | |||
| 35 | + | _, done = m.step(before) // surface A | |||
| 36 | + | require.False(t, done) | |||
| 37 | + | a, done = m.step(after) // surface B reaches the end | |||
| 38 | + | require.Equal(t, 1.0, a) | |||
| 39 | + | require.True(t, done) | |||
| 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))) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 23 | func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | 44 | func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | ||
| 24 | l := fadeLayer() | 45 | l := fadeLayer() | ||
| 25 | require.True(t, l.AllOutputs) | 46 | require.True(t, l.AllOutputs) | ||