detached 15f8339

refactor(core): limit focus flash to windows and columns

Focus the nearest visible window at the entry edge when directional focus crosses screens. Preserve destination focus without flashing when no window is visible.
commit
15f833928c647ed5edccf9f3fc72f769a799c350
author
brice · · 2026-10-23 05:59 UTC
committer
brice · · 2026-10-23 05:59 UTC
parent
3a820d6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +219 -59
M docs/config.md +9 -5
@@ -54,7 +54,7 @@ See [examples/config](../examples/config) for every key with its default.
5454 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
5555 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
5656 | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured |
57-| `focus.flash` | `on` | Briefly draw an inner border, inside the window border, on the window, column or output an action changed |
57+| `focus.flash` | `on` | Briefly draw an inner border, inside the window border, on the window or column an action changed |
5858 | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
5959 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
6060 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
@@ -97,10 +97,14 @@ even when empty. Cascade workspaces use top-aligned band columns; scroll/fixed
9797 workspaces use one vertical workspace column. Large lists show at most 16 entries
9898 with the active entry kept visible; band rows are capped at 16.
9999
100-The focus flash marks the window, column or output an action changes with a brief
101-inner border. When a flash-triggering action crosses screens, it marks the whole
102-destination output, even if that output has no windows. Same-screen workspace
103-switches and mouse focus do not trigger a flash.
100+The focus flash marks the window or column an action changes with a brief
101+inner border. When a flash-triggering action crosses screens, it marks the focused window on the
102+destination output; an empty destination does not flash. Directional monitor focus
103+and focus past a screen edge select the visible window nearest the entry edge
104+(rightmost when moving left, bottommost when moving up, and conversely). Hidden
105+and off-screen windows are ignored. If none are visible, the destination keeps
106+its focus and does not flash. Same-screen workspace switches and mouse focus do
107+not trigger a flash.
104108 It runs when `animations = on`.
105109
106110 ## Automatic terminal
@@ -54,7 +54,7 @@ See [examples/config](../examples/config) for every key with its default.
54 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |54 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
55 | `focus.follow-move` | `off` | Follow a column moved to another workspace |55 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
56 | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured |56 | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured |
57-| `focus.flash` | `on` | Briefly draw an inner border, inside the window border, on the window, column or output an action changed |57+| `focus.flash` | `on` | Briefly draw an inner border, inside the window border, on the window or column an action changed |
58 | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |58 | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
59 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |59 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
60 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |60 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
@@ -97,10 +97,14 @@ even when empty. Cascade workspaces use top-aligned band columns; scroll/fixed
97 workspaces use one vertical workspace column. Large lists show at most 16 entries97 workspaces use one vertical workspace column. Large lists show at most 16 entries
98 with the active entry kept visible; band rows are capped at 16.98 with the active entry kept visible; band rows are capped at 16.
99 99
100-The focus flash marks the window, column or output an action changes with a brief100+The focus flash marks the window or column an action changes with a brief
101-inner border. When a flash-triggering action crosses screens, it marks the whole101+inner border. When a flash-triggering action crosses screens, it marks the focused window on the
102-destination output, even if that output has no windows. Same-screen workspace102+destination output; an empty destination does not flash. Directional monitor focus
103-switches and mouse focus do not trigger a flash.103+and focus past a screen edge select the visible window nearest the entry edge
104+(rightmost when moving left, bottommost when moving up, and conversely). Hidden
105+and off-screen windows are ignored. If none are visible, the destination keeps
106+its focus and does not flash. Same-screen workspace switches and mouse focus do
107+not trigger a flash.
104 It runs when `animations = on`.108 It runs when `animations = on`.
105 109
106 ## Automatic terminal110 ## Automatic terminal
M internal/core/action.go +43 -3
@@ -216,7 +216,7 @@ type Effect struct {
216216 // workspace, then a neighbor screen, then a trailing empty workspace. Vertical
217217 // edges do this only with a vertical workspace axis; cascade stays within bands.
218218 // Other actions apply to the focused screen's monitor unless explicitly moving
219-// between screens. Screen changes request a flash on the destination output.
219+// between screens. Screen changes flash the focused window on the destination.
220220 func (c *Core) applyAction(a Action) Effect {
221221 if !c.flashOn() {
222222 return c.dispatchAction(a)
@@ -252,9 +252,16 @@ func (c *Core) applyAction(a Action) Effect {
252252 }
253253 return
254254 }
255- // Crossing outputs marks the destination itself, even if it is empty.
255+ // Crossing outputs marks the destination’s focused window, if any.
256256 if sc != beforeScreen {
257- c.requestFlash(flashMonitor, 0)
257+ if id != 0 {
258+ for _, p := range sc.mon.Layout() {
259+ if p.ID == id && sc.mon.viewport().shows(p) {
260+ c.requestFlash(flashWindow, id)
261+ break
262+ }
263+ }
264+ }
258265 return
259266 }
260267 level := flashWindow
@@ -312,6 +319,7 @@ func (c *Core) dispatchAction(a Action) Effect {
312319 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
313320 if i := c.neighbor(monitorDirection(a)); i >= 0 {
314321 c.focusScreen = i
322+ c.cur().mon.focusEntry(monitorDirection(a))
315323 }
316324 return Effect{}
317325 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
@@ -461,6 +469,7 @@ func (c *Core) edgeFocus(axis layoutAxis, dir int, d direction) bool {
461469 }
462470 if i := c.neighbor(d); i >= 0 {
463471 c.focusScreen = i
472+ c.cur().mon.focusEntry(d)
464473 return true
465474 }
466475 return m.stepWorkspace(axis, dir)
@@ -661,3 +670,34 @@ func moveFloat(cur, to *Workspace, id WindowID) {
661670 to.Activate(id)
662671 to.SetFullscreen(id, true)
663672 }
673+
674+// focusEntry selects the visible window nearest the edge entered from d.
675+// Layout order breaks ties; an empty viewport preserves the existing focus.
676+func (m *Monitor) focusEntry(d direction) {
677+ view := m.viewport()
678+ var id WindowID
679+ best := 0
680+ for _, p := range m.Layout() {
681+ if !view.shows(p) {
682+ continue
683+ }
684+ var distance int
685+ area := view.area(p)
686+ switch d {
687+ case dirLeft:
688+ distance = max(0, area.X+area.W-(p.Rect.X+p.Rect.W))
689+ case dirRight:
690+ distance = max(0, p.Rect.X-area.X)
691+ case dirUp:
692+ distance = max(0, area.Y+area.H-(p.Rect.Y+p.Rect.H))
693+ case dirDown:
694+ distance = max(0, p.Rect.Y-area.Y)
695+ }
696+ if id == 0 || distance < best {
697+ id, best = p.ID, distance
698+ }
699+ }
700+ if id != 0 {
701+ m.Current().FocusID(id)
702+ }
703+}
@@ -216,7 +216,7 @@ type Effect struct {
216 // workspace, then a neighbor screen, then a trailing empty workspace. Vertical216 // workspace, then a neighbor screen, then a trailing empty workspace. Vertical
217 // edges do this only with a vertical workspace axis; cascade stays within bands.217 // edges do this only with a vertical workspace axis; cascade stays within bands.
218 // Other actions apply to the focused screen's monitor unless explicitly moving218 // Other actions apply to the focused screen's monitor unless explicitly moving
219-// between screens. Screen changes request a flash on the destination output.219+// between screens. Screen changes flash the focused window on the destination.
220 func (c *Core) applyAction(a Action) Effect {220 func (c *Core) applyAction(a Action) Effect {
221 if !c.flashOn() {221 if !c.flashOn() {
222 return c.dispatchAction(a)222 return c.dispatchAction(a)
@@ -252,9 +252,16 @@ func (c *Core) applyAction(a Action) Effect {
252 }252 }
253 return253 return
254 }254 }
255- // Crossing outputs marks the destination itself, even if it is empty.255+ // Crossing outputs marks the destination’s focused window, if any.
256 if sc != beforeScreen {256 if sc != beforeScreen {
257- c.requestFlash(flashMonitor, 0)257+ if id != 0 {
258+ for _, p := range sc.mon.Layout() {
259+ if p.ID == id && sc.mon.viewport().shows(p) {
260+ c.requestFlash(flashWindow, id)
261+ break
262+ }
263+ }
264+ }
258 return265 return
259 }266 }
260 level := flashWindow267 level := flashWindow
@@ -312,6 +319,7 @@ func (c *Core) dispatchAction(a Action) Effect {
312 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:319 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
313 if i := c.neighbor(monitorDirection(a)); i >= 0 {320 if i := c.neighbor(monitorDirection(a)); i >= 0 {
314 c.focusScreen = i321 c.focusScreen = i
322+ c.cur().mon.focusEntry(monitorDirection(a))
315 }323 }
316 return Effect{}324 return Effect{}
317 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:325 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
@@ -461,6 +469,7 @@ func (c *Core) edgeFocus(axis layoutAxis, dir int, d direction) bool {
461 }469 }
462 if i := c.neighbor(d); i >= 0 {470 if i := c.neighbor(d); i >= 0 {
463 c.focusScreen = i471 c.focusScreen = i
472+ c.cur().mon.focusEntry(d)
464 return true473 return true
465 }474 }
466 return m.stepWorkspace(axis, dir)475 return m.stepWorkspace(axis, dir)
@@ -661,3 +670,34 @@ func moveFloat(cur, to *Workspace, id WindowID) {
661 to.Activate(id)670 to.Activate(id)
662 to.SetFullscreen(id, true)671 to.SetFullscreen(id, true)
663 }672 }
673+
674+// focusEntry selects the visible window nearest the edge entered from d.
675+// Layout order breaks ties; an empty viewport preserves the existing focus.
676+func (m *Monitor) focusEntry(d direction) {
677+ view := m.viewport()
678+ var id WindowID
679+ best := 0
680+ for _, p := range m.Layout() {
681+ if !view.shows(p) {
682+ continue
683+ }
684+ var distance int
685+ area := view.area(p)
686+ switch d {
687+ case dirLeft:
688+ distance = max(0, area.X+area.W-(p.Rect.X+p.Rect.W))
689+ case dirRight:
690+ distance = max(0, p.Rect.X-area.X)
691+ case dirUp:
692+ distance = max(0, area.Y+area.H-(p.Rect.Y+p.Rect.H))
693+ case dirDown:
694+ distance = max(0, p.Rect.Y-area.Y)
695+ }
696+ if id == 0 || distance < best {
697+ id, best = p.ID, distance
698+ }
699+ }
700+ if id != 0 {
701+ m.Current().FocusID(id)
702+ }
703+}
M internal/core/flash.go +5 -11
@@ -12,7 +12,6 @@ type flashLevel uint8
1212 const (
1313 flashWindow flashLevel = iota
1414 flashColumn
15- flashMonitor
1615 flashSettle = 150 * time.Millisecond
1716 flashRecheck = 50 * time.Millisecond
1817 flashCooldown = time.Second
@@ -72,13 +71,12 @@ func (c *Core) flashTick() bool {
7271 return false
7372 }
7473 now := c.now()
75- if target.level != flashMonitor {
76- owner, _ := c.screenOf(target.window)
77- record := c.windows.lookup(target.window)
78- if owner != sc || record.client.ID == 0 {
79- return false
80- }
74+ owner, _ := c.screenOf(target.window)
75+ record := c.windows.lookup(target.window)
76+ if owner != sc || record.client.ID == 0 {
77+ return false
8178 }
79+
8280 wait := flashSettle - now.Sub(f.since)
8381 if wait > 0 || !sc.settled() {
8482 if wait <= 0 {
@@ -143,10 +141,6 @@ func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, int, bool) {
143141 if sc.mon.ov.open {
144142 return Rect{}, 0, false
145143 }
146- if target.level == flashMonitor {
147- o := sc.mon.Output()
148- return Rect{W: o.W, H: o.H}, 0, true
149- }
150144 owner, _ := c.screenOf(target.window)
151145 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
152146 return Rect{}, 0, false
@@ -12,7 +12,6 @@ type flashLevel uint8
12 const (12 const (
13 flashWindow flashLevel = iota13 flashWindow flashLevel = iota
14 flashColumn14 flashColumn
15- flashMonitor
16 flashSettle = 150 * time.Millisecond15 flashSettle = 150 * time.Millisecond
17 flashRecheck = 50 * time.Millisecond16 flashRecheck = 50 * time.Millisecond
18 flashCooldown = time.Second17 flashCooldown = time.Second
@@ -72,13 +71,12 @@ func (c *Core) flashTick() bool {
72 return false71 return false
73 }72 }
74 now := c.now()73 now := c.now()
75- if target.level != flashMonitor {74+ owner, _ := c.screenOf(target.window)
76- owner, _ := c.screenOf(target.window)75+ record := c.windows.lookup(target.window)
77- record := c.windows.lookup(target.window)76+ if owner != sc || record.client.ID == 0 {
78- if owner != sc || record.client.ID == 0 {77+ return false
79- return false
80- }
81 }78 }
79+
82 wait := flashSettle - now.Sub(f.since)80 wait := flashSettle - now.Sub(f.since)
83 if wait > 0 || !sc.settled() {81 if wait > 0 || !sc.settled() {
84 if wait <= 0 {82 if wait <= 0 {
@@ -143,10 +141,6 @@ func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, int, bool) {
143 if sc.mon.ov.open {141 if sc.mon.ov.open {
144 return Rect{}, 0, false142 return Rect{}, 0, false
145 }143 }
146- if target.level == flashMonitor {
147- o := sc.mon.Output()
148- return Rect{W: o.W, H: o.H}, 0, true
149- }
150 owner, _ := c.screenOf(target.window)144 owner, _ := c.screenOf(target.window)
151 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {145 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
152 return Rect{}, 0, false146 return Rect{}, 0, false
M internal/core/flash_test.go +48 -35
@@ -98,7 +98,7 @@ func TestFlashDebounceCooldownAndReplacement(t *testing.T) {
9898 }
9999
100100 func TestFlashSettlesAndResolvesFinalRect(t *testing.T) {
101- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
101+ for _, level := range []flashLevel{flashWindow, flashColumn} {
102102 t.Run(string(rune('0'+level)), func(t *testing.T) {
103103 c, clock := flashCore(t)
104104 w := c.cur().mon.Current()
@@ -115,13 +115,12 @@ func TestFlashSettlesAndResolvesFinalRect(t *testing.T) {
115115 t.Fatal("did not start after settling")
116116 }
117117 want := Rect{W: 300, H: 200}
118- if level != flashMonitor {
119- for _, p := range c.cur().mon.Layout() {
120- if p.ID == 3 {
121- want = p.Rect
122- }
118+ for _, p := range c.cur().mon.Layout() {
119+ if p.ID == 3 {
120+ want = p.Rect
123121 }
124122 }
123+
125124 if c.flash.rect != want {
126125 t.Fatalf("rect %+v want %+v", c.flash.rect, want)
127126 }
@@ -232,7 +231,7 @@ func TestFlashExcludedActions(t *testing.T) {
232231 }
233232 }
234233
235-func TestFlashMonitorTriggers(t *testing.T) {
234+func TestFlashCrossOutputActionTriggers(t *testing.T) {
236235 for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
237236 t.Run(string(action), func(t *testing.T) {
238237 c, clock := flashCore(t)
@@ -249,12 +248,23 @@ func TestFlashMonitorTriggers(t *testing.T) {
249248 b.x, b.y = 0, 200
250249 }
251250 c.applyAction(action)
252- if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" {
251+ id, _ := b.mon.Focused()
252+ if id == 0 {
253+ if c.flash.pending != nil {
254+ t.Fatalf("empty destination request %+v", c.flash.pending)
255+ }
256+ return
257+ }
258+ if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
253259 t.Fatalf("destination request %+v", c.flash.pending)
254260 }
255261 clock.now = clock.now.Add(flashSettle)
256- if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) {
257- t.Fatalf("monitor rect %+v", c.flash.rect)
262+ if !c.flashTick() {
263+ t.Fatal("destination window flash did not start")
264+ }
265+ want, _, ok := c.flashRect(*c.flash.running, b)
266+ if !ok || c.flash.rect != want {
267+ t.Fatalf("window rect %+v want %+v", c.flash.rect, want)
258268 }
259269 })
260270 }
@@ -395,7 +405,7 @@ func TestFlashNoImplicitFocusTriggers(t *testing.T) {
395405 }
396406
397407 func TestFlashCancelsStaleRunningTargets(t *testing.T) {
398- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
408+ for _, level := range []flashLevel{flashWindow, flashColumn} {
399409 for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
400410 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
401411 c, clock := flashCore(t)
@@ -428,13 +438,8 @@ func TestFlashCancelsStaleRunningTargets(t *testing.T) {
428438 }
429439 // Cancellation must not wait for the target output's next flip.
430440 scenes := stepScenes(t, c, b)
431- shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"
432- if shouldCancel {
433- if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
434- t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
435- }
436- } else if scenes["A"].Flash == nil || !c.animating() {
437- t.Fatal("monitor flash cancelled for a window change")
441+ if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
442+ t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
438443 }
439444 })
440445 }
@@ -458,7 +463,7 @@ func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
458463 }
459464 // Inserting after all previous starts expire prunes old target keys.
460465 clock.now = clock.now.Add(flashCooldown)
461- startFlash(t, c, clock, flashMonitor, 0)
466+ startFlash(t, c, clock, flashWindow, 3)
462467 if len(c.flash.lastStarts) != 1 {
463468 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
464469 }
@@ -596,7 +601,7 @@ func TestFlashUnchangedSampleIsReused(t *testing.T) {
596601 }
597602 }
598603
599-func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
604+func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
600605 for _, tc := range []struct {
601606 name string
602607 action Action
@@ -621,7 +626,18 @@ func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
621626 if c.cur() != b {
622627 t.Fatal("action did not cross screens")
623628 }
624- if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
629+ id, _ := b.mon.Focused()
630+ if id == 0 {
631+ if c.flash.pending != nil {
632+ t.Fatalf("empty destination request %+v", c.flash.pending)
633+ }
634+ clock.now = clock.now.Add(flashSettle)
635+ if c.flashTick() || stepScenes(t, c, b)["B"].Flash != nil {
636+ t.Fatal("empty destination flashed")
637+ }
638+ return
639+ }
640+ if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
625641 t.Fatalf("destination request %+v", c.flash.pending)
626642 }
627643 clock.now = clock.now.Add(flashSettle)
@@ -633,7 +649,8 @@ func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
633649 t.Fatal("source output flashed")
634650 }
635651 f := scenes["B"].Flash
636- if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {
652+ want, _, ok := c.flashRect(*c.flash.running, b)
653+ if f == nil || !ok || f.Rect != want || !scenes["B"].Animating {
637654 t.Fatalf("destination scene flash %+v", f)
638655 }
639656 if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
@@ -669,7 +686,7 @@ func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
669686 }
670687
671688 func TestFlashCancelsOnGeometryChange(t *testing.T) {
672- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
689+ for _, level := range []flashLevel{flashWindow, flashColumn} {
673690 for _, change := range []string{"resize", "new window"} {
674691 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
675692 c, clock := flashCore(t)
@@ -678,9 +695,7 @@ func TestFlashCancelsOnGeometryChange(t *testing.T) {
678695 if before.Flash == nil {
679696 t.Fatal("setup: no flash")
680697 }
681- if level == flashMonitor {
682- c.cur().mon.SetOutput(400, 300)
683- } else if change == "resize" {
698+ if change == "resize" {
684699 c.cur().mon.Current().Apply(ActionCycleColumnWidth)
685700 } else {
686701 c.cur().mon.AddWindow(4)
@@ -699,7 +714,7 @@ func TestFlashCancelsOnGeometryChange(t *testing.T) {
699714 }
700715
701716 func TestFlashGeometryRevalidationAllocations(t *testing.T) {
702- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
717+ for _, level := range []flashLevel{flashWindow, flashColumn} {
703718 t.Run(string(rune('0'+level)), func(t *testing.T) {
704719 c, clock := flashCore(t)
705720 startFlash(t, c, clock, level, 3)
@@ -727,7 +742,6 @@ func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
727742 {"column with gaps", 12, false, flashColumn, 11},
728743 {"tile without gaps", 0, false, flashWindow, 0},
729744 {"float", 0, true, flashWindow, 11},
730- {"monitor", 12, false, flashMonitor, 0},
731745 } {
732746 t.Run(test.name, func(t *testing.T) {
733747 c, clock := flashCore(t)
@@ -745,16 +759,15 @@ func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
745759 }
746760 startFlash(t, c, clock, test.level, id)
747761 want := Rect{W: 300, H: 200}
748- if test.level != flashMonitor {
749- for _, p := range c.cur().mon.Layout() {
750- if p.ID == id {
751- want = p.Rect.Inset(p.Inset, 3)
752- if test.level == flashColumn {
753- want = c.flash.rect
754- }
762+ for _, p := range c.cur().mon.Layout() {
763+ if p.ID == id {
764+ want = p.Rect.Inset(p.Inset, 3)
765+ if test.level == flashColumn {
766+ want = c.flash.rect
755767 }
756768 }
757769 }
770+
758771 if c.flash.rect != want || c.flash.radius != test.radius {
759772 t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
760773 }
@@ -98,7 +98,7 @@ func TestFlashDebounceCooldownAndReplacement(t *testing.T) {
98 }98 }
99 99
100 func TestFlashSettlesAndResolvesFinalRect(t *testing.T) {100 func TestFlashSettlesAndResolvesFinalRect(t *testing.T) {
101- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {101+ for _, level := range []flashLevel{flashWindow, flashColumn} {
102 t.Run(string(rune('0'+level)), func(t *testing.T) {102 t.Run(string(rune('0'+level)), func(t *testing.T) {
103 c, clock := flashCore(t)103 c, clock := flashCore(t)
104 w := c.cur().mon.Current()104 w := c.cur().mon.Current()
@@ -115,13 +115,12 @@ func TestFlashSettlesAndResolvesFinalRect(t *testing.T) {
115 t.Fatal("did not start after settling")115 t.Fatal("did not start after settling")
116 }116 }
117 want := Rect{W: 300, H: 200}117 want := Rect{W: 300, H: 200}
118- if level != flashMonitor {118+ for _, p := range c.cur().mon.Layout() {
119- for _, p := range c.cur().mon.Layout() {119+ if p.ID == 3 {
120- if p.ID == 3 {120+ want = p.Rect
121- want = p.Rect
122- }
123 }121 }
124 }122 }
123+
125 if c.flash.rect != want {124 if c.flash.rect != want {
126 t.Fatalf("rect %+v want %+v", c.flash.rect, want)125 t.Fatalf("rect %+v want %+v", c.flash.rect, want)
127 }126 }
@@ -232,7 +231,7 @@ func TestFlashExcludedActions(t *testing.T) {
232 }231 }
233 }232 }
234 233
235-func TestFlashMonitorTriggers(t *testing.T) {234+func TestFlashCrossOutputActionTriggers(t *testing.T) {
236 for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {235 for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
237 t.Run(string(action), func(t *testing.T) {236 t.Run(string(action), func(t *testing.T) {
238 c, clock := flashCore(t)237 c, clock := flashCore(t)
@@ -249,12 +248,23 @@ func TestFlashMonitorTriggers(t *testing.T) {
249 b.x, b.y = 0, 200248 b.x, b.y = 0, 200
250 }249 }
251 c.applyAction(action)250 c.applyAction(action)
252- if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" {251+ id, _ := b.mon.Focused()
252+ if id == 0 {
253+ if c.flash.pending != nil {
254+ t.Fatalf("empty destination request %+v", c.flash.pending)
255+ }
256+ return
257+ }
258+ if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
253 t.Fatalf("destination request %+v", c.flash.pending)259 t.Fatalf("destination request %+v", c.flash.pending)
254 }260 }
255 clock.now = clock.now.Add(flashSettle)261 clock.now = clock.now.Add(flashSettle)
256- if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) {262+ if !c.flashTick() {
257- t.Fatalf("monitor rect %+v", c.flash.rect)263+ t.Fatal("destination window flash did not start")
264+ }
265+ want, _, ok := c.flashRect(*c.flash.running, b)
266+ if !ok || c.flash.rect != want {
267+ t.Fatalf("window rect %+v want %+v", c.flash.rect, want)
258 }268 }
259 })269 })
260 }270 }
@@ -395,7 +405,7 @@ func TestFlashNoImplicitFocusTriggers(t *testing.T) {
395 }405 }
396 406
397 func TestFlashCancelsStaleRunningTargets(t *testing.T) {407 func TestFlashCancelsStaleRunningTargets(t *testing.T) {
398- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {408+ for _, level := range []flashLevel{flashWindow, flashColumn} {
399 for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {409 for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
400 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {410 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
401 c, clock := flashCore(t)411 c, clock := flashCore(t)
@@ -428,13 +438,8 @@ func TestFlashCancelsStaleRunningTargets(t *testing.T) {
428 }438 }
429 // Cancellation must not wait for the target output's next flip.439 // Cancellation must not wait for the target output's next flip.
430 scenes := stepScenes(t, c, b)440 scenes := stepScenes(t, c, b)
431- shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"441+ if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
432- if shouldCancel {442+ t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
433- if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
434- t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
435- }
436- } else if scenes["A"].Flash == nil || !c.animating() {
437- t.Fatal("monitor flash cancelled for a window change")
438 }443 }
439 })444 })
440 }445 }
@@ -458,7 +463,7 @@ func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
458 }463 }
459 // Inserting after all previous starts expire prunes old target keys.464 // Inserting after all previous starts expire prunes old target keys.
460 clock.now = clock.now.Add(flashCooldown)465 clock.now = clock.now.Add(flashCooldown)
461- startFlash(t, c, clock, flashMonitor, 0)466+ startFlash(t, c, clock, flashWindow, 3)
462 if len(c.flash.lastStarts) != 1 {467 if len(c.flash.lastStarts) != 1 {
463 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)468 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
464 }469 }
@@ -596,7 +601,7 @@ func TestFlashUnchangedSampleIsReused(t *testing.T) {
596 }601 }
597 }602 }
598 603
599-func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {604+func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
600 for _, tc := range []struct {605 for _, tc := range []struct {
601 name string606 name string
602 action Action607 action Action
@@ -621,7 +626,18 @@ func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
621 if c.cur() != b {626 if c.cur() != b {
622 t.Fatal("action did not cross screens")627 t.Fatal("action did not cross screens")
623 }628 }
624- if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {629+ id, _ := b.mon.Focused()
630+ if id == 0 {
631+ if c.flash.pending != nil {
632+ t.Fatalf("empty destination request %+v", c.flash.pending)
633+ }
634+ clock.now = clock.now.Add(flashSettle)
635+ if c.flashTick() || stepScenes(t, c, b)["B"].Flash != nil {
636+ t.Fatal("empty destination flashed")
637+ }
638+ return
639+ }
640+ if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
625 t.Fatalf("destination request %+v", c.flash.pending)641 t.Fatalf("destination request %+v", c.flash.pending)
626 }642 }
627 clock.now = clock.now.Add(flashSettle)643 clock.now = clock.now.Add(flashSettle)
@@ -633,7 +649,8 @@ func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
633 t.Fatal("source output flashed")649 t.Fatal("source output flashed")
634 }650 }
635 f := scenes["B"].Flash651 f := scenes["B"].Flash
636- if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {652+ want, _, ok := c.flashRect(*c.flash.running, b)
653+ if f == nil || !ok || f.Rect != want || !scenes["B"].Animating {
637 t.Fatalf("destination scene flash %+v", f)654 t.Fatalf("destination scene flash %+v", f)
638 }655 }
639 if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {656 if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
@@ -669,7 +686,7 @@ func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
669 }686 }
670 687
671 func TestFlashCancelsOnGeometryChange(t *testing.T) {688 func TestFlashCancelsOnGeometryChange(t *testing.T) {
672- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {689+ for _, level := range []flashLevel{flashWindow, flashColumn} {
673 for _, change := range []string{"resize", "new window"} {690 for _, change := range []string{"resize", "new window"} {
674 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {691 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
675 c, clock := flashCore(t)692 c, clock := flashCore(t)
@@ -678,9 +695,7 @@ func TestFlashCancelsOnGeometryChange(t *testing.T) {
678 if before.Flash == nil {695 if before.Flash == nil {
679 t.Fatal("setup: no flash")696 t.Fatal("setup: no flash")
680 }697 }
681- if level == flashMonitor {698+ if change == "resize" {
682- c.cur().mon.SetOutput(400, 300)
683- } else if change == "resize" {
684 c.cur().mon.Current().Apply(ActionCycleColumnWidth)699 c.cur().mon.Current().Apply(ActionCycleColumnWidth)
685 } else {700 } else {
686 c.cur().mon.AddWindow(4)701 c.cur().mon.AddWindow(4)
@@ -699,7 +714,7 @@ func TestFlashCancelsOnGeometryChange(t *testing.T) {
699 }714 }
700 715
701 func TestFlashGeometryRevalidationAllocations(t *testing.T) {716 func TestFlashGeometryRevalidationAllocations(t *testing.T) {
702- for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {717+ for _, level := range []flashLevel{flashWindow, flashColumn} {
703 t.Run(string(rune('0'+level)), func(t *testing.T) {718 t.Run(string(rune('0'+level)), func(t *testing.T) {
704 c, clock := flashCore(t)719 c, clock := flashCore(t)
705 startFlash(t, c, clock, level, 3)720 startFlash(t, c, clock, level, 3)
@@ -727,7 +742,6 @@ func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
727 {"column with gaps", 12, false, flashColumn, 11},742 {"column with gaps", 12, false, flashColumn, 11},
728 {"tile without gaps", 0, false, flashWindow, 0},743 {"tile without gaps", 0, false, flashWindow, 0},
729 {"float", 0, true, flashWindow, 11},744 {"float", 0, true, flashWindow, 11},
730- {"monitor", 12, false, flashMonitor, 0},
731 } {745 } {
732 t.Run(test.name, func(t *testing.T) {746 t.Run(test.name, func(t *testing.T) {
733 c, clock := flashCore(t)747 c, clock := flashCore(t)
@@ -745,16 +759,15 @@ func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
745 }759 }
746 startFlash(t, c, clock, test.level, id)760 startFlash(t, c, clock, test.level, id)
747 want := Rect{W: 300, H: 200}761 want := Rect{W: 300, H: 200}
748- if test.level != flashMonitor {762+ for _, p := range c.cur().mon.Layout() {
749- for _, p := range c.cur().mon.Layout() {763+ if p.ID == id {
750- if p.ID == id {764+ want = p.Rect.Inset(p.Inset, 3)
751- want = p.Rect.Inset(p.Inset, 3)765+ if test.level == flashColumn {
752- if test.level == flashColumn {766+ want = c.flash.rect
753- want = c.flash.rect
754- }
755 }767 }
756 }768 }
757 }769 }
770+
758 if c.flash.rect != want || c.flash.radius != test.radius {771 if c.flash.rect != want || c.flash.radius != test.radius {
759 t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)772 t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
760 }773 }
A internal/core/screen_entry_test.go +105 -0
@@ -0,0 +1,105 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func TestCrossScreenFocusEntry(t *testing.T) {
10+ for _, tc := range []struct {
11+ action Action
12+ d direction
13+ x, y int
14+ want WindowID
15+ }{
16+ {ActionFocusMonitorLeft, dirLeft, -400, 0, 5},
17+ {ActionFocusMonitorRight, dirRight, 300, 0, 4},
18+ {ActionFocusMonitorUp, dirUp, 0, -300, 7},
19+ {ActionFocusMonitorDown, dirDown, 0, 200, 6},
20+ } {
21+ for _, edge := range []bool{false, true} {
22+ t.Run(string(tc.action)+"/edge="+map[bool]string{false: "false", true: "true"}[edge], func(t *testing.T) {
23+ c, clock := flashCore(t)
24+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
25+ b := c.screens[1]
26+ b.x, b.y = tc.x, tc.y
27+ w := b.mon.Current()
28+ for i, pos := range [][2]float64{{.1, .5}, {.9, .5}, {.5, .1}, {.5, .9}} {
29+ id := WindowID(i + 4)
30+ c.windows.mapped(ports.WindowMapped{ID: id}, clock.now)
31+ w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true)
32+ }
33+ w.FocusID(4)
34+ if edge {
35+ if !c.edgeFocus(horizontalAxis, 1, tc.d) {
36+ t.Fatal("did not cross")
37+ }
38+ } else {
39+ c.applyAction(tc.action)
40+ }
41+ id, _ := b.mon.Focused()
42+ if c.cur() != b || id != tc.want {
43+ t.Fatalf("screen %s focus %d want %d", c.cur().name(), id, tc.want)
44+ }
45+ if !edge && (c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: tc.want, output: "B"})) {
46+ t.Fatalf("flash %+v", c.flash.pending)
47+ }
48+ })
49+ }
50+ }
51+}
52+
53+func TestCrossScreenFocusEntryIgnoresOffscreenColumns(t *testing.T) {
54+ c, clock := flashCore(t)
55+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
56+ b := c.screens[1]
57+ w := b.mon.Current()
58+ for id := WindowID(4); id <= 10; id++ {
59+ c.windows.mapped(ports.WindowMapped{ID: id}, clock.now)
60+ b.mon.AddWindow(id)
61+ }
62+ // Leave focus at the right end, so the earlier columns are off-screen.
63+ w.view.stop()
64+ view := b.mon.viewport()
65+ var want WindowID
66+ best := 0
67+ offscreen := false
68+ for _, p := range b.mon.Layout() {
69+ if !view.shows(p) {
70+ offscreen = true
71+ continue
72+ }
73+ if want == 0 || p.Rect.X < best {
74+ want, best = p.ID, p.Rect.X
75+ }
76+ }
77+ if !offscreen || want == 0 {
78+ t.Fatal("setup: need both visible and off-screen columns")
79+ }
80+ c.applyAction(ActionFocusMonitorRight)
81+ id, _ := b.mon.Focused()
82+ if id != want {
83+ t.Fatalf("focus %d want visible leftmost %d", id, want)
84+ }
85+}
86+
87+func TestCrossScreenNoVisibleWindowPreservesFocus(t *testing.T) {
88+ c, clock := flashCore(t)
89+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
90+ b := c.screens[1]
91+ w := b.mon.Current()
92+ c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
93+ b.mon.AddWindow(4)
94+ w.view.off = 2000
95+ for _, p := range b.mon.Layout() {
96+ if b.mon.viewport().shows(p) {
97+ t.Fatal("setup: visible window")
98+ }
99+ }
100+ c.applyAction(ActionFocusMonitorRight)
101+ id, _ := b.mon.Focused()
102+ if c.cur() != b || id != 4 || c.flash.pending != nil {
103+ t.Fatalf("focus %d flash %+v", id, c.flash.pending)
104+ }
105+}
@@ -0,0 +1,105 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func TestCrossScreenFocusEntry(t *testing.T) {
10+ for _, tc := range []struct {
11+ action Action
12+ d direction
13+ x, y int
14+ want WindowID
15+ }{
16+ {ActionFocusMonitorLeft, dirLeft, -400, 0, 5},
17+ {ActionFocusMonitorRight, dirRight, 300, 0, 4},
18+ {ActionFocusMonitorUp, dirUp, 0, -300, 7},
19+ {ActionFocusMonitorDown, dirDown, 0, 200, 6},
20+ } {
21+ for _, edge := range []bool{false, true} {
22+ t.Run(string(tc.action)+"/edge="+map[bool]string{false: "false", true: "true"}[edge], func(t *testing.T) {
23+ c, clock := flashCore(t)
24+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
25+ b := c.screens[1]
26+ b.x, b.y = tc.x, tc.y
27+ w := b.mon.Current()
28+ for i, pos := range [][2]float64{{.1, .5}, {.9, .5}, {.5, .1}, {.5, .9}} {
29+ id := WindowID(i + 4)
30+ c.windows.mapped(ports.WindowMapped{ID: id}, clock.now)
31+ w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true)
32+ }
33+ w.FocusID(4)
34+ if edge {
35+ if !c.edgeFocus(horizontalAxis, 1, tc.d) {
36+ t.Fatal("did not cross")
37+ }
38+ } else {
39+ c.applyAction(tc.action)
40+ }
41+ id, _ := b.mon.Focused()
42+ if c.cur() != b || id != tc.want {
43+ t.Fatalf("screen %s focus %d want %d", c.cur().name(), id, tc.want)
44+ }
45+ if !edge && (c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: tc.want, output: "B"})) {
46+ t.Fatalf("flash %+v", c.flash.pending)
47+ }
48+ })
49+ }
50+ }
51+}
52+
53+func TestCrossScreenFocusEntryIgnoresOffscreenColumns(t *testing.T) {
54+ c, clock := flashCore(t)
55+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
56+ b := c.screens[1]
57+ w := b.mon.Current()
58+ for id := WindowID(4); id <= 10; id++ {
59+ c.windows.mapped(ports.WindowMapped{ID: id}, clock.now)
60+ b.mon.AddWindow(id)
61+ }
62+ // Leave focus at the right end, so the earlier columns are off-screen.
63+ w.view.stop()
64+ view := b.mon.viewport()
65+ var want WindowID
66+ best := 0
67+ offscreen := false
68+ for _, p := range b.mon.Layout() {
69+ if !view.shows(p) {
70+ offscreen = true
71+ continue
72+ }
73+ if want == 0 || p.Rect.X < best {
74+ want, best = p.ID, p.Rect.X
75+ }
76+ }
77+ if !offscreen || want == 0 {
78+ t.Fatal("setup: need both visible and off-screen columns")
79+ }
80+ c.applyAction(ActionFocusMonitorRight)
81+ id, _ := b.mon.Focused()
82+ if id != want {
83+ t.Fatalf("focus %d want visible leftmost %d", id, want)
84+ }
85+}
86+
87+func TestCrossScreenNoVisibleWindowPreservesFocus(t *testing.T) {
88+ c, clock := flashCore(t)
89+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
90+ b := c.screens[1]
91+ w := b.mon.Current()
92+ c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
93+ b.mon.AddWindow(4)
94+ w.view.off = 2000
95+ for _, p := range b.mon.Layout() {
96+ if b.mon.viewport().shows(p) {
97+ t.Fatal("setup: visible window")
98+ }
99+ }
100+ c.applyAction(ActionFocusMonitorRight)
101+ id, _ := b.mon.Focused()
102+ if c.cur() != b || id != 4 || c.flash.pending != nil {
103+ t.Fatalf("focus %d flash %+v", id, c.flash.pending)
104+ }
105+}
M internal/core/screens_test.go +4 -0
@@ -946,6 +946,8 @@ func TestExpelCrossesToFullNeighbor(t *testing.T) {
946946 r.mapWindow(t, 4)
947947 r.mapWindow(t, 5)
948948 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
949+ // Entry now selects the top window of the rightmost column; select 3.
950+ r.key(t, "Down", ports.ModAlt)
949951 // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
950952 set := r.key(t, "bracketright", ports.ModAlt)
951953 if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
@@ -983,6 +985,8 @@ func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) {
983985 r.mapWindow(t, 5)
984986 r.key(t, "f", ports.ModAlt|ports.ModShift)
985987 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
988+ // Entry now selects the top window of the rightmost column; select 3.
989+ r.key(t, "Down", ports.ModAlt)
986990 // 3 joins as a tile: fullscreen ends so it is seen, focused.
987991 set := r.key(t, "bracketright", ports.ModAlt)
988992 got, focused := windowsOf(set, "DP-2")
@@ -946,6 +946,8 @@ func TestExpelCrossesToFullNeighbor(t *testing.T) {
946 r.mapWindow(t, 4)946 r.mapWindow(t, 4)
947 r.mapWindow(t, 5)947 r.mapWindow(t, 5)
948 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)948 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
949+ // Entry now selects the top window of the rightmost column; select 3.
950+ r.key(t, "Down", ports.ModAlt)
949 // Expel right at the edge: 3 stacks under 4, the first column of DP-2.951 // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
950 set := r.key(t, "bracketright", ports.ModAlt)952 set := r.key(t, "bracketright", ports.ModAlt)
951 if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {953 if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
@@ -983,6 +985,8 @@ func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) {
983 r.mapWindow(t, 5)985 r.mapWindow(t, 5)
984 r.key(t, "f", ports.ModAlt|ports.ModShift)986 r.key(t, "f", ports.ModAlt|ports.ModShift)
985 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)987 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
988+ // Entry now selects the top window of the rightmost column; select 3.
989+ r.key(t, "Down", ports.ModAlt)
986 // 3 joins as a tile: fullscreen ends so it is seen, focused.990 // 3 joins as a tile: fullscreen ends so it is seen, focused.
987 set := r.key(t, "bracketright", ports.ModAlt)991 set := r.key(t, "bracketright", ports.ModAlt)
988 got, focused := windowsOf(set, "DP-2")992 got, focused := windowsOf(set, "DP-2")
M internal/core/workspace_axis_test.go +2 -2
@@ -325,7 +325,7 @@ func TestCascadeVerticalEdgeDoesNotCrossScreen(t *testing.T) {
325325 }
326326 }
327327
328-func TestVerticalEdgeFocusFlashesDestination(t *testing.T) {
328+func TestVerticalEdgeFocusEmptyDestinationDoesNotFlash(t *testing.T) {
329329 for _, tc := range []struct {
330330 a Action
331331 y int
@@ -336,7 +336,7 @@ func TestVerticalEdgeFocusFlashesDestination(t *testing.T) {
336336 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
337337 c.screens[1].x, c.screens[1].y = 0, tc.y
338338 c.applyAction(tc.a)
339- if c.focusScreen != 1 || c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
339+ if c.focusScreen != 1 || c.flash.pending != nil {
340340 t.Fatalf("screen %d, flash %+v", c.focusScreen, c.flash.pending)
341341 }
342342 })
@@ -325,7 +325,7 @@ func TestCascadeVerticalEdgeDoesNotCrossScreen(t *testing.T) {
325 }325 }
326 }326 }
327 327
328-func TestVerticalEdgeFocusFlashesDestination(t *testing.T) {328+func TestVerticalEdgeFocusEmptyDestinationDoesNotFlash(t *testing.T) {
329 for _, tc := range []struct {329 for _, tc := range []struct {
330 a Action330 a Action
331 y int331 y int
@@ -336,7 +336,7 @@ func TestVerticalEdgeFocusFlashesDestination(t *testing.T) {
336 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})336 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
337 c.screens[1].x, c.screens[1].y = 0, tc.y337 c.screens[1].x, c.screens[1].y = 0, tc.y
338 c.applyAction(tc.a)338 c.applyAction(tc.a)
339- if c.focusScreen != 1 || c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {339+ if c.focusScreen != 1 || c.flash.pending != nil {
340 t.Fatalf("screen %d, flash %+v", c.focusScreen, c.flash.pending)340 t.Fatalf("screen %d, flash %+v", c.focusScreen, c.flash.pending)
341 }341 }
342 })342 })
M internal/ports/config.go +1 -1
@@ -154,7 +154,7 @@ type Config struct {
154154 // moves to it.
155155 FollowMove bool
156156 // Flash briefly draws an inner border on the entity an action changed
157- // (window, column, output). FlashWidth is in logical pixels (1 to 8),
157+ // (window or column). FlashWidth is in logical pixels (1 to 8),
158158 // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
159159 // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or
160160 // empty for Border.Active.
@@ -154,7 +154,7 @@ type Config struct {
154 // moves to it.154 // moves to it.
155 FollowMove bool155 FollowMove bool
156 // Flash briefly draws an inner border on the entity an action changed156 // Flash briefly draws an inner border on the entity an action changed
157- // (window, column, output). FlashWidth is in logical pixels (1 to 8),157+ // (window or column). FlashWidth is in logical pixels (1 to 8),
158 // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade158 // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
159 // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or159 // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or
160 // empty for Border.Active.160 // empty for Border.Active.
M internal/ports/scene.go +2 -2
@@ -85,7 +85,7 @@ type Scene struct {
8585 // then only those of Wayland clients.
8686 HintLabels []HintLabel
8787 HintStyle HintStyle
88- // Flash is the focus flash drawn over everything of this output; nil
88+ // Flash is the focus flash highlighting a window or column on this output; nil
8989 // draws nothing.
9090 Flash *FocusFlash
9191 // PositionHint is drawn on the physical output only, never in captures.
@@ -209,7 +209,7 @@ type SceneLayer struct {
209209 }
210210
211211 // FocusFlash is a brief inner border marking the entity an action just
212-// changed: a window, a column or a whole output. Rect is in logical
212+// changed: a window or a column. Rect is in logical
213213 // pixels, Width the border width inside it, Color #rrggbb and Alpha its
214214 // opacity at this frame (0 to 1).
215215 type FocusFlash struct {
@@ -85,7 +85,7 @@ type Scene struct {
85 // then only those of Wayland clients.85 // then only those of Wayland clients.
86 HintLabels []HintLabel86 HintLabels []HintLabel
87 HintStyle HintStyle87 HintStyle HintStyle
88- // Flash is the focus flash drawn over everything of this output; nil88+ // Flash is the focus flash highlighting a window or column on this output; nil
89 // draws nothing.89 // draws nothing.
90 Flash *FocusFlash90 Flash *FocusFlash
91 // PositionHint is drawn on the physical output only, never in captures.91 // PositionHint is drawn on the physical output only, never in captures.
@@ -209,7 +209,7 @@ type SceneLayer struct {
209 }209 }
210 210
211 // FocusFlash is a brief inner border marking the entity an action just211 // FocusFlash is a brief inner border marking the entity an action just
212-// changed: a window, a column or a whole output. Rect is in logical212+// changed: a window or a column. Rect is in logical
213 // pixels, Width the border width inside it, Color #rrggbb and Alpha its213 // pixels, Width the border width inside it, Color #rrggbb and Alpha its
214 // opacity at this frame (0 to 1).214 // opacity at this frame (0 to 1).
215 type FocusFlash struct {215 type FocusFlash struct {

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