detached c0fdb38

perf(core): advance only the flipped output's springs and keep the pulse value between its flips

commit
c0fdb38d8b6640a992570c785d5b646882df3e84
author
brice · · 2026-10-04 15:44 UTC
committer
brice · · 2026-10-04 19:08 UTC
parent
d8dfa1d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +171 -18
M internal/core/core.go +19 -9
@@ -130,6 +130,9 @@ type Core struct {
130130 swipe *swipeGesture
131131 frameC <-chan time.Time
132132 frameStop func() bool
133+ // stepping is the screen a page flip advances (nil: all of them); it is
134+ // set while step publishes.
135+ stepping *screen
133136 // motionTime is the time of the last pointer motion; pointerAt is the
134137 // last position sent in the pointer's window.
135138 motionTime time.Duration
@@ -530,10 +533,14 @@ func (c *Core) publish(ctx context.Context) error {
530533 focus := c.keyboardFocus()
531534 window, _ := c.cur().mon.Focused()
532535 c.pulseFocus(window, focus == window)
533- var pulse float64
534- if c.pulse.id != 0 {
535- pulse = c.advancePulse(c.now())
536- }
536+ // The pulse runs on the focused output: a flip of another output reuses
537+ // its value so the focused scene keeps its Seq.
538+ if c.pulse.id == 0 {
539+ c.pulse.value = 0
540+ } else if c.stepping == nil || c.stepping == c.cur() {
541+ c.pulse.value = c.advancePulse(c.now())
542+ }
543+ pulse := c.pulse.value
537544 // drawable: the scene can show a pulse on the focused window.
538545 drawable := false
539546 scenes := make([]ports.Scene, 0, len(c.screens))
@@ -679,10 +686,13 @@ func (c *Core) publish(ctx context.Context) error {
679686 return nil
680687 }
681688
682-// step moves the running slides to now and publishes the frame.
683-func (c *Core) step(ctx context.Context) error {
689+// step moves the running slides of only (nil: every screen) to now and
690+// publishes the frame.
691+func (c *Core) step(ctx context.Context, only *screen) error {
684692 c.stopFrame()
685- c.animate(c.now())
693+ c.animate(c.now(), only)
694+ c.stepping = only
695+ defer func() { c.stepping = nil }()
686696 return c.slid(ctx, false)
687697 }
688698
@@ -1156,7 +1166,7 @@ func (c *Core) Run(ctx context.Context) error {
11561166 c.ch.Frames = nil
11571167 continue
11581168 }
1159- if c.sliding(f.Output) && c.step(ctx) != nil {
1169+ if i := c.screenIndex(f.Output); i >= 0 && c.sliding(f.Output) && c.step(ctx, c.screens[i]) != nil {
11601170 return nil
11611171 }
11621172 continue
@@ -1165,7 +1175,7 @@ func (c *Core) Run(ctx context.Context) error {
11651175 return nil
11661176 }
11671177 c.frameC, c.frameStop = nil, nil
1168- if c.step(ctx) != nil {
1178+ if c.step(ctx, nil) != nil {
11691179 return nil
11701180 }
11711181 continue
@@ -130,6 +130,9 @@ type Core struct {
130 swipe *swipeGesture130 swipe *swipeGesture
131 frameC <-chan time.Time131 frameC <-chan time.Time
132 frameStop func() bool132 frameStop func() bool
133+ // stepping is the screen a page flip advances (nil: all of them); it is
134+ // set while step publishes.
135+ stepping *screen
133 // motionTime is the time of the last pointer motion; pointerAt is the136 // motionTime is the time of the last pointer motion; pointerAt is the
134 // last position sent in the pointer's window.137 // last position sent in the pointer's window.
135 motionTime time.Duration138 motionTime time.Duration
@@ -530,10 +533,14 @@ func (c *Core) publish(ctx context.Context) error {
530 focus := c.keyboardFocus()533 focus := c.keyboardFocus()
531 window, _ := c.cur().mon.Focused()534 window, _ := c.cur().mon.Focused()
532 c.pulseFocus(window, focus == window)535 c.pulseFocus(window, focus == window)
533- var pulse float64536+ // The pulse runs on the focused output: a flip of another output reuses
534- if c.pulse.id != 0 {537+ // its value so the focused scene keeps its Seq.
535- pulse = c.advancePulse(c.now())538+ if c.pulse.id == 0 {
536- }539+ c.pulse.value = 0
540+ } else if c.stepping == nil || c.stepping == c.cur() {
541+ c.pulse.value = c.advancePulse(c.now())
542+ }
543+ pulse := c.pulse.value
537 // drawable: the scene can show a pulse on the focused window.544 // drawable: the scene can show a pulse on the focused window.
538 drawable := false545 drawable := false
539 scenes := make([]ports.Scene, 0, len(c.screens))546 scenes := make([]ports.Scene, 0, len(c.screens))
@@ -679,10 +686,13 @@ func (c *Core) publish(ctx context.Context) error {
679 return nil686 return nil
680 }687 }
681 688
682-// step moves the running slides to now and publishes the frame.689+// step moves the running slides of only (nil: every screen) to now and
683-func (c *Core) step(ctx context.Context) error {690+// publishes the frame.
691+func (c *Core) step(ctx context.Context, only *screen) error {
684 c.stopFrame()692 c.stopFrame()
685- c.animate(c.now())693+ c.animate(c.now(), only)
694+ c.stepping = only
695+ defer func() { c.stepping = nil }()
686 return c.slid(ctx, false)696 return c.slid(ctx, false)
687 }697 }
688 698
@@ -1156,7 +1166,7 @@ func (c *Core) Run(ctx context.Context) error {
1156 c.ch.Frames = nil1166 c.ch.Frames = nil
1157 continue1167 continue
1158 }1168 }
1159- if c.sliding(f.Output) && c.step(ctx) != nil {1169+ if i := c.screenIndex(f.Output); i >= 0 && c.sliding(f.Output) && c.step(ctx, c.screens[i]) != nil {
1160 return nil1170 return nil
1161 }1171 }
1162 continue1172 continue
@@ -1165,7 +1175,7 @@ func (c *Core) Run(ctx context.Context) error {
1165 return nil1175 return nil
1166 }1176 }
1167 c.frameC, c.frameStop = nil, nil1177 c.frameC, c.frameStop = nil, nil
1168- if c.step(ctx) != nil {1178+ if c.step(ctx, nil) != nil {
1169 return nil1179 return nil
1170 }1180 }
1171 continue1181 continue
A internal/core/frame_output_test.go +67 -0
@@ -0,0 +1,67 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func sceneFor(set []ports.Scene, output string) ports.Scene {
11+ for _, s := range set {
12+ if s.Output == output {
13+ return s
14+ }
15+ }
16+ return ports.Scene{}
17+}
18+
19+// A page flip of one output advances only that output's springs: the other
20+// output keeps its scene (and Seq) until its own flip.
21+func TestFlipAdvancesOnlyItsOutput(t *testing.T) {
22+ r := startSwipe(t, nil)
23+ threeColumns(t, r)
24+ second := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
25+ r.plug(t, second)
26+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
27+ scene(t, r.scenes)
28+ for id := ports.WindowID(4); id <= 6; id++ {
29+ r.mapWindow(t, id)
30+ }
31+ // A running landing on each output.
32+ r.input <- ports.PointerMotion{X: 100, Y: 300}
33+ scene(t, r.scenes)
34+ r.flick(t, 10, -40, 0)
35+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
36+ scene(t, r.scenes)
37+ r.flick(t, 10, -40, 0)
38+ r.drain()
39+
40+ // The first flip of each output moves its columns.
41+ r.advance(32 * time.Millisecond)
42+ r.frames <- ports.OutputFrame{Output: wide.Name}
43+ set := receive(t, r.scenes)
44+ a, b := sceneFor(set, wide.Name), sceneFor(set, second.Name)
45+ r.advance(32 * time.Millisecond)
46+ r.frames <- ports.OutputFrame{Output: second.Name}
47+ set = receive(t, r.scenes)
48+ a2, b2 := sceneFor(set, wide.Name), sceneFor(set, second.Name)
49+ if a2.Seq != a.Seq || !a2.SameAs(a) {
50+ t.Fatalf("DP-1 changed on a flip of DP-2: Seq %d, was %d", a2.Seq, a.Seq)
51+ }
52+ if b2.Seq <= b.Seq || b2.SameAs(b) {
53+ t.Fatalf("DP-2 did not move on its own flip: Seq %d, was %d", b2.Seq, b.Seq)
54+ }
55+
56+ // And the other way round.
57+ r.advance(32 * time.Millisecond)
58+ r.frames <- ports.OutputFrame{Output: wide.Name}
59+ set = receive(t, r.scenes)
60+ a3, b3 := sceneFor(set, wide.Name), sceneFor(set, second.Name)
61+ if a3.Seq <= a2.Seq || a3.SameAs(a2) {
62+ t.Fatalf("DP-1 did not move on its own flip: Seq %d, was %d", a3.Seq, a2.Seq)
63+ }
64+ if b3.Seq != b2.Seq || !b3.SameAs(b2) {
65+ t.Fatalf("DP-2 changed on a flip of DP-1: Seq %d, was %d", b3.Seq, b2.Seq)
66+ }
67+}
@@ -0,0 +1,67 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func sceneFor(set []ports.Scene, output string) ports.Scene {
11+ for _, s := range set {
12+ if s.Output == output {
13+ return s
14+ }
15+ }
16+ return ports.Scene{}
17+}
18+
19+// A page flip of one output advances only that output's springs: the other
20+// output keeps its scene (and Seq) until its own flip.
21+func TestFlipAdvancesOnlyItsOutput(t *testing.T) {
22+ r := startSwipe(t, nil)
23+ threeColumns(t, r)
24+ second := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
25+ r.plug(t, second)
26+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
27+ scene(t, r.scenes)
28+ for id := ports.WindowID(4); id <= 6; id++ {
29+ r.mapWindow(t, id)
30+ }
31+ // A running landing on each output.
32+ r.input <- ports.PointerMotion{X: 100, Y: 300}
33+ scene(t, r.scenes)
34+ r.flick(t, 10, -40, 0)
35+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
36+ scene(t, r.scenes)
37+ r.flick(t, 10, -40, 0)
38+ r.drain()
39+
40+ // The first flip of each output moves its columns.
41+ r.advance(32 * time.Millisecond)
42+ r.frames <- ports.OutputFrame{Output: wide.Name}
43+ set := receive(t, r.scenes)
44+ a, b := sceneFor(set, wide.Name), sceneFor(set, second.Name)
45+ r.advance(32 * time.Millisecond)
46+ r.frames <- ports.OutputFrame{Output: second.Name}
47+ set = receive(t, r.scenes)
48+ a2, b2 := sceneFor(set, wide.Name), sceneFor(set, second.Name)
49+ if a2.Seq != a.Seq || !a2.SameAs(a) {
50+ t.Fatalf("DP-1 changed on a flip of DP-2: Seq %d, was %d", a2.Seq, a.Seq)
51+ }
52+ if b2.Seq <= b.Seq || b2.SameAs(b) {
53+ t.Fatalf("DP-2 did not move on its own flip: Seq %d, was %d", b2.Seq, b.Seq)
54+ }
55+
56+ // And the other way round.
57+ r.advance(32 * time.Millisecond)
58+ r.frames <- ports.OutputFrame{Output: wide.Name}
59+ set = receive(t, r.scenes)
60+ a3, b3 := sceneFor(set, wide.Name), sceneFor(set, second.Name)
61+ if a3.Seq <= a2.Seq || a3.SameAs(a2) {
62+ t.Fatalf("DP-1 did not move on its own flip: Seq %d, was %d", a3.Seq, a2.Seq)
63+ }
64+ if b3.Seq != b2.Seq || !b3.SameAs(b2) {
65+ t.Fatalf("DP-2 changed on a flip of DP-1: Seq %d, was %d", b3.Seq, b2.Seq)
66+ }
67+}
A internal/core/frame_pulse_test.go +69 -0
@@ -0,0 +1,69 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// stepScenes steps only the screen and returns the scene of each output.
12+func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
13+ t.Helper()
14+ if err := c.step(context.Background(), only); err != nil {
15+ t.Fatal(err)
16+ }
17+ out := map[string]ports.Scene{}
18+ for _, s := range <-c.ch.Scenes {
19+ out[s.Output] = s
20+ }
21+ return out
22+}
23+
24+// While the focus pulse runs on the focused output, a flip of another output
25+// keeps the focused output's Seq; its own flip samples the pulse.
26+func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) {
27+ c, ic := pulseCore(t)
28+ a := c.cur()
29+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
30+ b := c.screens[1]
31+ for id := WindowID(3); id <= 5; id++ {
32+ b.mon.AddWindow(id)
33+ }
34+ indicatorScene(t, c)
35+
36+ // A pulse on window 1 of A.
37+ a.mon.Current().FocusID(1)
38+ indicatorScene(t, c)
39+ ic.now = ic.now.Add(pulseSettle)
40+ if !c.pulseTick() {
41+ t.Fatal("no pulse after settle")
42+ }
43+ // A camera transition runs on B.
44+ w := b.mon.Current()
45+ w.shift = 100
46+ w.motion = newMotion(viewSpring(w.shift, 0), ic.now, 1)
47+
48+ ic.now = ic.now.Add(pulseRise / 2)
49+ first := stepScenes(t, c, a)
50+ if pulseOf(first["A"], 1) <= 0 {
51+ t.Fatalf("no pulse on A: %v", pulseOf(first["A"], 1))
52+ }
53+ ic.now = ic.now.Add(10 * time.Millisecond)
54+ second := stepScenes(t, c, b)
55+ if second["A"].Seq != first["A"].Seq || !second["A"].SameAs(first["A"]) {
56+ t.Fatalf("A changed on a flip of B: Seq %d, was %d", second["A"].Seq, first["A"].Seq)
57+ }
58+ if second["B"].SameAs(first["B"]) {
59+ t.Fatal("B did not move on its own flip")
60+ }
61+ ic.now = ic.now.Add(10 * time.Millisecond)
62+ third := stepScenes(t, c, a)
63+ if third["A"].Seq <= second["A"].Seq || pulseOf(third["A"], 1) == pulseOf(second["A"], 1) {
64+ t.Fatalf("A did not sample the pulse on its own flip: Seq %d, was %d", third["A"].Seq, second["A"].Seq)
65+ }
66+ if third["B"].Seq != second["B"].Seq {
67+ t.Fatalf("B changed on a flip of A: Seq %d, was %d", third["B"].Seq, second["B"].Seq)
68+ }
69+}
@@ -0,0 +1,69 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// stepScenes steps only the screen and returns the scene of each output.
12+func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
13+ t.Helper()
14+ if err := c.step(context.Background(), only); err != nil {
15+ t.Fatal(err)
16+ }
17+ out := map[string]ports.Scene{}
18+ for _, s := range <-c.ch.Scenes {
19+ out[s.Output] = s
20+ }
21+ return out
22+}
23+
24+// While the focus pulse runs on the focused output, a flip of another output
25+// keeps the focused output's Seq; its own flip samples the pulse.
26+func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) {
27+ c, ic := pulseCore(t)
28+ a := c.cur()
29+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
30+ b := c.screens[1]
31+ for id := WindowID(3); id <= 5; id++ {
32+ b.mon.AddWindow(id)
33+ }
34+ indicatorScene(t, c)
35+
36+ // A pulse on window 1 of A.
37+ a.mon.Current().FocusID(1)
38+ indicatorScene(t, c)
39+ ic.now = ic.now.Add(pulseSettle)
40+ if !c.pulseTick() {
41+ t.Fatal("no pulse after settle")
42+ }
43+ // A camera transition runs on B.
44+ w := b.mon.Current()
45+ w.shift = 100
46+ w.motion = newMotion(viewSpring(w.shift, 0), ic.now, 1)
47+
48+ ic.now = ic.now.Add(pulseRise / 2)
49+ first := stepScenes(t, c, a)
50+ if pulseOf(first["A"], 1) <= 0 {
51+ t.Fatalf("no pulse on A: %v", pulseOf(first["A"], 1))
52+ }
53+ ic.now = ic.now.Add(10 * time.Millisecond)
54+ second := stepScenes(t, c, b)
55+ if second["A"].Seq != first["A"].Seq || !second["A"].SameAs(first["A"]) {
56+ t.Fatalf("A changed on a flip of B: Seq %d, was %d", second["A"].Seq, first["A"].Seq)
57+ }
58+ if second["B"].SameAs(first["B"]) {
59+ t.Fatal("B did not move on its own flip")
60+ }
61+ ic.now = ic.now.Add(10 * time.Millisecond)
62+ third := stepScenes(t, c, a)
63+ if third["A"].Seq <= second["A"].Seq || pulseOf(third["A"], 1) == pulseOf(second["A"], 1) {
64+ t.Fatalf("A did not sample the pulse on its own flip: Seq %d, was %d", third["A"].Seq, second["A"].Seq)
65+ }
66+ if third["B"].Seq != second["B"].Seq {
67+ t.Fatalf("B changed on a flip of A: Seq %d, was %d", third["B"].Seq, second["B"].Seq)
68+ }
69+}
M internal/core/gesture.go +6 -2
@@ -353,9 +353,13 @@ func (c *Core) stopAnimations() {
353353 c.stopFrame()
354354 }
355355
356-// animate moves the running springs to now; settled ones stop.
357-func (c *Core) animate(now time.Time) {
356+// animate moves the running springs to now; settled ones stop. A non-nil only
357+// limits it to that screen (its own page flip); nil moves every screen.
358+func (c *Core) animate(now time.Time, only *screen) {
358359 for _, sc := range c.screens {
360+ if only != nil && sc != only {
361+ continue
362+ }
359363 m := sc.mon
360364 if m.switchMotion.on {
361365 v, done := m.switchMotion.at(now)
@@ -353,9 +353,13 @@ func (c *Core) stopAnimations() {
353 c.stopFrame()353 c.stopFrame()
354 }354 }
355 355
356-// animate moves the running springs to now; settled ones stop.356+// animate moves the running springs to now; settled ones stop. A non-nil only
357-func (c *Core) animate(now time.Time) {357+// limits it to that screen (its own page flip); nil moves every screen.
358+func (c *Core) animate(now time.Time, only *screen) {
358 for _, sc := range c.screens {359 for _, sc := range c.screens {
360+ if only != nil && sc != only {
361+ continue
362+ }
359 m := sc.mon363 m := sc.mon
360 if m.switchMotion.on {364 if m.switchMotion.on {
361 v, done := m.switchMotion.at(now)365 v, done := m.switchMotion.at(now)
M internal/core/pulse.go +10 -7
@@ -32,13 +32,16 @@ const (
3232 // are the previous pulse, for the cooldown. drawable is whether the last
3333 // scene could show a pulse on target.
3434 type focusPulse struct {
35- target WindowID
36- since time.Time
37- id WindowID
38- start time.Time
39- last WindowID
40- lastAt time.Time
41- drawable bool
35+ target WindowID
36+ since time.Time
37+ id WindowID
38+ start time.Time
39+ last WindowID
40+ lastAt time.Time
41+ drawable bool
42+ // value is the effect of the last sample, kept for scenes published
43+ // between the focused output's own flips.
44+ value float64
4245 timerC <-chan time.Time
4346 timerStop func() bool
4447 }
@@ -32,13 +32,16 @@ const (
32 // are the previous pulse, for the cooldown. drawable is whether the last32 // are the previous pulse, for the cooldown. drawable is whether the last
33 // scene could show a pulse on target.33 // scene could show a pulse on target.
34 type focusPulse struct {34 type focusPulse struct {
35- target WindowID35+ target WindowID
36- since time.Time36+ since time.Time
37- id WindowID37+ id WindowID
38- start time.Time38+ start time.Time
39- last WindowID39+ last WindowID
40- lastAt time.Time40+ lastAt time.Time
41- drawable bool41+ drawable bool
42+ // value is the effect of the last sample, kept for scenes published
43+ // between the focused output's own flips.
44+ value float64
42 timerC <-chan time.Time45 timerC <-chan time.Time
43 timerStop func() bool46 timerStop func() bool
44 }47 }

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 23

Tags 10

↑↓ moveenter openesc close