detached ac43558

fix(core): keep overlapping outputs reachable from monitor navigation

commit
ac435585065382d2164c5b9159dd966ce17d54c7
author
brice · · 2026-10-03 14:16 UTC
parent
93b73a7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +180 -49
M docs/config.md +1 -1
@@ -69,7 +69,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
6969
7070 ## Output placement
7171
72-An output with no relation is placed automatically, left to right after the others, at y=0. An output whose reference is not connected is placed automatically too, and returns to its relation when the reference connects. Relation cycles are ignored with a warning. Positions set by an output-management client (kanshi, wdisplays, wlr-randr) take precedence over relations until the next config reload. The pointer crosses any touching edge.
72+An output with no relation is placed automatically, left to right after the others, at y=0. An output whose reference is not connected is placed automatically too, and returns to its relation when the reference connects. Relation cycles are ignored with a warning. Positions set by an output-management client (kanshi, wdisplays, wlr-randr) take precedence over relations until the next config reload. The pointer crosses any touching edge. Monitor navigation and column edge crossings follow output geometry; stacked outputs are reached with focus-monitor-up/down.
7373
7474 Two rows of three, with DP-1 as the base:
7575
@@ -69,7 +69,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
69 69
70 ## Output placement70 ## Output placement
71 71
72-An output with no relation is placed automatically, left to right after the others, at y=0. An output whose reference is not connected is placed automatically too, and returns to its relation when the reference connects. Relation cycles are ignored with a warning. Positions set by an output-management client (kanshi, wdisplays, wlr-randr) take precedence over relations until the next config reload. The pointer crosses any touching edge.72+An output with no relation is placed automatically, left to right after the others, at y=0. An output whose reference is not connected is placed automatically too, and returns to its relation when the reference connects. Relation cycles are ignored with a warning. Positions set by an output-management client (kanshi, wdisplays, wlr-randr) take precedence over relations until the next config reload. The pointer crosses any touching edge. Monitor navigation and column edge crossings follow output geometry; stacked outputs are reached with focus-monitor-up/down.
73 73
74 Two rows of three, with DP-1 as the base:74 Two rows of three, with DP-1 as the base:
75 75
M internal/adapters/config/config.go +14 -1
@@ -251,7 +251,8 @@ func outputRelation(name string) (ports.OutputRelation, string, bool) {
251251 }
252252
253253 // checkOutputAnchors warns about an offset without a relation, then drops the
254-// relations that form a cycle (every output of the cycle, one warning each).
254+// relations that form a cycle (every output of the cycle, one warning each),
255+// and warns about outputs sharing the same relation, reference and offset.
255256 // Core places a cycle automatically anyway; runtime overrides never create
256257 // relations, so this is the only place to detect them.
257258 func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string, offsetLines, seen map[string]int) []Warning {
@@ -285,6 +286,18 @@ func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string,
285286 warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)})
286287 o.Anchor.Relation, o.Anchor.To = ports.RelationNone, ""
287288 }
289+ // Two outputs with the same relation, reference and offset overlap.
290+ for i, o := range outputs {
291+ if o.Anchor.Relation == ports.RelationNone {
292+ continue
293+ }
294+ for _, prev := range outputs[:i] {
295+ if prev.Anchor == o.Anchor {
296+ warnings = append(warnings, Warning{Line: seen[relKeys[o.Name]], Msg: fmt.Sprintf("output.%s: same relation as output.%s; both get the same position", o.Name, prev.Name)})
297+ break
298+ }
299+ }
300+ }
288301 return warnings
289302 }
290303
@@ -251,7 +251,8 @@ func outputRelation(name string) (ports.OutputRelation, string, bool) {
251 }251 }
252 252
253 // checkOutputAnchors warns about an offset without a relation, then drops the253 // checkOutputAnchors warns about an offset without a relation, then drops the
254-// relations that form a cycle (every output of the cycle, one warning each).254+// relations that form a cycle (every output of the cycle, one warning each),
255+// and warns about outputs sharing the same relation, reference and offset.
255 // Core places a cycle automatically anyway; runtime overrides never create256 // Core places a cycle automatically anyway; runtime overrides never create
256 // relations, so this is the only place to detect them.257 // relations, so this is the only place to detect them.
257 func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string, offsetLines, seen map[string]int) []Warning {258 func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string, offsetLines, seen map[string]int) []Warning {
@@ -285,6 +286,18 @@ func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string,
285 warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)})286 warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)})
286 o.Anchor.Relation, o.Anchor.To = ports.RelationNone, ""287 o.Anchor.Relation, o.Anchor.To = ports.RelationNone, ""
287 }288 }
289+ // Two outputs with the same relation, reference and offset overlap.
290+ for i, o := range outputs {
291+ if o.Anchor.Relation == ports.RelationNone {
292+ continue
293+ }
294+ for _, prev := range outputs[:i] {
295+ if prev.Anchor == o.Anchor {
296+ warnings = append(warnings, Warning{Line: seen[relKeys[o.Name]], Msg: fmt.Sprintf("output.%s: same relation as output.%s; both get the same position", o.Name, prev.Name)})
297+ break
298+ }
299+ }
300+ }
288 return warnings301 return warnings
289 }302 }
290 303
M internal/adapters/config/config_test.go +4 -0
@@ -671,6 +671,10 @@ func TestOutputRelationWarnings(t *testing.T) {
671671 {"tail into cycle", "output.A.right-of = B\noutput.B.right-of = A\noutput.C.left-of = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, []string{"C"}},
672672 {"relation overridden twice", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C", []string{"overrides line 1", "overrides line 2"}, []int{2, 3}, []string{"DP-2"}},
673673 {"relation overridden three times", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C\noutput.DP-2.below = D", []string{"overrides line 1", "overrides line 2", "overrides line 3"}, []int{2, 3, 4}, []string{"DP-2"}},
674+ {"same relation on two outputs", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1", []string{"output.DP-3: same relation as output.DP-2; both get the same position"}, []int{2}, []string{"DP-2", "DP-3"}},
675+ {"same relation and offset on three outputs", "output.A.below = R\noutput.A.offset = 5\noutput.B.below = R\noutput.B.offset = 5\noutput.C.below = R\noutput.C.offset = 5", []string{"output.B: same relation as output.A", "output.C: same relation as output.A"}, []int{3, 5}, []string{"A", "B", "C"}},
676+ {"same reference, other offset", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1\noutput.DP-3.offset = 10", nil, nil, []string{"DP-2", "DP-3"}},
677+ {"same reference, other relation", "output.DP-2.right-of = DP-1\noutput.DP-3.left-of = DP-1", nil, nil, []string{"DP-2", "DP-3"}},
674678 {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
675679 }
676680 for _, tc := range cases {
@@ -671,6 +671,10 @@ func TestOutputRelationWarnings(t *testing.T) {
671 {"tail into cycle", "output.A.right-of = B\noutput.B.right-of = A\noutput.C.left-of = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, []string{"C"}},671 {"tail into cycle", "output.A.right-of = B\noutput.B.right-of = A\noutput.C.left-of = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, []string{"C"}},
672 {"relation overridden twice", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C", []string{"overrides line 1", "overrides line 2"}, []int{2, 3}, []string{"DP-2"}},672 {"relation overridden twice", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C", []string{"overrides line 1", "overrides line 2"}, []int{2, 3}, []string{"DP-2"}},
673 {"relation overridden three times", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C\noutput.DP-2.below = D", []string{"overrides line 1", "overrides line 2", "overrides line 3"}, []int{2, 3, 4}, []string{"DP-2"}},673 {"relation overridden three times", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C\noutput.DP-2.below = D", []string{"overrides line 1", "overrides line 2", "overrides line 3"}, []int{2, 3, 4}, []string{"DP-2"}},
674+ {"same relation on two outputs", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1", []string{"output.DP-3: same relation as output.DP-2; both get the same position"}, []int{2}, []string{"DP-2", "DP-3"}},
675+ {"same relation and offset on three outputs", "output.A.below = R\noutput.A.offset = 5\noutput.B.below = R\noutput.B.offset = 5\noutput.C.below = R\noutput.C.offset = 5", []string{"output.B: same relation as output.A", "output.C: same relation as output.A"}, []int{3, 5}, []string{"A", "B", "C"}},
676+ {"same reference, other offset", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1\noutput.DP-3.offset = 10", nil, nil, []string{"DP-2", "DP-3"}},
677+ {"same reference, other relation", "output.DP-2.right-of = DP-1\noutput.DP-3.left-of = DP-1", nil, nil, []string{"DP-2", "DP-3"}},
674 {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},678 {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
675 }679 }
676 for _, tc := range cases {680 for _, tc := range cases {
M internal/core/action.go +16 -9
@@ -58,13 +58,20 @@ const (
5858 ActionToggleFloating Action = "toggle-floating"
5959 )
6060
61-// monitorDirections maps the focus-monitor and move-workspace-to-monitor
62-// actions to the direction of the neighbor screen they target.
63-var monitorDirections = map[Action]direction{
64- ActionFocusMonitorLeft: dirLeft, ActionFocusMonitorRight: dirRight,
65- ActionFocusMonitorUp: dirUp, ActionFocusMonitorDown: dirDown,
66- ActionMoveWorkspaceLeft: dirLeft, ActionMoveWorkspaceRight: dirRight,
67- ActionMoveWorkspaceToMonitorUp: dirUp, ActionMoveWorkspaceToMonitorDown: dirDown,
61+// monitorDirection returns the direction of the neighbor screen targeted by
62+// a focus-monitor or move-workspace-to-monitor action, 0 for other actions.
63+func monitorDirection(a Action) direction {
64+ switch a {
65+ case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
66+ return dirLeft
67+ case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
68+ return dirRight
69+ case ActionFocusMonitorUp, ActionMoveWorkspaceToMonitorUp:
70+ return dirUp
71+ case ActionFocusMonitorDown, ActionMoveWorkspaceToMonitorDown:
72+ return dirDown
73+ }
74+ return 0
6875 }
6976
7077 // Resize axes of ResizeArg.
@@ -189,12 +196,12 @@ func (c *Core) applyAction(a Action) Effect {
189196 }
190197 switch a {
191198 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
192- if i := c.neighbor(monitorDirections[a]); i >= 0 {
199+ if i := c.neighbor(monitorDirection(a)); i >= 0 {
193200 c.focusScreen = i
194201 }
195202 return Effect{}
196203 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
197- c.moveWorkspace(monitorDirections[a])
204+ c.moveWorkspace(monitorDirection(a))
198205 return Effect{}
199206 case ActionFocusColumnLeft, ActionFocusColumnRight:
200207 w := c.cur().mon.Current()
@@ -58,13 +58,20 @@ const (
58 ActionToggleFloating Action = "toggle-floating"58 ActionToggleFloating Action = "toggle-floating"
59 )59 )
60 60
61-// monitorDirections maps the focus-monitor and move-workspace-to-monitor61+// monitorDirection returns the direction of the neighbor screen targeted by
62-// actions to the direction of the neighbor screen they target.62+// a focus-monitor or move-workspace-to-monitor action, 0 for other actions.
63-var monitorDirections = map[Action]direction{63+func monitorDirection(a Action) direction {
64- ActionFocusMonitorLeft: dirLeft, ActionFocusMonitorRight: dirRight,64+ switch a {
65- ActionFocusMonitorUp: dirUp, ActionFocusMonitorDown: dirDown,65+ case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
66- ActionMoveWorkspaceLeft: dirLeft, ActionMoveWorkspaceRight: dirRight,66+ return dirLeft
67- ActionMoveWorkspaceToMonitorUp: dirUp, ActionMoveWorkspaceToMonitorDown: dirDown,67+ case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
68+ return dirRight
69+ case ActionFocusMonitorUp, ActionMoveWorkspaceToMonitorUp:
70+ return dirUp
71+ case ActionFocusMonitorDown, ActionMoveWorkspaceToMonitorDown:
72+ return dirDown
73+ }
74+ return 0
68 }75 }
69 76
70 // Resize axes of ResizeArg.77 // Resize axes of ResizeArg.
@@ -189,12 +196,12 @@ func (c *Core) applyAction(a Action) Effect {
189 }196 }
190 switch a {197 switch a {
191 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:198 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
192- if i := c.neighbor(monitorDirections[a]); i >= 0 {199+ if i := c.neighbor(monitorDirection(a)); i >= 0 {
193 c.focusScreen = i200 c.focusScreen = i
194 }201 }
195 return Effect{}202 return Effect{}
196 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:203 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
197- c.moveWorkspace(monitorDirections[a])204+ c.moveWorkspace(monitorDirection(a))
198 return Effect{}205 return Effect{}
199 case ActionFocusColumnLeft, ActionFocusColumnRight:206 case ActionFocusColumnLeft, ActionFocusColumnRight:
200 w := c.cur().mon.Current()207 w := c.cur().mon.Current()
M internal/core/neighbor_test.go +33 -15
@@ -25,6 +25,7 @@ func layoutCore(focus int, tiles ...tile) *Core {
2525
2626 func TestNeighbor(t *testing.T) {
2727 dirs := []direction{dirLeft, dirRight, dirUp, dirDown}
28+ dirNames := [...]string{"left", "right", "up", "down"}
2829 // want holds the expected neighbor name for left, right, up, down; "" is
2930 // the edge.
3031 type step struct {
@@ -71,7 +72,22 @@ func TestNeighbor(t *testing.T) {
7172 {"lowest index at a full tie", []tile{{"A", 0, 0, 100, 100}, {"first", 100, 0, 100, 100}, {"second", 100, 0, 100, 100}}, []step{
7273 {"A", [4]string{"", "first", "", ""}},
7374 }},
74- {"overlapping screens are not neighbors", []tile{{"A", 0, 0, 200, 100}, {"B", 100, 0, 200, 100}}, []step{
75+ {"overlapping screens fall back to centers", []tile{{"A", 0, 0, 200, 100}, {"B", 100, 0, 200, 100}}, []step{
76+ // Centers are 100 and 200 apart on x only.
77+ {"A", [4]string{"", "B", "", ""}},
78+ {"B", [4]string{"A", "", "", ""}},
79+ }},
80+ {"outputs anchored to the same reference", []tile{{"R", 0, 0, 100, 100}, {"X", 100, 0, 100, 100}, {"Y", 100, 0, 300, 50}}, []step{
81+ // X and Y overlap; the strict pass finds nothing between them.
82+ {"X", [4]string{"R", "Y", "Y", ""}},
83+ // R is fully left of Y and wins over the closer, overlapping X.
84+ {"Y", [4]string{"R", "", "", "X"}},
85+ }},
86+ {"strict candidate beats a closer overlapping one", []tile{{"A", 0, 0, 100, 100}, {"near", 50, 0, 100, 100}, {"far", 300, 0, 100, 100}}, []step{
87+ {"A", [4]string{"", "far", "", ""}},
88+ {"near", [4]string{"A", "far", "", ""}},
89+ }},
90+ {"identical centers are unreachable", []tile{{"A", 0, 0, 100, 100}, {"B", 0, 0, 100, 100}}, []step{
7591 {"A", [4]string{"", "", "", ""}},
7692 {"B", [4]string{"", "", "", ""}},
7793 }},
@@ -92,23 +108,25 @@ func TestNeighbor(t *testing.T) {
92108 }},
93109 }
94110 for _, tc := range cases {
95- for _, st := range tc.steps {
96- c := layoutCore(0, tc.tiles...)
97- for i, s := range c.screens {
98- if s.name() == st.from {
99- c.focusScreen = i
100- }
101- }
102- for i, d := range dirs {
103- got := ""
104- if n := c.neighbor(d); n >= 0 {
105- got = c.screens[n].name()
111+ t.Run(tc.name, func(t *testing.T) {
112+ for _, st := range tc.steps {
113+ c := layoutCore(0, tc.tiles...)
114+ for i, s := range c.screens {
115+ if s.name() == st.from {
116+ c.focusScreen = i
117+ }
106118 }
107- if got != st.want[i] {
108- t.Errorf("%s: from %s direction %d: got %q, want %q", tc.name, st.from, d, got, st.want[i])
119+ for i, d := range dirs {
120+ got := ""
121+ if n := c.neighbor(d); n >= 0 {
122+ got = c.screens[n].name()
123+ }
124+ if got != st.want[i] {
125+ t.Errorf("from %s %s: got %q, want %q", st.from, dirNames[i], got, st.want[i])
126+ }
109127 }
110128 }
111- }
129+ })
112130 }
113131 if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 {
114132 t.Fatalf("invalid direction: %d", n)
@@ -25,6 +25,7 @@ func layoutCore(focus int, tiles ...tile) *Core {
25 25
26 func TestNeighbor(t *testing.T) {26 func TestNeighbor(t *testing.T) {
27 dirs := []direction{dirLeft, dirRight, dirUp, dirDown}27 dirs := []direction{dirLeft, dirRight, dirUp, dirDown}
28+ dirNames := [...]string{"left", "right", "up", "down"}
28 // want holds the expected neighbor name for left, right, up, down; "" is29 // want holds the expected neighbor name for left, right, up, down; "" is
29 // the edge.30 // the edge.
30 type step struct {31 type step struct {
@@ -71,7 +72,22 @@ func TestNeighbor(t *testing.T) {
71 {"lowest index at a full tie", []tile{{"A", 0, 0, 100, 100}, {"first", 100, 0, 100, 100}, {"second", 100, 0, 100, 100}}, []step{72 {"lowest index at a full tie", []tile{{"A", 0, 0, 100, 100}, {"first", 100, 0, 100, 100}, {"second", 100, 0, 100, 100}}, []step{
72 {"A", [4]string{"", "first", "", ""}},73 {"A", [4]string{"", "first", "", ""}},
73 }},74 }},
74- {"overlapping screens are not neighbors", []tile{{"A", 0, 0, 200, 100}, {"B", 100, 0, 200, 100}}, []step{75+ {"overlapping screens fall back to centers", []tile{{"A", 0, 0, 200, 100}, {"B", 100, 0, 200, 100}}, []step{
76+ // Centers are 100 and 200 apart on x only.
77+ {"A", [4]string{"", "B", "", ""}},
78+ {"B", [4]string{"A", "", "", ""}},
79+ }},
80+ {"outputs anchored to the same reference", []tile{{"R", 0, 0, 100, 100}, {"X", 100, 0, 100, 100}, {"Y", 100, 0, 300, 50}}, []step{
81+ // X and Y overlap; the strict pass finds nothing between them.
82+ {"X", [4]string{"R", "Y", "Y", ""}},
83+ // R is fully left of Y and wins over the closer, overlapping X.
84+ {"Y", [4]string{"R", "", "", "X"}},
85+ }},
86+ {"strict candidate beats a closer overlapping one", []tile{{"A", 0, 0, 100, 100}, {"near", 50, 0, 100, 100}, {"far", 300, 0, 100, 100}}, []step{
87+ {"A", [4]string{"", "far", "", ""}},
88+ {"near", [4]string{"A", "far", "", ""}},
89+ }},
90+ {"identical centers are unreachable", []tile{{"A", 0, 0, 100, 100}, {"B", 0, 0, 100, 100}}, []step{
75 {"A", [4]string{"", "", "", ""}},91 {"A", [4]string{"", "", "", ""}},
76 {"B", [4]string{"", "", "", ""}},92 {"B", [4]string{"", "", "", ""}},
77 }},93 }},
@@ -92,23 +108,25 @@ func TestNeighbor(t *testing.T) {
92 }},108 }},
93 }109 }
94 for _, tc := range cases {110 for _, tc := range cases {
95- for _, st := range tc.steps {111+ t.Run(tc.name, func(t *testing.T) {
96- c := layoutCore(0, tc.tiles...)112+ for _, st := range tc.steps {
97- for i, s := range c.screens {113+ c := layoutCore(0, tc.tiles...)
98- if s.name() == st.from {114+ for i, s := range c.screens {
99- c.focusScreen = i115+ if s.name() == st.from {
100- }116+ c.focusScreen = i
101- }117+ }
102- for i, d := range dirs {
103- got := ""
104- if n := c.neighbor(d); n >= 0 {
105- got = c.screens[n].name()
106 }118 }
107- if got != st.want[i] {119+ for i, d := range dirs {
108- t.Errorf("%s: from %s direction %d: got %q, want %q", tc.name, st.from, d, got, st.want[i])120+ got := ""
121+ if n := c.neighbor(d); n >= 0 {
122+ got = c.screens[n].name()
123+ }
124+ if got != st.want[i] {
125+ t.Errorf("from %s %s: got %q, want %q", st.from, dirNames[i], got, st.want[i])
126+ }
109 }127 }
110 }128 }
111- }129+ })
112 }130 }
113 if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 {131 if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 {
114 t.Fatalf("invalid direction: %d", n)132 t.Fatalf("invalid direction: %d", n)
M internal/core/screens.go +41 -23
@@ -415,7 +415,12 @@ const (
415415 // direction d, or -1 at the edge. Candidates lie fully beyond the focused
416416 // screen's edge; one sharing part of that edge wins over a diagonal one, then
417417 // the smallest gap, the largest shared edge, the closest centers and the
418-// lowest index decide. There is no wrap.
418+// lowest index decide. When none lies fully beyond, a second pass handles
419+// overlapping outputs (for instance two anchored to the same reference): it
420+// takes the screens whose rectangle intersects the focused one and whose
421+// center is strictly beyond the focused center, ranked the same way with a
422+// gap of 0, so they stay reachable. Screens with identical centers are not
423+// reachable by direction. There is no wrap.
419424 func (c *Core) neighbor(d direction) int {
420425 if d < dirLeft || d > dirDown {
421426 return -1
@@ -429,30 +434,43 @@ func (c *Core) neighbor(d direction) int {
429434 return s.x, s.x + o.W, s.y, s.y + o.H
430435 }
431436 lo, hi, clo, chi := span(c.cur())
432- best, bestKey := -1, [4]int{}
433- for i, s := range c.screens {
434- if i == c.focusScreen || s.name() == "" {
435- continue
436- }
437- l, h, cl, ch := span(s)
438- gap := l - hi
439- if d == dirLeft || d == dirUp {
440- gap = lo - h
441- }
442- if gap < 0 {
443- continue
444- }
445- overlap := min(chi, ch) - max(clo, cl)
446- diagonal := 0
447- if overlap <= 0 {
448- diagonal = 1
449- }
450- key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}
451- if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {
452- best, bestKey = i, key
437+ pick := func(fallback bool) int {
438+ best, bestKey := -1, [4]int{}
439+ for i, s := range c.screens {
440+ if i == c.focusScreen || s.name() == "" {
441+ continue
442+ }
443+ l, h, cl, ch := span(s)
444+ gap := l - hi
445+ beyond := l+h > lo+hi // centers, doubled
446+ if d == dirLeft || d == dirUp {
447+ gap = lo - h
448+ beyond = l+h < lo+hi
449+ }
450+ if fallback {
451+ if !beyond || min(hi, h)-max(lo, l) <= 0 || min(chi, ch)-max(clo, cl) <= 0 {
452+ continue
453+ }
454+ gap = 0
455+ } else if gap < 0 {
456+ continue
457+ }
458+ overlap := min(chi, ch) - max(clo, cl)
459+ diagonal := 0
460+ if overlap <= 0 {
461+ diagonal = 1
462+ }
463+ key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}
464+ if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {
465+ best, bestKey = i, key
466+ }
453467 }
468+ return best
469+ }
470+ if i := pick(false); i >= 0 {
471+ return i
454472 }
455- return best
473+ return pick(true)
456474 }
457475
458476 // moveWorkspace moves the workspace on screen to the neighbor screen, where
@@ -415,7 +415,12 @@ const (
415 // direction d, or -1 at the edge. Candidates lie fully beyond the focused415 // direction d, or -1 at the edge. Candidates lie fully beyond the focused
416 // screen's edge; one sharing part of that edge wins over a diagonal one, then416 // screen's edge; one sharing part of that edge wins over a diagonal one, then
417 // the smallest gap, the largest shared edge, the closest centers and the417 // the smallest gap, the largest shared edge, the closest centers and the
418-// lowest index decide. There is no wrap.418+// lowest index decide. When none lies fully beyond, a second pass handles
419+// overlapping outputs (for instance two anchored to the same reference): it
420+// takes the screens whose rectangle intersects the focused one and whose
421+// center is strictly beyond the focused center, ranked the same way with a
422+// gap of 0, so they stay reachable. Screens with identical centers are not
423+// reachable by direction. There is no wrap.
419 func (c *Core) neighbor(d direction) int {424 func (c *Core) neighbor(d direction) int {
420 if d < dirLeft || d > dirDown {425 if d < dirLeft || d > dirDown {
421 return -1426 return -1
@@ -429,30 +434,43 @@ func (c *Core) neighbor(d direction) int {
429 return s.x, s.x + o.W, s.y, s.y + o.H434 return s.x, s.x + o.W, s.y, s.y + o.H
430 }435 }
431 lo, hi, clo, chi := span(c.cur())436 lo, hi, clo, chi := span(c.cur())
432- best, bestKey := -1, [4]int{}437+ pick := func(fallback bool) int {
433- for i, s := range c.screens {438+ best, bestKey := -1, [4]int{}
434- if i == c.focusScreen || s.name() == "" {439+ for i, s := range c.screens {
435- continue440+ if i == c.focusScreen || s.name() == "" {
436- }441+ continue
437- l, h, cl, ch := span(s)442+ }
438- gap := l - hi443+ l, h, cl, ch := span(s)
439- if d == dirLeft || d == dirUp {444+ gap := l - hi
440- gap = lo - h445+ beyond := l+h > lo+hi // centers, doubled
441- }446+ if d == dirLeft || d == dirUp {
442- if gap < 0 {447+ gap = lo - h
443- continue448+ beyond = l+h < lo+hi
444- }449+ }
445- overlap := min(chi, ch) - max(clo, cl)450+ if fallback {
446- diagonal := 0451+ if !beyond || min(hi, h)-max(lo, l) <= 0 || min(chi, ch)-max(clo, cl) <= 0 {
447- if overlap <= 0 {452+ continue
448- diagonal = 1453+ }
449- }454+ gap = 0
450- key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}455+ } else if gap < 0 {
451- if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {456+ continue
452- best, bestKey = i, key457+ }
458+ overlap := min(chi, ch) - max(clo, cl)
459+ diagonal := 0
460+ if overlap <= 0 {
461+ diagonal = 1
462+ }
463+ key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}
464+ if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {
465+ best, bestKey = i, key
466+ }
453 }467 }
468+ return best
469+ }
470+ if i := pick(false); i >= 0 {
471+ return i
454 }472 }
455- return best473+ return pick(true)
456 }474 }
457 475
458 // moveWorkspace moves the workspace on screen to the neighbor screen, where476 // moveWorkspace moves the workspace on screen to the neighbor screen, where
M internal/core/screens_test.go +71 -0
@@ -1335,3 +1335,74 @@ func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) {
13351335 t.Fatal("focus not on DP-1:", got)
13361336 }
13371337 }
1338+
1339+// focusedOutput drains the commands and returns the focused connector,
1340+// current when no SetOutputs was sent (it is sent only on a change).
1341+func focusedOutput(r *multiRig, current string) string {
1342+ for len(r.commands) > 0 {
1343+ if v, ok := (<-r.commands).(ports.SetOutputs); ok {
1344+ current = v.Focused
1345+ }
1346+ }
1347+ return current
1348+}
1349+
1350+// The column edge crosses to the screen on that side of the geometry, not to
1351+// the next one in configuration order.
1352+func TestFocusColumnEdgeFollowsGeometryNotConfigOrder(t *testing.T) {
1353+ r := startMulti(t, func(c *ports.Config) {
1354+ c.Outputs = []ports.OutputConfig{
1355+ {Name: "DP-1", Pos: &image.Point{X: 500, Y: 0}},
1356+ {Name: "DP-2", Pos: &image.Point{X: 0, Y: 0}},
1357+ }
1358+ }, left, right)
1359+ // DP-1 (x 500..700) is focused first; DP-2 (x 0..400) lies to its left.
1360+ current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1361+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
1362+ if current = focusedOutput(r, current); current != "DP-2" {
1363+ t.Fatalf("focus-monitor-left: %s, want DP-2", current)
1364+ }
1365+ r.mapWindow(t, 1)
1366+ r.key(t, "Right", ports.ModAlt)
1367+ if got := focusedOutput(r, current); got != "DP-1" {
1368+ t.Fatalf("focus-column-right at the right edge: %s, want DP-1", got)
1369+ }
1370+ r.key(t, "Left", ports.ModAlt)
1371+ if got := focusedOutput(r, "DP-1"); got != "DP-2" {
1372+ t.Fatalf("focus-column-left at the left edge: %s, want DP-2", got)
1373+ }
1374+}
1375+
1376+// With the outputs stacked, a column edge has no neighbor on its side.
1377+func TestFocusColumnEdgeStaysOnStackedOutputs(t *testing.T) {
1378+ r := startMulti(t, func(c *ports.Config) {
1379+ c.Outputs = []ports.OutputConfig{
1380+ {Name: "DP-1"},
1381+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
1382+ }
1383+ }, left, right)
1384+ r.mapWindow(t, 1)
1385+ current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1386+ for _, k := range []string{"Left", "Right", "Left"} {
1387+ r.key(t, k, ports.ModAlt)
1388+ if got := focusedOutput(r, current); got != current {
1389+ t.Fatalf("focus-column %s moved focus from %s to %s", k, current, got)
1390+ }
1391+ }
1392+}
1393+
1394+// A screen turned off by power management is still a neighbor.
1395+func TestPoweredOffScreenStaysNeighbor(t *testing.T) {
1396+ r := startMulti(t, nil, left, right)
1397+ // DP-1 is focused, DP-2 follows it on the right.
1398+ current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1399+ r.client <- ports.OutputPower{Output: "DP-2", On: false}
1400+ _ = receive(t, r.scenes)
1401+ if out := lastOutputs(t, r.commands); current != "DP-1" || !slices.Equal(out.Off, []string{"DP-2"}) {
1402+ t.Fatalf("focused %s, off %v", current, out.Off)
1403+ }
1404+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1405+ if got := focusedOutput(r, current); got != "DP-2" {
1406+ t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)
1407+ }
1408+}
@@ -1335,3 +1335,74 @@ func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) {
1335 t.Fatal("focus not on DP-1:", got)1335 t.Fatal("focus not on DP-1:", got)
1336 }1336 }
1337 }1337 }
1338+
1339+// focusedOutput drains the commands and returns the focused connector,
1340+// current when no SetOutputs was sent (it is sent only on a change).
1341+func focusedOutput(r *multiRig, current string) string {
1342+ for len(r.commands) > 0 {
1343+ if v, ok := (<-r.commands).(ports.SetOutputs); ok {
1344+ current = v.Focused
1345+ }
1346+ }
1347+ return current
1348+}
1349+
1350+// The column edge crosses to the screen on that side of the geometry, not to
1351+// the next one in configuration order.
1352+func TestFocusColumnEdgeFollowsGeometryNotConfigOrder(t *testing.T) {
1353+ r := startMulti(t, func(c *ports.Config) {
1354+ c.Outputs = []ports.OutputConfig{
1355+ {Name: "DP-1", Pos: &image.Point{X: 500, Y: 0}},
1356+ {Name: "DP-2", Pos: &image.Point{X: 0, Y: 0}},
1357+ }
1358+ }, left, right)
1359+ // DP-1 (x 500..700) is focused first; DP-2 (x 0..400) lies to its left.
1360+ current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1361+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
1362+ if current = focusedOutput(r, current); current != "DP-2" {
1363+ t.Fatalf("focus-monitor-left: %s, want DP-2", current)
1364+ }
1365+ r.mapWindow(t, 1)
1366+ r.key(t, "Right", ports.ModAlt)
1367+ if got := focusedOutput(r, current); got != "DP-1" {
1368+ t.Fatalf("focus-column-right at the right edge: %s, want DP-1", got)
1369+ }
1370+ r.key(t, "Left", ports.ModAlt)
1371+ if got := focusedOutput(r, "DP-1"); got != "DP-2" {
1372+ t.Fatalf("focus-column-left at the left edge: %s, want DP-2", got)
1373+ }
1374+}
1375+
1376+// With the outputs stacked, a column edge has no neighbor on its side.
1377+func TestFocusColumnEdgeStaysOnStackedOutputs(t *testing.T) {
1378+ r := startMulti(t, func(c *ports.Config) {
1379+ c.Outputs = []ports.OutputConfig{
1380+ {Name: "DP-1"},
1381+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
1382+ }
1383+ }, left, right)
1384+ r.mapWindow(t, 1)
1385+ current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1386+ for _, k := range []string{"Left", "Right", "Left"} {
1387+ r.key(t, k, ports.ModAlt)
1388+ if got := focusedOutput(r, current); got != current {
1389+ t.Fatalf("focus-column %s moved focus from %s to %s", k, current, got)
1390+ }
1391+ }
1392+}
1393+
1394+// A screen turned off by power management is still a neighbor.
1395+func TestPoweredOffScreenStaysNeighbor(t *testing.T) {
1396+ r := startMulti(t, nil, left, right)
1397+ // DP-1 is focused, DP-2 follows it on the right.
1398+ current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1399+ r.client <- ports.OutputPower{Output: "DP-2", On: false}
1400+ _ = receive(t, r.scenes)
1401+ if out := lastOutputs(t, r.commands); current != "DP-1" || !slices.Equal(out.Off, []string{"DP-2"}) {
1402+ t.Fatalf("focused %s, off %v", current, out.Off)
1403+ }
1404+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1405+ if got := focusedOutput(r, current); got != "DP-2" {
1406+ t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)
1407+ }
1408+}

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