detached 97aa477

fix(core): skip focus flash on edge workspace switches

commit
97aa477c59835e0c9ddeb6b4ee3e93dd78d3a6f8
author
brice · · 2026-10-19 05:11 UTC
parent
ad49c58
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +146 -6
M internal/core/action.go +3 -0
@@ -272,6 +272,9 @@ func (c *Core) applyAction(a Action) Effect {
272272 default:
273273 return
274274 }
275+ if sc == beforeScreen && sc.mon.Current() != beforeWS {
276+ return
277+ }
275278 after := sc.mon.Layout()
276279 changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
277280 return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
@@ -272,6 +272,9 @@ func (c *Core) applyAction(a Action) Effect {
272 default:272 default:
273 return273 return
274 }274 }
275+ if sc == beforeScreen && sc.mon.Current() != beforeWS {
276+ return
277+ }
275 after := sc.mon.Layout()278 after := sc.mon.Layout()
276 changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {279 changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
277 return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating280 return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
M internal/core/flash.go +11 -5
@@ -34,6 +34,8 @@ type focusFlash struct {
3434 start time.Time
3535 value float64 // Retained between this output's own flips.
3636 lastStarts map[flashTarget]time.Time
37+ layout []Placement // Owner scratch, never shared with a published scene.
38+ published *ports.FocusFlash // Immutable last sample, reused when unchanged.
3739 }
3840
3941 func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() }
@@ -72,7 +74,7 @@ func (c *Core) flashTick() bool {
7274 if target.level != flashMonitor {
7375 owner, _ := c.screenOf(target.window)
7476 record := c.windows.lookup(target.window)
75- if owner != sc || record.client.ID == 0 || f.since.Sub(record.mappedAt) < flashSettle {
77+ if owner != sc || record.client.ID == 0 {
7678 return false
7779 }
7880 }
@@ -106,8 +108,7 @@ func (c *Core) flashTick() bool {
106108 if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) {
107109 continue
108110 }
109- x, y := min(rect.X, p.Rect.X), min(rect.Y, p.Rect.Y)
110- rect = Rect{X: x, Y: y, W: max(rect.X+rect.W, p.Rect.X+p.Rect.W) - x, H: max(rect.Y+rect.H, p.Rect.Y+p.Rect.H) - y}
111+ rect = union(rect, p.Rect)
111112 }
112113 }
113114 }
@@ -169,7 +170,8 @@ func (c *Core) flashVisible(target flashTarget, sc *screen) bool {
169170 return false
170171 }
171172 view := sc.mon.viewport()
172- for _, p := range sc.mon.Layout() {
173+ c.flash.layout = sc.mon.layoutInto(c.flash.layout)
174+ for _, p := range c.flash.layout {
173175 if p.ID == target.window {
174176 return !p.Fullscreen && p.Preview == 0 && view.shows(p)
175177 }
@@ -186,7 +188,11 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
186188 if color == "" {
187189 color = c.cfg.Border.Active
188190 }
189- return &ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}
191+ sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}
192+ if f.published == nil || *f.published != sample {
193+ f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha}
194+ }
195+ return f.published
190196 }
191197
192198 func (c *Core) flashing(sc *screen) bool {
@@ -34,6 +34,8 @@ type focusFlash struct {
34 start time.Time34 start time.Time
35 value float64 // Retained between this output's own flips.35 value float64 // Retained between this output's own flips.
36 lastStarts map[flashTarget]time.Time36 lastStarts map[flashTarget]time.Time
37+ layout []Placement // Owner scratch, never shared with a published scene.
38+ published *ports.FocusFlash // Immutable last sample, reused when unchanged.
37 }39 }
38 40
39 func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() }41 func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() }
@@ -72,7 +74,7 @@ func (c *Core) flashTick() bool {
72 if target.level != flashMonitor {74 if target.level != flashMonitor {
73 owner, _ := c.screenOf(target.window)75 owner, _ := c.screenOf(target.window)
74 record := c.windows.lookup(target.window)76 record := c.windows.lookup(target.window)
75- if owner != sc || record.client.ID == 0 || f.since.Sub(record.mappedAt) < flashSettle {77+ if owner != sc || record.client.ID == 0 {
76 return false78 return false
77 }79 }
78 }80 }
@@ -106,8 +108,7 @@ func (c *Core) flashTick() bool {
106 if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) {108 if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) {
107 continue109 continue
108 }110 }
109- x, y := min(rect.X, p.Rect.X), min(rect.Y, p.Rect.Y)111+ rect = union(rect, p.Rect)
110- rect = Rect{X: x, Y: y, W: max(rect.X+rect.W, p.Rect.X+p.Rect.W) - x, H: max(rect.Y+rect.H, p.Rect.Y+p.Rect.H) - y}
111 }112 }
112 }113 }
113 }114 }
@@ -169,7 +170,8 @@ func (c *Core) flashVisible(target flashTarget, sc *screen) bool {
169 return false170 return false
170 }171 }
171 view := sc.mon.viewport()172 view := sc.mon.viewport()
172- for _, p := range sc.mon.Layout() {173+ c.flash.layout = sc.mon.layoutInto(c.flash.layout)
174+ for _, p := range c.flash.layout {
173 if p.ID == target.window {175 if p.ID == target.window {
174 return !p.Fullscreen && p.Preview == 0 && view.shows(p)176 return !p.Fullscreen && p.Preview == 0 && view.shows(p)
175 }177 }
@@ -186,7 +188,11 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
186 if color == "" {188 if color == "" {
187 color = c.cfg.Border.Active189 color = c.cfg.Border.Active
188 }190 }
189- return &ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}191+ sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}
192+ if f.published == nil || *f.published != sample {
193+ f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha}
194+ }
195+ return f.published
190 }196 }
191 197
192 func (c *Core) flashing(sc *screen) bool {198 func (c *Core) flashing(sc *screen) bool {
M internal/core/flash_test.go +132 -1
@@ -140,7 +140,6 @@ func TestFlashSkipsIneligibleTargets(t *testing.T) {
140140 {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }},
141141 {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }},
142142 {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }},
143- {"just mapped", func(c *Core, clock *indicatorClock) { c.windows.mapped(ports.WindowMapped{ID: 3}, clock.now) }},
144143 } {
145144 t.Run(test.name, func(t *testing.T) {
146145 c, clock := flashCore(t)
@@ -467,3 +466,135 @@ func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
467466 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
468467 }
469468 }
469+
470+// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
471+func BenchmarkFlashSceneBuild(b *testing.B) {
472+ var cfg ports.Config
473+ cfg.Layout.MaxColumns = 2
474+ cfg.Keyboard.CmdKey = "super"
475+ cfg.Animations.On = true
476+ cfg.Focus.Flash = true
477+ cfg.Focus.FlashOpacity = 0.8
478+ cfg.Focus.FlashDuration = time.Hour
479+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
480+ if err != nil {
481+ b.Fatal(err)
482+ }
483+ c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
484+ c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
485+ c.cur().mon.AddWindow(1)
486+ c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
487+ c.flash.start = time.Now()
488+ c.flash.rect = Rect{W: 1000, H: 600}
489+ c.refreshShown()
490+ ctx := context.Background()
491+ b.ReportAllocs()
492+ b.ResetTimer()
493+ for range b.N {
494+ c.refreshShown()
495+ c.advanceFlash(nil)
496+ scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
497+ if err != nil {
498+ b.Fatal(err)
499+ }
500+ c.cur().last = scene
501+ }
502+}
503+
504+func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
505+ for _, tc := range []struct {
506+ name string
507+ overflow Overflow
508+ action Action
509+ }{
510+ {"vertical", OverflowScroll, ActionFocusWindowDown},
511+ {"horizontal", OverflowCascade, ActionFocusColumnRight},
512+ } {
513+ t.Run(tc.name, func(t *testing.T) {
514+ c, _ := flashCore(t)
515+ m := c.cur().mon
516+ m.SetOverflow(tc.overflow)
517+ before := m.Current()
518+ m.Focus(1)
519+ m.AddWindow(4)
520+ m.Focus(0)
521+ c.applyAction(tc.action)
522+ if m.Current() == before {
523+ t.Fatal("setup: edge did not switch workspace")
524+ }
525+ if id, _ := m.Focused(); id != 4 {
526+ t.Fatalf("did not land on occupied workspace: %d", id)
527+ }
528+ if c.flash.pending != nil || c.flash.running != nil {
529+ t.Fatal("workspace switch flashed")
530+ }
531+ })
532+ }
533+}
534+
535+func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
536+ c, clock := flashCore(t)
537+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
538+ if c.flash.pending != nil {
539+ t.Fatal("mapping requested flash")
540+ }
541+ c.applyAction(ActionToggleFloating)
542+ if c.flash.pending == nil || c.flash.pending.window != 4 {
543+ t.Fatal("explicit action did not request flash")
544+ }
545+ c.stopAnimations()
546+ clock.now = clock.now.Add(flashSettle)
547+ if !c.flashTick() {
548+ t.Fatal("freshly mapped target rejected")
549+ }
550+}
551+
552+func TestFlashPendingTimerStopped(t *testing.T) {
553+ for _, reason := range []string{"replacement", "security", "run exit"} {
554+ t.Run(reason, func(t *testing.T) {
555+ c, clock := flashCore(t)
556+ timer := portsmocks.NewMockTimer(t)
557+ timer.EXPECT().C().Return(make(chan time.Time)).Once()
558+ timer.EXPECT().Stop().Return(true).Times(1)
559+ strictClock := portsmocks.NewMockClock(t)
560+ strictClock.EXPECT().Now().Return(clock.now).Maybe()
561+ strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
562+ c.opts.Clock = strictClock
563+ c.requestFlash(flashWindow, 3)
564+ // Only the pending flash timer belongs to the strict clock.
565+ c.opts.Clock = nil
566+ switch reason {
567+ case "replacement":
568+ c.requestFlash(flashWindow, 2)
569+ c.stopFlash()
570+ case "security":
571+ gate := portsmocks.NewMockSessionSecurity(t)
572+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
573+ c.opts.Security = gate
574+ c.syncSecurity()
575+ case "run exit":
576+ ctx, cancel := context.WithCancel(context.Background())
577+ cancel()
578+ if err := c.Run(ctx); err != nil {
579+ t.Fatal(err)
580+ }
581+ }
582+ })
583+ }
584+}
585+
586+func TestFlashUnchangedSampleIsReused(t *testing.T) {
587+ c, clock := flashCore(t)
588+ startFlash(t, c, clock, flashWindow, 3)
589+ first := indicatorScene(t, c).Flash
590+ if first == nil {
591+ t.Fatal("missing flash")
592+ }
593+ if next := indicatorScene(t, c).Flash; next != first {
594+ t.Fatal("unchanged immutable sample not reused")
595+ }
596+ clock.now = clock.now.Add(time.Millisecond)
597+ if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
598+ t.Fatal("changed sample mutated prior scene")
599+ }
600+}
@@ -140,7 +140,6 @@ func TestFlashSkipsIneligibleTargets(t *testing.T) {
140 {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }},140 {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }},
141 {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }},141 {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }},
142 {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }},142 {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }},
143- {"just mapped", func(c *Core, clock *indicatorClock) { c.windows.mapped(ports.WindowMapped{ID: 3}, clock.now) }},
144 } {143 } {
145 t.Run(test.name, func(t *testing.T) {144 t.Run(test.name, func(t *testing.T) {
146 c, clock := flashCore(t)145 c, clock := flashCore(t)
@@ -467,3 +466,135 @@ func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
467 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)466 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
468 }467 }
469 }468 }
469+
470+// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
471+func BenchmarkFlashSceneBuild(b *testing.B) {
472+ var cfg ports.Config
473+ cfg.Layout.MaxColumns = 2
474+ cfg.Keyboard.CmdKey = "super"
475+ cfg.Animations.On = true
476+ cfg.Focus.Flash = true
477+ cfg.Focus.FlashOpacity = 0.8
478+ cfg.Focus.FlashDuration = time.Hour
479+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
480+ if err != nil {
481+ b.Fatal(err)
482+ }
483+ c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
484+ c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
485+ c.cur().mon.AddWindow(1)
486+ c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
487+ c.flash.start = time.Now()
488+ c.flash.rect = Rect{W: 1000, H: 600}
489+ c.refreshShown()
490+ ctx := context.Background()
491+ b.ReportAllocs()
492+ b.ResetTimer()
493+ for range b.N {
494+ c.refreshShown()
495+ c.advanceFlash(nil)
496+ scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
497+ if err != nil {
498+ b.Fatal(err)
499+ }
500+ c.cur().last = scene
501+ }
502+}
503+
504+func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
505+ for _, tc := range []struct {
506+ name string
507+ overflow Overflow
508+ action Action
509+ }{
510+ {"vertical", OverflowScroll, ActionFocusWindowDown},
511+ {"horizontal", OverflowCascade, ActionFocusColumnRight},
512+ } {
513+ t.Run(tc.name, func(t *testing.T) {
514+ c, _ := flashCore(t)
515+ m := c.cur().mon
516+ m.SetOverflow(tc.overflow)
517+ before := m.Current()
518+ m.Focus(1)
519+ m.AddWindow(4)
520+ m.Focus(0)
521+ c.applyAction(tc.action)
522+ if m.Current() == before {
523+ t.Fatal("setup: edge did not switch workspace")
524+ }
525+ if id, _ := m.Focused(); id != 4 {
526+ t.Fatalf("did not land on occupied workspace: %d", id)
527+ }
528+ if c.flash.pending != nil || c.flash.running != nil {
529+ t.Fatal("workspace switch flashed")
530+ }
531+ })
532+ }
533+}
534+
535+func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
536+ c, clock := flashCore(t)
537+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
538+ if c.flash.pending != nil {
539+ t.Fatal("mapping requested flash")
540+ }
541+ c.applyAction(ActionToggleFloating)
542+ if c.flash.pending == nil || c.flash.pending.window != 4 {
543+ t.Fatal("explicit action did not request flash")
544+ }
545+ c.stopAnimations()
546+ clock.now = clock.now.Add(flashSettle)
547+ if !c.flashTick() {
548+ t.Fatal("freshly mapped target rejected")
549+ }
550+}
551+
552+func TestFlashPendingTimerStopped(t *testing.T) {
553+ for _, reason := range []string{"replacement", "security", "run exit"} {
554+ t.Run(reason, func(t *testing.T) {
555+ c, clock := flashCore(t)
556+ timer := portsmocks.NewMockTimer(t)
557+ timer.EXPECT().C().Return(make(chan time.Time)).Once()
558+ timer.EXPECT().Stop().Return(true).Times(1)
559+ strictClock := portsmocks.NewMockClock(t)
560+ strictClock.EXPECT().Now().Return(clock.now).Maybe()
561+ strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
562+ c.opts.Clock = strictClock
563+ c.requestFlash(flashWindow, 3)
564+ // Only the pending flash timer belongs to the strict clock.
565+ c.opts.Clock = nil
566+ switch reason {
567+ case "replacement":
568+ c.requestFlash(flashWindow, 2)
569+ c.stopFlash()
570+ case "security":
571+ gate := portsmocks.NewMockSessionSecurity(t)
572+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
573+ c.opts.Security = gate
574+ c.syncSecurity()
575+ case "run exit":
576+ ctx, cancel := context.WithCancel(context.Background())
577+ cancel()
578+ if err := c.Run(ctx); err != nil {
579+ t.Fatal(err)
580+ }
581+ }
582+ })
583+ }
584+}
585+
586+func TestFlashUnchangedSampleIsReused(t *testing.T) {
587+ c, clock := flashCore(t)
588+ startFlash(t, c, clock, flashWindow, 3)
589+ first := indicatorScene(t, c).Flash
590+ if first == nil {
591+ t.Fatal("missing flash")
592+ }
593+ if next := indicatorScene(t, c).Flash; next != first {
594+ t.Fatal("unchanged immutable sample not reused")
595+ }
596+ clock.now = clock.now.Add(time.Millisecond)
597+ if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
598+ t.Fatal("changed sample mutated prior scene")
599+ }
600+}

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