detached dff6a00

fix(core): publish the settled scene when only Animating changes

commit
dff6a00f5b23025f8957f96958d34c860a2fb3ed
author
brice · · 2026-10-13 17:58 UTC
committer
brice · · 2026-10-14 04:34 UTC
parent
76b5fd7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +57 -7
M internal/core/core.go +3 -2
@@ -43,8 +43,9 @@ type Channels struct {
4343 // Scales, when set, receives each scale a scale bind sets, to persist it.
4444 // Core does not wait: a full channel drops the change.
4545 Scales chan<- ports.ScaleChanged
46- // Frames, when set, reports outputs' page flips: a running slide moves
47- // one step per flip. Without flips it moves on a timer.
46+ // Frames, when set, carries the outputs' reports of each flip of an
47+ // animating scene (Scene.Animating): a running slide moves one step per
48+ // report. Without them it moves on a timer.
4849 Frames <-chan ports.OutputFrame
4950 }
5051
@@ -43,8 +43,9 @@ type Channels struct {
43 // Scales, when set, receives each scale a scale bind sets, to persist it.43 // Scales, when set, receives each scale a scale bind sets, to persist it.
44 // Core does not wait: a full channel drops the change.44 // Core does not wait: a full channel drops the change.
45 Scales chan<- ports.ScaleChanged45 Scales chan<- ports.ScaleChanged
46- // Frames, when set, reports outputs' page flips: a running slide moves46+ // Frames, when set, carries the outputs' reports of each flip of an
47- // one step per flip. Without flips it moves on a timer.47+ // animating scene (Scene.Animating): a running slide moves one step per
48+ // report. Without them it moves on a timer.
48 Frames <-chan ports.OutputFrame49 Frames <-chan ports.OutputFrame
49 }50 }
50 51
M internal/core/scene.go +7 -5
@@ -130,12 +130,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
130130 // does one of an animating screen (the one being stepped, or any
131131 // when none is): the outputs drop a scene they already show, and a
132132 // spring that starts at the previous scene, or rounds to it in its
133- // tail, would get no flip and wait for the fallback timer. Any other
134- // keeps its output's Seq while it draws the same, so an idle output
135- // is not recomposed.
133+ // tail, would get no flip and wait for the fallback timer. So does the
134+ // scene that flips Animating off: SameAs ignores the flag, and keeping
135+ // the Seq of the animating scene would leave outputs showing it, and
136+ // waking core on every later flip. Any other keeps its output's Seq
137+ // while it draws the same, so an idle output is not recomposed.
136138 withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
137- animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc)
138- if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
139+ animating := scene.Animating && (only == nil || only == sc)
140+ if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
139141 scene.Seq = sc.last.Seq
140142 } else {
141143 c.seq++
@@ -130,12 +130,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
130 // does one of an animating screen (the one being stepped, or any130 // does one of an animating screen (the one being stepped, or any
131 // when none is): the outputs drop a scene they already show, and a131 // when none is): the outputs drop a scene they already show, and a
132 // spring that starts at the previous scene, or rounds to it in its132 // spring that starts at the previous scene, or rounds to it in its
133- // tail, would get no flip and wait for the fallback timer. Any other133+ // tail, would get no flip and wait for the fallback timer. So does the
134- // keeps its output's Seq while it draws the same, so an idle output134+ // scene that flips Animating off: SameAs ignores the flag, and keeping
135- // is not recomposed.135+ // the Seq of the animating scene would leave outputs showing it, and
136+ // waking core on every later flip. Any other keeps its output's Seq
137+ // while it draws the same, so an idle output is not recomposed.
136 withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)138 withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
137- animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc)139+ animating := scene.Animating && (only == nil || only == sc)
138- if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {140+ if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
139 scene.Seq = sc.last.Seq141 scene.Seq = sc.last.Seq
140 } else {142 } else {
141 c.seq++143 c.seq++
M internal/core/scene_animating_test.go +47 -0
@@ -74,3 +74,50 @@ func TestSceneAnimatingIsPerOutput(t *testing.T) {
7474 t.Fatalf("animating A=%v B=%v, want B only", got["A"].Animating, got["B"].Animating)
7575 }
7676 }
77+
78+// A spring that settles on the very scene its last running step drew (the
79+// tail rounds to the final pixel) must still publish a scene with Animating
80+// off, with a fresh Seq: SameAs ignores the flag, and the outputs would
81+// otherwise keep the animating scene they show and wake core on every later
82+// flip.
83+func TestSettledSceneSameAsPreviousClearsAnimating(t *testing.T) {
84+ // Commands (rehit) are not read: room for every step.
85+ c, ic := indicatorCoreWith(t, make(chan ports.ClientCommand, 4096))
86+ c.cfg.Animations.On = true
87+ a := c.cur()
88+ a.mon.AddWindow(1)
89+ a.mon.AddWindow(2)
90+ indicatorScene(t, c)
91+
92+ w := a.mon.Current()
93+ w.view.off = 100
94+ w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
95+ // Step in 1 ms until the spring settles: the snap settles it once it is
96+ // within half a pixel of its target, where the last running scene can
97+ // draw the same pixels as the settled one.
98+ start := ic.now
99+ var running, settled ports.Scene
100+ for ms := 1; ; ms++ {
101+ if ms > 2000 {
102+ t.Fatal("the spring never settles")
103+ }
104+ ic.now = start.Add(time.Duration(ms) * time.Millisecond)
105+ settled = stepScenes(t, c, a)["A"]
106+ if !a.springing() {
107+ break
108+ }
109+ running = settled
110+ }
111+ if !running.Animating {
112+ t.Fatalf("bad setup: last running scene animating=%v", running.Animating)
113+ }
114+ if !settled.SameAs(running) {
115+ t.Fatal("bad setup: the settled scene differs from the last running one")
116+ }
117+ if settled.Animating {
118+ t.Fatal("the settled scene is animating")
119+ }
120+ if settled.Seq == running.Seq {
121+ t.Fatalf("settled scene kept the animating scene's Seq %d", running.Seq)
122+ }
123+}
@@ -74,3 +74,50 @@ func TestSceneAnimatingIsPerOutput(t *testing.T) {
74 t.Fatalf("animating A=%v B=%v, want B only", got["A"].Animating, got["B"].Animating)74 t.Fatalf("animating A=%v B=%v, want B only", got["A"].Animating, got["B"].Animating)
75 }75 }
76 }76 }
77+
78+// A spring that settles on the very scene its last running step drew (the
79+// tail rounds to the final pixel) must still publish a scene with Animating
80+// off, with a fresh Seq: SameAs ignores the flag, and the outputs would
81+// otherwise keep the animating scene they show and wake core on every later
82+// flip.
83+func TestSettledSceneSameAsPreviousClearsAnimating(t *testing.T) {
84+ // Commands (rehit) are not read: room for every step.
85+ c, ic := indicatorCoreWith(t, make(chan ports.ClientCommand, 4096))
86+ c.cfg.Animations.On = true
87+ a := c.cur()
88+ a.mon.AddWindow(1)
89+ a.mon.AddWindow(2)
90+ indicatorScene(t, c)
91+
92+ w := a.mon.Current()
93+ w.view.off = 100
94+ w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
95+ // Step in 1 ms until the spring settles: the snap settles it once it is
96+ // within half a pixel of its target, where the last running scene can
97+ // draw the same pixels as the settled one.
98+ start := ic.now
99+ var running, settled ports.Scene
100+ for ms := 1; ; ms++ {
101+ if ms > 2000 {
102+ t.Fatal("the spring never settles")
103+ }
104+ ic.now = start.Add(time.Duration(ms) * time.Millisecond)
105+ settled = stepScenes(t, c, a)["A"]
106+ if !a.springing() {
107+ break
108+ }
109+ running = settled
110+ }
111+ if !running.Animating {
112+ t.Fatalf("bad setup: last running scene animating=%v", running.Animating)
113+ }
114+ if !settled.SameAs(running) {
115+ t.Fatal("bad setup: the settled scene differs from the last running one")
116+ }
117+ if settled.Animating {
118+ t.Fatal("the settled scene is animating")
119+ }
120+ if settled.Seq == running.Seq {
121+ t.Fatalf("settled scene kept the animating scene's Seq %d", running.Seq)
122+ }
123+}

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 25

Tags 10

↑↓ moveenter openesc close