detached 4cbd8f8

test(core): cover fresh Seq, fallback timer, chained velocity and transitions

Adds tests for the animating Seq, the fallback timer under another
output's flips, velocity carry-over, click and discrete-swipe transitions,
a following swipe and framed switches. Folds the pulse transition test
into the slide table, merges the moved-workspace tests, strengthens the
overview test, and removes two duplicates.
commit
4cbd8f89908f95bec1ff8c90fbbcab0190e9b552
author
brice · · 2026-10-04 19:06 UTC
committer
brice · · 2026-10-04 19:08 UTC
parent
3f1045d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +398 -109
M internal/core/animations_off_test.go +7 -2
@@ -66,11 +66,16 @@ func TestReloadToOffSettlesALanding(t *testing.T) {
6666
6767 // noFrameScene sends page flips and expects no scene: no spring runs, so
6868 // the flips have nothing to move and publish nothing.
69-func (r *swipeRig) noFrameScene(t *testing.T) {
69+func (r *swipeRig) noFrameScene(t *testing.T, outputs ...string) {
7070 t.Helper()
71+ if len(outputs) == 0 {
72+ outputs = []string{wide.Name}
73+ }
7174 for range 2 {
7275 r.advance(16 * time.Millisecond)
73- r.frames <- ports.OutputFrame{Output: wide.Name}
76+ for _, o := range outputs {
77+ r.frames <- ports.OutputFrame{Output: o}
78+ }
7479 }
7580 select {
7681 case s := <-r.scenes:
@@ -66,11 +66,16 @@ func TestReloadToOffSettlesALanding(t *testing.T) {
66 66
67 // noFrameScene sends page flips and expects no scene: no spring runs, so67 // noFrameScene sends page flips and expects no scene: no spring runs, so
68 // the flips have nothing to move and publish nothing.68 // the flips have nothing to move and publish nothing.
69-func (r *swipeRig) noFrameScene(t *testing.T) {69+func (r *swipeRig) noFrameScene(t *testing.T, outputs ...string) {
70 t.Helper()70 t.Helper()
71+ if len(outputs) == 0 {
72+ outputs = []string{wide.Name}
73+ }
71 for range 2 {74 for range 2 {
72 r.advance(16 * time.Millisecond)75 r.advance(16 * time.Millisecond)
73- r.frames <- ports.OutputFrame{Output: wide.Name}76+ for _, o := range outputs {
77+ r.frames <- ports.OutputFrame{Output: o}
78+ }
74 }79 }
75 select {80 select {
76 case s := <-r.scenes:81 case s := <-r.scenes:
M internal/core/animations_stop_test.go +1 -15
@@ -1,9 +1,6 @@
11 package core
22
3-import (
4- "testing"
5- "time"
6-)
3+import "testing"
74
85 func TestStopAnimationsSettlesEverySpring(t *testing.T) {
96 c, ic := indicatorCore(t)
@@ -22,14 +19,3 @@ func TestStopAnimationsSettlesEverySpring(t *testing.T) {
2219 t.Fatalf("springs left: shift %v, switch %v, motion %v %v, timer %v", w.shift, m.switchOff, w.motion.on, m.switchMotion.on, c.frameC != nil)
2320 }
2421 }
25-
26-func TestSpringAppliesSlowdown(t *testing.T) {
27- c, ic := indicatorCore(t)
28- c.cfg.Animations.Slowdown = 1
29- one := c.spring(viewSpring(40, 0), ic.now)
30- c.cfg.Animations.Slowdown = 2
31- two := c.spring(viewSpring(40, 0), ic.now)
32- if d := two.end - 2*one.end; d < -time.Microsecond || d > time.Microsecond {
33- t.Fatalf("slowdown 2 ends at %v, slowdown 1 at %v", two.end, one.end)
34- }
35-}
@@ -1,9 +1,6 @@
1 package core1 package core
2 2
3-import (3+import "testing"
4- "testing"
5- "time"
6-)
7 4
8 func TestStopAnimationsSettlesEverySpring(t *testing.T) {5 func TestStopAnimationsSettlesEverySpring(t *testing.T) {
9 c, ic := indicatorCore(t)6 c, ic := indicatorCore(t)
@@ -22,14 +19,3 @@ func TestStopAnimationsSettlesEverySpring(t *testing.T) {
22 t.Fatalf("springs left: shift %v, switch %v, motion %v %v, timer %v", w.shift, m.switchOff, w.motion.on, m.switchMotion.on, c.frameC != nil)19 t.Fatalf("springs left: shift %v, switch %v, motion %v %v, timer %v", w.shift, m.switchOff, w.motion.on, m.switchMotion.on, c.frameC != nil)
23 }20 }
24 }21 }
25-
26-func TestSpringAppliesSlowdown(t *testing.T) {
27- c, ic := indicatorCore(t)
28- c.cfg.Animations.Slowdown = 1
29- one := c.spring(viewSpring(40, 0), ic.now)
30- c.cfg.Animations.Slowdown = 2
31- two := c.spring(viewSpring(40, 0), ic.now)
32- if d := two.end - 2*one.end; d < -time.Microsecond || d > time.Microsecond {
33- t.Fatalf("slowdown 2 ends at %v, slowdown 1 at %v", two.end, one.end)
34- }
35-}
M internal/core/pulse_test.go +31 -7
@@ -185,30 +185,54 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
185185 }
186186 }
187187
188-// A swipe or its landing slide still shows the neighbours: the pulse waits
189-// for the layout to settle, then decides on it.
188+// A swipe, its landing slide or a transition still shows the neighbours: the
189+// pulse waits for the layout to settle, then decides on it.
190190 func TestFocusPulseWaitsForSlide(t *testing.T) {
191191 for _, tc := range []struct {
192192 name string
193- slide func(*Monitor, time.Time)
193+ slide func(*testing.T, *Core, time.Time)
194194 alone bool
195195 }{
196- {"workspace landing, alone", func(m *Monitor, now time.Time) { m.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1) }, true},
197- {"workspace drag, alone", func(m *Monitor, _ time.Time) { m.switchOff = 0.3 }, true},
198- {"column drag, beside another", func(m *Monitor, _ time.Time) { m.Current().shift = 40 }, false},
196+ {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
197+ c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1)
198+ }, true},
199+ {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchOff = 0.3 }, true},
200+ {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().shift = 40 }, false},
201+ {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
202+ sc := c.cur()
203+ sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
204+ sc.rectsWS = sc.mon.Current()
205+ }, false},
206+ {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
207+ m := c.cur().mon
208+ for id := WindowID(3); id <= 4; id++ {
209+ m.AddWindow(id)
210+ }
211+ indicatorScene(t, c)
212+ before := c.snapshot()
213+ for range 3 {
214+ c.applyAction(ActionFocusColumnLeft)
215+ }
216+ c.transition(before, now)
217+ if !m.Current().motion.on || m.settled() {
218+ t.Fatal("no camera transition after the focus moved the view")
219+ }
220+ }, false},
199221 } {
200222 t.Run(tc.name, func(t *testing.T) {
201223 c, ic := pulseCore(t)
202224 m := c.cur().mon
203225 m.Current().FocusID(1)
204226 indicatorScene(t, c)
205- tc.slide(m, ic.now)
227+ tc.slide(t, c, ic.now)
228+ indicatorScene(t, c)
206229 ic.now = ic.now.Add(pulseSettle)
207230 if c.pulseTick() || c.pulse.timerC == nil {
208231 t.Fatal("pulse decided during a slide")
209232 }
210233 m.stopSwitch()
211234 m.Current().stopSlide()
235+ c.cur().stopRects()
212236 if tc.alone {
213237 m.Current().ToggleFullWidth()
214238 }
@@ -185,30 +185,54 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
185 }185 }
186 }186 }
187 187
188-// A swipe or its landing slide still shows the neighbours: the pulse waits188+// A swipe, its landing slide or a transition still shows the neighbours: the
189-// for the layout to settle, then decides on it.189+// pulse waits for the layout to settle, then decides on it.
190 func TestFocusPulseWaitsForSlide(t *testing.T) {190 func TestFocusPulseWaitsForSlide(t *testing.T) {
191 for _, tc := range []struct {191 for _, tc := range []struct {
192 name string192 name string
193- slide func(*Monitor, time.Time)193+ slide func(*testing.T, *Core, time.Time)
194 alone bool194 alone bool
195 }{195 }{
196- {"workspace landing, alone", func(m *Monitor, now time.Time) { m.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1) }, true},196+ {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
197- {"workspace drag, alone", func(m *Monitor, _ time.Time) { m.switchOff = 0.3 }, true},197+ c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1)
198- {"column drag, beside another", func(m *Monitor, _ time.Time) { m.Current().shift = 40 }, false},198+ }, true},
199+ {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchOff = 0.3 }, true},
200+ {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().shift = 40 }, false},
201+ {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
202+ sc := c.cur()
203+ sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
204+ sc.rectsWS = sc.mon.Current()
205+ }, false},
206+ {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
207+ m := c.cur().mon
208+ for id := WindowID(3); id <= 4; id++ {
209+ m.AddWindow(id)
210+ }
211+ indicatorScene(t, c)
212+ before := c.snapshot()
213+ for range 3 {
214+ c.applyAction(ActionFocusColumnLeft)
215+ }
216+ c.transition(before, now)
217+ if !m.Current().motion.on || m.settled() {
218+ t.Fatal("no camera transition after the focus moved the view")
219+ }
220+ }, false},
199 } {221 } {
200 t.Run(tc.name, func(t *testing.T) {222 t.Run(tc.name, func(t *testing.T) {
201 c, ic := pulseCore(t)223 c, ic := pulseCore(t)
202 m := c.cur().mon224 m := c.cur().mon
203 m.Current().FocusID(1)225 m.Current().FocusID(1)
204 indicatorScene(t, c)226 indicatorScene(t, c)
205- tc.slide(m, ic.now)227+ tc.slide(t, c, ic.now)
228+ indicatorScene(t, c)
206 ic.now = ic.now.Add(pulseSettle)229 ic.now = ic.now.Add(pulseSettle)
207 if c.pulseTick() || c.pulse.timerC == nil {230 if c.pulseTick() || c.pulse.timerC == nil {
208 t.Fatal("pulse decided during a slide")231 t.Fatal("pulse decided during a slide")
209 }232 }
210 m.stopSwitch()233 m.stopSwitch()
211 m.Current().stopSlide()234 m.Current().stopSlide()
235+ c.cur().stopRects()
212 if tc.alone {236 if tc.alone {
213 m.Current().ToggleFullWidth()237 m.Current().ToggleFullWidth()
214 }238 }
M internal/core/scene_seq_test.go +52 -0
@@ -24,3 +24,55 @@ func TestSceneSeqKeptWhenNothingChanged(t *testing.T) {
2424 t.Fatalf("Seq %d after mapping a window (was %d), %d windows", next.Seq, first.Seq, len(next.Windows))
2525 }
2626 }
27+
28+// A key that starts a transition can publish a scene that draws what the
29+// previous one did (the springs start there): it still gets a fresh Seq, or
30+// the outputs would drop it and the motion would wait for the fallback timer
31+// to begin.
32+func TestAnimatingKeyGetsFreshSeq(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ edit func(*ports.Config)
36+ // setup returns the scene before the key.
37+ setup func(*testing.T, *swipeRig) ports.Scene
38+ sym string
39+ mods ports.Mods
40+ // same: the key's scene draws what the previous one did.
41+ same bool
42+ }{
43+ {"width change", func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" },
44+ func(t *testing.T, r *swipeRig) ports.Scene {
45+ twoColumns(t, r)
46+ return r.key(t, "Left", ports.ModAlt)[0]
47+ }, "Right", ports.ModAlt | ports.ModCtrl, true},
48+ {"focus scroll", nil, func(t *testing.T, r *swipeRig) ports.Scene {
49+ threeColumns(t, r)
50+ return r.key(t, "Left", ports.ModAlt)[0] // 2 is on screen: no scroll
51+ }, "Left", ports.ModAlt, false},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ r := startSwipe(t, tt.edit)
56+ prev := tt.setup(t, r)
57+ s := r.key(t, tt.sym, tt.mods)[0]
58+ if s.SameAs(prev) != tt.same {
59+ t.Fatalf("scene right after the key same as the previous one: %t, want %t", s.SameAs(prev), tt.same)
60+ }
61+ if s.Seq <= prev.Seq {
62+ t.Fatalf("Seq %d right after the key, want a new one (was %d)", s.Seq, prev.Seq)
63+ }
64+ })
65+ }
66+}
67+
68+// A key that animates nothing keeps its output's Seq.
69+func TestNoOpKeyKeepsSeqWithAnimationsOn(t *testing.T) {
70+ r := startSwipe(t, nil)
71+ threeColumns(t, r)
72+ first := r.key(t, "Right", ports.ModAlt)[0] // 3 is the last column
73+ for range 2 {
74+ if s := r.key(t, "Right", ports.ModAlt)[0]; s.Seq != first.Seq || !s.SameAs(first) {
75+ t.Fatalf("Seq %d on a key at the edge, want the held %d", s.Seq, first.Seq)
76+ }
77+ }
78+}
@@ -24,3 +24,55 @@ func TestSceneSeqKeptWhenNothingChanged(t *testing.T) {
24 t.Fatalf("Seq %d after mapping a window (was %d), %d windows", next.Seq, first.Seq, len(next.Windows))24 t.Fatalf("Seq %d after mapping a window (was %d), %d windows", next.Seq, first.Seq, len(next.Windows))
25 }25 }
26 }26 }
27+
28+// A key that starts a transition can publish a scene that draws what the
29+// previous one did (the springs start there): it still gets a fresh Seq, or
30+// the outputs would drop it and the motion would wait for the fallback timer
31+// to begin.
32+func TestAnimatingKeyGetsFreshSeq(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ edit func(*ports.Config)
36+ // setup returns the scene before the key.
37+ setup func(*testing.T, *swipeRig) ports.Scene
38+ sym string
39+ mods ports.Mods
40+ // same: the key's scene draws what the previous one did.
41+ same bool
42+ }{
43+ {"width change", func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" },
44+ func(t *testing.T, r *swipeRig) ports.Scene {
45+ twoColumns(t, r)
46+ return r.key(t, "Left", ports.ModAlt)[0]
47+ }, "Right", ports.ModAlt | ports.ModCtrl, true},
48+ {"focus scroll", nil, func(t *testing.T, r *swipeRig) ports.Scene {
49+ threeColumns(t, r)
50+ return r.key(t, "Left", ports.ModAlt)[0] // 2 is on screen: no scroll
51+ }, "Left", ports.ModAlt, false},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ r := startSwipe(t, tt.edit)
56+ prev := tt.setup(t, r)
57+ s := r.key(t, tt.sym, tt.mods)[0]
58+ if s.SameAs(prev) != tt.same {
59+ t.Fatalf("scene right after the key same as the previous one: %t, want %t", s.SameAs(prev), tt.same)
60+ }
61+ if s.Seq <= prev.Seq {
62+ t.Fatalf("Seq %d right after the key, want a new one (was %d)", s.Seq, prev.Seq)
63+ }
64+ })
65+ }
66+}
67+
68+// A key that animates nothing keeps its output's Seq.
69+func TestNoOpKeyKeepsSeqWithAnimationsOn(t *testing.T) {
70+ r := startSwipe(t, nil)
71+ threeColumns(t, r)
72+ first := r.key(t, "Right", ports.ModAlt)[0] // 3 is the last column
73+ for range 2 {
74+ if s := r.key(t, "Right", ports.ModAlt)[0]; s.Seq != first.Seq || !s.SameAs(first) {
75+ t.Fatalf("Seq %d on a key at the edge, want the held %d", s.Seq, first.Seq)
76+ }
77+ }
78+}
M internal/core/swipe_test.go +6 -12
@@ -477,7 +477,7 @@ func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) {
477477 }
478478
479479 func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
480- r := startSwipe(t, animationsOff)
480+ r := startSwipe(t, nil)
481481 before, _ := rectOf(threeColumns(t, r), 2)
482482 r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
483483 // Window 4 on DP-2, so it has a workspace below to switch to.
@@ -492,7 +492,11 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
492492 // The pointer goes to DP-2 and a bind switches its workspace.
493493 r.input <- ports.PointerMotion{X: 1000, Y: 300}
494494 scene(t, r.scenes)
495- for _, sc := range r.key(t, "Next", ports.ModAlt) {
495+ r.key(t, "Next", ports.ModAlt)
496+ // DP-2 slides to its other workspace on its own flips.
497+ r.advance(5 * time.Second)
498+ r.frames <- ports.OutputFrame{Output: "DP-2"}
499+ for _, sc := range receive(t, r.scenes) {
496500 if _, ok := rectOf(sc, 4); ok {
497501 t.Fatal("DP-2 did not switch workspace")
498502 }
@@ -567,16 +571,6 @@ func (r *swipeRig) drain() {
567571 }
568572 }
569573
570-func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {
571- r := startSwipe(t, animationsOff)
572- threeColumns(t, r)
573- r.key(t, "Left", ports.ModAlt)
574- s := r.key(t, "Left", ports.ModAlt)[0]
575- if got, _ := rectOf(s, 1); got.X != 0 {
576- t.Fatalf("column 1 at %d", got.X)
577- }
578-}
579-
580574 // In the overview a three-finger swipe does not slide the view: nothing
581575 // is drawn while the fingers move, and when they lift the selection moves
582576 // one column, the previews where they were.
@@ -477,7 +477,7 @@ func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) {
477 }477 }
478 478
479 func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {479 func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
480- r := startSwipe(t, animationsOff)480+ r := startSwipe(t, nil)
481 before, _ := rectOf(threeColumns(t, r), 2)481 before, _ := rectOf(threeColumns(t, r), 2)
482 r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})482 r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
483 // Window 4 on DP-2, so it has a workspace below to switch to.483 // Window 4 on DP-2, so it has a workspace below to switch to.
@@ -492,7 +492,11 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
492 // The pointer goes to DP-2 and a bind switches its workspace.492 // The pointer goes to DP-2 and a bind switches its workspace.
493 r.input <- ports.PointerMotion{X: 1000, Y: 300}493 r.input <- ports.PointerMotion{X: 1000, Y: 300}
494 scene(t, r.scenes)494 scene(t, r.scenes)
495- for _, sc := range r.key(t, "Next", ports.ModAlt) {495+ r.key(t, "Next", ports.ModAlt)
496+ // DP-2 slides to its other workspace on its own flips.
497+ r.advance(5 * time.Second)
498+ r.frames <- ports.OutputFrame{Output: "DP-2"}
499+ for _, sc := range receive(t, r.scenes) {
496 if _, ok := rectOf(sc, 4); ok {500 if _, ok := rectOf(sc, 4); ok {
497 t.Fatal("DP-2 did not switch workspace")501 t.Fatal("DP-2 did not switch workspace")
498 }502 }
@@ -567,16 +571,6 @@ func (r *swipeRig) drain() {
567 }571 }
568 }572 }
569 573
570-func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {
571- r := startSwipe(t, animationsOff)
572- threeColumns(t, r)
573- r.key(t, "Left", ports.ModAlt)
574- s := r.key(t, "Left", ports.ModAlt)[0]
575- if got, _ := rectOf(s, 1); got.X != 0 {
576- t.Fatalf("column 1 at %d", got.X)
577- }
578-}
579-
580 // In the overview a three-finger swipe does not slide the view: nothing574 // In the overview a three-finger swipe does not slide the view: nothing
581 // is drawn while the fingers move, and when they lift the selection moves575 // is drawn while the fingers move, and when they lift the selection moves
582 // one column, the previews where they were.576 // one column, the previews where they were.
A internal/core/transition_chain_internal_test.go +208 -0
@@ -0,0 +1,208 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// settleShown publishes so the screens' shown layouts are current.
12+func settleShown(t *testing.T, c *Core) {
13+ t.Helper()
14+ indicatorScene(t, c)
15+}
16+
17+// A bind that stops a spring while it acts (Monitor.Focus) must not lose its
18+// speed: the transition that follows starts from what the snapshot recorded.
19+func TestChainedActionsKeepVelocity(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ setup func(*Core)
23+ first []Action
24+ second []Action
25+ // velocity is the speed of the motion the first action started.
26+ velocity func(*Core) float64
27+ }{
28+ {"camera", func(c *Core) {
29+ m := c.cur().mon
30+ for id := WindowID(1); id <= 4; id++ {
31+ m.AddWindow(id)
32+ }
33+ }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft},
34+ []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight},
35+ func(c *Core) float64 { return c.cur().mon.Current().motion.velocity() }},
36+ {"workspace switch", func(c *Core) {
37+ m := c.cur().mon
38+ m.AddWindow(1)
39+ m.Focus(1)
40+ m.AddWindow(2)
41+ m.Focus(0)
42+ }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown},
43+ func(c *Core) float64 { return c.cur().mon.switchMotion.velocity() }},
44+ {"rect", func(c *Core) {
45+ m := c.cur().mon
46+ m.AddWindow(1)
47+ m.AddWindow(2)
48+ }, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight},
49+ func(c *Core) float64 { return c.cur().rects[1].x.velocity() }},
50+ }
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ c, ic := indicatorCore(t)
54+ c.cfg.Animations.On = true
55+ tt.setup(c)
56+ settleShown(t, c)
57+ act := func(actions []Action) {
58+ before := c.snapshot()
59+ for _, a := range actions {
60+ c.applyAction(a)
61+ }
62+ c.transition(before, ic.now)
63+ settleShown(t, c)
64+ }
65+ act(tt.first)
66+ if !c.animating() {
67+ t.Fatal("the first action started no motion")
68+ }
69+ ic.now = ic.now.Add(30 * time.Millisecond)
70+ c.animate(ic.now, nil)
71+ settleShown(t, c)
72+ want := tt.velocity(c)
73+ if want == 0 {
74+ t.Fatal("no speed to carry over")
75+ }
76+ act(tt.second)
77+ if got := tt.velocity(c); got != want {
78+ t.Fatalf("velocity %v after the second action, want the carried %v", got, want)
79+ }
80+ })
81+ }
82+}
83+
84+// The fallback timer guards every animating output: a page flip of one
85+// output (step with only set) keeps it while another output still animates.
86+func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
87+ c, ic := indicatorCore(t)
88+ c.cfg.Animations.On = true
89+ a := c.cur()
90+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
91+ b := c.screens[1]
92+ a.mon.AddWindow(1)
93+ b.mon.AddWindow(2)
94+ settleShown(t, c)
95+ for _, sc := range []*screen{a, b} {
96+ w := sc.mon.Current()
97+ w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now)
98+ }
99+ c.armFrame()
100+ armed := c.frameC
101+ ic.now = ic.now.Add(16 * time.Millisecond)
102+ if err := c.step(context.Background(), a); err != nil {
103+ t.Fatal(err)
104+ }
105+ <-c.ch.Scenes
106+ if c.frameC == nil || c.frameC != armed {
107+ t.Fatal("a flip of A re-armed or dropped the fallback timer while B animates")
108+ }
109+
110+ // With nothing else animating the flip's own publish decides.
111+ b.mon.Current().stopSlide()
112+ if err := c.step(context.Background(), a); err != nil {
113+ t.Fatal(err)
114+ }
115+ <-c.ch.Scenes
116+ if c.frameC == nil || c.frameC == armed {
117+ t.Fatal("A still animates but its timer was not re-armed")
118+ }
119+ ic.now = ic.now.Add(5 * time.Second)
120+ if err := c.step(context.Background(), nil); err != nil {
121+ t.Fatal(err)
122+ }
123+ <-c.ch.Scenes
124+ if c.animating() || c.frameC != nil {
125+ t.Fatalf("settled but the timer runs (%v)", c.frameC != nil)
126+ }
127+}
128+
129+// A window that left the layout while its rect motion ran drops the motion:
130+// it would ask for frames with nothing to move.
131+func TestRefreshShownDropsRectsOfWindowsOutOfLayout(t *testing.T) {
132+ c, ic := indicatorCore(t)
133+ c.cfg.Animations.On = true
134+ sc := c.cur()
135+ sc.mon.AddWindow(1)
136+ sc.mon.AddWindow(2)
137+ settleShown(t, c)
138+ sp := viewSpring(40, 0)
139+ sc.rects = map[WindowID]rectMotion{
140+ 1: {x: c.spring(sp, ic.now), dx: 40},
141+ 99: {x: c.spring(sp, ic.now), dx: 40},
142+ }
143+ sc.rectsWS = sc.mon.Current()
144+ settleShown(t, c)
145+ if _, ok := sc.rects[99]; ok {
146+ t.Fatal("a window outside the layout keeps its rect motion")
147+ }
148+ if _, ok := sc.rects[1]; !ok {
149+ t.Fatal("a window in the layout lost its rect motion")
150+ }
151+}
152+
153+// A swipe that follows the fingers owns the view: a focus bind pressed
154+// meanwhile starts no camera motion.
155+func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
156+ c, ic := indicatorCore(t)
157+ c.cfg.Animations.On = true
158+ sc := c.cur()
159+ for id := WindowID(1); id <= 4; id++ {
160+ sc.mon.AddWindow(id)
161+ }
162+ settleShown(t, c)
163+ w := sc.mon.Current()
164+ w.shift = 25
165+ c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
166+ before := c.snapshot()
167+ viewX := w.ViewX
168+ for range 3 {
169+ c.applyAction(ActionFocusColumnLeft)
170+ }
171+ if w.ViewX == viewX {
172+ t.Fatal("the focus did not move the view: the case checks nothing")
173+ }
174+ c.transition(before, ic.now)
175+ if w.motion.on || w.shift != 25 {
176+ t.Fatalf("camera motion %v, shift %v: the transition took over the fingers' view", w.motion.on, w.shift)
177+ }
178+}
179+
180+// A switch to or from a workspace with its own size stays settled: no
181+// monitor-wide slide would be cropped to it. The same switch between
182+// workspaces of the monitor's size slides.
183+func TestNoSwitchMotionForSizedWorkspace(t *testing.T) {
184+ for _, tc := range []struct {
185+ name string
186+ sized bool
187+ }{{"sized", true}, {"unsized", false}} {
188+ t.Run(tc.name, func(t *testing.T) {
189+ c, ic := indicatorCore(t)
190+ c.cfg.Animations.On = true
191+ m := c.cur().mon
192+ m.AddWindow(1)
193+ if tc.sized {
194+ m.Workspaces[1].SetSize(100, 100)
195+ }
196+ settleShown(t, c)
197+ before := c.snapshot()
198+ m.Focus(1)
199+ c.transition(before, ic.now)
200+ if got := m.switchMotion.on; got == tc.sized {
201+ t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized)
202+ }
203+ if tc.sized && m.switchOff != 0 {
204+ t.Fatalf("switchOff %v left for a framed switch", m.switchOff)
205+ }
206+ })
207+ }
208+}
@@ -0,0 +1,208 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// settleShown publishes so the screens' shown layouts are current.
12+func settleShown(t *testing.T, c *Core) {
13+ t.Helper()
14+ indicatorScene(t, c)
15+}
16+
17+// A bind that stops a spring while it acts (Monitor.Focus) must not lose its
18+// speed: the transition that follows starts from what the snapshot recorded.
19+func TestChainedActionsKeepVelocity(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ setup func(*Core)
23+ first []Action
24+ second []Action
25+ // velocity is the speed of the motion the first action started.
26+ velocity func(*Core) float64
27+ }{
28+ {"camera", func(c *Core) {
29+ m := c.cur().mon
30+ for id := WindowID(1); id <= 4; id++ {
31+ m.AddWindow(id)
32+ }
33+ }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft},
34+ []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight},
35+ func(c *Core) float64 { return c.cur().mon.Current().motion.velocity() }},
36+ {"workspace switch", func(c *Core) {
37+ m := c.cur().mon
38+ m.AddWindow(1)
39+ m.Focus(1)
40+ m.AddWindow(2)
41+ m.Focus(0)
42+ }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown},
43+ func(c *Core) float64 { return c.cur().mon.switchMotion.velocity() }},
44+ {"rect", func(c *Core) {
45+ m := c.cur().mon
46+ m.AddWindow(1)
47+ m.AddWindow(2)
48+ }, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight},
49+ func(c *Core) float64 { return c.cur().rects[1].x.velocity() }},
50+ }
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ c, ic := indicatorCore(t)
54+ c.cfg.Animations.On = true
55+ tt.setup(c)
56+ settleShown(t, c)
57+ act := func(actions []Action) {
58+ before := c.snapshot()
59+ for _, a := range actions {
60+ c.applyAction(a)
61+ }
62+ c.transition(before, ic.now)
63+ settleShown(t, c)
64+ }
65+ act(tt.first)
66+ if !c.animating() {
67+ t.Fatal("the first action started no motion")
68+ }
69+ ic.now = ic.now.Add(30 * time.Millisecond)
70+ c.animate(ic.now, nil)
71+ settleShown(t, c)
72+ want := tt.velocity(c)
73+ if want == 0 {
74+ t.Fatal("no speed to carry over")
75+ }
76+ act(tt.second)
77+ if got := tt.velocity(c); got != want {
78+ t.Fatalf("velocity %v after the second action, want the carried %v", got, want)
79+ }
80+ })
81+ }
82+}
83+
84+// The fallback timer guards every animating output: a page flip of one
85+// output (step with only set) keeps it while another output still animates.
86+func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
87+ c, ic := indicatorCore(t)
88+ c.cfg.Animations.On = true
89+ a := c.cur()
90+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
91+ b := c.screens[1]
92+ a.mon.AddWindow(1)
93+ b.mon.AddWindow(2)
94+ settleShown(t, c)
95+ for _, sc := range []*screen{a, b} {
96+ w := sc.mon.Current()
97+ w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now)
98+ }
99+ c.armFrame()
100+ armed := c.frameC
101+ ic.now = ic.now.Add(16 * time.Millisecond)
102+ if err := c.step(context.Background(), a); err != nil {
103+ t.Fatal(err)
104+ }
105+ <-c.ch.Scenes
106+ if c.frameC == nil || c.frameC != armed {
107+ t.Fatal("a flip of A re-armed or dropped the fallback timer while B animates")
108+ }
109+
110+ // With nothing else animating the flip's own publish decides.
111+ b.mon.Current().stopSlide()
112+ if err := c.step(context.Background(), a); err != nil {
113+ t.Fatal(err)
114+ }
115+ <-c.ch.Scenes
116+ if c.frameC == nil || c.frameC == armed {
117+ t.Fatal("A still animates but its timer was not re-armed")
118+ }
119+ ic.now = ic.now.Add(5 * time.Second)
120+ if err := c.step(context.Background(), nil); err != nil {
121+ t.Fatal(err)
122+ }
123+ <-c.ch.Scenes
124+ if c.animating() || c.frameC != nil {
125+ t.Fatalf("settled but the timer runs (%v)", c.frameC != nil)
126+ }
127+}
128+
129+// A window that left the layout while its rect motion ran drops the motion:
130+// it would ask for frames with nothing to move.
131+func TestRefreshShownDropsRectsOfWindowsOutOfLayout(t *testing.T) {
132+ c, ic := indicatorCore(t)
133+ c.cfg.Animations.On = true
134+ sc := c.cur()
135+ sc.mon.AddWindow(1)
136+ sc.mon.AddWindow(2)
137+ settleShown(t, c)
138+ sp := viewSpring(40, 0)
139+ sc.rects = map[WindowID]rectMotion{
140+ 1: {x: c.spring(sp, ic.now), dx: 40},
141+ 99: {x: c.spring(sp, ic.now), dx: 40},
142+ }
143+ sc.rectsWS = sc.mon.Current()
144+ settleShown(t, c)
145+ if _, ok := sc.rects[99]; ok {
146+ t.Fatal("a window outside the layout keeps its rect motion")
147+ }
148+ if _, ok := sc.rects[1]; !ok {
149+ t.Fatal("a window in the layout lost its rect motion")
150+ }
151+}
152+
153+// A swipe that follows the fingers owns the view: a focus bind pressed
154+// meanwhile starts no camera motion.
155+func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
156+ c, ic := indicatorCore(t)
157+ c.cfg.Animations.On = true
158+ sc := c.cur()
159+ for id := WindowID(1); id <= 4; id++ {
160+ sc.mon.AddWindow(id)
161+ }
162+ settleShown(t, c)
163+ w := sc.mon.Current()
164+ w.shift = 25
165+ c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
166+ before := c.snapshot()
167+ viewX := w.ViewX
168+ for range 3 {
169+ c.applyAction(ActionFocusColumnLeft)
170+ }
171+ if w.ViewX == viewX {
172+ t.Fatal("the focus did not move the view: the case checks nothing")
173+ }
174+ c.transition(before, ic.now)
175+ if w.motion.on || w.shift != 25 {
176+ t.Fatalf("camera motion %v, shift %v: the transition took over the fingers' view", w.motion.on, w.shift)
177+ }
178+}
179+
180+// A switch to or from a workspace with its own size stays settled: no
181+// monitor-wide slide would be cropped to it. The same switch between
182+// workspaces of the monitor's size slides.
183+func TestNoSwitchMotionForSizedWorkspace(t *testing.T) {
184+ for _, tc := range []struct {
185+ name string
186+ sized bool
187+ }{{"sized", true}, {"unsized", false}} {
188+ t.Run(tc.name, func(t *testing.T) {
189+ c, ic := indicatorCore(t)
190+ c.cfg.Animations.On = true
191+ m := c.cur().mon
192+ m.AddWindow(1)
193+ if tc.sized {
194+ m.Workspaces[1].SetSize(100, 100)
195+ }
196+ settleShown(t, c)
197+ before := c.snapshot()
198+ m.Focus(1)
199+ c.transition(before, ic.now)
200+ if got := m.switchMotion.on; got == tc.sized {
201+ t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized)
202+ }
203+ if tc.sized && m.switchOff != 0 {
204+ t.Fatalf("switchOff %v left for a framed switch", m.switchOff)
205+ }
206+ })
207+ }
208+}
D internal/core/transition_internal_test.go +0 -44
@@ -1,44 +0,0 @@
1-package core
2-
3-import (
4- "testing"
5- "time"
6-)
7-
8-// The focus pulse waits while a camera transition of a key runs, and starts
9-// once it settled.
10-func TestPulseWaitsForCameraTransition(t *testing.T) {
11- c, ic := pulseCore(t)
12- m := c.cur().mon
13- for id := WindowID(3); id <= 4; id++ {
14- m.AddWindow(id)
15- }
16- indicatorScene(t, c)
17- ic.now = ic.now.Add(pulseSettle + pulseCooldown)
18- c.pulseTick()
19- indicatorScene(t, c)
20-
21- before := c.snapshot()
22- for range 3 {
23- c.applyAction(ActionFocusColumnLeft)
24- }
25- c.transition(before, ic.now)
26- w := m.Current()
27- if !w.motion.on || m.settled() {
28- t.Fatal("no camera transition after the focus moved the view")
29- }
30- indicatorScene(t, c)
31- ic.now = ic.now.Add(pulseSettle)
32- if c.pulseTick() {
33- t.Fatal("pulse started while the view slides")
34- }
35- c.animate(ic.now.Add(5*time.Second), nil)
36- ic.now = ic.now.Add(5 * time.Second)
37- if !m.settled() {
38- t.Fatal("view still slides")
39- }
40- ic.now = ic.now.Add(pulseSettle)
41- if !c.pulseTick() {
42- t.Fatal("no pulse once the view settled")
43- }
44-}
@@ -1,44 +0,0 @@
1-package core
2-
3-import (
4- "testing"
5- "time"
6-)
7-
8-// The focus pulse waits while a camera transition of a key runs, and starts
9-// once it settled.
10-func TestPulseWaitsForCameraTransition(t *testing.T) {
11- c, ic := pulseCore(t)
12- m := c.cur().mon
13- for id := WindowID(3); id <= 4; id++ {
14- m.AddWindow(id)
15- }
16- indicatorScene(t, c)
17- ic.now = ic.now.Add(pulseSettle + pulseCooldown)
18- c.pulseTick()
19- indicatorScene(t, c)
20-
21- before := c.snapshot()
22- for range 3 {
23- c.applyAction(ActionFocusColumnLeft)
24- }
25- c.transition(before, ic.now)
26- w := m.Current()
27- if !w.motion.on || m.settled() {
28- t.Fatal("no camera transition after the focus moved the view")
29- }
30- indicatorScene(t, c)
31- ic.now = ic.now.Add(pulseSettle)
32- if c.pulseTick() {
33- t.Fatal("pulse started while the view slides")
34- }
35- c.animate(ic.now.Add(5*time.Second), nil)
36- ic.now = ic.now.Add(5 * time.Second)
37- if !m.settled() {
38- t.Fatal("view still slides")
39- }
40- ic.now = ic.now.Add(pulseSettle)
41- if !c.pulseTick() {
42- t.Fatal("no pulse once the view settled")
43- }
44-}
M internal/core/transition_test.go +93 -29
@@ -129,22 +129,47 @@ func TestAnimationsOffKeysLandAtOnce(t *testing.T) {
129129 r.noFrameScene(t)
130130 }
131131
132-// With the overview open before or after, nothing slides.
132+// With the overview open before or after, nothing slides: opening it on a
133+// scrolled view starts no motion, and neither does a bind that moves the view
134+// while it is open.
133135 func TestNoTransitionWithOverview(t *testing.T) {
134- r := startSwipe(t, nil)
135- r.workspaces(t)
136- r.key(t, "o", ports.ModAlt)
137- r.key(t, "o", ports.ModAlt)
138- r.noFrameScene(t)
136+ for _, tc := range []struct {
137+ name string
138+ // open is what the overview shows before the keys of the bind.
139+ sym string
140+ mods ports.Mods
141+ }{
142+ {"open and close", "", 0},
143+ {"focus bind while open", "Left", ports.ModAlt},
144+ {"workspace bind while open", "Next", ports.ModAlt},
145+ } {
146+ t.Run(tc.name, func(t *testing.T) {
147+ r := startSwipe(t, nil)
148+ fourColumns(t, r)
149+ r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll
150+ r.keySettled(t, "Right") // the view scrolls away from 0
151+ r.keySettled(t, "Right")
152+ r.key(t, "o", ports.ModAlt)
153+ r.noFrameScene(t)
154+ if tc.sym != "" {
155+ r.key(t, tc.sym, tc.mods)
156+ r.noFrameScene(t)
157+ }
158+ r.key(t, "o", ports.ModAlt)
159+ r.noFrameScene(t)
160+ })
161+ }
139162 }
140163
141-// A workspace that goes to another monitor gets no motion on either side.
164+// A workspace that goes to another monitor gets no rect or switch motion on
165+// either side.
142166 func TestMovedWorkspaceHasNoMotion(t *testing.T) {
143167 r := startSwipe(t, nil)
144- r.mapWindow(t, 1)
145- r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
168+ twoColumns(t, r)
169+ other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
170+ r.plug(t, other)
146171 r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
147- r.noFrameScene(t)
172+ r.noFrameScene(t, wide.Name, other.Name)
148173 }
149174
150175 // rectNow is the rect of window id in s; it must be in the scene.
@@ -309,25 +334,6 @@ func TestFullscreenRectTransition(t *testing.T) {
309334 }
310335 }
311336
312-// A workspace that moves to another monitor gets no rect or switch motion
313-// on either monitor.
314-func TestMovedWorkspaceGetsNoRectMotion(t *testing.T) {
315- r := startSwipe(t, nil)
316- twoColumns(t, r)
317- other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
318- r.plug(t, other)
319- r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
320- for _, o := range []string{wide.Name, other.Name} {
321- r.advance(16 * time.Millisecond)
322- r.frames <- ports.OutputFrame{Output: o}
323- }
324- select {
325- case s := <-r.scenes:
326- t.Fatalf("a flip published a scene (%d outputs): a motion runs", len(s))
327- case <-time.After(50 * time.Millisecond):
328- }
329-}
330-
331337 // With animations off a layout change lands at once.
332338 func TestAnimationsOffRectsLandAtOnce(t *testing.T) {
333339 r := startSwipe(t, animationsOff)
@@ -357,3 +363,61 @@ func TestChainedMovesStartFromShownRects(t *testing.T) {
357363 t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2)
358364 }
359365 }
366+
367+// A click on a column that is partly off screen focuses it: the scene right
368+// after the click still shows the old view, a frame an intermediate one and
369+// the settled scene the new one.
370+func TestClickFocusTransition(t *testing.T) {
371+ r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
372+ threeColumns(t, r)
373+ // Column 3 grows to 480: column 2 sticks out 80 px on the left.
374+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
375+ if got := xOf(t, r.settle(t), 2); got != -80 {
376+ t.Fatalf("column 2 at %d, want -80", got)
377+ }
378+ // The release of the modifiers: a plain click, not a window drag.
379+ r.input <- ports.KeyEvent{Keysym: "Alt_L"}
380+ r.input <- ports.PointerMotion{X: 100, Y: 300}
381+ r.drain()
382+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
383+ s := scene(t, r.scenes)
384+ if got := xOf(t, s, 2); got != -80 {
385+ t.Fatalf("column 2 at %d right after the click, want the old -80", got)
386+ }
387+ got := xOf(t, r.frame(t, 16*time.Millisecond), 2)
388+ if got <= -80 || got >= 0 {
389+ t.Fatalf("column 2 at %d on the first frame, want between -80 and 0", got)
390+ }
391+ if got := xOf(t, r.settle(t), 2); got != 0 {
392+ t.Fatalf("column 2 at %d settled, want 0", got)
393+ }
394+}
395+
396+// A discrete swipe step (fixed overflow: nothing follows the fingers) moves
397+// through a rect motion: the scene right after the lift shows the old rects.
398+func TestDiscreteSwipeTransition(t *testing.T) {
399+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
400+ r.mapWindow(t, 1)
401+ r.mapWindow(t, 2)
402+ r.key(t, "f", ports.ModAlt) // maximize window 2
403+ if got := rectNow(t, r.settle(t), 2); got.W != 800 {
404+ t.Fatalf("window 2 is %d wide, want maximized 800", got.W)
405+ }
406+ r.begin()
407+ for range 10 {
408+ r.at += 8 * time.Millisecond
409+ r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
410+ }
411+ s := r.end(t, false)
412+ if got := rectNow(t, s, 2); got.W != 800 {
413+ t.Fatalf("window 2 is %d wide right after the lift, want the old 800", got.W)
414+ }
415+ got := rectNow(t, r.frame(t, 16*time.Millisecond), 2)
416+ if got.W <= 400 || got.W >= 800 {
417+ t.Fatalf("window 2 is %d wide on the first frame, want between 400 and 800", got.W)
418+ }
419+ s = r.settle(t)
420+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.W != 400 || got2.X != 400 || got2.W != 400 {
421+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 1 beside 2 on halves", got1, got2)
422+ }
423+}
@@ -129,22 +129,47 @@ func TestAnimationsOffKeysLandAtOnce(t *testing.T) {
129 r.noFrameScene(t)129 r.noFrameScene(t)
130 }130 }
131 131
132-// With the overview open before or after, nothing slides.132+// With the overview open before or after, nothing slides: opening it on a
133+// scrolled view starts no motion, and neither does a bind that moves the view
134+// while it is open.
133 func TestNoTransitionWithOverview(t *testing.T) {135 func TestNoTransitionWithOverview(t *testing.T) {
134- r := startSwipe(t, nil)136+ for _, tc := range []struct {
135- r.workspaces(t)137+ name string
136- r.key(t, "o", ports.ModAlt)138+ // open is what the overview shows before the keys of the bind.
137- r.key(t, "o", ports.ModAlt)139+ sym string
138- r.noFrameScene(t)140+ mods ports.Mods
141+ }{
142+ {"open and close", "", 0},
143+ {"focus bind while open", "Left", ports.ModAlt},
144+ {"workspace bind while open", "Next", ports.ModAlt},
145+ } {
146+ t.Run(tc.name, func(t *testing.T) {
147+ r := startSwipe(t, nil)
148+ fourColumns(t, r)
149+ r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll
150+ r.keySettled(t, "Right") // the view scrolls away from 0
151+ r.keySettled(t, "Right")
152+ r.key(t, "o", ports.ModAlt)
153+ r.noFrameScene(t)
154+ if tc.sym != "" {
155+ r.key(t, tc.sym, tc.mods)
156+ r.noFrameScene(t)
157+ }
158+ r.key(t, "o", ports.ModAlt)
159+ r.noFrameScene(t)
160+ })
161+ }
139 }162 }
140 163
141-// A workspace that goes to another monitor gets no motion on either side.164+// A workspace that goes to another monitor gets no rect or switch motion on
165+// either side.
142 func TestMovedWorkspaceHasNoMotion(t *testing.T) {166 func TestMovedWorkspaceHasNoMotion(t *testing.T) {
143 r := startSwipe(t, nil)167 r := startSwipe(t, nil)
144- r.mapWindow(t, 1)168+ twoColumns(t, r)
145- r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})169+ other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
170+ r.plug(t, other)
146 r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)171 r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
147- r.noFrameScene(t)172+ r.noFrameScene(t, wide.Name, other.Name)
148 }173 }
149 174
150 // rectNow is the rect of window id in s; it must be in the scene.175 // rectNow is the rect of window id in s; it must be in the scene.
@@ -309,25 +334,6 @@ func TestFullscreenRectTransition(t *testing.T) {
309 }334 }
310 }335 }
311 336
312-// A workspace that moves to another monitor gets no rect or switch motion
313-// on either monitor.
314-func TestMovedWorkspaceGetsNoRectMotion(t *testing.T) {
315- r := startSwipe(t, nil)
316- twoColumns(t, r)
317- other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
318- r.plug(t, other)
319- r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
320- for _, o := range []string{wide.Name, other.Name} {
321- r.advance(16 * time.Millisecond)
322- r.frames <- ports.OutputFrame{Output: o}
323- }
324- select {
325- case s := <-r.scenes:
326- t.Fatalf("a flip published a scene (%d outputs): a motion runs", len(s))
327- case <-time.After(50 * time.Millisecond):
328- }
329-}
330-
331 // With animations off a layout change lands at once.337 // With animations off a layout change lands at once.
332 func TestAnimationsOffRectsLandAtOnce(t *testing.T) {338 func TestAnimationsOffRectsLandAtOnce(t *testing.T) {
333 r := startSwipe(t, animationsOff)339 r := startSwipe(t, animationsOff)
@@ -357,3 +363,61 @@ func TestChainedMovesStartFromShownRects(t *testing.T) {
357 t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2)363 t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2)
358 }364 }
359 }365 }
366+
367+// A click on a column that is partly off screen focuses it: the scene right
368+// after the click still shows the old view, a frame an intermediate one and
369+// the settled scene the new one.
370+func TestClickFocusTransition(t *testing.T) {
371+ r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
372+ threeColumns(t, r)
373+ // Column 3 grows to 480: column 2 sticks out 80 px on the left.
374+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
375+ if got := xOf(t, r.settle(t), 2); got != -80 {
376+ t.Fatalf("column 2 at %d, want -80", got)
377+ }
378+ // The release of the modifiers: a plain click, not a window drag.
379+ r.input <- ports.KeyEvent{Keysym: "Alt_L"}
380+ r.input <- ports.PointerMotion{X: 100, Y: 300}
381+ r.drain()
382+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
383+ s := scene(t, r.scenes)
384+ if got := xOf(t, s, 2); got != -80 {
385+ t.Fatalf("column 2 at %d right after the click, want the old -80", got)
386+ }
387+ got := xOf(t, r.frame(t, 16*time.Millisecond), 2)
388+ if got <= -80 || got >= 0 {
389+ t.Fatalf("column 2 at %d on the first frame, want between -80 and 0", got)
390+ }
391+ if got := xOf(t, r.settle(t), 2); got != 0 {
392+ t.Fatalf("column 2 at %d settled, want 0", got)
393+ }
394+}
395+
396+// A discrete swipe step (fixed overflow: nothing follows the fingers) moves
397+// through a rect motion: the scene right after the lift shows the old rects.
398+func TestDiscreteSwipeTransition(t *testing.T) {
399+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
400+ r.mapWindow(t, 1)
401+ r.mapWindow(t, 2)
402+ r.key(t, "f", ports.ModAlt) // maximize window 2
403+ if got := rectNow(t, r.settle(t), 2); got.W != 800 {
404+ t.Fatalf("window 2 is %d wide, want maximized 800", got.W)
405+ }
406+ r.begin()
407+ for range 10 {
408+ r.at += 8 * time.Millisecond
409+ r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
410+ }
411+ s := r.end(t, false)
412+ if got := rectNow(t, s, 2); got.W != 800 {
413+ t.Fatalf("window 2 is %d wide right after the lift, want the old 800", got.W)
414+ }
415+ got := rectNow(t, r.frame(t, 16*time.Millisecond), 2)
416+ if got.W <= 400 || got.W >= 800 {
417+ t.Fatalf("window 2 is %d wide on the first frame, want between 400 and 800", got.W)
418+ }
419+ s = r.settle(t)
420+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.W != 400 || got2.X != 400 || got2.W != 400 {
421+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 1 beside 2 on halves", got1, got2)
422+ }
423+}

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