fix(core): stop the placeholder's animations when the last output is unplugged
The last output's screen stays as a placeholder and kept its rect, camera and workspace springs, so the fallback timer kept waking core for a screen nothing draws. Also adds session lock and output unplug mid rect motion tests.
- commit
- b6a8e33244a356fbee48116fbf03744f1de40d74
- author
- brice · · 2026-10-04 20:34 UTC
- parent
- 52e9635
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +160 -9
A internal/core/animations_interrupt_internal_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // movingRects makes two windows on sc swap places and returns with their | |
| 13 | + | // rect motions running, one frame in (the clock moved 16 ms and stepped). | |
| 14 | + | func movingRects(t *testing.T, c *Core, ic *indicatorClock, sc *screen) { | |
| 15 | + | t.Helper() | |
| 16 | + | c.cfg.Animations.On = true | |
| 17 | + | sc.mon.AddWindow(1) | |
| 18 | + | sc.mon.AddWindow(2) | |
| 19 | + | settleShown(t, c) | |
| 20 | + | c.focusScreen = c.screenIndex(sc.name()) | |
| 21 | + | before := c.snapshot() | |
| 22 | + | c.applyAction(ActionMoveColumnLeft) | |
| 23 | + | c.transition(before, ic.now) | |
| 24 | + | if len(sc.rects) != 2 { | |
| 25 | + | t.Fatalf("%d rect motions after the move, want 2", len(sc.rects)) | |
| 26 | + | } | |
| 27 | + | indicatorScene(t, c) | |
| 28 | + | ic.now = ic.now.Add(16 * time.Millisecond) | |
| 29 | + | if err := c.step(context.Background(), nil); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | <-c.ch.Scenes | |
| 33 | + | if rm := sc.rects[1]; rm.dx == 0 || !rm.on() { | |
| 34 | + | t.Fatalf("window 1 offset %v (running %t) one frame in: not mid-motion", rm.dx, rm.on()) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // A session lock in the middle of a rect motion stops every motion and the | |
| 39 | + | // frame timer, drops the screens' last scene and shown layouts, and the | |
| 40 | + | // scene after the unlock is the settled one with a Seq nothing used before. | |
| 41 | + | func TestSessionLockMidRectMotion(t *testing.T) { | |
| 42 | + | c, ic := indicatorCore(t) | |
| 43 | + | state := ports.SecurityState{} | |
| 44 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 45 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |
| 46 | + | c.ch.Security = gate | |
| 47 | + | sc := c.cur() | |
| 48 | + | movingRects(t, c, ic, sc) | |
| 49 | + | if !c.animating() || c.frameC == nil || sc.last.Seq == 0 { | |
| 50 | + | t.Fatalf("nothing to interrupt: animating %t, timer %t, last seq %d", c.animating(), c.frameC != nil, sc.last.Seq) | |
| 51 | + | } | |
| 52 | + | seq := sc.last.Seq | |
| 53 | + | ||
| 54 | + | state = ports.SecurityState{Generation: 1, Protected: true} | |
| 55 | + | if err := c.publish(context.Background()); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | locked := (<-c.ch.Scenes)[0] | |
| 59 | + | if len(locked.Windows) != 0 || len(locked.Separators) != 0 || locked.Seq <= seq { | |
| 60 | + | t.Fatalf("locked scene shows the desktop or reuses a Seq (%d <= %d): %+v", locked.Seq, seq, locked) | |
| 61 | + | } | |
| 62 | + | if len(sc.rects) != 0 || sc.rectsWS != nil || sc.springing() || c.animating() || c.frameC != nil { | |
| 63 | + | t.Fatalf("motion survived the lock: rects %d, springing %t, animating %t, timer %t", len(sc.rects), sc.springing(), c.animating(), c.frameC != nil) | |
| 64 | + | } | |
| 65 | + | if sc.last.Seq != 0 || sc.shown != nil || sc.settledLayout != nil { | |
| 66 | + | t.Fatalf("lock kept the desktop scene: last seq %d, shown %v, settled %v", sc.last.Seq, sc.shown != nil, sc.settledLayout != nil) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Unlocked: the windows are where the move left them, nothing runs. | |
| 70 | + | state = ports.SecurityState{Generation: 2} | |
| 71 | + | s := indicatorScene(t, c) | |
| 72 | + | if s.Seq <= locked.Seq { | |
| 73 | + | t.Fatalf("unlocked scene Seq %d reuses the locked one %d", s.Seq, locked.Seq) | |
| 74 | + | } | |
| 75 | + | var got1, got2 Rect | |
| 76 | + | for _, w := range s.Windows { | |
| 77 | + | switch w.ID { | |
| 78 | + | case 1: | |
| 79 | + | got1 = w.Rect | |
| 80 | + | case 2: | |
| 81 | + | got2 = w.Rect | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | // 300x200 output, two columns of 150: the move put 2 first. | |
| 85 | + | if got2.X != 0 || got1.X != 150 { | |
| 86 | + | t.Fatalf("after the unlock 1 at %+v and 2 at %+v, want the settled 150 and 0", got1, got2) | |
| 87 | + | } | |
| 88 | + | if c.animating() || len(sc.rects) != 0 || c.frameC != nil { | |
| 89 | + | t.Fatalf("a motion runs after the unlock: animating %t, rects %d, timer %t", c.animating(), len(sc.rects), c.frameC != nil) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // An output unplugged in the middle of its windows' rect motion: no panic, | |
| 94 | + | // its motions go with it, a late page flip for it does nothing, and the | |
| 95 | + | // remaining output keeps no motion of the gone one. | |
| 96 | + | func TestOutputUnplugMidRectMotion(t *testing.T) { | |
| 97 | + | c, ic := indicatorCore(t) | |
| 98 | + | a := c.cur() | |
| 99 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 100 | + | b := c.screens[1] | |
| 101 | + | movingRects(t, c, ic, b) | |
| 102 | + | c.removeScreen("B") | |
| 103 | + | if c.screenIndex("B") >= 0 || len(c.screens) != 1 { | |
| 104 | + | t.Fatalf("B still plugged: %d screens", len(c.screens)) | |
| 105 | + | } | |
| 106 | + | if len(a.rects) != 0 || a.springing() { | |
| 107 | + | t.Fatalf("A inherited the motions of B: rects %d, springing %t", len(a.rects), a.springing()) | |
| 108 | + | } | |
| 109 | + | if c.sliding("B") { | |
| 110 | + | t.Fatal("the gone output still slides") | |
| 111 | + | } | |
| 112 | + | // B's windows are guests of A now: the next publish and a stray flip are fine. | |
| 113 | + | if err := c.publish(context.Background()); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | <-c.ch.Scenes | |
| 117 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 118 | + | if err := c.step(context.Background(), nil); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | <-c.ch.Scenes | |
| 122 | + | if c.animating() || c.frameC != nil { | |
| 123 | + | t.Fatalf("still animating after the unplug and a settle: animating %t, timer %t", c.animating(), c.frameC != nil) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // The last output going away keeps its screen as a placeholder: its rect | |
| 128 | + | // motions must not outlive it (no frame would ever settle them but the | |
| 129 | + | // fallback timer). | |
| 130 | + | func TestLastOutputUnplugMidRectMotion(t *testing.T) { | |
| 131 | + | c, ic := indicatorCore(t) | |
| 132 | + | sc := c.cur() | |
| 133 | + | movingRects(t, c, ic, sc) | |
| 134 | + | c.removeScreen("A") | |
| 135 | + | if err := c.publish(context.Background()); err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | <-c.ch.Scenes | |
| 139 | + | if len(sc.rects) != 0 || c.animating() { | |
| 140 | + | t.Fatalf("the placeholder keeps motions: rects %d, animating %t", len(sc.rects), c.animating()) | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // movingRects makes two windows on sc swap places and returns with their | |||
| 13 | + | // rect motions running, one frame in (the clock moved 16 ms and stepped). | |||
| 14 | + | func movingRects(t *testing.T, c *Core, ic *indicatorClock, sc *screen) { | |||
| 15 | + | t.Helper() | |||
| 16 | + | c.cfg.Animations.On = true | |||
| 17 | + | sc.mon.AddWindow(1) | |||
| 18 | + | sc.mon.AddWindow(2) | |||
| 19 | + | settleShown(t, c) | |||
| 20 | + | c.focusScreen = c.screenIndex(sc.name()) | |||
| 21 | + | before := c.snapshot() | |||
| 22 | + | c.applyAction(ActionMoveColumnLeft) | |||
| 23 | + | c.transition(before, ic.now) | |||
| 24 | + | if len(sc.rects) != 2 { | |||
| 25 | + | t.Fatalf("%d rect motions after the move, want 2", len(sc.rects)) | |||
| 26 | + | } | |||
| 27 | + | indicatorScene(t, c) | |||
| 28 | + | ic.now = ic.now.Add(16 * time.Millisecond) | |||
| 29 | + | if err := c.step(context.Background(), nil); err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | <-c.ch.Scenes | |||
| 33 | + | if rm := sc.rects[1]; rm.dx == 0 || !rm.on() { | |||
| 34 | + | t.Fatalf("window 1 offset %v (running %t) one frame in: not mid-motion", rm.dx, rm.on()) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // A session lock in the middle of a rect motion stops every motion and the | |||
| 39 | + | // frame timer, drops the screens' last scene and shown layouts, and the | |||
| 40 | + | // scene after the unlock is the settled one with a Seq nothing used before. | |||
| 41 | + | func TestSessionLockMidRectMotion(t *testing.T) { | |||
| 42 | + | c, ic := indicatorCore(t) | |||
| 43 | + | state := ports.SecurityState{} | |||
| 44 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 45 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |||
| 46 | + | c.ch.Security = gate | |||
| 47 | + | sc := c.cur() | |||
| 48 | + | movingRects(t, c, ic, sc) | |||
| 49 | + | if !c.animating() || c.frameC == nil || sc.last.Seq == 0 { | |||
| 50 | + | t.Fatalf("nothing to interrupt: animating %t, timer %t, last seq %d", c.animating(), c.frameC != nil, sc.last.Seq) | |||
| 51 | + | } | |||
| 52 | + | seq := sc.last.Seq | |||
| 53 | + | ||||
| 54 | + | state = ports.SecurityState{Generation: 1, Protected: true} | |||
| 55 | + | if err := c.publish(context.Background()); err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | locked := (<-c.ch.Scenes)[0] | |||
| 59 | + | if len(locked.Windows) != 0 || len(locked.Separators) != 0 || locked.Seq <= seq { | |||
| 60 | + | t.Fatalf("locked scene shows the desktop or reuses a Seq (%d <= %d): %+v", locked.Seq, seq, locked) | |||
| 61 | + | } | |||
| 62 | + | if len(sc.rects) != 0 || sc.rectsWS != nil || sc.springing() || c.animating() || c.frameC != nil { | |||
| 63 | + | t.Fatalf("motion survived the lock: rects %d, springing %t, animating %t, timer %t", len(sc.rects), sc.springing(), c.animating(), c.frameC != nil) | |||
| 64 | + | } | |||
| 65 | + | if sc.last.Seq != 0 || sc.shown != nil || sc.settledLayout != nil { | |||
| 66 | + | t.Fatalf("lock kept the desktop scene: last seq %d, shown %v, settled %v", sc.last.Seq, sc.shown != nil, sc.settledLayout != nil) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Unlocked: the windows are where the move left them, nothing runs. | |||
| 70 | + | state = ports.SecurityState{Generation: 2} | |||
| 71 | + | s := indicatorScene(t, c) | |||
| 72 | + | if s.Seq <= locked.Seq { | |||
| 73 | + | t.Fatalf("unlocked scene Seq %d reuses the locked one %d", s.Seq, locked.Seq) | |||
| 74 | + | } | |||
| 75 | + | var got1, got2 Rect | |||
| 76 | + | for _, w := range s.Windows { | |||
| 77 | + | switch w.ID { | |||
| 78 | + | case 1: | |||
| 79 | + | got1 = w.Rect | |||
| 80 | + | case 2: | |||
| 81 | + | got2 = w.Rect | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | // 300x200 output, two columns of 150: the move put 2 first. | |||
| 85 | + | if got2.X != 0 || got1.X != 150 { | |||
| 86 | + | t.Fatalf("after the unlock 1 at %+v and 2 at %+v, want the settled 150 and 0", got1, got2) | |||
| 87 | + | } | |||
| 88 | + | if c.animating() || len(sc.rects) != 0 || c.frameC != nil { | |||
| 89 | + | t.Fatalf("a motion runs after the unlock: animating %t, rects %d, timer %t", c.animating(), len(sc.rects), c.frameC != nil) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // An output unplugged in the middle of its windows' rect motion: no panic, | |||
| 94 | + | // its motions go with it, a late page flip for it does nothing, and the | |||
| 95 | + | // remaining output keeps no motion of the gone one. | |||
| 96 | + | func TestOutputUnplugMidRectMotion(t *testing.T) { | |||
| 97 | + | c, ic := indicatorCore(t) | |||
| 98 | + | a := c.cur() | |||
| 99 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 100 | + | b := c.screens[1] | |||
| 101 | + | movingRects(t, c, ic, b) | |||
| 102 | + | c.removeScreen("B") | |||
| 103 | + | if c.screenIndex("B") >= 0 || len(c.screens) != 1 { | |||
| 104 | + | t.Fatalf("B still plugged: %d screens", len(c.screens)) | |||
| 105 | + | } | |||
| 106 | + | if len(a.rects) != 0 || a.springing() { | |||
| 107 | + | t.Fatalf("A inherited the motions of B: rects %d, springing %t", len(a.rects), a.springing()) | |||
| 108 | + | } | |||
| 109 | + | if c.sliding("B") { | |||
| 110 | + | t.Fatal("the gone output still slides") | |||
| 111 | + | } | |||
| 112 | + | // B's windows are guests of A now: the next publish and a stray flip are fine. | |||
| 113 | + | if err := c.publish(context.Background()); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | <-c.ch.Scenes | |||
| 117 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 118 | + | if err := c.step(context.Background(), nil); err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | <-c.ch.Scenes | |||
| 122 | + | if c.animating() || c.frameC != nil { | |||
| 123 | + | t.Fatalf("still animating after the unplug and a settle: animating %t, timer %t", c.animating(), c.frameC != nil) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // The last output going away keeps its screen as a placeholder: its rect | |||
| 128 | + | // motions must not outlive it (no frame would ever settle them but the | |||
| 129 | + | // fallback timer). | |||
| 130 | + | func TestLastOutputUnplugMidRectMotion(t *testing.T) { | |||
| 131 | + | c, ic := indicatorCore(t) | |||
| 132 | + | sc := c.cur() | |||
| 133 | + | movingRects(t, c, ic, sc) | |||
| 134 | + | c.removeScreen("A") | |||
| 135 | + | if err := c.publish(context.Background()); err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | <-c.ch.Scenes | |||
| 139 | + | if len(sc.rects) != 0 || c.animating() { | |||
| 140 | + | t.Fatalf("the placeholder keeps motions: rects %d, animating %t", len(sc.rects), c.animating()) | |||
| 141 | + | } | |||
| 142 | + | } | |||
M internal/core/gesture.go +14 -9
| @@ -343,19 +343,24 @@ func (c *Core) spring(s spring, now time.Time) motion { | |||
| 343 | 343 | // rect motions stop too. | |
| 344 | 344 | func (c *Core) stopAnimations() { | |
| 345 | 345 | for _, sc := range c.screens { | |
| 346 | - | if sc.mon.switchMotion.on { | |
| 347 | - | sc.mon.stopSwitch() | |
| 348 | - | } | |
| 349 | - | for w := range sc.mon.all() { | |
| 350 | - | if w.motion.on { | |
| 351 | - | w.stopSlide() | |
| 352 | - | } | |
| 353 | - | } | |
| 354 | - | sc.stopRects() | |
| 346 | + | sc.stopAnimations() | |
| 355 | 347 | } | |
| 356 | 348 | c.stopFrame() | |
| 357 | 349 | } | |
| 358 | 350 | ||
| 351 | + | // stopAnimations settles the screen's running springs where they are going. | |
| 352 | + | func (s *screen) stopAnimations() { | |
| 353 | + | if s.mon.switchMotion.on { | |
| 354 | + | s.mon.stopSwitch() | |
| 355 | + | } | |
| 356 | + | for w := range s.mon.all() { | |
| 357 | + | if w.motion.on { | |
| 358 | + | w.stopSlide() | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | s.stopRects() | |
| 362 | + | } | |
| 363 | + | ||
| 359 | 364 | // animate moves the running springs to now; settled ones stop. A non-nil only | |
| 360 | 365 | // limits it to that screen (its own page flip); nil moves every screen. | |
| 361 | 366 | func (c *Core) animate(now time.Time, only *screen) { | |
| @@ -343,19 +343,24 @@ func (c *Core) spring(s spring, now time.Time) motion { | |||||
| 343 | // rect motions stop too. | 343 | // rect motions stop too. | ||
| 344 | func (c *Core) stopAnimations() { | 344 | func (c *Core) stopAnimations() { | ||
| 345 | for _, sc := range c.screens { | 345 | for _, sc := range c.screens { | ||
| 346 | - | if sc.mon.switchMotion.on { | 346 | + | sc.stopAnimations() |
| 347 | - | sc.mon.stopSwitch() | |||
| 348 | - | } | |||
| 349 | - | for w := range sc.mon.all() { | |||
| 350 | - | if w.motion.on { | |||
| 351 | - | w.stopSlide() | |||
| 352 | - | } | |||
| 353 | - | } | |||
| 354 | - | sc.stopRects() | |||
| 355 | } | 347 | } | ||
| 356 | c.stopFrame() | 348 | c.stopFrame() | ||
| 357 | } | 349 | } | ||
| 358 | 350 | ||||
| 351 | + | // stopAnimations settles the screen's running springs where they are going. | |||
| 352 | + | func (s *screen) stopAnimations() { | |||
| 353 | + | if s.mon.switchMotion.on { | |||
| 354 | + | s.mon.stopSwitch() | |||
| 355 | + | } | |||
| 356 | + | for w := range s.mon.all() { | |||
| 357 | + | if w.motion.on { | |||
| 358 | + | w.stopSlide() | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | s.stopRects() | |||
| 362 | + | } | |||
| 363 | + | ||||
| 359 | // animate moves the running springs to now; settled ones stop. A non-nil only | 364 | // animate moves the running springs to now; settled ones stop. A non-nil only | ||
| 360 | // limits it to that screen (its own page flip); nil moves every screen. | 365 | // limits it to that screen (its own page flip); nil moves every screen. | ||
| 361 | func (c *Core) animate(now time.Time, only *screen) { | 366 | func (c *Core) animate(now time.Time, only *screen) { | ||
M internal/core/screens.go +4 -0
| @@ -326,6 +326,10 @@ func (c *Core) removeScreen(name string) { | |||
| 326 | 326 | if len(c.screens) == 1 { | |
| 327 | 327 | c.screens[0].info = ports.OutputInfo{} | |
| 328 | 328 | c.screens[0].layers = nil | |
| 329 | + | // Nothing draws the placeholder: its springs would only wake the | |
| 330 | + | // fallback timer, and start from the old output's geometry if an | |
| 331 | + | // output came back. | |
| 332 | + | c.screens[0].stopAnimations() | |
| 329 | 333 | return | |
| 330 | 334 | } | |
| 331 | 335 | gone := c.screens[i] | |
| @@ -326,6 +326,10 @@ func (c *Core) removeScreen(name string) { | |||||
| 326 | if len(c.screens) == 1 { | 326 | if len(c.screens) == 1 { | ||
| 327 | c.screens[0].info = ports.OutputInfo{} | 327 | c.screens[0].info = ports.OutputInfo{} | ||
| 328 | c.screens[0].layers = nil | 328 | c.screens[0].layers = nil | ||
| 329 | + | // Nothing draws the placeholder: its springs would only wake the | |||
| 330 | + | // fallback timer, and start from the old output's geometry if an | |||
| 331 | + | // output came back. | |||
| 332 | + | c.screens[0].stopAnimations() | |||
| 329 | return | 333 | return | ||
| 330 | } | 334 | } | ||
| 331 | gone := c.screens[i] | 335 | gone := c.screens[i] | ||