detached f2967d7

feat(core): camera transitions for binds, click focus and discrete swipes

commit
f2967d7aad272ae972e982bfbdca1a3acd50e668
author
brice · · 2026-10-04 16:01 UTC
committer
brice · · 2026-10-04 19:08 UTC
parent
724608c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +363 -13
M internal/core/core.go +3 -0
@@ -133,6 +133,9 @@ type Core struct {
133133 // stepping is the screen a page flip advances (nil: all of them); it is
134134 // set while step publishes.
135135 stepping *screen
136+ // shots is the snapshot of what the screens show before an action
137+ // (transition.go), reused by every action.
138+ shots []viewShot
136139 // motionTime is the time of the last pointer motion; pointerAt is the
137140 // last position sent in the pointer's window.
138141 motionTime time.Duration
@@ -133,6 +133,9 @@ type Core struct {
133 // stepping is the screen a page flip advances (nil: all of them); it is133 // stepping is the screen a page flip advances (nil: all of them); it is
134 // set while step publishes.134 // set while step publishes.
135 stepping *screen135 stepping *screen
136+ // shots is the snapshot of what the screens show before an action
137+ // (transition.go), reused by every action.
138+ shots []viewShot
136 // motionTime is the time of the last pointer motion; pointerAt is the139 // motionTime is the time of the last pointer motion; pointerAt is the
137 // last position sent in the pointer's window.140 // last position sent in the pointer's window.
138 motionTime time.Duration141 motionTime time.Duration
M internal/core/core_test.go +3 -1
@@ -385,6 +385,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
385385
386386 func TestWorkspaceSwitch(t *testing.T) {
387387 cfg := config.Defaults()
388+ cfg.Animations.On = false // the test reads the settled scene right after the key
388389 client := make(chan ports.ClientEvent, 8)
389390 input := make(chan ports.InputEvent, 8)
390391 output := make(chan ports.OutputEvent, 8)
@@ -433,6 +434,7 @@ func TestWorkspaceSwitch(t *testing.T) {
433434
434435 func TestClickAfterWorkspaceSwitch(t *testing.T) {
435436 cfg := config.Defaults()
437+ cfg.Animations.On = false // the test reads the settled scene right after the key
436438 client := make(chan ports.ClientEvent, 8)
437439 input := make(chan ports.InputEvent, 8)
438440 output := make(chan ports.OutputEvent, 8)
@@ -1022,7 +1024,7 @@ func TestExternalFullscreenInOverview(t *testing.T) {
10221024 // focus-window-down from a fullscreen tile at the bottom of its column
10231025 // goes to the next workspace and fullscreen stays.
10241026 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
1025- r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1027+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed"; c.Animations.On = false }, left)
10261028 r.mapWindow(t, 1)
10271029 r.mapWindow(t, 2)
10281030 r.key(t, "f", ports.ModAlt|ports.ModShift)
@@ -385,6 +385,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
385 385
386 func TestWorkspaceSwitch(t *testing.T) {386 func TestWorkspaceSwitch(t *testing.T) {
387 cfg := config.Defaults()387 cfg := config.Defaults()
388+ cfg.Animations.On = false // the test reads the settled scene right after the key
388 client := make(chan ports.ClientEvent, 8)389 client := make(chan ports.ClientEvent, 8)
389 input := make(chan ports.InputEvent, 8)390 input := make(chan ports.InputEvent, 8)
390 output := make(chan ports.OutputEvent, 8)391 output := make(chan ports.OutputEvent, 8)
@@ -433,6 +434,7 @@ func TestWorkspaceSwitch(t *testing.T) {
433 434
434 func TestClickAfterWorkspaceSwitch(t *testing.T) {435 func TestClickAfterWorkspaceSwitch(t *testing.T) {
435 cfg := config.Defaults()436 cfg := config.Defaults()
437+ cfg.Animations.On = false // the test reads the settled scene right after the key
436 client := make(chan ports.ClientEvent, 8)438 client := make(chan ports.ClientEvent, 8)
437 input := make(chan ports.InputEvent, 8)439 input := make(chan ports.InputEvent, 8)
438 output := make(chan ports.OutputEvent, 8)440 output := make(chan ports.OutputEvent, 8)
@@ -1022,7 +1024,7 @@ func TestExternalFullscreenInOverview(t *testing.T) {
1022 // focus-window-down from a fullscreen tile at the bottom of its column1024 // focus-window-down from a fullscreen tile at the bottom of its column
1023 // goes to the next workspace and fullscreen stays.1025 // goes to the next workspace and fullscreen stays.
1024 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {1026 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
1025- r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)1027+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed"; c.Animations.On = false }, left)
1026 r.mapWindow(t, 1)1028 r.mapWindow(t, 1)
1027 r.mapWindow(t, 2)1029 r.mapWindow(t, 2)
1028 r.key(t, "f", ports.ModAlt|ports.ModShift)1030 r.key(t, "f", ports.ModAlt|ports.ModShift)
M internal/core/drag_test.go +2 -2
@@ -484,7 +484,7 @@ func TestLockClearsDrag(t *testing.T) {
484484 }
485485
486486 func TestDragEdgeScroll(t *testing.T) {
487- r := startDragRig(t, nil, dragOut)
487+ r := startDragRig(t, animationsOff, dragOut)
488488 for id := ports.WindowID(1); id <= 4; id++ {
489489 r.client <- ports.WindowMapped{ID: id}
490490 r.settle(t)
@@ -623,7 +623,7 @@ func TestClientDragUsesLastPress(t *testing.T) {
623623 }
624624
625625 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626- r := startDragRig(t, nil, dragOut)
626+ r := startDragRig(t, animationsOff, dragOut)
627627 for id := ports.WindowID(1); id <= 4; id++ {
628628 r.client <- ports.WindowMapped{ID: id}
629629 r.settle(t)
@@ -484,7 +484,7 @@ func TestLockClearsDrag(t *testing.T) {
484 }484 }
485 485
486 func TestDragEdgeScroll(t *testing.T) {486 func TestDragEdgeScroll(t *testing.T) {
487- r := startDragRig(t, nil, dragOut)487+ r := startDragRig(t, animationsOff, dragOut)
488 for id := ports.WindowID(1); id <= 4; id++ {488 for id := ports.WindowID(1); id <= 4; id++ {
489 r.client <- ports.WindowMapped{ID: id}489 r.client <- ports.WindowMapped{ID: id}
490 r.settle(t)490 r.settle(t)
@@ -623,7 +623,7 @@ func TestClientDragUsesLastPress(t *testing.T) {
623 }623 }
624 624
625 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {625 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626- r := startDragRig(t, nil, dragOut)626+ r := startDragRig(t, animationsOff, dragOut)
627 for id := ports.WindowID(1); id <= 4; id++ {627 for id := ports.WindowID(1); id <= 4; id++ {
628 r.client <- ports.WindowMapped{ID: id}628 r.client <- ports.WindowMapped{ID: id}
629 r.settle(t)629 r.settle(t)
M internal/core/gesture.go +2 -0
@@ -279,11 +279,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
279279 return false
280280 }
281281 before := c.cur().mon.Current()
282+ shots := c.snapshot()
282283 c.keyboard.takeBack()
283284 if mon := c.cur().mon; mon.ov.open {
284285 mon.overviewFocus(a)
285286 } else {
286287 c.applyAction(a)
288+ c.transition(shots, now)
287289 }
288290 return c.cur().mon.Current() != before
289291 }
@@ -279,11 +279,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
279 return false279 return false
280 }280 }
281 before := c.cur().mon.Current()281 before := c.cur().mon.Current()
282+ shots := c.snapshot()
282 c.keyboard.takeBack()283 c.keyboard.takeBack()
283 if mon := c.cur().mon; mon.ov.open {284 if mon := c.cur().mon; mon.ov.open {
284 mon.overviewFocus(a)285 mon.overviewFocus(a)
285 } else {286 } else {
286 c.applyAction(a)287 c.applyAction(a)
288+ c.transition(shots, now)
287 }289 }
288 return c.cur().mon.Current() != before290 return c.cur().mon.Current() != before
289 }291 }
M internal/core/input.go +4 -0
@@ -125,8 +125,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
125125 // A click focuses the window and its output.
126126 s, w := c.screenOf(id)
127127 if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {
128+ before := c.snapshot()
128129 w.Click(id)
129130 c.focusScreen = c.screenIndex(s.name())
131+ c.transition(before, c.now())
130132 if err := c.publish(ctx); err != nil {
131133 return err
132134 }
@@ -260,6 +262,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
260262 }
261263 before := c.cur().mon.Current()
262264 swiped := c.swipedWorkspace()
265+ shots := c.snapshot()
263266 c.keyboard.takeBack() // a bind acts on the windows
264267 effect := c.applyAction(action)
265268 if effect.Quit {
@@ -280,6 +283,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
280283 if c.swipedWorkspace() != swiped {
281284 c.dropSwipe()
282285 }
286+ c.transition(shots, c.now())
283287 if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {
284288 return err
285289 }
@@ -125,8 +125,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
125 // A click focuses the window and its output.125 // A click focuses the window and its output.
126 s, w := c.screenOf(id)126 s, w := c.screenOf(id)
127 if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {127 if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {
128+ before := c.snapshot()
128 w.Click(id)129 w.Click(id)
129 c.focusScreen = c.screenIndex(s.name())130 c.focusScreen = c.screenIndex(s.name())
131+ c.transition(before, c.now())
130 if err := c.publish(ctx); err != nil {132 if err := c.publish(ctx); err != nil {
131 return err133 return err
132 }134 }
@@ -260,6 +262,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
260 }262 }
261 before := c.cur().mon.Current()263 before := c.cur().mon.Current()
262 swiped := c.swipedWorkspace()264 swiped := c.swipedWorkspace()
265+ shots := c.snapshot()
263 c.keyboard.takeBack() // a bind acts on the windows266 c.keyboard.takeBack() // a bind acts on the windows
264 effect := c.applyAction(action)267 effect := c.applyAction(action)
265 if effect.Quit {268 if effect.Quit {
@@ -280,6 +283,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
280 if c.swipedWorkspace() != swiped {283 if c.swipedWorkspace() != swiped {
281 c.dropSwipe()284 c.dropSwipe()
282 }285 }
286+ c.transition(shots, c.now())
283 if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {287 if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {
284 return err288 return err
285 }289 }
M internal/core/screens_test.go +2 -2
@@ -400,7 +400,7 @@ func TestMonitorActionsFollowGeometry(t *testing.T) {
400400 }
401401
402402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
403- r := startMulti(t, nil, left, right)
403+ r := startMulti(t, animationsOff, left, right)
404404 r.mapWindow(t, 1)
405405 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
406406 r.mapWindow(t, 2)
@@ -763,7 +763,7 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
763763 }
764764
765765 func TestStateSnapshot(t *testing.T) {
766- r := startMulti(t, nil, left, right)
766+ r := startMulti(t, animationsOff, left, right)
767767 r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
768768 receive(t, r.scenes)
769769 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
@@ -400,7 +400,7 @@ func TestMonitorActionsFollowGeometry(t *testing.T) {
400 }400 }
401 401
402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
403- r := startMulti(t, nil, left, right)403+ r := startMulti(t, animationsOff, left, right)
404 r.mapWindow(t, 1)404 r.mapWindow(t, 1)
405 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)405 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
406 r.mapWindow(t, 2)406 r.mapWindow(t, 2)
@@ -763,7 +763,7 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
763 }763 }
764 764
765 func TestStateSnapshot(t *testing.T) {765 func TestStateSnapshot(t *testing.T) {
766- r := startMulti(t, nil, left, right)766+ r := startMulti(t, animationsOff, left, right)
767 r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}767 r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
768 receive(t, r.scenes)768 receive(t, r.scenes)
769 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)769 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
M internal/core/swipe_test.go +23 -8
@@ -121,9 +121,19 @@ func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
121121 func (r *swipeRig) workspaces(t *testing.T) {
122122 t.Helper()
123123 r.mapWindow(t, 1)
124- r.key(t, "Next", ports.ModAlt)
124+ r.keySettled(t, "Next")
125125 r.mapWindow(t, 2)
126- r.key(t, "Prior", ports.ModAlt)
126+ r.keySettled(t, "Prior")
127+}
128+
129+// keySettled presses a workspace key and waits for the slide it starts, so
130+// the next step begins from the settled view.
131+func (r *swipeRig) keySettled(t *testing.T, sym string) {
132+ t.Helper()
133+ r.key(t, sym, ports.ModAlt)
134+ if r.cfg.Animations.On {
135+ r.settle(t)
136+ }
127137 }
128138
129139 func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {
@@ -324,8 +334,8 @@ func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) {
324334 r := startSwipe(t, nil)
325335 r.workspaces(t)
326336 // Workspace 3 is the trailing empty one; start there and swipe up to 2.
327- r.key(t, "Next", ports.ModAlt)
328- r.key(t, "Next", ports.ModAlt)
337+ r.keySettled(t, "Next")
338+ r.keySettled(t, "Next")
329339 r.begin()
330340 var s ports.Scene
331341 for range 6 {
@@ -382,6 +392,10 @@ func TestSwipeDropsWhenWorkspacesChange(t *testing.T) {
382392 r.move(t, 0, 30)
383393 // A key switches workspace mid-swipe: the swipe lets go at once.
384394 s := r.key(t, "Next", ports.ModAlt)[0]
395+ if got, ok := rectOf(s, 2); !ok || got.Y == 0 {
396+ t.Fatalf("workspace 2 at %v %t after the key, want the old position", got, ok)
397+ }
398+ s = r.settle(t)
385399 if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
386400 t.Fatalf("workspace 2 at %v %t after the key", got, ok)
387401 }
@@ -463,7 +477,7 @@ func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) {
463477 }
464478
465479 func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
466- r := startSwipe(t, nil)
480+ r := startSwipe(t, animationsOff)
467481 before, _ := rectOf(threeColumns(t, r), 2)
468482 r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
469483 // Window 4 on DP-2, so it has a workspace below to switch to.
@@ -554,7 +568,7 @@ func (r *swipeRig) drain() {
554568 }
555569
556570 func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {
557- r := startSwipe(t, nil)
571+ r := startSwipe(t, animationsOff)
558572 threeColumns(t, r)
559573 r.key(t, "Left", ports.ModAlt)
560574 s := r.key(t, "Left", ports.ModAlt)[0]
@@ -837,6 +851,7 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) {
837851 for range 3 {
838852 r.key(t, "Left", ports.ModAlt)
839853 }
854+ r.settle(t)
840855 // A flick toward the right lands on a later column: the view moves
841856 // right, so column 1 moves left.
842857 r.flick(t, 4, 40, 0)
@@ -849,8 +864,8 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) {
849864 t.Fatalf("view did not move right: column 1 at %d", prev.X)
850865 }
851866 r.key(t, "Right", ports.ModAlt)
852- // The retargeted spring takes its start time at the first frame, which
853- // leaves the view where it is; it moves on from the second, never back.
867+ // The retargeted spring keeps the speed of the landing: the view moves
868+ // on from the first frame and never goes back.
854869 for i := range 2 {
855870 s := r.frame(t, 16*time.Millisecond)
856871 got, _ := rectOf(s, 1)
@@ -121,9 +121,19 @@ func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
121 func (r *swipeRig) workspaces(t *testing.T) {121 func (r *swipeRig) workspaces(t *testing.T) {
122 t.Helper()122 t.Helper()
123 r.mapWindow(t, 1)123 r.mapWindow(t, 1)
124- r.key(t, "Next", ports.ModAlt)124+ r.keySettled(t, "Next")
125 r.mapWindow(t, 2)125 r.mapWindow(t, 2)
126- r.key(t, "Prior", ports.ModAlt)126+ r.keySettled(t, "Prior")
127+}
128+
129+// keySettled presses a workspace key and waits for the slide it starts, so
130+// the next step begins from the settled view.
131+func (r *swipeRig) keySettled(t *testing.T, sym string) {
132+ t.Helper()
133+ r.key(t, sym, ports.ModAlt)
134+ if r.cfg.Animations.On {
135+ r.settle(t)
136+ }
127 }137 }
128 138
129 func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {139 func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {
@@ -324,8 +334,8 @@ func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) {
324 r := startSwipe(t, nil)334 r := startSwipe(t, nil)
325 r.workspaces(t)335 r.workspaces(t)
326 // Workspace 3 is the trailing empty one; start there and swipe up to 2.336 // Workspace 3 is the trailing empty one; start there and swipe up to 2.
327- r.key(t, "Next", ports.ModAlt)337+ r.keySettled(t, "Next")
328- r.key(t, "Next", ports.ModAlt)338+ r.keySettled(t, "Next")
329 r.begin()339 r.begin()
330 var s ports.Scene340 var s ports.Scene
331 for range 6 {341 for range 6 {
@@ -382,6 +392,10 @@ func TestSwipeDropsWhenWorkspacesChange(t *testing.T) {
382 r.move(t, 0, 30)392 r.move(t, 0, 30)
383 // A key switches workspace mid-swipe: the swipe lets go at once.393 // A key switches workspace mid-swipe: the swipe lets go at once.
384 s := r.key(t, "Next", ports.ModAlt)[0]394 s := r.key(t, "Next", ports.ModAlt)[0]
395+ if got, ok := rectOf(s, 2); !ok || got.Y == 0 {
396+ t.Fatalf("workspace 2 at %v %t after the key, want the old position", got, ok)
397+ }
398+ s = r.settle(t)
385 if got, ok := rectOf(s, 2); !ok || got.Y != 0 {399 if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
386 t.Fatalf("workspace 2 at %v %t after the key", got, ok)400 t.Fatalf("workspace 2 at %v %t after the key", got, ok)
387 }401 }
@@ -463,7 +477,7 @@ func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) {
463 }477 }
464 478
465 func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {479 func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
466- r := startSwipe(t, nil)480+ r := startSwipe(t, animationsOff)
467 before, _ := rectOf(threeColumns(t, r), 2)481 before, _ := rectOf(threeColumns(t, r), 2)
468 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})
469 // 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.
@@ -554,7 +568,7 @@ func (r *swipeRig) drain() {
554 }568 }
555 569
556 func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {570 func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {
557- r := startSwipe(t, nil)571+ r := startSwipe(t, animationsOff)
558 threeColumns(t, r)572 threeColumns(t, r)
559 r.key(t, "Left", ports.ModAlt)573 r.key(t, "Left", ports.ModAlt)
560 s := r.key(t, "Left", ports.ModAlt)[0]574 s := r.key(t, "Left", ports.ModAlt)[0]
@@ -837,6 +851,7 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) {
837 for range 3 {851 for range 3 {
838 r.key(t, "Left", ports.ModAlt)852 r.key(t, "Left", ports.ModAlt)
839 }853 }
854+ r.settle(t)
840 // A flick toward the right lands on a later column: the view moves855 // A flick toward the right lands on a later column: the view moves
841 // right, so column 1 moves left.856 // right, so column 1 moves left.
842 r.flick(t, 4, 40, 0)857 r.flick(t, 4, 40, 0)
@@ -849,8 +864,8 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) {
849 t.Fatalf("view did not move right: column 1 at %d", prev.X)864 t.Fatalf("view did not move right: column 1 at %d", prev.X)
850 }865 }
851 r.key(t, "Right", ports.ModAlt)866 r.key(t, "Right", ports.ModAlt)
852- // The retargeted spring takes its start time at the first frame, which867+ // The retargeted spring keeps the speed of the landing: the view moves
853- // leaves the view where it is; it moves on from the second, never back.868+ // on from the first frame and never goes back.
854 for i := range 2 {869 for i := range 2 {
855 s := r.frame(t, 16*time.Millisecond)870 s := r.frame(t, 16*time.Millisecond)
856 got, _ := rectOf(s, 1)871 got, _ := rectOf(s, 1)
A internal/core/transition.go +132 -0
@@ -0,0 +1,132 @@
1+package core
2+
3+import (
4+ "slices"
5+ "time"
6+)
7+
8+// Transitions start after a user action (a bind, a click that focuses, a
9+// discrete swipe step): a snapshot of what each screen shows is taken before
10+// the action and diffed after it. The state the action leaves is the
11+// settled one; the transition only adds presentation springs over it
12+// (Workspace.shift, Monitor.switchOff), starting from what was on screen.
13+// Nothing here runs for events that come from clients or outputs.
14+
15+// viewShot is what one screen showed before an action.
16+type viewShot struct {
17+ sc *screen
18+ ws *Workspace
19+ // viewX is ws's settled view and view where its columns were on screen
20+ // (viewX plus shift).
21+ viewX int
22+ view float64
23+ // list and pos are the numbered workspaces and the monitor's fractional
24+ // position in them (index of the current one plus switchOff); ok is
25+ // false when there is none (a shown stash workspace).
26+ list []*Workspace
27+ pos float64
28+ ok bool
29+}
30+
31+// snapshot records what every screen without an open overview shows. The
32+// returned slice and the lists in it belong to Core and are reused by the
33+// next snapshot: use it before the next action only.
34+func (c *Core) snapshot() []viewShot {
35+ full := c.shots[:cap(c.shots)]
36+ out := c.shots[:0]
37+ for _, sc := range c.screens {
38+ m := sc.mon
39+ var s viewShot
40+ if len(out) < len(full) {
41+ s.list = full[len(out)].list[:0]
42+ }
43+ s.sc = sc
44+ if !m.ov.open {
45+ w := m.Current()
46+ s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift
47+ if m.shown == nil {
48+ // A landing slide measures in the list it began on.
49+ list := m.Workspaces
50+ if m.switchList != nil {
51+ list = m.switchList
52+ }
53+ if i := indexOf(list, w); i >= 0 {
54+ s.list = append(s.list, list...)
55+ s.pos, s.ok = float64(i)+m.switchOff, true
56+ }
57+ }
58+ }
59+ out = append(out, s)
60+ }
61+ for i := len(out); i < len(full); i++ {
62+ full[i].sc, full[i].ws = nil, nil
63+ }
64+ c.shots = out
65+ return out
66+}
67+
68+// transition starts the springs that take each screen from what before
69+// showed to what the action left, at now. It does nothing with animations
70+// off, with the overview open before or after, or while a swipe follows the
71+// fingers on that screen. Per-window rect motions join the camera here.
72+func (c *Core) transition(before []viewShot, now time.Time) {
73+ if !c.animOn() {
74+ return
75+ }
76+ for i := range before {
77+ b := &before[i]
78+ if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) {
79+ continue
80+ }
81+ c.transitionCamera(b, now)
82+ }
83+}
84+
85+// following reports whether a swipe moves sc's view with the fingers.
86+func (c *Core) following(sc *screen) bool {
87+ g := c.swipe
88+ return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces)
89+}
90+
91+// transitionCamera slides the columns or the monitor from what b showed.
92+func (c *Core) transitionCamera(b *viewShot, now time.Time) {
93+ m := b.sc.mon
94+ w := m.Current()
95+ switch {
96+ case w == b.ws:
97+ if w.ViewX == b.viewX || !w.slidable() {
98+ return
99+ }
100+ // A running landing slide was retargeted by scroll(): its velocity
101+ // carries over, and the spring starts now like every action's.
102+ v := w.motion.velocity()
103+ w.shift = b.view - float64(w.ViewX)
104+ w.motion = c.spring(viewSpring(w.shift, v), now)
105+ case b.ok && m.shown == nil && !c.movedAway(b.sc, b.ws):
106+ j := indexOf(b.list, w)
107+ if j < 0 {
108+ return
109+ }
110+ off := b.pos - float64(j)
111+ v := m.switchMotion.velocity()
112+ // The snapshot's list is reused by the next action: the slide keeps
113+ // its own copy.
114+ m.switchOff, m.switchList = off, slices.Clone(b.list)
115+ if off == 0 || m.framedSwitch() {
116+ m.stopSwitch()
117+ return
118+ }
119+ m.switchMotion = c.spring(workspaceSpring(off, v), now)
120+ }
121+}
122+
123+// movedAway reports whether w, the workspace sc showed, went to another
124+// screen: it gets no motion on either side.
125+func (c *Core) movedAway(sc *screen, w *Workspace) bool {
126+ for _, o := range c.screens {
127+ if o != sc && o.mon.has(w) {
128+ return true
129+ }
130+ }
131+ return false
132+}
@@ -0,0 +1,132 @@
1+package core
2+
3+import (
4+ "slices"
5+ "time"
6+)
7+
8+// Transitions start after a user action (a bind, a click that focuses, a
9+// discrete swipe step): a snapshot of what each screen shows is taken before
10+// the action and diffed after it. The state the action leaves is the
11+// settled one; the transition only adds presentation springs over it
12+// (Workspace.shift, Monitor.switchOff), starting from what was on screen.
13+// Nothing here runs for events that come from clients or outputs.
14+
15+// viewShot is what one screen showed before an action.
16+type viewShot struct {
17+ sc *screen
18+ ws *Workspace
19+ // viewX is ws's settled view and view where its columns were on screen
20+ // (viewX plus shift).
21+ viewX int
22+ view float64
23+ // list and pos are the numbered workspaces and the monitor's fractional
24+ // position in them (index of the current one plus switchOff); ok is
25+ // false when there is none (a shown stash workspace).
26+ list []*Workspace
27+ pos float64
28+ ok bool
29+}
30+
31+// snapshot records what every screen without an open overview shows. The
32+// returned slice and the lists in it belong to Core and are reused by the
33+// next snapshot: use it before the next action only.
34+func (c *Core) snapshot() []viewShot {
35+ full := c.shots[:cap(c.shots)]
36+ out := c.shots[:0]
37+ for _, sc := range c.screens {
38+ m := sc.mon
39+ var s viewShot
40+ if len(out) < len(full) {
41+ s.list = full[len(out)].list[:0]
42+ }
43+ s.sc = sc
44+ if !m.ov.open {
45+ w := m.Current()
46+ s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift
47+ if m.shown == nil {
48+ // A landing slide measures in the list it began on.
49+ list := m.Workspaces
50+ if m.switchList != nil {
51+ list = m.switchList
52+ }
53+ if i := indexOf(list, w); i >= 0 {
54+ s.list = append(s.list, list...)
55+ s.pos, s.ok = float64(i)+m.switchOff, true
56+ }
57+ }
58+ }
59+ out = append(out, s)
60+ }
61+ for i := len(out); i < len(full); i++ {
62+ full[i].sc, full[i].ws = nil, nil
63+ }
64+ c.shots = out
65+ return out
66+}
67+
68+// transition starts the springs that take each screen from what before
69+// showed to what the action left, at now. It does nothing with animations
70+// off, with the overview open before or after, or while a swipe follows the
71+// fingers on that screen. Per-window rect motions join the camera here.
72+func (c *Core) transition(before []viewShot, now time.Time) {
73+ if !c.animOn() {
74+ return
75+ }
76+ for i := range before {
77+ b := &before[i]
78+ if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) {
79+ continue
80+ }
81+ c.transitionCamera(b, now)
82+ }
83+}
84+
85+// following reports whether a swipe moves sc's view with the fingers.
86+func (c *Core) following(sc *screen) bool {
87+ g := c.swipe
88+ return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces)
89+}
90+
91+// transitionCamera slides the columns or the monitor from what b showed.
92+func (c *Core) transitionCamera(b *viewShot, now time.Time) {
93+ m := b.sc.mon
94+ w := m.Current()
95+ switch {
96+ case w == b.ws:
97+ if w.ViewX == b.viewX || !w.slidable() {
98+ return
99+ }
100+ // A running landing slide was retargeted by scroll(): its velocity
101+ // carries over, and the spring starts now like every action's.
102+ v := w.motion.velocity()
103+ w.shift = b.view - float64(w.ViewX)
104+ w.motion = c.spring(viewSpring(w.shift, v), now)
105+ case b.ok && m.shown == nil && !c.movedAway(b.sc, b.ws):
106+ j := indexOf(b.list, w)
107+ if j < 0 {
108+ return
109+ }
110+ off := b.pos - float64(j)
111+ v := m.switchMotion.velocity()
112+ // The snapshot's list is reused by the next action: the slide keeps
113+ // its own copy.
114+ m.switchOff, m.switchList = off, slices.Clone(b.list)
115+ if off == 0 || m.framedSwitch() {
116+ m.stopSwitch()
117+ return
118+ }
119+ m.switchMotion = c.spring(workspaceSpring(off, v), now)
120+ }
121+}
122+
123+// movedAway reports whether w, the workspace sc showed, went to another
124+// screen: it gets no motion on either side.
125+func (c *Core) movedAway(sc *screen, w *Workspace) bool {
126+ for _, o := range c.screens {
127+ if o != sc && o.mon.has(w) {
128+ return true
129+ }
130+ }
131+ return false
132+}
A internal/core/transition_internal_test.go +44 -0
@@ -0,0 +1,44 @@
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+}
@@ -0,0 +1,44 @@
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+}
A internal/core/transition_test.go +148 -0
@@ -0,0 +1,148 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// xOf is the X of window id in s, shown or not.
11+func xOf(t *testing.T, s ports.Scene, id ports.WindowID) int {
12+ t.Helper()
13+ for _, w := range s.Windows {
14+ if w.ID == id {
15+ return w.Rect.X
16+ }
17+ }
18+ t.Fatalf("window %d not in the scene", id)
19+ return 0
20+}
21+
22+// fourColumns maps four windows of half the width and goes back to the
23+// first column, settled: the view is at 0.
24+func fourColumns(t *testing.T, r *swipeRig) {
25+ t.Helper()
26+ for id := ports.WindowID(1); id <= 4; id++ {
27+ r.mapWindow(t, id)
28+ }
29+ for range 3 {
30+ r.key(t, "Left", ports.ModAlt)
31+ }
32+ if r.cfg.Animations.On {
33+ r.settle(t)
34+ }
35+}
36+
37+// A key that scrolls the view shows the old view right after it, an
38+// intermediate one on the first frame and the final one once settled.
39+func TestKeyScrollTransition(t *testing.T) {
40+ r := startSwipe(t, nil)
41+ threeColumns(t, r)
42+ r.key(t, "Left", ports.ModAlt)
43+ r.key(t, "Left", ports.ModAlt)
44+ r.settle(t)
45+ r.key(t, "Right", ports.ModAlt) // 2 is on screen: nothing scrolls
46+ s := r.key(t, "Right", ports.ModAlt)[0]
47+ if got := xOf(t, s, 1); got != 0 {
48+ t.Fatalf("column 1 at %d right after the key, want the old 0", got)
49+ }
50+ got := xOf(t, r.frame(t, 16*time.Millisecond), 1)
51+ if got >= 0 || got <= -400 {
52+ t.Fatalf("column 1 at %d on the first frame, want between 0 and -400", got)
53+ }
54+ if got := xOf(t, r.settle(t), 1); got != -400 {
55+ t.Fatalf("column 1 at %d settled, want -400", got)
56+ }
57+}
58+
59+// Presses during a transition never send the view backward.
60+func TestChainedKeysNeverJumpBack(t *testing.T) {
61+ r := startSwipe(t, nil)
62+ fourColumns(t, r)
63+ r.key(t, "Right", ports.ModAlt)
64+ r.key(t, "Right", ports.ModAlt)
65+ prev := xOf(t, r.frame(t, 16*time.Millisecond), 1)
66+ r.key(t, "Right", ports.ModAlt)
67+ for i := range 40 {
68+ got := xOf(t, r.frame(t, 16*time.Millisecond), 1)
69+ if got > prev {
70+ t.Fatalf("frame %d: column 1 at %d after %d, the view went back", i, got, prev)
71+ }
72+ prev = got
73+ if got == -800 {
74+ return
75+ }
76+ }
77+ t.Fatalf("column 1 settled at %d, want -800", prev)
78+}
79+
80+func TestWorkspaceKeyTransition(t *testing.T) {
81+ r := startSwipe(t, nil)
82+ r.workspaces(t)
83+ s := r.key(t, "Next", ports.ModAlt)[0]
84+ if got, ok := rectOf(s, 1); !ok || got.Y != 0 {
85+ t.Fatalf("workspace 1 at %+v (shown %t) right after the key, want the old position", got, ok)
86+ }
87+ s = r.frame(t, 16*time.Millisecond)
88+ got, ok := rectOf(s, 2)
89+ if !ok || got.Y <= 0 || got.Y >= 600 {
90+ t.Fatalf("workspace 2 at %+v (shown %t) on the first frame, want Y between 0 and 600", got, ok)
91+ }
92+ if got, _ := rectOf(r.settle(t), 2); got.Y != 0 {
93+ t.Fatalf("workspace 2 at %d settled, want 0", got.Y)
94+ }
95+}
96+
97+// The workspace slide of a key keeps running when the next key goes on.
98+func TestChainedWorkspaceKeysKeepSliding(t *testing.T) {
99+ r := startSwipe(t, nil)
100+ r.workspaces(t)
101+ r.keySettled(t, "Next")
102+ r.keySettled(t, "Next")
103+ r.mapWindow(t, 3)
104+ r.keySettled(t, "Prior")
105+ r.keySettled(t, "Prior")
106+ r.key(t, "Next", ports.ModAlt)
107+ r.frame(t, 16*time.Millisecond)
108+ r.key(t, "Next", ports.ModAlt)
109+ if got, ok := rectOf(r.settle(t), 3); !ok || got.Y != 0 {
110+ t.Fatalf("workspace 3 at %+v (shown %t) settled", got, ok)
111+ }
112+}
113+
114+func TestAnimationsOffKeysLandAtOnce(t *testing.T) {
115+ r := startSwipe(t, animationsOff)
116+ fourColumns(t, r)
117+ r.key(t, "Right", ports.ModAlt)
118+ if got := xOf(t, r.key(t, "Right", ports.ModAlt)[0], 1); got != -400 {
119+ t.Fatalf("column 1 at %d right after the key, want -400", got)
120+ }
121+ r.noFrameScene(t)
122+
123+ r = startSwipe(t, animationsOff)
124+ r.workspaces(t)
125+ s := r.key(t, "Next", ports.ModAlt)[0]
126+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
127+ t.Fatalf("workspace 2 at %+v (shown %t) right after the key, want the settled rect", got, ok)
128+ }
129+ r.noFrameScene(t)
130+}
131+
132+// With the overview open before or after, nothing slides.
133+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)
139+}
140+
141+// A workspace that goes to another monitor gets no motion on either side.
142+func TestMovedWorkspaceHasNoMotion(t *testing.T) {
143+ r := startSwipe(t, nil)
144+ r.mapWindow(t, 1)
145+ r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
146+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
147+ r.noFrameScene(t)
148+}
@@ -0,0 +1,148 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// xOf is the X of window id in s, shown or not.
11+func xOf(t *testing.T, s ports.Scene, id ports.WindowID) int {
12+ t.Helper()
13+ for _, w := range s.Windows {
14+ if w.ID == id {
15+ return w.Rect.X
16+ }
17+ }
18+ t.Fatalf("window %d not in the scene", id)
19+ return 0
20+}
21+
22+// fourColumns maps four windows of half the width and goes back to the
23+// first column, settled: the view is at 0.
24+func fourColumns(t *testing.T, r *swipeRig) {
25+ t.Helper()
26+ for id := ports.WindowID(1); id <= 4; id++ {
27+ r.mapWindow(t, id)
28+ }
29+ for range 3 {
30+ r.key(t, "Left", ports.ModAlt)
31+ }
32+ if r.cfg.Animations.On {
33+ r.settle(t)
34+ }
35+}
36+
37+// A key that scrolls the view shows the old view right after it, an
38+// intermediate one on the first frame and the final one once settled.
39+func TestKeyScrollTransition(t *testing.T) {
40+ r := startSwipe(t, nil)
41+ threeColumns(t, r)
42+ r.key(t, "Left", ports.ModAlt)
43+ r.key(t, "Left", ports.ModAlt)
44+ r.settle(t)
45+ r.key(t, "Right", ports.ModAlt) // 2 is on screen: nothing scrolls
46+ s := r.key(t, "Right", ports.ModAlt)[0]
47+ if got := xOf(t, s, 1); got != 0 {
48+ t.Fatalf("column 1 at %d right after the key, want the old 0", got)
49+ }
50+ got := xOf(t, r.frame(t, 16*time.Millisecond), 1)
51+ if got >= 0 || got <= -400 {
52+ t.Fatalf("column 1 at %d on the first frame, want between 0 and -400", got)
53+ }
54+ if got := xOf(t, r.settle(t), 1); got != -400 {
55+ t.Fatalf("column 1 at %d settled, want -400", got)
56+ }
57+}
58+
59+// Presses during a transition never send the view backward.
60+func TestChainedKeysNeverJumpBack(t *testing.T) {
61+ r := startSwipe(t, nil)
62+ fourColumns(t, r)
63+ r.key(t, "Right", ports.ModAlt)
64+ r.key(t, "Right", ports.ModAlt)
65+ prev := xOf(t, r.frame(t, 16*time.Millisecond), 1)
66+ r.key(t, "Right", ports.ModAlt)
67+ for i := range 40 {
68+ got := xOf(t, r.frame(t, 16*time.Millisecond), 1)
69+ if got > prev {
70+ t.Fatalf("frame %d: column 1 at %d after %d, the view went back", i, got, prev)
71+ }
72+ prev = got
73+ if got == -800 {
74+ return
75+ }
76+ }
77+ t.Fatalf("column 1 settled at %d, want -800", prev)
78+}
79+
80+func TestWorkspaceKeyTransition(t *testing.T) {
81+ r := startSwipe(t, nil)
82+ r.workspaces(t)
83+ s := r.key(t, "Next", ports.ModAlt)[0]
84+ if got, ok := rectOf(s, 1); !ok || got.Y != 0 {
85+ t.Fatalf("workspace 1 at %+v (shown %t) right after the key, want the old position", got, ok)
86+ }
87+ s = r.frame(t, 16*time.Millisecond)
88+ got, ok := rectOf(s, 2)
89+ if !ok || got.Y <= 0 || got.Y >= 600 {
90+ t.Fatalf("workspace 2 at %+v (shown %t) on the first frame, want Y between 0 and 600", got, ok)
91+ }
92+ if got, _ := rectOf(r.settle(t), 2); got.Y != 0 {
93+ t.Fatalf("workspace 2 at %d settled, want 0", got.Y)
94+ }
95+}
96+
97+// The workspace slide of a key keeps running when the next key goes on.
98+func TestChainedWorkspaceKeysKeepSliding(t *testing.T) {
99+ r := startSwipe(t, nil)
100+ r.workspaces(t)
101+ r.keySettled(t, "Next")
102+ r.keySettled(t, "Next")
103+ r.mapWindow(t, 3)
104+ r.keySettled(t, "Prior")
105+ r.keySettled(t, "Prior")
106+ r.key(t, "Next", ports.ModAlt)
107+ r.frame(t, 16*time.Millisecond)
108+ r.key(t, "Next", ports.ModAlt)
109+ if got, ok := rectOf(r.settle(t), 3); !ok || got.Y != 0 {
110+ t.Fatalf("workspace 3 at %+v (shown %t) settled", got, ok)
111+ }
112+}
113+
114+func TestAnimationsOffKeysLandAtOnce(t *testing.T) {
115+ r := startSwipe(t, animationsOff)
116+ fourColumns(t, r)
117+ r.key(t, "Right", ports.ModAlt)
118+ if got := xOf(t, r.key(t, "Right", ports.ModAlt)[0], 1); got != -400 {
119+ t.Fatalf("column 1 at %d right after the key, want -400", got)
120+ }
121+ r.noFrameScene(t)
122+
123+ r = startSwipe(t, animationsOff)
124+ r.workspaces(t)
125+ s := r.key(t, "Next", ports.ModAlt)[0]
126+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
127+ t.Fatalf("workspace 2 at %+v (shown %t) right after the key, want the settled rect", got, ok)
128+ }
129+ r.noFrameScene(t)
130+}
131+
132+// With the overview open before or after, nothing slides.
133+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)
139+}
140+
141+// A workspace that goes to another monitor gets no motion on either side.
142+func TestMovedWorkspaceHasNoMotion(t *testing.T) {
143+ r := startSwipe(t, nil)
144+ r.mapWindow(t, 1)
145+ r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
146+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
147+ r.noFrameScene(t)
148+}

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