detached b6a8e33

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
343343 // rect motions stop too.
344344 func (c *Core) stopAnimations() {
345345 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()
355347 }
356348 c.stopFrame()
357349 }
358350
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+
359364 // animate moves the running springs to now; settled ones stop. A non-nil only
360365 // limits it to that screen (its own page flip); nil moves every screen.
361366 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 only364 // 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) {
326326 if len(c.screens) == 1 {
327327 c.screens[0].info = ports.OutputInfo{}
328328 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()
329333 return
330334 }
331335 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 = nil328 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 return333 return
330 }334 }
331 gone := c.screens[i]335 gone := c.screens[i]

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