detached e8a5ba6

fix(core): flash the output when an action moves focus to another screen

commit
e8a5ba67e7808a959cf5c165fe3c21f2d7598db2
author
brice · · 2026-10-19 05:38 UTC
parent
2117e03
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
2/2
3 files · +82 -7
M docs/config.md +3 -1
@@ -78,7 +78,9 @@ See [examples/config](../examples/config) for every key with its default.
7878 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
7979
8080 The focus flash marks the window, column or output an action changes with a brief
81-inner border. Workspace switches and mouse focus do not trigger a flash.
81+inner border. When a flash-triggering action crosses screens, it marks the whole
82+destination output, even if that output has no windows. Same-screen workspace
83+switches and mouse focus do not trigger a flash.
8284 It runs when `animations = on`.
8385
8486 ## Window rules
@@ -78,7 +78,9 @@ See [examples/config](../examples/config) for every key with its default.
78 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).78 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
79 79
80 The focus flash marks the window, column or output an action changes with a brief80 The focus flash marks the window, column or output an action changes with a brief
81-inner border. Workspace switches and mouse focus do not trigger a flash.81+inner border. When a flash-triggering action crosses screens, it marks the whole
82+destination output, even if that output has no windows. Same-screen workspace
83+switches and mouse focus do not trigger a flash.
82 It runs when `animations = on`.84 It runs when `animations = on`.
83 85
84 ## Window rules86 ## Window rules
M internal/core/action.go +7 -6
@@ -248,6 +248,11 @@ func (c *Core) applyAction(a Action) Effect {
248248 }
249249 return
250250 }
251+ // Crossing outputs marks the destination itself, even if it is empty.
252+ if sc != beforeScreen {
253+ c.requestFlash(flashMonitor, 0)
254+ return
255+ }
251256 level := flashWindow
252257 switch a {
253258 case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown,
@@ -264,19 +269,15 @@ func (c *Core) applyAction(a Action) Effect {
264269 level = flashColumn
265270 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown,
266271 ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
267- if sc == beforeScreen {
268- return
269- }
270- c.requestFlash(flashMonitor, 0)
271272 return
272273 default:
273274 return
274275 }
275- if sc == beforeScreen && sc.mon.Current() != beforeWS {
276+ if sc.mon.Current() != beforeWS {
276277 return
277278 }
278279 after := sc.mon.Layout()
279- changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
280+ changed := id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
280281 return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
281282 })
282283 if changed && id != 0 {
@@ -248,6 +248,11 @@ func (c *Core) applyAction(a Action) Effect {
248 }248 }
249 return249 return
250 }250 }
251+ // Crossing outputs marks the destination itself, even if it is empty.
252+ if sc != beforeScreen {
253+ c.requestFlash(flashMonitor, 0)
254+ return
255+ }
251 level := flashWindow256 level := flashWindow
252 switch a {257 switch a {
253 case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown,258 case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown,
@@ -264,19 +269,15 @@ func (c *Core) applyAction(a Action) Effect {
264 level = flashColumn269 level = flashColumn
265 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown,270 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown,
266 ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:271 ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
267- if sc == beforeScreen {
268- return
269- }
270- c.requestFlash(flashMonitor, 0)
271 return272 return
272 default:273 default:
273 return274 return
274 }275 }
275- if sc == beforeScreen && sc.mon.Current() != beforeWS {276+ if sc.mon.Current() != beforeWS {
276 return277 return
277 }278 }
278 after := sc.mon.Layout()279 after := sc.mon.Layout()
279- changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {280+ changed := id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
280 return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating281 return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
281 })282 })
282 if changed && id != 0 {283 if changed && id != 0 {
M internal/core/flash_test.go +72 -0
@@ -598,3 +598,75 @@ func TestFlashUnchangedSampleIsReused(t *testing.T) {
598598 t.Fatal("changed sample mutated prior scene")
599599 }
600600 }
601+
602+func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
603+ for _, tc := range []struct {
604+ name string
605+ action Action
606+ occupied bool
607+ }{
608+ {"column focus to empty", ActionFocusColumnRight, false},
609+ {"column focus to occupied", ActionFocusColumnRight, true},
610+ {"monitor focus to empty", ActionFocusMonitorRight, false},
611+ {"column move to empty", ActionMoveColumnRight, false},
612+ {"column move to occupied", ActionMoveColumnRight, true},
613+ } {
614+ t.Run(tc.name, func(t *testing.T) {
615+ c, clock := flashCore(t)
616+ a := c.cur()
617+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
618+ b := c.screens[1]
619+ if tc.occupied {
620+ c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
621+ b.mon.AddWindow(4)
622+ }
623+ c.applyAction(tc.action)
624+ if c.cur() != b {
625+ t.Fatal("action did not cross screens")
626+ }
627+ if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
628+ t.Fatalf("destination request %+v", c.flash.pending)
629+ }
630+ clock.now = clock.now.Add(flashSettle)
631+ if !c.flashTick() {
632+ t.Fatal("destination flash did not start")
633+ }
634+ scenes := stepScenes(t, c, b)
635+ if scenes["A"].Flash != nil {
636+ t.Fatal("source output flashed")
637+ }
638+ f := scenes["B"].Flash
639+ if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {
640+ t.Fatalf("destination scene flash %+v", f)
641+ }
642+ if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
643+ t.Fatal("setup: destination is not empty")
644+ }
645+ clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
646+ scenes = stepScenes(t, c, b)
647+ if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
648+ t.Fatal("destination flash did not end")
649+ }
650+ })
651+ }
652+}
653+
654+func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
655+ c, clock := flashCore(t)
656+ sc := c.cur()
657+ c.cur().mon.Current().FocusID(1)
658+ c.applyAction(ActionFocusColumnRight)
659+ if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
660+ t.Fatalf("same-screen request %+v", c.flash.pending)
661+ }
662+ clock.now = clock.now.Add(flashSettle)
663+ if !c.flashTick() {
664+ t.Fatal("column flash did not start")
665+ }
666+ s := indicatorScene(t, c)
667+ for _, w := range s.Windows {
668+ if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
669+ t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
670+ }
671+ }
672+}
@@ -598,3 +598,75 @@ func TestFlashUnchangedSampleIsReused(t *testing.T) {
598 t.Fatal("changed sample mutated prior scene")598 t.Fatal("changed sample mutated prior scene")
599 }599 }
600 }600 }
601+
602+func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
603+ for _, tc := range []struct {
604+ name string
605+ action Action
606+ occupied bool
607+ }{
608+ {"column focus to empty", ActionFocusColumnRight, false},
609+ {"column focus to occupied", ActionFocusColumnRight, true},
610+ {"monitor focus to empty", ActionFocusMonitorRight, false},
611+ {"column move to empty", ActionMoveColumnRight, false},
612+ {"column move to occupied", ActionMoveColumnRight, true},
613+ } {
614+ t.Run(tc.name, func(t *testing.T) {
615+ c, clock := flashCore(t)
616+ a := c.cur()
617+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
618+ b := c.screens[1]
619+ if tc.occupied {
620+ c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
621+ b.mon.AddWindow(4)
622+ }
623+ c.applyAction(tc.action)
624+ if c.cur() != b {
625+ t.Fatal("action did not cross screens")
626+ }
627+ if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
628+ t.Fatalf("destination request %+v", c.flash.pending)
629+ }
630+ clock.now = clock.now.Add(flashSettle)
631+ if !c.flashTick() {
632+ t.Fatal("destination flash did not start")
633+ }
634+ scenes := stepScenes(t, c, b)
635+ if scenes["A"].Flash != nil {
636+ t.Fatal("source output flashed")
637+ }
638+ f := scenes["B"].Flash
639+ if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {
640+ t.Fatalf("destination scene flash %+v", f)
641+ }
642+ if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
643+ t.Fatal("setup: destination is not empty")
644+ }
645+ clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
646+ scenes = stepScenes(t, c, b)
647+ if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
648+ t.Fatal("destination flash did not end")
649+ }
650+ })
651+ }
652+}
653+
654+func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
655+ c, clock := flashCore(t)
656+ sc := c.cur()
657+ c.cur().mon.Current().FocusID(1)
658+ c.applyAction(ActionFocusColumnRight)
659+ if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
660+ t.Fatalf("same-screen request %+v", c.flash.pending)
661+ }
662+ clock.now = clock.now.Add(flashSettle)
663+ if !c.flashTick() {
664+ t.Fatal("column flash did not start")
665+ }
666+ s := indicatorScene(t, c)
667+ for _, w := range s.Windows {
668+ if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
669+ t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
670+ }
671+ }
672+}

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