feat(core): one creation and one stop path for animations, off lands instantly
- commit
- d8dfa1d39231a187b6bc57fbe5df1025dd29f81f
- author
- brice · · 2026-10-04 15:44 UTC
- committer
- brice · · 2026-10-04 19:08 UTC
- parent
- 6e31b43
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +211 -10
A internal/core/animations_off_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func animationsOff(c *ports.Config) { c.Animations.On = false } | |
| 11 | + | ||
| 12 | + | func TestAnimationsOffFlickLandsAtOnce(t *testing.T) { | |
| 13 | + | r := startSwipe(t, animationsOff) | |
| 14 | + | threeColumns(t, r) | |
| 15 | + | s := r.flick(t, 10, -40, 0) | |
| 16 | + | if got, ok := rectOf(s, 1); !ok || got.X != 0 { | |
| 17 | + | t.Fatalf("column 1 at %+v (shown %t) right after the lift, want the settled rect", got, ok) | |
| 18 | + | } | |
| 19 | + | r.noFrameScene(t) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) { | |
| 23 | + | r := startSwipe(t, animationsOff) | |
| 24 | + | r.workspaces(t) | |
| 25 | + | r.begin() | |
| 26 | + | for range 9 { | |
| 27 | + | r.move(t, 0, 30) | |
| 28 | + | } | |
| 29 | + | s := r.end(t, false) | |
| 30 | + | if _, ok := rectOf(s, 1); ok { | |
| 31 | + | t.Fatal("old workspace still shown right after the lift") | |
| 32 | + | } | |
| 33 | + | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |
| 34 | + | t.Fatalf("workspace 2 at %+v (shown %t), want the settled rect", got, ok) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestAnimationsOffFingersStillFollowed(t *testing.T) { | |
| 39 | + | r := startSwipe(t, animationsOff) | |
| 40 | + | before, _ := rectOf(threeColumns(t, r), 2) | |
| 41 | + | r.begin() | |
| 42 | + | r.input <- ports.SwipeUpdate{DX: -10, Time: r.at} | |
| 43 | + | r.move(t, -10, 0) | |
| 44 | + | s := r.move(t, -290, 0) | |
| 45 | + | if got, _ := rectOf(s, 2); got.X-before.X != 200 { | |
| 46 | + | t.Fatalf("column moved %d px with the fingers, want 200", got.X-before.X) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestReloadToOffSettlesALanding(t *testing.T) { | |
| 51 | + | r := startSwipe(t, nil) | |
| 52 | + | threeColumns(t, r) | |
| 53 | + | s := r.flick(t, 10, -40, 0) | |
| 54 | + | if got, _ := rectOf(s, 1); got.X == 0 { | |
| 55 | + | t.Fatal("no landing slide to interrupt") | |
| 56 | + | } | |
| 57 | + | cfg := r.cfg | |
| 58 | + | cfg.Animations.On = false | |
| 59 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 60 | + | s = scene(t, r.scenes) | |
| 61 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |
| 62 | + | t.Fatalf("column 1 at %d after the reload, want the settled rect", got.X) | |
| 63 | + | } | |
| 64 | + | r.noFrameScene(t) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // noFrameScene sends page flips and expects no scene: no spring runs, so | |
| 68 | + | // the flips have nothing to move and publish nothing. | |
| 69 | + | func (r *swipeRig) noFrameScene(t *testing.T) { | |
| 70 | + | t.Helper() | |
| 71 | + | for range 2 { | |
| 72 | + | r.advance(16 * time.Millisecond) | |
| 73 | + | r.frames <- ports.OutputFrame{Output: wide.Name} | |
| 74 | + | } | |
| 75 | + | select { | |
| 76 | + | case s := <-r.scenes: | |
| 77 | + | t.Fatalf("a flip published a scene (%d windows): a spring still runs", len(s)) | |
| 78 | + | case <-time.After(50 * time.Millisecond): | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // landing flicks left and returns the rect of column 1 after each given | |
| 83 | + | // time since the lift. | |
| 84 | + | func landing(t *testing.T, slowdown float64, after ...time.Duration) []int { | |
| 85 | + | t.Helper() | |
| 86 | + | r := startSwipe(t, func(c *ports.Config) { c.Animations.Slowdown = slowdown }) | |
| 87 | + | threeColumns(t, r) | |
| 88 | + | r.flick(t, 10, -40, 0) | |
| 89 | + | var xs []int | |
| 90 | + | var at time.Duration | |
| 91 | + | for _, d := range after { | |
| 92 | + | got, _ := rectOf(r.frame(t, d-at), 1) | |
| 93 | + | xs = append(xs, got.X) | |
| 94 | + | at = d | |
| 95 | + | } | |
| 96 | + | return xs | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestSlowdownStretchesALanding(t *testing.T) { | |
| 100 | + | // Critically damped at stiffness 800: settled after about 330 ms, 660 ms | |
| 101 | + | // with slowdown 2 (the exact doubling is checked on the motion). | |
| 102 | + | one := landing(t, 1, 100*time.Millisecond, 400*time.Millisecond) | |
| 103 | + | two := landing(t, 2, 100*time.Millisecond, 400*time.Millisecond, 700*time.Millisecond) | |
| 104 | + | if one[0] == 0 || two[0] == 0 { | |
| 105 | + | t.Fatalf("landing does not move after 100 ms: %v %v", one, two) | |
| 106 | + | } | |
| 107 | + | if one[1] != 0 || two[1] == 0 { | |
| 108 | + | t.Fatalf("at 400 ms: slowdown 1 at %d, want settled; slowdown 2 at %d, want moving", one[1], two[1]) | |
| 109 | + | } | |
| 110 | + | if two[2] != 0 { | |
| 111 | + | t.Fatalf("slowdown 2 does not settle by 700 ms: %v", two) | |
| 112 | + | } | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func animationsOff(c *ports.Config) { c.Animations.On = false } | |||
| 11 | + | ||||
| 12 | + | func TestAnimationsOffFlickLandsAtOnce(t *testing.T) { | |||
| 13 | + | r := startSwipe(t, animationsOff) | |||
| 14 | + | threeColumns(t, r) | |||
| 15 | + | s := r.flick(t, 10, -40, 0) | |||
| 16 | + | if got, ok := rectOf(s, 1); !ok || got.X != 0 { | |||
| 17 | + | t.Fatalf("column 1 at %+v (shown %t) right after the lift, want the settled rect", got, ok) | |||
| 18 | + | } | |||
| 19 | + | r.noFrameScene(t) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) { | |||
| 23 | + | r := startSwipe(t, animationsOff) | |||
| 24 | + | r.workspaces(t) | |||
| 25 | + | r.begin() | |||
| 26 | + | for range 9 { | |||
| 27 | + | r.move(t, 0, 30) | |||
| 28 | + | } | |||
| 29 | + | s := r.end(t, false) | |||
| 30 | + | if _, ok := rectOf(s, 1); ok { | |||
| 31 | + | t.Fatal("old workspace still shown right after the lift") | |||
| 32 | + | } | |||
| 33 | + | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |||
| 34 | + | t.Fatalf("workspace 2 at %+v (shown %t), want the settled rect", got, ok) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestAnimationsOffFingersStillFollowed(t *testing.T) { | |||
| 39 | + | r := startSwipe(t, animationsOff) | |||
| 40 | + | before, _ := rectOf(threeColumns(t, r), 2) | |||
| 41 | + | r.begin() | |||
| 42 | + | r.input <- ports.SwipeUpdate{DX: -10, Time: r.at} | |||
| 43 | + | r.move(t, -10, 0) | |||
| 44 | + | s := r.move(t, -290, 0) | |||
| 45 | + | if got, _ := rectOf(s, 2); got.X-before.X != 200 { | |||
| 46 | + | t.Fatalf("column moved %d px with the fingers, want 200", got.X-before.X) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestReloadToOffSettlesALanding(t *testing.T) { | |||
| 51 | + | r := startSwipe(t, nil) | |||
| 52 | + | threeColumns(t, r) | |||
| 53 | + | s := r.flick(t, 10, -40, 0) | |||
| 54 | + | if got, _ := rectOf(s, 1); got.X == 0 { | |||
| 55 | + | t.Fatal("no landing slide to interrupt") | |||
| 56 | + | } | |||
| 57 | + | cfg := r.cfg | |||
| 58 | + | cfg.Animations.On = false | |||
| 59 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 60 | + | s = scene(t, r.scenes) | |||
| 61 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |||
| 62 | + | t.Fatalf("column 1 at %d after the reload, want the settled rect", got.X) | |||
| 63 | + | } | |||
| 64 | + | r.noFrameScene(t) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // noFrameScene sends page flips and expects no scene: no spring runs, so | |||
| 68 | + | // the flips have nothing to move and publish nothing. | |||
| 69 | + | func (r *swipeRig) noFrameScene(t *testing.T) { | |||
| 70 | + | t.Helper() | |||
| 71 | + | for range 2 { | |||
| 72 | + | r.advance(16 * time.Millisecond) | |||
| 73 | + | r.frames <- ports.OutputFrame{Output: wide.Name} | |||
| 74 | + | } | |||
| 75 | + | select { | |||
| 76 | + | case s := <-r.scenes: | |||
| 77 | + | t.Fatalf("a flip published a scene (%d windows): a spring still runs", len(s)) | |||
| 78 | + | case <-time.After(50 * time.Millisecond): | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // landing flicks left and returns the rect of column 1 after each given | |||
| 83 | + | // time since the lift. | |||
| 84 | + | func landing(t *testing.T, slowdown float64, after ...time.Duration) []int { | |||
| 85 | + | t.Helper() | |||
| 86 | + | r := startSwipe(t, func(c *ports.Config) { c.Animations.Slowdown = slowdown }) | |||
| 87 | + | threeColumns(t, r) | |||
| 88 | + | r.flick(t, 10, -40, 0) | |||
| 89 | + | var xs []int | |||
| 90 | + | var at time.Duration | |||
| 91 | + | for _, d := range after { | |||
| 92 | + | got, _ := rectOf(r.frame(t, d-at), 1) | |||
| 93 | + | xs = append(xs, got.X) | |||
| 94 | + | at = d | |||
| 95 | + | } | |||
| 96 | + | return xs | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestSlowdownStretchesALanding(t *testing.T) { | |||
| 100 | + | // Critically damped at stiffness 800: settled after about 330 ms, 660 ms | |||
| 101 | + | // with slowdown 2 (the exact doubling is checked on the motion). | |||
| 102 | + | one := landing(t, 1, 100*time.Millisecond, 400*time.Millisecond) | |||
| 103 | + | two := landing(t, 2, 100*time.Millisecond, 400*time.Millisecond, 700*time.Millisecond) | |||
| 104 | + | if one[0] == 0 || two[0] == 0 { | |||
| 105 | + | t.Fatalf("landing does not move after 100 ms: %v %v", one, two) | |||
| 106 | + | } | |||
| 107 | + | if one[1] != 0 || two[1] == 0 { | |||
| 108 | + | t.Fatalf("at 400 ms: slowdown 1 at %d, want settled; slowdown 2 at %d, want moving", one[1], two[1]) | |||
| 109 | + | } | |||
| 110 | + | if two[2] != 0 { | |||
| 111 | + | t.Fatalf("slowdown 2 does not settle by 700 ms: %v", two) | |||
| 112 | + | } | |||
| 113 | + | } | |||
A internal/core/animations_stop_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestStopAnimationsSettlesEverySpring(t *testing.T) { | |
| 9 | + | c, ic := indicatorCore(t) | |
| 10 | + | c.cfg.Animations.On = true | |
| 11 | + | m := c.cur().mon | |
| 12 | + | m.AddWindow(1) | |
| 13 | + | w := m.Current() | |
| 14 | + | w.shift, w.motion = 40, c.spring(viewSpring(40, 0), ic.now) | |
| 15 | + | m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) | |
| 16 | + | c.armFrame() | |
| 17 | + | if !c.animating() || c.frameC == nil { | |
| 18 | + | t.Fatal("no running spring to stop") | |
| 19 | + | } | |
| 20 | + | c.stopAnimations() | |
| 21 | + | if c.animating() || w.motion.on || w.shift != 0 || m.switchMotion.on || m.switchOff != 0 || c.frameC != nil { | |
| 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) | |
| 23 | + | } | |
| 24 | + | } | |
| 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 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestStopAnimationsSettlesEverySpring(t *testing.T) { | |||
| 9 | + | c, ic := indicatorCore(t) | |||
| 10 | + | c.cfg.Animations.On = true | |||
| 11 | + | m := c.cur().mon | |||
| 12 | + | m.AddWindow(1) | |||
| 13 | + | w := m.Current() | |||
| 14 | + | w.shift, w.motion = 40, c.spring(viewSpring(40, 0), ic.now) | |||
| 15 | + | m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) | |||
| 16 | + | c.armFrame() | |||
| 17 | + | if !c.animating() || c.frameC == nil { | |||
| 18 | + | t.Fatal("no running spring to stop") | |||
| 19 | + | } | |||
| 20 | + | c.stopAnimations() | |||
| 21 | + | if c.animating() || w.motion.on || w.shift != 0 || m.switchMotion.on || m.switchOff != 0 || c.frameC != nil { | |||
| 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) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 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/core.go +3 -0
| @@ -1228,6 +1228,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1228 | 1228 | } | |
| 1229 | 1229 | continue | |
| 1230 | 1230 | } | |
| 1231 | + | if !c.animOn() { | |
| 1232 | + | c.stopAnimations() | |
| 1233 | + | } | |
| 1231 | 1234 | // A rotation can shrink the layout under a still pointer. | |
| 1232 | 1235 | c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY) | |
| 1233 | 1236 | if c.workspaceVisible(ctx, false) != nil { | |
| @@ -1228,6 +1228,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1228 | } | 1228 | } | ||
| 1229 | continue | 1229 | continue | ||
| 1230 | } | 1230 | } | ||
| 1231 | + | if !c.animOn() { | |||
| 1232 | + | c.stopAnimations() | |||
| 1233 | + | } | |||
| 1231 | // A rotation can shrink the layout under a still pointer. | 1234 | // A rotation can shrink the layout under a still pointer. | ||
| 1232 | c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY) | 1235 | c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY) | ||
| 1233 | if c.workspaceVisible(ctx, false) != nil { | 1236 | if c.workspaceVisible(ctx, false) != nil { | ||
M internal/core/gesture.go +38 -2
| @@ -216,7 +216,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 216 | 216 | c.keyboard.takeBack() | |
| 217 | 217 | } | |
| 218 | 218 | w.shift = shown - float64(w.ViewX) | |
| 219 | - | w.motion = newMotion(viewSpring(w.shift, velocity), now, 1) | |
| 219 | + | if c.animOn() { | |
| 220 | + | w.motion = c.spring(viewSpring(w.shift, velocity), now) | |
| 221 | + | } else { | |
| 222 | + | w.stopSlide() | |
| 223 | + | } | |
| 220 | 224 | case swipeWorkspaces: | |
| 221 | 225 | if g.listChanged(m) { | |
| 222 | 226 | m.stopSwitch() | |
| @@ -240,7 +244,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 240 | 244 | c.keyboard.takeBack() | |
| 241 | 245 | } | |
| 242 | 246 | m.switchOff, m.switchList = off, g.list | |
| 243 | - | m.switchMotion = newMotion(workspaceSpring(off, velocity), now, 1) | |
| 247 | + | if c.animOn() { | |
| 248 | + | m.switchMotion = c.spring(workspaceSpring(off, velocity), now) | |
| 249 | + | } else { | |
| 250 | + | m.stopSwitch() | |
| 251 | + | } | |
| 244 | 252 | case swipeOverview: | |
| 245 | 253 | step := g.snap.step(e.Cancelled, e.Time) | |
| 246 | 254 | if step == 0 || (step < 0) == m.ov.open { | |
| @@ -317,6 +325,34 @@ func (c *Core) hasScreen(s *screen) bool { | |||
| 317 | 325 | return false | |
| 318 | 326 | } | |
| 319 | 327 | ||
| 328 | + | // animOn reports whether transitions run (animations = on). | |
| 329 | + | func (c *Core) animOn() bool { return c.cfg.Animations.On } | |
| 330 | + | ||
| 331 | + | // spring starts s at now, slowed down by animations.slowdown. Every motion | |
| 332 | + | // core creates goes through it, except a retarget, which keeps the slowdown | |
| 333 | + | // of the motion it replaces. | |
| 334 | + | func (c *Core) spring(s spring, now time.Time) motion { | |
| 335 | + | return newMotion(s, now, c.cfg.Animations.Slowdown) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // stopAnimations settles every running spring where it is going and stops | |
| 339 | + | // the frame timer: a session lock or a switch to animations = off. A swipe | |
| 340 | + | // in progress keeps its fingers' view: only running springs stop. Per-window | |
| 341 | + | // rect motions stop here too once they exist. | |
| 342 | + | func (c *Core) stopAnimations() { | |
| 343 | + | for _, sc := range c.screens { | |
| 344 | + | if sc.mon.switchMotion.on { | |
| 345 | + | sc.mon.stopSwitch() | |
| 346 | + | } | |
| 347 | + | for w := range sc.mon.all() { | |
| 348 | + | if w.motion.on { | |
| 349 | + | w.stopSlide() | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | c.stopFrame() | |
| 354 | + | } | |
| 355 | + | ||
| 320 | 356 | // animate moves the running springs to now; settled ones stop. | |
| 321 | 357 | func (c *Core) animate(now time.Time) { | |
| 322 | 358 | for _, sc := range c.screens { | |
| @@ -216,7 +216,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 216 | c.keyboard.takeBack() | 216 | c.keyboard.takeBack() | ||
| 217 | } | 217 | } | ||
| 218 | w.shift = shown - float64(w.ViewX) | 218 | w.shift = shown - float64(w.ViewX) | ||
| 219 | - | w.motion = newMotion(viewSpring(w.shift, velocity), now, 1) | 219 | + | if c.animOn() { |
| 220 | + | w.motion = c.spring(viewSpring(w.shift, velocity), now) | |||
| 221 | + | } else { | |||
| 222 | + | w.stopSlide() | |||
| 223 | + | } | |||
| 220 | case swipeWorkspaces: | 224 | case swipeWorkspaces: | ||
| 221 | if g.listChanged(m) { | 225 | if g.listChanged(m) { | ||
| 222 | m.stopSwitch() | 226 | m.stopSwitch() | ||
| @@ -240,7 +244,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 240 | c.keyboard.takeBack() | 244 | c.keyboard.takeBack() | ||
| 241 | } | 245 | } | ||
| 242 | m.switchOff, m.switchList = off, g.list | 246 | m.switchOff, m.switchList = off, g.list | ||
| 243 | - | m.switchMotion = newMotion(workspaceSpring(off, velocity), now, 1) | 247 | + | if c.animOn() { |
| 248 | + | m.switchMotion = c.spring(workspaceSpring(off, velocity), now) | |||
| 249 | + | } else { | |||
| 250 | + | m.stopSwitch() | |||
| 251 | + | } | |||
| 244 | case swipeOverview: | 252 | case swipeOverview: | ||
| 245 | step := g.snap.step(e.Cancelled, e.Time) | 253 | step := g.snap.step(e.Cancelled, e.Time) | ||
| 246 | if step == 0 || (step < 0) == m.ov.open { | 254 | if step == 0 || (step < 0) == m.ov.open { | ||
| @@ -317,6 +325,34 @@ func (c *Core) hasScreen(s *screen) bool { | |||||
| 317 | return false | 325 | return false | ||
| 318 | } | 326 | } | ||
| 319 | 327 | ||||
| 328 | + | // animOn reports whether transitions run (animations = on). | |||
| 329 | + | func (c *Core) animOn() bool { return c.cfg.Animations.On } | |||
| 330 | + | ||||
| 331 | + | // spring starts s at now, slowed down by animations.slowdown. Every motion | |||
| 332 | + | // core creates goes through it, except a retarget, which keeps the slowdown | |||
| 333 | + | // of the motion it replaces. | |||
| 334 | + | func (c *Core) spring(s spring, now time.Time) motion { | |||
| 335 | + | return newMotion(s, now, c.cfg.Animations.Slowdown) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // stopAnimations settles every running spring where it is going and stops | |||
| 339 | + | // the frame timer: a session lock or a switch to animations = off. A swipe | |||
| 340 | + | // in progress keeps its fingers' view: only running springs stop. Per-window | |||
| 341 | + | // rect motions stop here too once they exist. | |||
| 342 | + | func (c *Core) stopAnimations() { | |||
| 343 | + | for _, sc := range c.screens { | |||
| 344 | + | if sc.mon.switchMotion.on { | |||
| 345 | + | sc.mon.stopSwitch() | |||
| 346 | + | } | |||
| 347 | + | for w := range sc.mon.all() { | |||
| 348 | + | if w.motion.on { | |||
| 349 | + | w.stopSlide() | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | c.stopFrame() | |||
| 354 | + | } | |||
| 355 | + | ||||
| 320 | // animate moves the running springs to now; settled ones stop. | 356 | // animate moves the running springs to now; settled ones stop. | ||
| 321 | func (c *Core) animate(now time.Time) { | 357 | func (c *Core) animate(now time.Time) { | ||
| 322 | for _, sc := range c.screens { | 358 | for _, sc := range c.screens { | ||
M internal/core/pulse.go +3 -1
| @@ -43,7 +43,9 @@ type focusPulse struct { | |||
| 43 | 43 | timerStop func() bool | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | func (c *Core) pulseOn() bool { return c.cfg.Focus.Animation == ports.FocusAnimationPulse } | |
| 46 | + | func (c *Core) pulseOn() bool { | |
| 47 | + | return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse | |
| 48 | + | } | |
| 47 | 49 | ||
| 48 | 50 | // pulseFocus follows the window focus. held is false while something else | |
| 49 | 51 | // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse | |
| @@ -43,7 +43,9 @@ type focusPulse struct { | |||||
| 43 | timerStop func() bool | 43 | timerStop func() bool | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | - | func (c *Core) pulseOn() bool { return c.cfg.Focus.Animation == ports.FocusAnimationPulse } | 46 | + | func (c *Core) pulseOn() bool { |
| 47 | + | return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse | |||
| 48 | + | } | |||
| 47 | 49 | ||||
| 48 | // pulseFocus follows the window focus. held is false while something else | 50 | // pulseFocus follows the window focus. held is false while something else | ||
| 49 | // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse | 51 | // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse | ||
M internal/core/pulse_test.go +18 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | func pulseCore(t *testing.T) (*Core, *indicatorClock) { | |
| 13 | 13 | t.Helper() | |
| 14 | 14 | c, ic := indicatorCore(t) | |
| 15 | + | c.cfg.Animations.On = true | |
| 15 | 16 | c.cfg.Focus.Animation = ports.FocusAnimationPulse | |
| 16 | 17 | c.cfg.Focus.Strength = 0.05 | |
| 17 | 18 | c.cur().mon.AddWindow(1) | |
| @@ -259,3 +260,20 @@ func TestFocusPulseOff(t *testing.T) { | |||
| 259 | 260 | t.Fatal("enabling the pulse pulses the focused window") | |
| 260 | 261 | } | |
| 261 | 262 | } | |
| 263 | + | ||
| 264 | + | func TestFocusPulseOffWithAnimationsOff(t *testing.T) { | |
| 265 | + | c, ic := pulseCore(t) | |
| 266 | + | c.cfg.Animations.On = false | |
| 267 | + | if c.pulseOn() { | |
| 268 | + | t.Fatal("pulse on with animations off") | |
| 269 | + | } | |
| 270 | + | c.cur().mon.Current().FocusID(1) | |
| 271 | + | indicatorScene(t, c) | |
| 272 | + | if c.pulse.timerC != nil || c.pulse.id != 0 { | |
| 273 | + | t.Fatal("pulse armed with animations off") | |
| 274 | + | } | |
| 275 | + | ic.now = ic.now.Add(pulseSettle) | |
| 276 | + | if c.pulseTick() || c.animating() { | |
| 277 | + | t.Fatal("pulse with animations off") | |
| 278 | + | } | |
| 279 | + | } | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | func pulseCore(t *testing.T) (*Core, *indicatorClock) { | 12 | func pulseCore(t *testing.T) (*Core, *indicatorClock) { | ||
| 13 | t.Helper() | 13 | t.Helper() | ||
| 14 | c, ic := indicatorCore(t) | 14 | c, ic := indicatorCore(t) | ||
| 15 | + | c.cfg.Animations.On = true | |||
| 15 | c.cfg.Focus.Animation = ports.FocusAnimationPulse | 16 | c.cfg.Focus.Animation = ports.FocusAnimationPulse | ||
| 16 | c.cfg.Focus.Strength = 0.05 | 17 | c.cfg.Focus.Strength = 0.05 | ||
| 17 | c.cur().mon.AddWindow(1) | 18 | c.cur().mon.AddWindow(1) | ||
| @@ -259,3 +260,20 @@ func TestFocusPulseOff(t *testing.T) { | |||||
| 259 | t.Fatal("enabling the pulse pulses the focused window") | 260 | t.Fatal("enabling the pulse pulses the focused window") | ||
| 260 | } | 261 | } | ||
| 261 | } | 262 | } | ||
| 263 | + | ||||
| 264 | + | func TestFocusPulseOffWithAnimationsOff(t *testing.T) { | |||
| 265 | + | c, ic := pulseCore(t) | |||
| 266 | + | c.cfg.Animations.On = false | |||
| 267 | + | if c.pulseOn() { | |||
| 268 | + | t.Fatal("pulse on with animations off") | |||
| 269 | + | } | |||
| 270 | + | c.cur().mon.Current().FocusID(1) | |||
| 271 | + | indicatorScene(t, c) | |||
| 272 | + | if c.pulse.timerC != nil || c.pulse.id != 0 { | |||
| 273 | + | t.Fatal("pulse armed with animations off") | |||
| 274 | + | } | |||
| 275 | + | ic.now = ic.now.Add(pulseSettle) | |||
| 276 | + | if c.pulseTick() || c.animating() { | |||
| 277 | + | t.Fatal("pulse with animations off") | |||
| 278 | + | } | |||
| 279 | + | } | |||
M internal/core/security.go +1 -7
| @@ -33,13 +33,7 @@ func (c *Core) syncSecurity() bool { | |||
| 33 | 33 | } | |
| 34 | 34 | c.dropSwipe() | |
| 35 | 35 | c.swipe = nil | |
| 36 | - | for _, sc := range c.screens { | |
| 37 | - | sc.mon.stopSwitch() | |
| 38 | - | for w := range sc.mon.all() { | |
| 39 | - | w.stopSlide() | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | c.stopFrame() | |
| 36 | + | c.stopAnimations() | |
| 43 | 37 | c.stopPulse() | |
| 44 | 38 | if state.Protected { | |
| 45 | 39 | c.dropCaptureSessions() | |
| @@ -33,13 +33,7 @@ func (c *Core) syncSecurity() bool { | |||||
| 33 | } | 33 | } | ||
| 34 | c.dropSwipe() | 34 | c.dropSwipe() | ||
| 35 | c.swipe = nil | 35 | c.swipe = nil | ||
| 36 | - | for _, sc := range c.screens { | 36 | + | c.stopAnimations() |
| 37 | - | sc.mon.stopSwitch() | |||
| 38 | - | for w := range sc.mon.all() { | |||
| 39 | - | w.stopSlide() | |||
| 40 | - | } | |||
| 41 | - | } | |||
| 42 | - | c.stopFrame() | |||
| 43 | c.stopPulse() | 37 | c.stopPulse() | ||
| 44 | if state.Protected { | 38 | if state.Protected { | ||
| 45 | c.dropCaptureSessions() | 39 | c.dropCaptureSessions() | ||