fix(core): keep overlapping outputs reachable from monitor navigation
- commit
- ac435585065382d2164c5b9159dd966ce17d54c7
- author
- brice · · 2026-10-03 14:16 UTC
- parent
- 93b73a7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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`). | |||
| 69 | 69 | ||
| 70 | 70 | ## 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 | 74 | Two rows of three, with DP-1 as the base: | |
| 75 | 75 | ||
| @@ -69,7 +69,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 69 | 69 | ||||
| 70 | ## Output placement | 70 | ## 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) { | |||
| 251 | 251 | } | |
| 252 | 252 | ||
| 253 | 253 | // 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 | 256 | // Core places a cycle automatically anyway; runtime overrides never create | |
| 256 | 257 | // relations, so this is the only place to detect them. | |
| 257 | 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 | 286 | warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)}) | |
| 286 | 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 | 301 | return warnings | |
| 289 | 302 | } | |
| 290 | 303 | ||
| @@ -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 the | 253 | // 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 create | 256 | // 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 warnings | 301 | return warnings | ||
| 289 | } | 302 | } | ||
| 290 | 303 | ||||
M internal/adapters/config/config_test.go +4 -0
| @@ -671,6 +671,10 @@ func TestOutputRelationWarnings(t *testing.T) { | |||
| 671 | 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 | 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 | 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 | 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 | 680 | 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 ( | |||
| 58 | 58 | ActionToggleFloating Action = "toggle-floating" | |
| 59 | 59 | ) | |
| 60 | 60 | ||
| 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 | |
| 68 | 75 | } | |
| 69 | 76 | ||
| 70 | 77 | // Resize axes of ResizeArg. | |
| @@ -189,12 +196,12 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 189 | 196 | } | |
| 190 | 197 | switch a { | |
| 191 | 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 | 200 | c.focusScreen = i | |
| 194 | 201 | } | |
| 195 | 202 | return Effect{} | |
| 196 | 203 | case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |
| 197 | - | c.moveWorkspace(monitorDirections[a]) | |
| 204 | + | c.moveWorkspace(monitorDirection(a)) | |
| 198 | 205 | return Effect{} | |
| 199 | 206 | case ActionFocusColumnLeft, ActionFocusColumnRight: | |
| 200 | 207 | 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-monitor | 61 | + | // 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 = i | 200 | 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 { | |||
| 25 | 25 | ||
| 26 | 26 | func TestNeighbor(t *testing.T) { | |
| 27 | 27 | dirs := []direction{dirLeft, dirRight, dirUp, dirDown} | |
| 28 | + | dirNames := [...]string{"left", "right", "up", "down"} | |
| 28 | 29 | // want holds the expected neighbor name for left, right, up, down; "" is | |
| 29 | 30 | // the edge. | |
| 30 | 31 | type step struct { | |
| @@ -71,7 +72,22 @@ func TestNeighbor(t *testing.T) { | |||
| 71 | 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 | 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 | 91 | {"A", [4]string{"", "", "", ""}}, | |
| 76 | 92 | {"B", [4]string{"", "", "", ""}}, | |
| 77 | 93 | }}, | |
| @@ -92,23 +108,25 @@ func TestNeighbor(t *testing.T) { | |||
| 92 | 108 | }}, | |
| 93 | 109 | } | |
| 94 | 110 | 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 | + | } | |
| 106 | 118 | } | |
| 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 | + | } | |
| 109 | 127 | } | |
| 110 | 128 | } | |
| 111 | - | } | |
| 129 | + | }) | |
| 112 | 130 | } | |
| 113 | 131 | if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 { | |
| 114 | 132 | 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; "" is | 29 | // 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 = i | 115 | + | 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 ( | |||
| 415 | 415 | // direction d, or -1 at the edge. Candidates lie fully beyond the focused | |
| 416 | 416 | // screen's edge; one sharing part of that edge wins over a diagonal one, then | |
| 417 | 417 | // 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 | 424 | func (c *Core) neighbor(d direction) int { | |
| 420 | 425 | if d < dirLeft || d > dirDown { | |
| 421 | 426 | return -1 | |
| @@ -429,30 +434,43 @@ func (c *Core) neighbor(d direction) int { | |||
| 429 | 434 | return s.x, s.x + o.W, s.y, s.y + o.H | |
| 430 | 435 | } | |
| 431 | 436 | 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 | + | } | |
| 453 | 467 | } | |
| 468 | + | return best | |
| 469 | + | } | |
| 470 | + | if i := pick(false); i >= 0 { | |
| 471 | + | return i | |
| 454 | 472 | } | |
| 455 | - | return best | |
| 473 | + | return pick(true) | |
| 456 | 474 | } | |
| 457 | 475 | ||
| 458 | 476 | // 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 focused | 415 | // 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, then | 416 | // 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 the | 417 | // 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 -1 | 426 | 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.H | 434 | 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 | - | continue | 440 | + | if i == c.focusScreen || s.name() == "" { |
| 436 | - | } | 441 | + | continue |
| 437 | - | l, h, cl, ch := span(s) | 442 | + | } |
| 438 | - | gap := l - hi | 443 | + | l, h, cl, ch := span(s) |
| 439 | - | if d == dirLeft || d == dirUp { | 444 | + | gap := l - hi |
| 440 | - | gap = lo - h | 445 | + | beyond := l+h > lo+hi // centers, doubled |
| 441 | - | } | 446 | + | if d == dirLeft || d == dirUp { |
| 442 | - | if gap < 0 { | 447 | + | gap = lo - h |
| 443 | - | continue | 448 | + | beyond = l+h < lo+hi |
| 444 | - | } | 449 | + | } |
| 445 | - | overlap := min(chi, ch) - max(clo, cl) | 450 | + | if fallback { |
| 446 | - | diagonal := 0 | 451 | + | if !beyond || min(hi, h)-max(lo, l) <= 0 || min(chi, ch)-max(clo, cl) <= 0 { |
| 447 | - | if overlap <= 0 { | 452 | + | continue |
| 448 | - | diagonal = 1 | 453 | + | } |
| 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, key | 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 | + | } | |||
| 453 | } | 467 | } | ||
| 468 | + | return best | |||
| 469 | + | } | |||
| 470 | + | if i := pick(false); i >= 0 { | |||
| 471 | + | return i | |||
| 454 | } | 472 | } | ||
| 455 | - | return best | 473 | + | return pick(true) |
| 456 | } | 474 | } | ||
| 457 | 475 | ||||
| 458 | // moveWorkspace moves the workspace on screen to the neighbor screen, where | 476 | // 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) { | |||
| 1335 | 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 | + | } | |
| @@ -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 | + | } | |||