fix(core): publish the settled scene when a pulse is cancelled
A pulse that nothing can show (fullscreen, overview) was cancelled after the focused scene had been built as animating. Outputs kept that scene and woke core on every flip until an unrelated publish. The cancel now clears Animating on the focused scene and gives it a fresh Seq when it kept the previous one.
- commit
- c7a0951a837d9b0f90ec45ef5eb464b2aea0c4fa
- author
- brice · · 2026-10-14 03:04 UTC
- committer
- brice · · 2026-10-14 04:34 UTC
- parent
- ec33ffa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +43 -2
M internal/core/core.go +14 -2
| @@ -651,13 +651,25 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 651 | 651 | return err | |
| 652 | 652 | } | |
| 653 | 653 | drawable = drawable || shown | |
| 654 | - | sc.last = scene | |
| 655 | 654 | scenes = append(scenes, scene) | |
| 656 | 655 | } | |
| 657 | 656 | c.configures.prune() | |
| 658 | 657 | // A pulse nothing shows (fullscreen, overview) asks for no frames. | |
| 659 | - | if c.pulse.drawable = drawable; !drawable { | |
| 658 | + | if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 { | |
| 660 | 659 | c.pulse.id = 0 | |
| 660 | + | // The focused scene was built as animating: publish it settled, | |
| 661 | + | // or its flips would keep waking core. A scene that kept its Seq | |
| 662 | + | // needs a fresh one, or outputs keep the animating one they show. | |
| 663 | + | if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() { | |
| 664 | + | scenes[i].Animating = false | |
| 665 | + | if scenes[i].Seq == c.cur().last.Seq { | |
| 666 | + | c.seq++ | |
| 667 | + | scenes[i].Seq = c.seq | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | for i, sc := range c.screens { | |
| 672 | + | sc.last = scenes[i] | |
| 661 | 673 | } | |
| 662 | 674 | // A workspace switch can hide the window under the pointer; it must not get | |
| 663 | 675 | // clicks. The next motion re-runs hit-testing. | |
| @@ -651,13 +651,25 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 651 | return err | 651 | return err | ||
| 652 | } | 652 | } | ||
| 653 | drawable = drawable || shown | 653 | drawable = drawable || shown | ||
| 654 | - | sc.last = scene | |||
| 655 | scenes = append(scenes, scene) | 654 | scenes = append(scenes, scene) | ||
| 656 | } | 655 | } | ||
| 657 | c.configures.prune() | 656 | c.configures.prune() | ||
| 658 | // A pulse nothing shows (fullscreen, overview) asks for no frames. | 657 | // A pulse nothing shows (fullscreen, overview) asks for no frames. | ||
| 659 | - | if c.pulse.drawable = drawable; !drawable { | 658 | + | if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 { |
| 660 | c.pulse.id = 0 | 659 | c.pulse.id = 0 | ||
| 660 | + | // The focused scene was built as animating: publish it settled, | |||
| 661 | + | // or its flips would keep waking core. A scene that kept its Seq | |||
| 662 | + | // needs a fresh one, or outputs keep the animating one they show. | |||
| 663 | + | if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() { | |||
| 664 | + | scenes[i].Animating = false | |||
| 665 | + | if scenes[i].Seq == c.cur().last.Seq { | |||
| 666 | + | c.seq++ | |||
| 667 | + | scenes[i].Seq = c.seq | |||
| 668 | + | } | |||
| 669 | + | } | |||
| 670 | + | } | |||
| 671 | + | for i, sc := range c.screens { | |||
| 672 | + | sc.last = scenes[i] | |||
| 661 | } | 673 | } | ||
| 662 | // A workspace switch can hide the window under the pointer; it must not get | 674 | // A workspace switch can hide the window under the pointer; it must not get | ||
| 663 | // clicks. The next motion re-runs hit-testing. | 675 | // clicks. The next motion re-runs hit-testing. | ||
M internal/core/scene_animating_test.go +29 -0
| @@ -55,6 +55,35 @@ func TestSceneAnimatingFollowsSprings(t *testing.T) { | |||
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | // A pulse cancelled because nothing can show it (fullscreen) publishes the | |
| 59 | + | // focused scene settled, with a fresh Seq: otherwise outputs keep the | |
| 60 | + | // animating scene and wake core on every flip. | |
| 61 | + | func TestCancelledPulseClearsAnimating(t *testing.T) { | |
| 62 | + | c, ic := pulseCore(t) | |
| 63 | + | c.cur().mon.Current().FocusID(1) | |
| 64 | + | indicatorScene(t, c) | |
| 65 | + | ic.now = ic.now.Add(pulseSettle) | |
| 66 | + | if !c.pulseTick() { | |
| 67 | + | t.Fatal("bad setup: no pulse") | |
| 68 | + | } | |
| 69 | + | running := indicatorScene(t, c) | |
| 70 | + | if !running.Animating { | |
| 71 | + | t.Fatal("bad setup: the pulsing scene is not animating") | |
| 72 | + | } | |
| 73 | + | c.cur().mon.ToggleFullscreen() | |
| 74 | + | cancelled := indicatorScene(t, c) | |
| 75 | + | if cancelled.Animating || c.animating() { | |
| 76 | + | t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating()) | |
| 77 | + | } | |
| 78 | + | if cancelled.Seq == running.Seq { | |
| 79 | + | t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq) | |
| 80 | + | } | |
| 81 | + | idle := indicatorScene(t, c) | |
| 82 | + | if idle.Animating || idle.Seq != cancelled.Seq { | |
| 83 | + | t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 58 | 87 | // Animating is per output: a spring on one does not mark the other. | |
| 59 | 88 | func TestSceneAnimatingIsPerOutput(t *testing.T) { | |
| 60 | 89 | c, ic := indicatorCore(t) | |
| @@ -55,6 +55,35 @@ func TestSceneAnimatingFollowsSprings(t *testing.T) { | |||||
| 55 | } | 55 | } | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | + | // A pulse cancelled because nothing can show it (fullscreen) publishes the | |||
| 59 | + | // focused scene settled, with a fresh Seq: otherwise outputs keep the | |||
| 60 | + | // animating scene and wake core on every flip. | |||
| 61 | + | func TestCancelledPulseClearsAnimating(t *testing.T) { | |||
| 62 | + | c, ic := pulseCore(t) | |||
| 63 | + | c.cur().mon.Current().FocusID(1) | |||
| 64 | + | indicatorScene(t, c) | |||
| 65 | + | ic.now = ic.now.Add(pulseSettle) | |||
| 66 | + | if !c.pulseTick() { | |||
| 67 | + | t.Fatal("bad setup: no pulse") | |||
| 68 | + | } | |||
| 69 | + | running := indicatorScene(t, c) | |||
| 70 | + | if !running.Animating { | |||
| 71 | + | t.Fatal("bad setup: the pulsing scene is not animating") | |||
| 72 | + | } | |||
| 73 | + | c.cur().mon.ToggleFullscreen() | |||
| 74 | + | cancelled := indicatorScene(t, c) | |||
| 75 | + | if cancelled.Animating || c.animating() { | |||
| 76 | + | t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating()) | |||
| 77 | + | } | |||
| 78 | + | if cancelled.Seq == running.Seq { | |||
| 79 | + | t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq) | |||
| 80 | + | } | |||
| 81 | + | idle := indicatorScene(t, c) | |||
| 82 | + | if idle.Animating || idle.Seq != cancelled.Seq { | |||
| 83 | + | t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 58 | // Animating is per output: a spring on one does not mark the other. | 87 | // Animating is per output: a spring on one does not mark the other. | ||
| 59 | func TestSceneAnimatingIsPerOutput(t *testing.T) { | 88 | func TestSceneAnimatingIsPerOutput(t *testing.T) { | ||
| 60 | c, ic := indicatorCore(t) | 89 | c, ic := indicatorCore(t) | ||