detached 8377bd2

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
1818 dur time.Duration
1919 }
2020
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.
2324 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
2731 // finish ends the run once a reveal completed; set by the runner before
2832 // the loop starts.
2933 finish context.CancelFunc
@@ -76,9 +80,22 @@ func fadeCSS(a float64) string {
7680 return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100)
7781 }
7882
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+
7988 // fadeView applies queued retargets, then draws the overlay for this instant.
8089 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) {
8299 for {
83100 select {
84101 case c := <-m.cmds:
@@ -88,17 +105,14 @@ func fadeView(f *nefergui.Frame, m *fadeModel) {
88105 m.to = 1
89106 }
90107 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())
91110 continue
92111 default:
93112 }
94113 break
95114 }
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)
102116 }
103117
104118 // fadeRunner runs the overlay until ctx ends or a reveal completes.
@@ -120,8 +134,8 @@ func (guiFader) RunFade(ctx context.Context, m *fadeModel) error {
120134 select {
121135 case <-ctx.Done():
122136 return
123- case <-t.C:
124- if m.animating.Load() {
137+ case now := <-t.C:
138+ if m.animatingAt(now) {
125139 m.poke()
126140 }
127141 }
@@ -18,12 +18,16 @@ type fadeCmd struct {
18 dur time.Duration18 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 fadeCmd25+ cmds chan fadeCmd
25- wake chan struct{}26+ wake chan struct{}
26- animating atomic.Bool27+ // 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 before31 // finish ends the run once a reveal completed; set by the runner before
28 // the loop starts.32 // the loop starts.
29 finish context.CancelFunc33 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 = 1105 m.to = 1
89 }106 }
90 m.start, m.dur, m.started = now, c.dur, true107 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 continue110 continue
92 default:111 default:
93 }112 }
94 break113 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 return136 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) {
2020 require.True(t, done)
2121 }
2222
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+
2344 func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
2445 l := fadeLayer()
2546 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close