detached 2881c25

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
2424 type fadeModel struct {
2525 cmds chan fadeCmd
2626 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
3031 tickUntil atomic.Int64
3132 // finish ends the run once a reveal completed; set by the runner before
3233 // the loop starts.
@@ -40,7 +41,7 @@ type fadeModel struct {
4041 }
4142
4243 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()}
4445 }
4546
4647 // send queues a retarget (dropping the oldest when full) and requests a redraw.
@@ -82,7 +83,7 @@ func fadeCSS(a float64) string {
8283
8384 // animatingAt reports whether the ticker must still request redraws.
8485 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()
8687 }
8788
8889 // fadeView applies queued retargets, then draws the overlay for this instant.
@@ -105,8 +106,10 @@ func (m *fadeModel) step(now time.Time) (float64, bool) {
105106 m.to = 1
106107 }
107108 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)))
110113 continue
111114 default:
112115 }
@@ -130,14 +133,19 @@ func (guiFader) RunFade(ctx context.Context, m *fadeModel) error {
130133 go func() {
131134 t := time.NewTicker(fadeTick)
132135 defer t.Stop()
136+ was := false
133137 for {
134138 select {
135139 case <-ctx.Done():
136140 return
137141 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 {
139146 m.poke()
140147 }
148+ was = animating
141149 }
142150 }
143151 }()
@@ -24,9 +24,10 @@ type fadeCmd struct {
24 type fadeModel struct {24 type fadeModel struct {
25 cmds chan fadeCmd25 cmds chan fadeCmd
26 wake chan struct{}26 wake chan struct{}
27- // tickUntil (UnixNano) keeps redraws coming until every surface has drawn27+ // 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 drew28+ // 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.Int6431 tickUntil atomic.Int64
31 // finish ends the run once a reveal completed; set by the runner before32 // 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 = 1106 m.to = 1
106 }107 }
107 m.start, m.dur, m.started = now, c.dur, true108 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 continue113 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 return140 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) {
2525 func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) {
2626 m := newFadeModel()
2727 m.send(fadeCmd{black: true, dur: time.Second})
28- t0 := time.Unix(100, 0)
28+ t0 := m.base.Add(time.Minute)
2929 a, done := m.step(t0)
3030 require.Equal(t, 0.0, a)
3131 require.False(t, done)
@@ -38,7 +38,7 @@ func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) {
3838 require.Equal(t, 1.0, a)
3939 require.True(t, done)
4040 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)))
4242 }
4343
4444 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) {

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