detached c7a0951

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
651651 return err
652652 }
653653 drawable = drawable || shown
654- sc.last = scene
655654 scenes = append(scenes, scene)
656655 }
657656 c.configures.prune()
658657 // 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 {
660659 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]
661673 }
662674 // A workspace switch can hide the window under the pointer; it must not get
663675 // clicks. The next motion re-runs hit-testing.
@@ -651,13 +651,25 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
651 return err651 return err
652 }652 }
653 drawable = drawable || shown653 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 = 0659 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 get674 // 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) {
5555 }
5656 }
5757
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+
5887 // Animating is per output: a spring on one does not mark the other.
5988 func TestSceneAnimatingIsPerOutput(t *testing.T) {
6089 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)

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 25

Tags 10

↑↓ moveenter openesc close