detached 7e208c5

feat(output): relative output placement and geometric monitor navigation (#109)

* refactor(core): extract output placement and config lookup

* feat(output): place outputs relative to another with an offset

* fix(config): bound output offsets and tidy relation warning tests

* feat(core): navigate monitors by geometry, add up/down monitor actions

* docs(config): document relative output placement and monitor navigation

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

* refactor(core): document overlap reach limit and parse output relations without a map

* docs(config): show output placement with diagrams and a commented example
commit
7e208c518a60779b6b945fae75c9ec5d0471ab37
author
bnema · · 2026-10-03 14:36 UTC
committer
GitHub · · 2026-10-03 14:36 UTC
parent
819389e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
15 files · +1159 -87
M docs/config.md +59 -2
@@ -56,6 +56,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
5656 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
5757 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
5858 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |
59+| `output.<name>.right-of` / `.left-of` / `.above` / `.below` | automatic | Connector to place the output against, e.g. `DP-1` |
60+| `output.<name>.offset` | `0` | Logical px along the shared edge, −65536 to 65536; see [Output placement](#output-placement) |
5961 | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |
6062 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
6163 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
@@ -65,6 +67,59 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
6567 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
6668 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
6769
70+## Output placement
71+
72+Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet.
73+
74+```ini
75+output.DP-2.right-of = DP-1
76+output.DP-2.offset = 360 # DP-2 starts 360 px lower than DP-1's top
77+```
78+
79+```
80+┌────────────┐
81+│ │┌─────────┐
82+│ DP-1 ││ DP-2 │
83+│ 2560x1440 ││1920x1080│
84+│ │└─────────┘
85+└────────────┘
86+```
87+
88+Rules:
89+
90+- `offset` moves down for `right-of`/`left-of` and right for `above`/`below`; negative moves the other way.
91+- Sizes are logical: the layout follows scale changes on its own.
92+- An output whose reference is not connected is placed automatically until the reference returns.
93+- Cycles and invalid values are ignored with a warning in the log.
94+- Positions set by kanshi, wdisplays or wlr-randr win until the next config reload.
95+- `focus-monitor-*`, `move-workspace-to-monitor-*` and column moves past the edge go to the output on that side.
96+
97+Two rows of three, with DP-1 as the base:
98+
99+```
100+┌────────┐┌────────────┐┌────────┐
101+│ DP-4 ││ HDMI-1 ││ DP-5 │
102+└────────┘└────────────┘└────────┘
103+┌────────┐┌────────────┐┌────────┐
104+│ DP-2 ││ DP-1 ││ DP-3 │
105+└────────┘└────────────┘└────────┘
106+```
107+
108+```ini
109+# Bottom row: 1080p sides, bottoms aligned with DP-1 (1440 - 1080 = 360)
110+output.DP-2.left-of = DP-1
111+output.DP-2.offset = 360
112+output.DP-3.right-of = DP-1
113+output.DP-3.offset = 360
114+
115+# Top row: 1200p sides, bottoms aligned with HDMI-1 (1440 - 1200 = 240)
116+output.HDMI-1.above = DP-1
117+output.DP-4.left-of = HDMI-1
118+output.DP-4.offset = 240
119+output.DP-5.right-of = HDMI-1
120+output.DP-5.offset = 240
121+```
122+
68123 ## Workspace size
69124
70125 A workspace inherits its monitor size unless `workspace.<name>.size` sets a logical `WxH`. Its viewport is centered, and columns, maximization, floating windows and fullscreen use that real layout size—not scaled overview previews. Oversized dimensions are limited to the monitor; reserved layer zones still reduce usable space. Monitor mode and scale stay unchanged. Omit `size` or use `inherit` to restore the default. Workspace transitions involving a smaller viewport switch without the vertical slide, keeping scrolling columns inside their frame. Fullscreen inside a smaller viewport uses composition rather than output-covering direct scanout, so fullscreen-game VRR/tearing eligibility does not apply.
@@ -147,8 +202,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
147202 | `workspace <name>` | Toggle a named workspace |
148203 | `move-column-to-workspace <N>` / `-up/-down` | Move the focused column |
149204 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |
150-| `focus-monitor-left/right` | Focus the neighbor monitor |
151-| `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |
205+| `focus-monitor-left/right` / `focus-monitor-up/down` | Focus the neighbor monitor in that direction |
206+| `move-workspace-to-monitor-left/right` / `move-workspace-to-monitor-up/down` | Move the workspace to the neighbor monitor in that direction; it gets a new home |
152207 | `move-workspace-up/down` | Swap the current numbered workspace with its neighbor; the view follows it. It never passes the empty workspace at the end |
153208 | `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) |
154209 | `quit` | Exit NeferWL |
@@ -259,6 +314,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
259314 | `cmd+shift+code:10` | `move-column-to-workspace 9` |
260315 | `cmd+ctrl+left` | `focus-monitor-left` |
261316 | `cmd+ctrl+right` | `focus-monitor-right` |
317+| `cmd+ctrl+up` / `cmd+ctrl+k` | `focus-monitor-up` |
318+| `cmd+ctrl+down` / `cmd+ctrl+j` | `focus-monitor-down` |
262319 | `cmd+ctrl+h` | `focus-monitor-left` |
263320 | `cmd+ctrl+l` | `focus-monitor-right` |
264321 | `cmd+ctrl+shift+left` | `move-workspace-to-monitor-left` |
@@ -56,6 +56,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
56 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |56 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
57 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |57 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
58 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |58 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |
59+| `output.<name>.right-of` / `.left-of` / `.above` / `.below` | automatic | Connector to place the output against, e.g. `DP-1` |
60+| `output.<name>.offset` | `0` | Logical px along the shared edge, −65536 to 65536; see [Output placement](#output-placement) |
59 | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |61 | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |
60 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |62 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
61 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |63 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
@@ -65,6 +67,59 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
65 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |67 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
66 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |68 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
67 69
70+## Output placement
71+
72+Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet.
73+
74+```ini
75+output.DP-2.right-of = DP-1
76+output.DP-2.offset = 360 # DP-2 starts 360 px lower than DP-1's top
77+```
78+
79+```
80+┌────────────┐
81+│ │┌─────────┐
82+│ DP-1 ││ DP-2 │
83+│ 2560x1440 ││1920x1080│
84+│ │└─────────┘
85+└────────────┘
86+```
87+
88+Rules:
89+
90+- `offset` moves down for `right-of`/`left-of` and right for `above`/`below`; negative moves the other way.
91+- Sizes are logical: the layout follows scale changes on its own.
92+- An output whose reference is not connected is placed automatically until the reference returns.
93+- Cycles and invalid values are ignored with a warning in the log.
94+- Positions set by kanshi, wdisplays or wlr-randr win until the next config reload.
95+- `focus-monitor-*`, `move-workspace-to-monitor-*` and column moves past the edge go to the output on that side.
96+
97+Two rows of three, with DP-1 as the base:
98+
99+```
100+┌────────┐┌────────────┐┌────────┐
101+│ DP-4 ││ HDMI-1 ││ DP-5 │
102+└────────┘└────────────┘└────────┘
103+┌────────┐┌────────────┐┌────────┐
104+│ DP-2 ││ DP-1 ││ DP-3 │
105+└────────┘└────────────┘└────────┘
106+```
107+
108+```ini
109+# Bottom row: 1080p sides, bottoms aligned with DP-1 (1440 - 1080 = 360)
110+output.DP-2.left-of = DP-1
111+output.DP-2.offset = 360
112+output.DP-3.right-of = DP-1
113+output.DP-3.offset = 360
114+
115+# Top row: 1200p sides, bottoms aligned with HDMI-1 (1440 - 1200 = 240)
116+output.HDMI-1.above = DP-1
117+output.DP-4.left-of = HDMI-1
118+output.DP-4.offset = 240
119+output.DP-5.right-of = HDMI-1
120+output.DP-5.offset = 240
121+```
122+
68 ## Workspace size123 ## Workspace size
69 124
70 A workspace inherits its monitor size unless `workspace.<name>.size` sets a logical `WxH`. Its viewport is centered, and columns, maximization, floating windows and fullscreen use that real layout size—not scaled overview previews. Oversized dimensions are limited to the monitor; reserved layer zones still reduce usable space. Monitor mode and scale stay unchanged. Omit `size` or use `inherit` to restore the default. Workspace transitions involving a smaller viewport switch without the vertical slide, keeping scrolling columns inside their frame. Fullscreen inside a smaller viewport uses composition rather than output-covering direct scanout, so fullscreen-game VRR/tearing eligibility does not apply.125 A workspace inherits its monitor size unless `workspace.<name>.size` sets a logical `WxH`. Its viewport is centered, and columns, maximization, floating windows and fullscreen use that real layout size—not scaled overview previews. Oversized dimensions are limited to the monitor; reserved layer zones still reduce usable space. Monitor mode and scale stay unchanged. Omit `size` or use `inherit` to restore the default. Workspace transitions involving a smaller viewport switch without the vertical slide, keeping scrolling columns inside their frame. Fullscreen inside a smaller viewport uses composition rather than output-covering direct scanout, so fullscreen-game VRR/tearing eligibility does not apply.
@@ -147,8 +202,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
147 | `workspace <name>` | Toggle a named workspace |202 | `workspace <name>` | Toggle a named workspace |
148 | `move-column-to-workspace <N>` / `-up/-down` | Move the focused column |203 | `move-column-to-workspace <N>` / `-up/-down` | Move the focused column |
149 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |204 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |
150-| `focus-monitor-left/right` | Focus the neighbor monitor |205+| `focus-monitor-left/right` / `focus-monitor-up/down` | Focus the neighbor monitor in that direction |
151-| `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |206+| `move-workspace-to-monitor-left/right` / `move-workspace-to-monitor-up/down` | Move the workspace to the neighbor monitor in that direction; it gets a new home |
152 | `move-workspace-up/down` | Swap the current numbered workspace with its neighbor; the view follows it. It never passes the empty workspace at the end |207 | `move-workspace-up/down` | Swap the current numbered workspace with its neighbor; the view follows it. It never passes the empty workspace at the end |
153 | `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) |208 | `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) |
154 | `quit` | Exit NeferWL |209 | `quit` | Exit NeferWL |
@@ -259,6 +314,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
259 | `cmd+shift+code:10` | `move-column-to-workspace 9` |314 | `cmd+shift+code:10` | `move-column-to-workspace 9` |
260 | `cmd+ctrl+left` | `focus-monitor-left` |315 | `cmd+ctrl+left` | `focus-monitor-left` |
261 | `cmd+ctrl+right` | `focus-monitor-right` |316 | `cmd+ctrl+right` | `focus-monitor-right` |
317+| `cmd+ctrl+up` / `cmd+ctrl+k` | `focus-monitor-up` |
318+| `cmd+ctrl+down` / `cmd+ctrl+j` | `focus-monitor-down` |
262 | `cmd+ctrl+h` | `focus-monitor-left` |319 | `cmd+ctrl+h` | `focus-monitor-left` |
263 | `cmd+ctrl+l` | `focus-monitor-right` |320 | `cmd+ctrl+l` | `focus-monitor-right` |
264 | `cmd+ctrl+shift+left` | `move-workspace-to-monitor-left` |321 | `cmd+ctrl+shift+left` | `move-workspace-to-monitor-left` |
M internal/adapters/config/config.go +160 -2
@@ -52,6 +52,8 @@ var defaultBinds = []struct{ combo, action string }{
5252 {"cmd+shift+pagedown", "move-column-to-workspace-down"},
5353 {"cmd+ctrl+left", "focus-monitor-left"},
5454 {"cmd+ctrl+right", "focus-monitor-right"},
55+ {"cmd+ctrl+up", "focus-monitor-up"},
56+ {"cmd+ctrl+down", "focus-monitor-down"},
5557 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
5658 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
5759 {"cmd+shift+up", "move-window-up"},
@@ -64,7 +66,47 @@ var defaultBinds = []struct{ combo, action string }{
6466 {"cmd+ctrl+shift+down", "move-workspace-down"},
6567 {"cmd+shift+space", "toggle-floating"},
6668 }
67-var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "toggle-overview": true, "close-window": true, "quit": true, "move-window-up": true, "move-window-down": true, "move-workspace-up": true, "move-workspace-down": true, "toggle-floating": true}
69+var actions = map[string]bool{
70+ "focus-monitor-left": true,
71+ "focus-monitor-right": true,
72+ "move-workspace-to-monitor-left": true,
73+ "move-workspace-to-monitor-right": true,
74+ "focus-monitor-up": true,
75+ "focus-monitor-down": true,
76+ "move-workspace-to-monitor-up": true,
77+ "move-workspace-to-monitor-down": true,
78+ "scale-up": true,
79+ "scale-down": true,
80+ "focus-workspace-up": true,
81+ "focus-workspace-down": true,
82+ "move-column-to-workspace-up": true,
83+ "move-column-to-workspace-down": true,
84+ "move-window-to-workspace-up": true,
85+ "move-window-to-workspace-down": true,
86+ "none": true,
87+ "consume-or-expel-window-left": true,
88+ "consume-or-expel-window-right": true,
89+ "spawn-terminal": true,
90+ "focus-column-left": true,
91+ "focus-column-right": true,
92+ "focus-window-up": true,
93+ "focus-window-down": true,
94+ "move-column-left": true,
95+ "move-column-right": true,
96+ "cycle-column-width": true,
97+ "maximize-column": true,
98+ "toggle-fullscreen": true,
99+ "toggle-window-stash": true,
100+ "toggle-stash-visible": true,
101+ "toggle-overview": true,
102+ "close-window": true,
103+ "quit": true,
104+ "move-window-up": true,
105+ "move-window-down": true,
106+ "move-workspace-up": true,
107+ "move-workspace-down": true,
108+ "toggle-floating": true,
109+}
68110 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
69111 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
70112
@@ -188,6 +230,83 @@ func Load(path string) (ports.Config, []Warning, error) {
188230 return Parse(f)
189231 }
190232
233+// maxOutputOffset bounds output.<name>.offset (logical px), far beyond any
234+// real layout but small enough that placement arithmetic cannot overflow.
235+const maxOutputOffset = 65536
236+
237+// outputRelations lists the relation suffixes, tried in order.
238+var outputRelations = [...]struct {
239+ suffix string
240+ rel ports.OutputRelation
241+}{
242+ {".right-of", ports.RelationRightOf},
243+ {".left-of", ports.RelationLeftOf},
244+ {".above", ports.RelationAbove},
245+ {".below", ports.RelationBelow},
246+}
247+
248+// outputRelation splits "<output>.right-of" (left-of, above, below) into the
249+// relation and the output name.
250+func outputRelation(name string) (ports.OutputRelation, string, bool) {
251+ for _, r := range outputRelations {
252+ if base, ok := strings.CutSuffix(name, r.suffix); ok {
253+ return r.rel, base, true
254+ }
255+ }
256+ return ports.RelationNone, "", false
257+}
258+
259+// checkOutputAnchors warns about an offset without a relation, then drops the
260+// relations that form a cycle (every output of the cycle, one warning each),
261+// and warns about outputs sharing the same relation, reference and offset.
262+// Core places a cycle automatically anyway; runtime overrides never create
263+// relations, so this is the only place to detect them.
264+func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string, offsetLines, seen map[string]int) []Warning {
265+ var warnings []Warning
266+ for _, o := range outputs {
267+ if line, ok := offsetLines[o.Name]; ok && o.Anchor.Relation == ports.RelationNone {
268+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("output.%s.offset: ignored without a relation (right-of, left-of, above, below)", o.Name)})
269+ }
270+ }
271+ byName := make(map[string]*ports.OutputConfig, len(outputs))
272+ for i := range outputs {
273+ byName[outputs[i].Name] = &outputs[i]
274+ }
275+ var cycle []*ports.OutputConfig
276+ for i := range outputs {
277+ for cur, steps := &outputs[i], 0; steps <= len(outputs); steps++ {
278+ if cur.Anchor.Relation == ports.RelationNone {
279+ break
280+ }
281+ if cur = byName[cur.Anchor.To]; cur == nil {
282+ break
283+ }
284+ if cur == &outputs[i] {
285+ cycle = append(cycle, cur)
286+ break
287+ }
288+ }
289+ }
290+ for _, o := range cycle {
291+ key := relKeys[o.Name]
292+ warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)})
293+ o.Anchor.Relation, o.Anchor.To = ports.RelationNone, ""
294+ }
295+ // Two outputs with the same relation, reference and offset overlap.
296+ for i, o := range outputs {
297+ if o.Anchor.Relation == ports.RelationNone {
298+ continue
299+ }
300+ for _, prev := range outputs[:i] {
301+ if prev.Anchor == o.Anchor {
302+ 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)})
303+ break
304+ }
305+ }
306+ }
307+ return warnings
308+}
309+
191310 // Parse reads `key = value` lines on top of the defaults.
192311 func Parse(r io.Reader) (ports.Config, []Warning, error) {
193312 c, _, w, err := parse(r)
@@ -201,6 +320,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
201320 raw := map[string]string{}
202321 seen := map[string]int{}
203322 outputs := map[string]int{}
323+ relKeys := map[string]string{} // output -> key of its relation
324+ offsetLines := map[string]int{} // output -> line of its offset
204325 workspaces := map[string]int{}
205326 scanner := bufio.NewScanner(r)
206327 for n := 1; scanner.Scan(); n++ {
@@ -289,6 +410,42 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
289410 entry().SDRBrightness = nits
290411 continue
291412 }
413+ if base, ok := strings.CutSuffix(name, ".offset"); ok {
414+ name = base
415+ off, err := strconv.Atoi(value)
416+ if err != nil || off < -maxOutputOffset || off > maxOutputOffset {
417+ warn("%s: must be an integer between -%d and %d", key, maxOutputOffset, maxOutputOffset)
418+ continue
419+ }
420+ override()
421+ entry().Anchor.Offset = off
422+ offsetLines[name] = n
423+ continue
424+ }
425+ if rel, base, ok := outputRelation(name); ok {
426+ name = base
427+ if value == "" {
428+ warn("%s: expected an output name", key)
429+ continue
430+ }
431+ if value == name {
432+ warn("%s: an output cannot be placed relative to itself", key)
433+ continue
434+ }
435+ if prev, ok := relKeys[name]; ok && prev != key {
436+ warn("%s: overrides line %d (%s)", key, seen[prev], prev)
437+ delete(raw, prev)
438+ // Stale lines of the other relation keys must not trigger a
439+ // second override warning from override() or a later switch.
440+ delete(seen, prev)
441+ delete(seen, key)
442+ }
443+ override()
444+ relKeys[name] = key
445+ a := &entry().Anchor
446+ a.Relation, a.To = rel, value
447+ continue
448+ }
292449 if base, ok := strings.CutSuffix(name, ".scale"); ok {
293450 name = base
294451 s, err := parseScale(value)
@@ -311,7 +468,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
311468 }
312469 override()
313470 e := entry()
314- o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness
471+ o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
315472 *e = o
316473 continue
317474 }
@@ -353,6 +510,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
353510 if err := scanner.Err(); err != nil {
354511 return c, raw, warnings, err
355512 }
513+ warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
356514 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
357515 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
358516 // A user bind on a digit (cmd+1, even "none") replaces the default bound
@@ -52,6 +52,8 @@ var defaultBinds = []struct{ combo, action string }{
52 {"cmd+shift+pagedown", "move-column-to-workspace-down"},52 {"cmd+shift+pagedown", "move-column-to-workspace-down"},
53 {"cmd+ctrl+left", "focus-monitor-left"},53 {"cmd+ctrl+left", "focus-monitor-left"},
54 {"cmd+ctrl+right", "focus-monitor-right"},54 {"cmd+ctrl+right", "focus-monitor-right"},
55+ {"cmd+ctrl+up", "focus-monitor-up"},
56+ {"cmd+ctrl+down", "focus-monitor-down"},
55 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},57 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
56 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},58 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
57 {"cmd+shift+up", "move-window-up"},59 {"cmd+shift+up", "move-window-up"},
@@ -64,7 +66,47 @@ var defaultBinds = []struct{ combo, action string }{
64 {"cmd+ctrl+shift+down", "move-workspace-down"},66 {"cmd+ctrl+shift+down", "move-workspace-down"},
65 {"cmd+shift+space", "toggle-floating"},67 {"cmd+shift+space", "toggle-floating"},
66 }68 }
67-var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "toggle-overview": true, "close-window": true, "quit": true, "move-window-up": true, "move-window-down": true, "move-workspace-up": true, "move-workspace-down": true, "toggle-floating": true}69+var actions = map[string]bool{
70+ "focus-monitor-left": true,
71+ "focus-monitor-right": true,
72+ "move-workspace-to-monitor-left": true,
73+ "move-workspace-to-monitor-right": true,
74+ "focus-monitor-up": true,
75+ "focus-monitor-down": true,
76+ "move-workspace-to-monitor-up": true,
77+ "move-workspace-to-monitor-down": true,
78+ "scale-up": true,
79+ "scale-down": true,
80+ "focus-workspace-up": true,
81+ "focus-workspace-down": true,
82+ "move-column-to-workspace-up": true,
83+ "move-column-to-workspace-down": true,
84+ "move-window-to-workspace-up": true,
85+ "move-window-to-workspace-down": true,
86+ "none": true,
87+ "consume-or-expel-window-left": true,
88+ "consume-or-expel-window-right": true,
89+ "spawn-terminal": true,
90+ "focus-column-left": true,
91+ "focus-column-right": true,
92+ "focus-window-up": true,
93+ "focus-window-down": true,
94+ "move-column-left": true,
95+ "move-column-right": true,
96+ "cycle-column-width": true,
97+ "maximize-column": true,
98+ "toggle-fullscreen": true,
99+ "toggle-window-stash": true,
100+ "toggle-stash-visible": true,
101+ "toggle-overview": true,
102+ "close-window": true,
103+ "quit": true,
104+ "move-window-up": true,
105+ "move-window-down": true,
106+ "move-workspace-up": true,
107+ "move-workspace-down": true,
108+ "toggle-floating": true,
109+}
68 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}110 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
69 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)111 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
70 112
@@ -188,6 +230,83 @@ func Load(path string) (ports.Config, []Warning, error) {
188 return Parse(f)230 return Parse(f)
189 }231 }
190 232
233+// maxOutputOffset bounds output.<name>.offset (logical px), far beyond any
234+// real layout but small enough that placement arithmetic cannot overflow.
235+const maxOutputOffset = 65536
236+
237+// outputRelations lists the relation suffixes, tried in order.
238+var outputRelations = [...]struct {
239+ suffix string
240+ rel ports.OutputRelation
241+}{
242+ {".right-of", ports.RelationRightOf},
243+ {".left-of", ports.RelationLeftOf},
244+ {".above", ports.RelationAbove},
245+ {".below", ports.RelationBelow},
246+}
247+
248+// outputRelation splits "<output>.right-of" (left-of, above, below) into the
249+// relation and the output name.
250+func outputRelation(name string) (ports.OutputRelation, string, bool) {
251+ for _, r := range outputRelations {
252+ if base, ok := strings.CutSuffix(name, r.suffix); ok {
253+ return r.rel, base, true
254+ }
255+ }
256+ return ports.RelationNone, "", false
257+}
258+
259+// checkOutputAnchors warns about an offset without a relation, then drops the
260+// relations that form a cycle (every output of the cycle, one warning each),
261+// and warns about outputs sharing the same relation, reference and offset.
262+// Core places a cycle automatically anyway; runtime overrides never create
263+// relations, so this is the only place to detect them.
264+func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string, offsetLines, seen map[string]int) []Warning {
265+ var warnings []Warning
266+ for _, o := range outputs {
267+ if line, ok := offsetLines[o.Name]; ok && o.Anchor.Relation == ports.RelationNone {
268+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("output.%s.offset: ignored without a relation (right-of, left-of, above, below)", o.Name)})
269+ }
270+ }
271+ byName := make(map[string]*ports.OutputConfig, len(outputs))
272+ for i := range outputs {
273+ byName[outputs[i].Name] = &outputs[i]
274+ }
275+ var cycle []*ports.OutputConfig
276+ for i := range outputs {
277+ for cur, steps := &outputs[i], 0; steps <= len(outputs); steps++ {
278+ if cur.Anchor.Relation == ports.RelationNone {
279+ break
280+ }
281+ if cur = byName[cur.Anchor.To]; cur == nil {
282+ break
283+ }
284+ if cur == &outputs[i] {
285+ cycle = append(cycle, cur)
286+ break
287+ }
288+ }
289+ }
290+ for _, o := range cycle {
291+ key := relKeys[o.Name]
292+ warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)})
293+ o.Anchor.Relation, o.Anchor.To = ports.RelationNone, ""
294+ }
295+ // Two outputs with the same relation, reference and offset overlap.
296+ for i, o := range outputs {
297+ if o.Anchor.Relation == ports.RelationNone {
298+ continue
299+ }
300+ for _, prev := range outputs[:i] {
301+ if prev.Anchor == o.Anchor {
302+ 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)})
303+ break
304+ }
305+ }
306+ }
307+ return warnings
308+}
309+
191 // Parse reads `key = value` lines on top of the defaults.310 // Parse reads `key = value` lines on top of the defaults.
192 func Parse(r io.Reader) (ports.Config, []Warning, error) {311 func Parse(r io.Reader) (ports.Config, []Warning, error) {
193 c, _, w, err := parse(r)312 c, _, w, err := parse(r)
@@ -201,6 +320,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
201 raw := map[string]string{}320 raw := map[string]string{}
202 seen := map[string]int{}321 seen := map[string]int{}
203 outputs := map[string]int{}322 outputs := map[string]int{}
323+ relKeys := map[string]string{} // output -> key of its relation
324+ offsetLines := map[string]int{} // output -> line of its offset
204 workspaces := map[string]int{}325 workspaces := map[string]int{}
205 scanner := bufio.NewScanner(r)326 scanner := bufio.NewScanner(r)
206 for n := 1; scanner.Scan(); n++ {327 for n := 1; scanner.Scan(); n++ {
@@ -289,6 +410,42 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
289 entry().SDRBrightness = nits410 entry().SDRBrightness = nits
290 continue411 continue
291 }412 }
413+ if base, ok := strings.CutSuffix(name, ".offset"); ok {
414+ name = base
415+ off, err := strconv.Atoi(value)
416+ if err != nil || off < -maxOutputOffset || off > maxOutputOffset {
417+ warn("%s: must be an integer between -%d and %d", key, maxOutputOffset, maxOutputOffset)
418+ continue
419+ }
420+ override()
421+ entry().Anchor.Offset = off
422+ offsetLines[name] = n
423+ continue
424+ }
425+ if rel, base, ok := outputRelation(name); ok {
426+ name = base
427+ if value == "" {
428+ warn("%s: expected an output name", key)
429+ continue
430+ }
431+ if value == name {
432+ warn("%s: an output cannot be placed relative to itself", key)
433+ continue
434+ }
435+ if prev, ok := relKeys[name]; ok && prev != key {
436+ warn("%s: overrides line %d (%s)", key, seen[prev], prev)
437+ delete(raw, prev)
438+ // Stale lines of the other relation keys must not trigger a
439+ // second override warning from override() or a later switch.
440+ delete(seen, prev)
441+ delete(seen, key)
442+ }
443+ override()
444+ relKeys[name] = key
445+ a := &entry().Anchor
446+ a.Relation, a.To = rel, value
447+ continue
448+ }
292 if base, ok := strings.CutSuffix(name, ".scale"); ok {449 if base, ok := strings.CutSuffix(name, ".scale"); ok {
293 name = base450 name = base
294 s, err := parseScale(value)451 s, err := parseScale(value)
@@ -311,7 +468,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
311 }468 }
312 override()469 override()
313 e := entry()470 e := entry()
314- o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness471+ o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
315 *e = o472 *e = o
316 continue473 continue
317 }474 }
@@ -353,6 +510,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
353 if err := scanner.Err(); err != nil {510 if err := scanner.Err(); err != nil {
354 return c, raw, warnings, err511 return c, raw, warnings, err
355 }512 }
513+ warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
356 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)514 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
357 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })515 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
358 // A user bind on a digit (cmd+1, even "none") replaces the default bound516 // A user bind on a digit (cmd+1, even "none") replaces the default bound
M internal/adapters/config/config_test.go +132 -1
@@ -23,7 +23,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
2323 func TestDefaultsAndLoad(t *testing.T) {
2424 t.Setenv("TERMINAL", "")
2525 d := Defaults()
26- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 73 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
26+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 77 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
2727 t.Fatalf("defaults: %+v", d)
2828 }
2929 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
@@ -610,3 +610,134 @@ func TestFloatingDim(t *testing.T) {
610610 })
611611 }
612612 }
613+
614+func TestOutputRelations(t *testing.T) {
615+ c, w := parseString(t, `
616+output.DP-2.right-of = DP-1
617+output.DP-2.offset = 180
618+output.DP-3.left-of = DP-1
619+output.DP-3.offset = -40
620+output.DP-4.above = DP-1
621+output.DP-5.below = DP-1
622+output.DP-5.offset = 7
623+`)
624+ if len(w) != 0 {
625+ t.Fatal(w)
626+ }
627+ want := map[string]ports.OutputAnchor{
628+ "DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
629+ "DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
630+ "DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
631+ "DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
632+ }
633+ if len(c.Outputs) != len(want) {
634+ t.Fatalf("%+v", c.Outputs)
635+ }
636+ for _, o := range c.Outputs {
637+ if o.Anchor != want[o.Name] || !o.ScaleOnly {
638+ t.Fatalf("%s: %+v", o.Name, o)
639+ }
640+ }
641+}
642+
643+func TestOutputRelationOverride(t *testing.T) {
644+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
646+ t.Fatalf("warnings %v", w)
647+ }
648+ if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
649+ t.Fatalf("%+v", got)
650+ }
651+}
652+
653+func TestOutputRelationWarnings(t *testing.T) {
654+ const rangeMsg = "must be an integer between -65536 and 65536"
655+ rel := "output.DP-2.right-of = DP-1\n"
656+ cases := []struct {
657+ name, text string
658+ want []string // expected warning messages (substring), in order
659+ lines []int // expected warning lines, in order
660+ anchored []string // outputs that keep a relation
661+ }{
662+ {"self", "output.DP-2.right-of = DP-2", []string{"relative to itself"}, []int{1}, nil},
663+ {"empty", "output.DP-2.above =", []string{"expected an output name"}, []int{1}, nil},
664+ {"bad offset", rel + "output.DP-2.offset = abc", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
665+ {"offset too large", rel + "output.DP-2.offset = 65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
666+ {"offset too small", rel + "output.DP-2.offset = -65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
667+ {"offset overflow", rel + "output.DP-2.offset = 99999999999999999999", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
668+ {"offset alone", "output.DP-2.offset = 10", []string{"without a relation"}, []int{1}, nil},
669+ {"two cycle", "output.A.right-of = B\noutput.B.below = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, nil},
670+ {"three cycle", "output.A.right-of = B\noutput.B.right-of = C\noutput.C.above = A", []string{"relation cycle", "relation cycle", "relation cycle"}, []int{1, 2, 3}, nil},
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"}},
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"}},
678+ {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
679+ }
680+ for _, tc := range cases {
681+ t.Run(tc.name, func(t *testing.T) {
682+ c, w := parseString(t, tc.text)
683+ if len(w) != len(tc.want) {
684+ t.Fatalf("warnings %v, want %q", w, tc.want)
685+ }
686+ for i, x := range w {
687+ if !strings.Contains(x.Msg, tc.want[i]) || x.Line != tc.lines[i] {
688+ t.Fatalf("warning %d = %+v, want %q at line %d (all: %v)", i, x, tc.want[i], tc.lines[i], w)
689+ }
690+ }
691+ var got []string
692+ for _, o := range c.Outputs {
693+ if o.Anchor.Relation != ports.RelationNone {
694+ got = append(got, o.Name)
695+ }
696+ }
697+ if !reflect.DeepEqual(got, tc.anchored) {
698+ t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
699+ }
700+ })
701+ }
702+}
703+
704+func TestDefaultMonitorBinds(t *testing.T) {
705+ d := Defaults()
706+ for combo, action := range map[string]string{
707+ "Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up",
708+ "Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down",
709+ "Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right",
710+ } {
711+ if d.Binds[combo] != action {
712+ t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action)
713+ }
714+ }
715+ // The up/down move-workspace-to-monitor actions have no default bind but
716+ // load from a config file.
717+ c, w := parseString(t, "bind.cmd+ctrl+alt+up = move-workspace-to-monitor-up\nbind.cmd+ctrl+alt+down = move-workspace-to-monitor-down\nbind.cmd+alt+u = focus-monitor-up\n")
718+ if len(w) != 0 || c.Binds["Alt+Cmd+Ctrl+Up"] != "move-workspace-to-monitor-up" || c.Binds["Alt+Cmd+Ctrl+Down"] != "move-workspace-to-monitor-down" {
719+ t.Fatalf("%v %v", w, c.Binds)
720+ }
721+}
722+
723+func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) {
724+ c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n")
725+ if got := c.Outputs[0].Anchor.Offset; got != 40 {
726+ t.Fatalf("offset %d, want 40", got)
727+ }
728+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 65536\noutput.DP-2.offset = -65536\n")
729+ if len(w) != 1 || c.Outputs[0].Anchor.Offset != -65536 {
730+ t.Fatalf("%v %+v", w, c.Outputs)
731+ }
732+}
733+
734+func TestOutputModeLineKeepsAnchor(t *testing.T) {
735+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
736+ if len(w) != 0 {
737+ t.Fatal(w)
738+ }
739+ o := c.Outputs[0]
740+ if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
741+ t.Fatalf("%+v", o)
742+ }
743+}
@@ -23,7 +23,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
23 func TestDefaultsAndLoad(t *testing.T) {23 func TestDefaultsAndLoad(t *testing.T) {
24 t.Setenv("TERMINAL", "")24 t.Setenv("TERMINAL", "")
25 d := Defaults()25 d := Defaults()
26- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 73 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {26+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 77 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
27 t.Fatalf("defaults: %+v", d)27 t.Fatalf("defaults: %+v", d)
28 }28 }
29 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {29 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
@@ -610,3 +610,134 @@ func TestFloatingDim(t *testing.T) {
610 })610 })
611 }611 }
612 }612 }
613+
614+func TestOutputRelations(t *testing.T) {
615+ c, w := parseString(t, `
616+output.DP-2.right-of = DP-1
617+output.DP-2.offset = 180
618+output.DP-3.left-of = DP-1
619+output.DP-3.offset = -40
620+output.DP-4.above = DP-1
621+output.DP-5.below = DP-1
622+output.DP-5.offset = 7
623+`)
624+ if len(w) != 0 {
625+ t.Fatal(w)
626+ }
627+ want := map[string]ports.OutputAnchor{
628+ "DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
629+ "DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
630+ "DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
631+ "DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
632+ }
633+ if len(c.Outputs) != len(want) {
634+ t.Fatalf("%+v", c.Outputs)
635+ }
636+ for _, o := range c.Outputs {
637+ if o.Anchor != want[o.Name] || !o.ScaleOnly {
638+ t.Fatalf("%s: %+v", o.Name, o)
639+ }
640+ }
641+}
642+
643+func TestOutputRelationOverride(t *testing.T) {
644+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
646+ t.Fatalf("warnings %v", w)
647+ }
648+ if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
649+ t.Fatalf("%+v", got)
650+ }
651+}
652+
653+func TestOutputRelationWarnings(t *testing.T) {
654+ const rangeMsg = "must be an integer between -65536 and 65536"
655+ rel := "output.DP-2.right-of = DP-1\n"
656+ cases := []struct {
657+ name, text string
658+ want []string // expected warning messages (substring), in order
659+ lines []int // expected warning lines, in order
660+ anchored []string // outputs that keep a relation
661+ }{
662+ {"self", "output.DP-2.right-of = DP-2", []string{"relative to itself"}, []int{1}, nil},
663+ {"empty", "output.DP-2.above =", []string{"expected an output name"}, []int{1}, nil},
664+ {"bad offset", rel + "output.DP-2.offset = abc", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
665+ {"offset too large", rel + "output.DP-2.offset = 65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
666+ {"offset too small", rel + "output.DP-2.offset = -65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
667+ {"offset overflow", rel + "output.DP-2.offset = 99999999999999999999", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
668+ {"offset alone", "output.DP-2.offset = 10", []string{"without a relation"}, []int{1}, nil},
669+ {"two cycle", "output.A.right-of = B\noutput.B.below = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, nil},
670+ {"three cycle", "output.A.right-of = B\noutput.B.right-of = C\noutput.C.above = A", []string{"relation cycle", "relation cycle", "relation cycle"}, []int{1, 2, 3}, nil},
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"}},
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"}},
678+ {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
679+ }
680+ for _, tc := range cases {
681+ t.Run(tc.name, func(t *testing.T) {
682+ c, w := parseString(t, tc.text)
683+ if len(w) != len(tc.want) {
684+ t.Fatalf("warnings %v, want %q", w, tc.want)
685+ }
686+ for i, x := range w {
687+ if !strings.Contains(x.Msg, tc.want[i]) || x.Line != tc.lines[i] {
688+ t.Fatalf("warning %d = %+v, want %q at line %d (all: %v)", i, x, tc.want[i], tc.lines[i], w)
689+ }
690+ }
691+ var got []string
692+ for _, o := range c.Outputs {
693+ if o.Anchor.Relation != ports.RelationNone {
694+ got = append(got, o.Name)
695+ }
696+ }
697+ if !reflect.DeepEqual(got, tc.anchored) {
698+ t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
699+ }
700+ })
701+ }
702+}
703+
704+func TestDefaultMonitorBinds(t *testing.T) {
705+ d := Defaults()
706+ for combo, action := range map[string]string{
707+ "Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up",
708+ "Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down",
709+ "Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right",
710+ } {
711+ if d.Binds[combo] != action {
712+ t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action)
713+ }
714+ }
715+ // The up/down move-workspace-to-monitor actions have no default bind but
716+ // load from a config file.
717+ c, w := parseString(t, "bind.cmd+ctrl+alt+up = move-workspace-to-monitor-up\nbind.cmd+ctrl+alt+down = move-workspace-to-monitor-down\nbind.cmd+alt+u = focus-monitor-up\n")
718+ if len(w) != 0 || c.Binds["Alt+Cmd+Ctrl+Up"] != "move-workspace-to-monitor-up" || c.Binds["Alt+Cmd+Ctrl+Down"] != "move-workspace-to-monitor-down" {
719+ t.Fatalf("%v %v", w, c.Binds)
720+ }
721+}
722+
723+func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) {
724+ c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n")
725+ if got := c.Outputs[0].Anchor.Offset; got != 40 {
726+ t.Fatalf("offset %d, want 40", got)
727+ }
728+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 65536\noutput.DP-2.offset = -65536\n")
729+ if len(w) != 1 || c.Outputs[0].Anchor.Offset != -65536 {
730+ t.Fatalf("%v %+v", w, c.Outputs)
731+ }
732+}
733+
734+func TestOutputModeLineKeepsAnchor(t *testing.T) {
735+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
736+ if len(w) != 0 {
737+ t.Fatal(w)
738+ }
739+ o := c.Outputs[0]
740+ if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
741+ t.Fatalf("%+v", o)
742+ }
743+}
M internal/app/outputmanagement_test.go +55 -0
@@ -306,3 +306,58 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
306306 t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307307 }
308308 }
309+
310+// A runtime position never replaces a file relation: it only takes
311+// precedence over it in core, and the relation is followed again once the
312+// override is dropped.
313+func TestOutputApplyKeepsFileAnchor(t *testing.T) {
314+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
315+ anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
316+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
317+ o := newOutputOverrides(file, false)
318+ head := func(name string) ports.OutputHead {
319+ return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
320+ }
321+ o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
322+ change := func(name string, x int) ports.HeadChange {
323+ return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
324+ }
325+ byName := func(cfg ports.Config, name string) ports.OutputConfig {
326+ t.Helper()
327+ var found []ports.OutputConfig
328+ for _, v := range cfg.Outputs {
329+ if v.Name == name {
330+ found = append(found, v)
331+ }
332+ }
333+ if len(found) != 1 {
334+ t.Fatalf("%s entries: %+v", name, cfg.Outputs)
335+ }
336+ return found[0]
337+ }
338+
339+ cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
340+ if err != nil {
341+ t.Fatal(err)
342+ }
343+ dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
344+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
345+ t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
346+ }
347+
348+ cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
353+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
354+ t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
355+ }
356+
357+ // A non scale-only reload drops the overrides: the file relation is back alone.
358+ file.Keyboard.RepeatRate = 33
359+ cfg = o.reload(file)
360+ if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
361+ t.Fatalf("after reload: %+v", cfg.Outputs)
362+ }
363+}
@@ -306,3 +306,58 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
306 t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)306 t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307 }307 }
308 }308 }
309+
310+// A runtime position never replaces a file relation: it only takes
311+// precedence over it in core, and the relation is followed again once the
312+// override is dropped.
313+func TestOutputApplyKeepsFileAnchor(t *testing.T) {
314+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
315+ anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
316+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
317+ o := newOutputOverrides(file, false)
318+ head := func(name string) ports.OutputHead {
319+ return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
320+ }
321+ o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
322+ change := func(name string, x int) ports.HeadChange {
323+ return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
324+ }
325+ byName := func(cfg ports.Config, name string) ports.OutputConfig {
326+ t.Helper()
327+ var found []ports.OutputConfig
328+ for _, v := range cfg.Outputs {
329+ if v.Name == name {
330+ found = append(found, v)
331+ }
332+ }
333+ if len(found) != 1 {
334+ t.Fatalf("%s entries: %+v", name, cfg.Outputs)
335+ }
336+ return found[0]
337+ }
338+
339+ cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
340+ if err != nil {
341+ t.Fatal(err)
342+ }
343+ dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
344+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
345+ t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
346+ }
347+
348+ cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
353+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
354+ t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
355+ }
356+
357+ // A non scale-only reload drops the overrides: the file relation is back alone.
358+ file.Keyboard.RepeatRate = 33
359+ cfg = o.reload(file)
360+ if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
361+ t.Fatalf("after reload: %+v", cfg.Outputs)
362+ }
363+}
M internal/core/action.go +42 -29
@@ -32,11 +32,16 @@ const (
3232 ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down"
3333 ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up"
3434 ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down"
35- // Monitors are ordered left to right (ADR 011).
36- ActionFocusMonitorLeft Action = "focus-monitor-left"
37- ActionFocusMonitorRight Action = "focus-monitor-right"
38- ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"
39- ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"
35+ // Monitors act on the neighbor in that direction of the global layout
36+ // (ADR 011).
37+ ActionFocusMonitorLeft Action = "focus-monitor-left"
38+ ActionFocusMonitorRight Action = "focus-monitor-right"
39+ ActionFocusMonitorUp Action = "focus-monitor-up"
40+ ActionFocusMonitorDown Action = "focus-monitor-down"
41+ ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"
42+ ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"
43+ ActionMoveWorkspaceToMonitorUp Action = "move-workspace-to-monitor-up"
44+ ActionMoveWorkspaceToMonitorDown Action = "move-workspace-to-monitor-down"
4045 // Scale steps through the clean scales of the output (see CleanScales).
4146 ActionScaleUp Action = "scale-up"
4247 ActionScaleDown Action = "scale-down"
@@ -53,6 +58,22 @@ const (
5358 ActionToggleFloating Action = "toggle-floating"
5459 )
5560
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
75+}
76+
5677 // Resize axes of ResizeArg.
5778 const (
5879 ResizeWidth = 0
@@ -173,35 +194,27 @@ func (c *Core) applyAction(a Action) Effect {
173194 if name, ok := NamedArg(a); ok && c.bringNamed(name) {
174195 return Effect{}
175196 }
176- dir := 0
177- switch a {
178- case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
179- dir = -1
180- case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
181- dir = 1
182- }
183197 switch a {
184- case ActionFocusMonitorLeft, ActionFocusMonitorRight:
185- if i := c.neighbor(dir); i >= 0 {
198+ case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
199+ if i := c.neighbor(monitorDirection(a)); i >= 0 {
186200 c.focusScreen = i
187201 }
188202 return Effect{}
189- case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
190- c.moveWorkspace(dir)
203+ case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
204+ c.moveWorkspace(monitorDirection(a))
191205 return Effect{}
192206 case ActionFocusColumnLeft, ActionFocusColumnRight:
193207 w := c.cur().mon.Current()
208+ dir, d := 1, dirRight
194209 if a == ActionFocusColumnLeft {
195- dir = -1
196- } else {
197- dir = 1
210+ dir, d = -1, dirLeft
198211 }
199212 // A covering fullscreen window is left for the window on that side;
200213 // with none (the workspace or stash edge), the move goes to the
201214 // neighbor monitor and fullscreen stays.
202215 if w.pinned() {
203216 if !w.FocusColumn(dir) {
204- if i := c.neighbor(dir); i >= 0 {
217+ if i := c.neighbor(d); i >= 0 {
205218 c.focusScreen = i
206219 }
207220 }
@@ -211,14 +224,14 @@ func (c *Core) applyAction(a Action) Effect {
211224 // a native float leaves for the stash or the columns, and the stash
212225 // keeps the focus at its ends.
213226 edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
214- if i := c.neighbor(dir); edge && i >= 0 {
227+ if i := c.neighbor(d); edge && i >= 0 {
215228 c.focusScreen = i
216229 return Effect{}
217230 }
218231 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
219- dir = 1
232+ dir, d := 1, dirRight
220233 if a == ActionConsumeOrExpelLeft {
221- dir = -1
234+ dir, d = -1, dirLeft
222235 }
223236 from := c.cur()
224237 if from.mon.Current().ConsumeOrExpel(dir) {
@@ -227,7 +240,7 @@ func (c *Core) applyAction(a Action) Effect {
227240 }
228241 // Full fixed workspace, stacked edge column: one hop to the
229242 // neighbor monitor, if any; focus follows the window.
230- i := c.neighbor(dir)
243+ i := c.neighbor(d)
231244 if i < 0 {
232245 return Effect{}
233246 }
@@ -244,12 +257,12 @@ func (c *Core) applyAction(a Action) Effect {
244257 // Past the edge the column moves to the neighbor screen, on the
245258 // side facing this one, and focus follows it.
246259 w := c.cur().mon.Current()
247- dir = 1
260+ d := dirRight
248261 if a == ActionMoveColumnLeft {
249- dir = -1
262+ d = dirLeft
250263 }
251- edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1)
252- if i := c.neighbor(dir); edge && i >= 0 {
264+ edge := (d == dirLeft && w.Focus == 0) || (d == dirRight && w.Focus == len(w.Columns)-1)
265+ if i := c.neighbor(d); edge && i >= 0 {
253266 col, ok := w.takeColumn()
254267 if !ok {
255268 return Effect{}
@@ -258,7 +271,7 @@ func (c *Core) applyAction(a Action) Effect {
258271 c.focusScreen = i
259272 to := c.cur().mon.Current()
260273 at := 0
261- if dir < 0 {
274+ if d == dirLeft {
262275 at = len(to.Columns)
263276 }
264277 to.receive(col, at)
@@ -32,11 +32,16 @@ const (
32 ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down"32 ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down"
33 ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up"33 ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up"
34 ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down"34 ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down"
35- // Monitors are ordered left to right (ADR 011).35+ // Monitors act on the neighbor in that direction of the global layout
36- ActionFocusMonitorLeft Action = "focus-monitor-left"36+ // (ADR 011).
37- ActionFocusMonitorRight Action = "focus-monitor-right"37+ ActionFocusMonitorLeft Action = "focus-monitor-left"
38- ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"38+ ActionFocusMonitorRight Action = "focus-monitor-right"
39- ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"39+ ActionFocusMonitorUp Action = "focus-monitor-up"
40+ ActionFocusMonitorDown Action = "focus-monitor-down"
41+ ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"
42+ ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"
43+ ActionMoveWorkspaceToMonitorUp Action = "move-workspace-to-monitor-up"
44+ ActionMoveWorkspaceToMonitorDown Action = "move-workspace-to-monitor-down"
40 // Scale steps through the clean scales of the output (see CleanScales).45 // Scale steps through the clean scales of the output (see CleanScales).
41 ActionScaleUp Action = "scale-up"46 ActionScaleUp Action = "scale-up"
42 ActionScaleDown Action = "scale-down"47 ActionScaleDown Action = "scale-down"
@@ -53,6 +58,22 @@ const (
53 ActionToggleFloating Action = "toggle-floating"58 ActionToggleFloating Action = "toggle-floating"
54 )59 )
55 60
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
75+}
76+
56 // Resize axes of ResizeArg.77 // Resize axes of ResizeArg.
57 const (78 const (
58 ResizeWidth = 079 ResizeWidth = 0
@@ -173,35 +194,27 @@ func (c *Core) applyAction(a Action) Effect {
173 if name, ok := NamedArg(a); ok && c.bringNamed(name) {194 if name, ok := NamedArg(a); ok && c.bringNamed(name) {
174 return Effect{}195 return Effect{}
175 }196 }
176- dir := 0
177- switch a {
178- case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
179- dir = -1
180- case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
181- dir = 1
182- }
183 switch a {197 switch a {
184- case ActionFocusMonitorLeft, ActionFocusMonitorRight:198+ case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
185- if i := c.neighbor(dir); i >= 0 {199+ if i := c.neighbor(monitorDirection(a)); i >= 0 {
186 c.focusScreen = i200 c.focusScreen = i
187 }201 }
188 return Effect{}202 return Effect{}
189- case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:203+ case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
190- c.moveWorkspace(dir)204+ c.moveWorkspace(monitorDirection(a))
191 return Effect{}205 return Effect{}
192 case ActionFocusColumnLeft, ActionFocusColumnRight:206 case ActionFocusColumnLeft, ActionFocusColumnRight:
193 w := c.cur().mon.Current()207 w := c.cur().mon.Current()
208+ dir, d := 1, dirRight
194 if a == ActionFocusColumnLeft {209 if a == ActionFocusColumnLeft {
195- dir = -1210+ dir, d = -1, dirLeft
196- } else {
197- dir = 1
198 }211 }
199 // A covering fullscreen window is left for the window on that side;212 // A covering fullscreen window is left for the window on that side;
200 // with none (the workspace or stash edge), the move goes to the213 // with none (the workspace or stash edge), the move goes to the
201 // neighbor monitor and fullscreen stays.214 // neighbor monitor and fullscreen stays.
202 if w.pinned() {215 if w.pinned() {
203 if !w.FocusColumn(dir) {216 if !w.FocusColumn(dir) {
204- if i := c.neighbor(dir); i >= 0 {217+ if i := c.neighbor(d); i >= 0 {
205 c.focusScreen = i218 c.focusScreen = i
206 }219 }
207 }220 }
@@ -211,14 +224,14 @@ func (c *Core) applyAction(a Action) Effect {
211 // a native float leaves for the stash or the columns, and the stash224 // a native float leaves for the stash or the columns, and the stash
212 // keeps the focus at its ends.225 // keeps the focus at its ends.
213 edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())226 edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
214- if i := c.neighbor(dir); edge && i >= 0 {227+ if i := c.neighbor(d); edge && i >= 0 {
215 c.focusScreen = i228 c.focusScreen = i
216 return Effect{}229 return Effect{}
217 }230 }
218 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:231 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
219- dir = 1232+ dir, d := 1, dirRight
220 if a == ActionConsumeOrExpelLeft {233 if a == ActionConsumeOrExpelLeft {
221- dir = -1234+ dir, d = -1, dirLeft
222 }235 }
223 from := c.cur()236 from := c.cur()
224 if from.mon.Current().ConsumeOrExpel(dir) {237 if from.mon.Current().ConsumeOrExpel(dir) {
@@ -227,7 +240,7 @@ func (c *Core) applyAction(a Action) Effect {
227 }240 }
228 // Full fixed workspace, stacked edge column: one hop to the241 // Full fixed workspace, stacked edge column: one hop to the
229 // neighbor monitor, if any; focus follows the window.242 // neighbor monitor, if any; focus follows the window.
230- i := c.neighbor(dir)243+ i := c.neighbor(d)
231 if i < 0 {244 if i < 0 {
232 return Effect{}245 return Effect{}
233 }246 }
@@ -244,12 +257,12 @@ func (c *Core) applyAction(a Action) Effect {
244 // Past the edge the column moves to the neighbor screen, on the257 // Past the edge the column moves to the neighbor screen, on the
245 // side facing this one, and focus follows it.258 // side facing this one, and focus follows it.
246 w := c.cur().mon.Current()259 w := c.cur().mon.Current()
247- dir = 1260+ d := dirRight
248 if a == ActionMoveColumnLeft {261 if a == ActionMoveColumnLeft {
249- dir = -1262+ d = dirLeft
250 }263 }
251- edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1)264+ edge := (d == dirLeft && w.Focus == 0) || (d == dirRight && w.Focus == len(w.Columns)-1)
252- if i := c.neighbor(dir); edge && i >= 0 {265+ if i := c.neighbor(d); edge && i >= 0 {
253 col, ok := w.takeColumn()266 col, ok := w.takeColumn()
254 if !ok {267 if !ok {
255 return Effect{}268 return Effect{}
@@ -258,7 +271,7 @@ func (c *Core) applyAction(a Action) Effect {
258 c.focusScreen = i271 c.focusScreen = i
259 to := c.cur().mon.Current()272 to := c.cur().mon.Current()
260 at := 0273 at := 0
261- if dir < 0 {274+ if d == dirLeft {
262 at = len(to.Columns)275 at = len(to.Columns)
263 }276 }
264 to.receive(col, at)277 to.receive(col, at)
M internal/core/core.go +1 -1
@@ -228,7 +228,7 @@ func (c *Core) apply(cfg ports.Config) error {
228228 continue
229229 }
230230 switch Action(a) {
231- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
231+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
232232 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating:
233233 default:
234234 return fmt.Errorf("invalid action %q", a)
@@ -228,7 +228,7 @@ func (c *Core) apply(cfg ports.Config) error {
228 continue228 continue
229 }229 }
230 switch Action(a) {230 switch Action(a) {
231- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,231+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
232 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating:232 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating:
233 default:233 default:
234 return fmt.Errorf("invalid action %q", a)234 return fmt.Errorf("invalid action %q", a)
A internal/core/neighbor_test.go +139 -0
@@ -0,0 +1,139 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+type tile struct {
10+ name string
11+ x, y, w, h int
12+}
13+
14+// layoutCore builds a core whose screens sit at the given logical rectangles,
15+// in order, with the screen at index focus focused.
16+func layoutCore(focus int, tiles ...tile) *Core {
17+ c := &Core{focusScreen: focus}
18+ for _, t := range tiles {
19+ s := &screen{info: ports.OutputInfo{Name: t.name}, mon: newMonitor(t.name, ""), x: t.x, y: t.y}
20+ s.mon.SetOutput(t.w, t.h)
21+ c.screens = append(c.screens, s)
22+ }
23+ return c
24+}
25+
26+func TestNeighbor(t *testing.T) {
27+ dirs := []direction{dirLeft, dirRight, dirUp, dirDown}
28+ dirNames := [...]string{"left", "right", "up", "down"}
29+ // want holds the expected neighbor name for left, right, up, down; "" is
30+ // the edge.
31+ type step struct {
32+ from string
33+ want [4]string
34+ }
35+ cases := []struct {
36+ name string
37+ tiles []tile
38+ steps []step
39+ }{
40+ {"row of three in connection order", []tile{{"B", 200, 0, 200, 100}, {"A", 0, 0, 200, 100}, {"C", 400, 0, 200, 100}}, []step{
41+ {"A", [4]string{"", "B", "", ""}},
42+ {"B", [4]string{"A", "C", "", ""}},
43+ {"C", [4]string{"B", "", "", ""}},
44+ }},
45+ {"column of two", []tile{{"A", 0, 0, 200, 100}, {"B", 0, 100, 200, 100}}, []step{
46+ {"A", [4]string{"", "", "", "B"}},
47+ {"B", [4]string{"", "", "A", ""}},
48+ }},
49+ {"row with y offsets", []tile{{"A", 0, 0, 200, 100}, {"B", 200, 60, 200, 100}, {"C", 400, 120, 200, 100}}, []step{
50+ // A and C share no edge range: only diagonal vertical neighbors.
51+ {"A", [4]string{"", "B", "", "C"}},
52+ {"B", [4]string{"A", "C", "", ""}},
53+ {"C", [4]string{"B", "", "A", ""}},
54+ }},
55+ {"diagonal only", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}}, []step{
56+ {"A", [4]string{"", "B", "", "B"}},
57+ {"B", [4]string{"A", "", "A", ""}},
58+ }},
59+ {"shared edge beats nearer diagonal", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}, {"C", 300, 50, 100, 100}}, []step{
60+ {"A", [4]string{"", "C", "", "B"}},
61+ }},
62+ {"smaller gap wins", []tile{{"A", 0, 0, 100, 100}, {"far", 300, 0, 100, 100}, {"near", 150, 0, 100, 100}}, []step{
63+ {"A", [4]string{"", "near", "", ""}},
64+ }},
65+ {"larger shared edge wins at equal gap", []tile{{"A", 0, 0, 100, 100}, {"little", 100, 80, 100, 100}, {"big", 100, 20, 100, 60}}, []step{
66+ {"A", [4]string{"", "big", "", ""}},
67+ }},
68+ {"closer center wins at equal overlap", []tile{{"A", 0, 0, 100, 100}, {"low", 100, 50, 100, 100}, {"mid", 100, -20, 100, 70}}, []step{
69+ // Both share 50 rows; mid's center is nearer to A's.
70+ {"A", [4]string{"", "mid", "", ""}},
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{
73+ {"A", [4]string{"", "first", "", ""}},
74+ }},
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+ {"same-height outputs anchored to the same reference: one is unreachable by direction", []tile{{"R", 0, 0, 100, 100}, {"Y", 100, 0, 300, 100}, {"X", 100, 0, 100, 100}}, []step{
87+ {"R", [4]string{"", "Y", "", ""}},
88+ {"Y", [4]string{"R", "", "", ""}},
89+ {"X", [4]string{"R", "Y", "", ""}},
90+ }},
91+ {"strict candidate beats a closer overlapping one", []tile{{"A", 0, 0, 100, 100}, {"near", 50, 0, 100, 100}, {"far", 300, 0, 100, 100}}, []step{
92+ {"A", [4]string{"", "far", "", ""}},
93+ {"near", [4]string{"A", "far", "", ""}},
94+ }},
95+ {"identical centers are unreachable", []tile{{"A", 0, 0, 100, 100}, {"B", 0, 0, 100, 100}}, []step{
96+ {"A", [4]string{"", "", "", ""}},
97+ {"B", [4]string{"", "", "", ""}},
98+ }},
99+ {"placeholder ignored", []tile{{"A", 0, 0, 100, 100}, {"", 100, 0, 100, 100}, {"B", 300, 0, 100, 100}}, []step{
100+ {"A", [4]string{"", "B", "", ""}},
101+ {"B", [4]string{"A", "", "", ""}},
102+ }},
103+ {"two by three grid", []tile{
104+ {"DP-1", 0, 0, 2560, 1440}, {"DP-2", -1920, 360, 1920, 1080}, {"DP-3", 2560, 360, 1920, 1080},
105+ {"HDMI-1", 0, -1440, 2560, 1440}, {"DP-4", -1920, -1200, 1920, 1200}, {"DP-5", 2560, -1200, 1920, 1200},
106+ }, []step{
107+ {"DP-4", [4]string{"", "HDMI-1", "", "DP-2"}},
108+ {"HDMI-1", [4]string{"DP-4", "DP-5", "", "DP-1"}},
109+ {"DP-5", [4]string{"HDMI-1", "", "", "DP-3"}},
110+ {"DP-2", [4]string{"", "DP-1", "DP-4", ""}},
111+ {"DP-1", [4]string{"DP-2", "DP-3", "HDMI-1", ""}},
112+ {"DP-3", [4]string{"DP-1", "", "DP-5", ""}},
113+ }},
114+ }
115+ for _, tc := range cases {
116+ t.Run(tc.name, func(t *testing.T) {
117+ for _, st := range tc.steps {
118+ c := layoutCore(0, tc.tiles...)
119+ for i, s := range c.screens {
120+ if s.name() == st.from {
121+ c.focusScreen = i
122+ }
123+ }
124+ for i, d := range dirs {
125+ got := ""
126+ if n := c.neighbor(d); n >= 0 {
127+ got = c.screens[n].name()
128+ }
129+ if got != st.want[i] {
130+ t.Errorf("from %s %s: got %q, want %q", st.from, dirNames[i], got, st.want[i])
131+ }
132+ }
133+ }
134+ })
135+ }
136+ if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 {
137+ t.Fatalf("invalid direction: %d", n)
138+ }
139+}
@@ -0,0 +1,139 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+type tile struct {
10+ name string
11+ x, y, w, h int
12+}
13+
14+// layoutCore builds a core whose screens sit at the given logical rectangles,
15+// in order, with the screen at index focus focused.
16+func layoutCore(focus int, tiles ...tile) *Core {
17+ c := &Core{focusScreen: focus}
18+ for _, t := range tiles {
19+ s := &screen{info: ports.OutputInfo{Name: t.name}, mon: newMonitor(t.name, ""), x: t.x, y: t.y}
20+ s.mon.SetOutput(t.w, t.h)
21+ c.screens = append(c.screens, s)
22+ }
23+ return c
24+}
25+
26+func TestNeighbor(t *testing.T) {
27+ dirs := []direction{dirLeft, dirRight, dirUp, dirDown}
28+ dirNames := [...]string{"left", "right", "up", "down"}
29+ // want holds the expected neighbor name for left, right, up, down; "" is
30+ // the edge.
31+ type step struct {
32+ from string
33+ want [4]string
34+ }
35+ cases := []struct {
36+ name string
37+ tiles []tile
38+ steps []step
39+ }{
40+ {"row of three in connection order", []tile{{"B", 200, 0, 200, 100}, {"A", 0, 0, 200, 100}, {"C", 400, 0, 200, 100}}, []step{
41+ {"A", [4]string{"", "B", "", ""}},
42+ {"B", [4]string{"A", "C", "", ""}},
43+ {"C", [4]string{"B", "", "", ""}},
44+ }},
45+ {"column of two", []tile{{"A", 0, 0, 200, 100}, {"B", 0, 100, 200, 100}}, []step{
46+ {"A", [4]string{"", "", "", "B"}},
47+ {"B", [4]string{"", "", "A", ""}},
48+ }},
49+ {"row with y offsets", []tile{{"A", 0, 0, 200, 100}, {"B", 200, 60, 200, 100}, {"C", 400, 120, 200, 100}}, []step{
50+ // A and C share no edge range: only diagonal vertical neighbors.
51+ {"A", [4]string{"", "B", "", "C"}},
52+ {"B", [4]string{"A", "C", "", ""}},
53+ {"C", [4]string{"B", "", "A", ""}},
54+ }},
55+ {"diagonal only", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}}, []step{
56+ {"A", [4]string{"", "B", "", "B"}},
57+ {"B", [4]string{"A", "", "A", ""}},
58+ }},
59+ {"shared edge beats nearer diagonal", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}, {"C", 300, 50, 100, 100}}, []step{
60+ {"A", [4]string{"", "C", "", "B"}},
61+ }},
62+ {"smaller gap wins", []tile{{"A", 0, 0, 100, 100}, {"far", 300, 0, 100, 100}, {"near", 150, 0, 100, 100}}, []step{
63+ {"A", [4]string{"", "near", "", ""}},
64+ }},
65+ {"larger shared edge wins at equal gap", []tile{{"A", 0, 0, 100, 100}, {"little", 100, 80, 100, 100}, {"big", 100, 20, 100, 60}}, []step{
66+ {"A", [4]string{"", "big", "", ""}},
67+ }},
68+ {"closer center wins at equal overlap", []tile{{"A", 0, 0, 100, 100}, {"low", 100, 50, 100, 100}, {"mid", 100, -20, 100, 70}}, []step{
69+ // Both share 50 rows; mid's center is nearer to A's.
70+ {"A", [4]string{"", "mid", "", ""}},
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{
73+ {"A", [4]string{"", "first", "", ""}},
74+ }},
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+ {"same-height outputs anchored to the same reference: one is unreachable by direction", []tile{{"R", 0, 0, 100, 100}, {"Y", 100, 0, 300, 100}, {"X", 100, 0, 100, 100}}, []step{
87+ {"R", [4]string{"", "Y", "", ""}},
88+ {"Y", [4]string{"R", "", "", ""}},
89+ {"X", [4]string{"R", "Y", "", ""}},
90+ }},
91+ {"strict candidate beats a closer overlapping one", []tile{{"A", 0, 0, 100, 100}, {"near", 50, 0, 100, 100}, {"far", 300, 0, 100, 100}}, []step{
92+ {"A", [4]string{"", "far", "", ""}},
93+ {"near", [4]string{"A", "far", "", ""}},
94+ }},
95+ {"identical centers are unreachable", []tile{{"A", 0, 0, 100, 100}, {"B", 0, 0, 100, 100}}, []step{
96+ {"A", [4]string{"", "", "", ""}},
97+ {"B", [4]string{"", "", "", ""}},
98+ }},
99+ {"placeholder ignored", []tile{{"A", 0, 0, 100, 100}, {"", 100, 0, 100, 100}, {"B", 300, 0, 100, 100}}, []step{
100+ {"A", [4]string{"", "B", "", ""}},
101+ {"B", [4]string{"A", "", "", ""}},
102+ }},
103+ {"two by three grid", []tile{
104+ {"DP-1", 0, 0, 2560, 1440}, {"DP-2", -1920, 360, 1920, 1080}, {"DP-3", 2560, 360, 1920, 1080},
105+ {"HDMI-1", 0, -1440, 2560, 1440}, {"DP-4", -1920, -1200, 1920, 1200}, {"DP-5", 2560, -1200, 1920, 1200},
106+ }, []step{
107+ {"DP-4", [4]string{"", "HDMI-1", "", "DP-2"}},
108+ {"HDMI-1", [4]string{"DP-4", "DP-5", "", "DP-1"}},
109+ {"DP-5", [4]string{"HDMI-1", "", "", "DP-3"}},
110+ {"DP-2", [4]string{"", "DP-1", "DP-4", ""}},
111+ {"DP-1", [4]string{"DP-2", "DP-3", "HDMI-1", ""}},
112+ {"DP-3", [4]string{"DP-1", "", "DP-5", ""}},
113+ }},
114+ }
115+ for _, tc := range cases {
116+ t.Run(tc.name, func(t *testing.T) {
117+ for _, st := range tc.steps {
118+ c := layoutCore(0, tc.tiles...)
119+ for i, s := range c.screens {
120+ if s.name() == st.from {
121+ c.focusScreen = i
122+ }
123+ }
124+ for i, d := range dirs {
125+ got := ""
126+ if n := c.neighbor(d); n >= 0 {
127+ got = c.screens[n].name()
128+ }
129+ if got != st.want[i] {
130+ t.Errorf("from %s %s: got %q, want %q", st.from, dirNames[i], got, st.want[i])
131+ }
132+ }
133+ }
134+ })
135+ }
136+ if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 {
137+ t.Fatalf("invalid direction: %d", n)
138+ }
139+}
M internal/core/overview.go +1 -0
@@ -191,6 +191,7 @@ func overviewBlocks(a Action) bool {
191191 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
192192 ActionToggleStashVisible, ActionConsumeOrExpelLeft,
193193 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
194+ ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
194195 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp,
195196 ActionMoveWorkspaceDown, ActionToggleFloating:
196197 return true
@@ -191,6 +191,7 @@ func overviewBlocks(a Action) bool {
191 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,191 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
192 ActionToggleStashVisible, ActionConsumeOrExpelLeft,192 ActionToggleStashVisible, ActionConsumeOrExpelLeft,
193 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,193 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
194+ ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
194 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp,195 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp,
195 ActionMoveWorkspaceDown, ActionToggleFloating:196 ActionMoveWorkspaceDown, ActionToggleFloating:
196 return true197 return true
M internal/core/overview_regression_test.go +1 -0
@@ -127,6 +127,7 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) {
127127 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
128128 ActionToggleStashVisible, ActionConsumeOrExpelLeft,
129129 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
130+ ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
130131 }
131132 for _, a := range actions {
132133 t.Run(string(a), func(t *testing.T) {
@@ -127,6 +127,7 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) {
127 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,127 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
128 ActionToggleStashVisible, ActionConsumeOrExpelLeft,128 ActionToggleStashVisible, ActionConsumeOrExpelLeft,
129 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,129 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
130+ ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
130 }131 }
131 for _, a := range actions {132 for _, a := range actions {
132 t.Run(string(a), func(t *testing.T) {133 t.Run(string(a), func(t *testing.T) {
A internal/core/placement.go +89 -0
@@ -0,0 +1,89 @@
1+package core
2+
3+import (
4+ "image"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// placeItem is one output to place: its connector name, logical size, the
10+// explicit origin when one is set (pos: explicit origin set at runtime by
11+// wlr-output-management, not a config key) and the output.<name> relation to
12+// another output.
13+type placeItem struct {
14+ name string
15+ w, h int
16+ pos *image.Point
17+ anchor ports.OutputAnchor
18+}
19+
20+// placeOutputs returns the logical origin of every item, in item order.
21+// Precedence: an explicit position, then the relation to another placed
22+// output, then automatic placement: from the rightmost edge placed so far
23+// (never left of 0), at y=0. Automatic placement runs only when no relation
24+// can be resolved, so an anchored output never lands on an automatic one.
25+// A relation to an absent output, or in a cycle, falls back to automatic.
26+func placeOutputs(items []placeItem) []image.Point {
27+ out := make([]image.Point, len(items))
28+ done := make([]bool, len(items))
29+ left := len(items)
30+ for i, it := range items {
31+ if it.pos != nil {
32+ out[i], done[i] = *it.pos, true
33+ left--
34+ }
35+ }
36+ anchored := func(i int) bool { return !done[i] && items[i].anchor.Relation != ports.RelationNone }
37+ for left > 0 {
38+ for progress := true; progress; {
39+ progress = false
40+ for i, it := range items {
41+ if !anchored(i) {
42+ continue
43+ }
44+ for j, ref := range items {
45+ if !done[j] || ref.name == "" || ref.name != it.anchor.To {
46+ continue
47+ }
48+ out[i], done[i] = relativeTo(it, ref, out[j]), true
49+ left--
50+ progress = true
51+ break
52+ }
53+ }
54+ }
55+ if left == 0 {
56+ break
57+ }
58+ next := -1
59+ for i := range items {
60+ if !done[i] && (next < 0 || (anchored(next) && !anchored(i))) {
61+ next = i
62+ }
63+ }
64+ x := 0
65+ for i, it := range items {
66+ if done[i] {
67+ x = max(x, out[i].X+it.w)
68+ }
69+ }
70+ out[next], done[next] = image.Point{X: x}, true
71+ left--
72+ }
73+ return out
74+}
75+
76+// relativeTo is the origin of it placed against ref, whose origin is at.
77+func relativeTo(it, ref placeItem, at image.Point) image.Point {
78+ off := it.anchor.Offset
79+ switch it.anchor.Relation {
80+ case ports.RelationRightOf:
81+ return image.Point{X: at.X + ref.w, Y: at.Y + off}
82+ case ports.RelationLeftOf:
83+ return image.Point{X: at.X - it.w, Y: at.Y + off}
84+ case ports.RelationAbove:
85+ return image.Point{X: at.X + off, Y: at.Y - it.h}
86+ default: // RelationBelow
87+ return image.Point{X: at.X + off, Y: at.Y + ref.h}
88+ }
89+}
@@ -0,0 +1,89 @@
1+package core
2+
3+import (
4+ "image"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// placeItem is one output to place: its connector name, logical size, the
10+// explicit origin when one is set (pos: explicit origin set at runtime by
11+// wlr-output-management, not a config key) and the output.<name> relation to
12+// another output.
13+type placeItem struct {
14+ name string
15+ w, h int
16+ pos *image.Point
17+ anchor ports.OutputAnchor
18+}
19+
20+// placeOutputs returns the logical origin of every item, in item order.
21+// Precedence: an explicit position, then the relation to another placed
22+// output, then automatic placement: from the rightmost edge placed so far
23+// (never left of 0), at y=0. Automatic placement runs only when no relation
24+// can be resolved, so an anchored output never lands on an automatic one.
25+// A relation to an absent output, or in a cycle, falls back to automatic.
26+func placeOutputs(items []placeItem) []image.Point {
27+ out := make([]image.Point, len(items))
28+ done := make([]bool, len(items))
29+ left := len(items)
30+ for i, it := range items {
31+ if it.pos != nil {
32+ out[i], done[i] = *it.pos, true
33+ left--
34+ }
35+ }
36+ anchored := func(i int) bool { return !done[i] && items[i].anchor.Relation != ports.RelationNone }
37+ for left > 0 {
38+ for progress := true; progress; {
39+ progress = false
40+ for i, it := range items {
41+ if !anchored(i) {
42+ continue
43+ }
44+ for j, ref := range items {
45+ if !done[j] || ref.name == "" || ref.name != it.anchor.To {
46+ continue
47+ }
48+ out[i], done[i] = relativeTo(it, ref, out[j]), true
49+ left--
50+ progress = true
51+ break
52+ }
53+ }
54+ }
55+ if left == 0 {
56+ break
57+ }
58+ next := -1
59+ for i := range items {
60+ if !done[i] && (next < 0 || (anchored(next) && !anchored(i))) {
61+ next = i
62+ }
63+ }
64+ x := 0
65+ for i, it := range items {
66+ if done[i] {
67+ x = max(x, out[i].X+it.w)
68+ }
69+ }
70+ out[next], done[next] = image.Point{X: x}, true
71+ left--
72+ }
73+ return out
74+}
75+
76+// relativeTo is the origin of it placed against ref, whose origin is at.
77+func relativeTo(it, ref placeItem, at image.Point) image.Point {
78+ off := it.anchor.Offset
79+ switch it.anchor.Relation {
80+ case ports.RelationRightOf:
81+ return image.Point{X: at.X + ref.w, Y: at.Y + off}
82+ case ports.RelationLeftOf:
83+ return image.Point{X: at.X - it.w, Y: at.Y + off}
84+ case ports.RelationAbove:
85+ return image.Point{X: at.X + off, Y: at.Y - it.h}
86+ default: // RelationBelow
87+ return image.Point{X: at.X + off, Y: at.Y + ref.h}
88+ }
89+}
A internal/core/placement_test.go +169 -0
@@ -0,0 +1,169 @@
1+package core
2+
3+import (
4+ "image"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestPlaceOutputs(t *testing.T) {
12+ at := func(x, y int) *image.Point { return &image.Point{X: x, Y: y} }
13+ rel := func(r ports.OutputRelation, to string, off int) ports.OutputAnchor {
14+ return ports.OutputAnchor{Relation: r, To: to, Offset: off}
15+ }
16+ const (
17+ rightOf = ports.RelationRightOf
18+ leftOf = ports.RelationLeftOf
19+ above = ports.RelationAbove
20+ below = ports.RelationBelow
21+ )
22+ tests := []struct {
23+ name string
24+ items []placeItem
25+ want []image.Point
26+ }{
27+ {"none", nil, []image.Point{}},
28+ {"one automatic", []placeItem{{name: "A", w: 400, h: 300}}, []image.Point{{X: 0, Y: 0}}},
29+ {
30+ "three automatic",
31+ []placeItem{{name: "A", w: 400, h: 300}, {name: "B", w: 200, h: 300}, {name: "C", w: 300, h: 300}},
32+ []image.Point{{X: 0, Y: 0}, {X: 400, Y: 0}, {X: 600, Y: 0}},
33+ },
34+ {
35+ "explicit position then automatic",
36+ []placeItem{{name: "DP-1", w: 400, h: 300}, {name: "DP-2", w: 400, h: 300, pos: at(100, 40)}},
37+ []image.Point{{X: 500, Y: 0}, {X: 100, Y: 40}},
38+ },
39+ {
40+ "explicit negative x leaves automatic at zero",
41+ []placeItem{{name: "A", w: 300, h: 300}, {name: "B", w: 400, h: 300, pos: at(-500, 0)}},
42+ []image.Point{{X: 0, Y: 0}, {X: -500, Y: 0}},
43+ },
44+ {
45+ "all explicit with negative position",
46+ []placeItem{{name: "A", w: 400, h: 300, pos: at(-200, 12)}, {name: "B", w: 400, h: 300, pos: at(20, -120)}},
47+ []image.Point{{X: -200, Y: 12}, {X: 20, Y: -120}},
48+ },
49+ {
50+ "automatic after explicit origins at both ends",
51+ []placeItem{{name: "A", w: 400, h: 300, pos: at(1000, 0)}, {name: "B", w: 400, h: 300, pos: at(0, 0)}, {name: "C", w: 300, h: 300}},
52+ []image.Point{{X: 1000, Y: 0}, {X: 0, Y: 0}, {X: 1400, Y: 0}},
53+ },
54+ {
55+ "automatic around an explicit origin",
56+ []placeItem{{name: "A", w: 400, h: 300}, {name: "B", w: 800, h: 300, pos: at(0, 500)}, {name: "C", w: 300, h: 300}},
57+ []image.Point{{X: 800, Y: 0}, {X: 0, Y: 500}, {X: 1200, Y: 0}},
58+ },
59+ {
60+ "right-of with offset",
61+ []placeItem{
62+ {name: "DP-1", w: 2560, h: 1440},
63+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 180)},
64+ },
65+ []image.Point{{X: 0, Y: 0}, {X: 2560, Y: 180}},
66+ },
67+ {
68+ "left-of with negative offset",
69+ []placeItem{
70+ {name: "DP-1", w: 2560, h: 1440, pos: at(100, 50)},
71+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", -30)},
72+ },
73+ []image.Point{{X: 100, Y: 50}, {X: -1820, Y: 20}},
74+ },
75+ {
76+ "above",
77+ []placeItem{
78+ {name: "DP-1", w: 2560, h: 1440},
79+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(above, "DP-1", 40)},
80+ },
81+ []image.Point{{X: 0, Y: 0}, {X: 40, Y: -1080}},
82+ },
83+ {
84+ "below",
85+ []placeItem{
86+ {name: "DP-1", w: 2560, h: 1440},
87+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(below, "DP-1", -40)},
88+ },
89+ []image.Point{{X: 0, Y: 0}, {X: -40, Y: 1440}},
90+ },
91+ {
92+ "chain resolved against item order",
93+ []placeItem{
94+ {name: "C", w: 100, h: 100, anchor: rel(rightOf, "B", 0)},
95+ {name: "B", w: 200, h: 100, anchor: rel(below, "A", 10)},
96+ {name: "A", w: 300, h: 150, pos: at(0, 0)},
97+ },
98+ []image.Point{{X: 210, Y: 150}, {X: 10, Y: 150}, {X: 0, Y: 0}},
99+ },
100+ {
101+ "two by three grid",
102+ []placeItem{
103+ {name: "DP-1", w: 2560, h: 1440},
104+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", 360)},
105+ {name: "DP-3", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 360)},
106+ {name: "HDMI-1", w: 2560, h: 1440, anchor: rel(above, "DP-1", 0)},
107+ {name: "DP-4", w: 1920, h: 1200, anchor: rel(leftOf, "HDMI-1", 240)},
108+ {name: "DP-5", w: 1920, h: 1200, anchor: rel(rightOf, "HDMI-1", 240)},
109+ },
110+ []image.Point{{X: 0, Y: 0}, {X: -1920, Y: 360}, {X: 2560, Y: 360}, {X: 0, Y: -1440}, {X: -1920, Y: -1200}, {X: 2560, Y: -1200}},
111+ },
112+ {
113+ "missing reference is automatic, after the plain automatic ones",
114+ []placeItem{
115+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "GONE", 50)},
116+ {name: "B", w: 200, h: 300},
117+ },
118+ []image.Point{{X: 200, Y: 0}, {X: 0, Y: 0}},
119+ },
120+ {
121+ "missing reference left of a negative origin stays right of zero",
122+ []placeItem{
123+ {name: "A", w: 400, h: 300, pos: at(-900, 0)},
124+ {name: "B", w: 200, h: 300, anchor: rel(above, "GONE", 0)},
125+ },
126+ []image.Point{{X: -900, Y: 0}, {X: 0, Y: 0}},
127+ },
128+ {
129+ "cycle is broken at its first output, the rest follows it",
130+ []placeItem{
131+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "B", 0)},
132+ {name: "B", w: 200, h: 300, anchor: rel(below, "A", 0)},
133+ },
134+ []image.Point{{X: 0, Y: 0}, {X: 0, Y: 300}},
135+ },
136+ {
137+ "explicit origin beats relation",
138+ []placeItem{
139+ {name: "A", w: 400, h: 300},
140+ {name: "B", w: 200, h: 300, pos: at(7, 9), anchor: rel(rightOf, "A", 0)},
141+ },
142+ []image.Point{{X: 207, Y: 0}, {X: 7, Y: 9}},
143+ },
144+ {
145+ "relation follows an explicit origin",
146+ []placeItem{
147+ {name: "A", w: 400, h: 300, pos: at(1000, 200)},
148+ {name: "B", w: 200, h: 300, anchor: rel(rightOf, "A", 5)},
149+ },
150+ []image.Point{{X: 1000, Y: 200}, {X: 1400, Y: 205}},
151+ },
152+ {
153+ "anchored output is not overlapped by a later automatic one",
154+ []placeItem{
155+ {name: "A", w: 400, h: 300},
156+ {name: "B", w: 200, h: 300},
157+ {name: "C", w: 300, h: 300, anchor: rel(rightOf, "A", 0)},
158+ },
159+ []image.Point{{X: 0, Y: 0}, {X: 700, Y: 0}, {X: 400, Y: 0}},
160+ },
161+ }
162+ for _, tc := range tests {
163+ t.Run(tc.name, func(t *testing.T) {
164+ if got := placeOutputs(tc.items); !slices.Equal(got, tc.want) {
165+ t.Fatalf("got %v, want %v", got, tc.want)
166+ }
167+ })
168+ }
169+}
@@ -0,0 +1,169 @@
1+package core
2+
3+import (
4+ "image"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestPlaceOutputs(t *testing.T) {
12+ at := func(x, y int) *image.Point { return &image.Point{X: x, Y: y} }
13+ rel := func(r ports.OutputRelation, to string, off int) ports.OutputAnchor {
14+ return ports.OutputAnchor{Relation: r, To: to, Offset: off}
15+ }
16+ const (
17+ rightOf = ports.RelationRightOf
18+ leftOf = ports.RelationLeftOf
19+ above = ports.RelationAbove
20+ below = ports.RelationBelow
21+ )
22+ tests := []struct {
23+ name string
24+ items []placeItem
25+ want []image.Point
26+ }{
27+ {"none", nil, []image.Point{}},
28+ {"one automatic", []placeItem{{name: "A", w: 400, h: 300}}, []image.Point{{X: 0, Y: 0}}},
29+ {
30+ "three automatic",
31+ []placeItem{{name: "A", w: 400, h: 300}, {name: "B", w: 200, h: 300}, {name: "C", w: 300, h: 300}},
32+ []image.Point{{X: 0, Y: 0}, {X: 400, Y: 0}, {X: 600, Y: 0}},
33+ },
34+ {
35+ "explicit position then automatic",
36+ []placeItem{{name: "DP-1", w: 400, h: 300}, {name: "DP-2", w: 400, h: 300, pos: at(100, 40)}},
37+ []image.Point{{X: 500, Y: 0}, {X: 100, Y: 40}},
38+ },
39+ {
40+ "explicit negative x leaves automatic at zero",
41+ []placeItem{{name: "A", w: 300, h: 300}, {name: "B", w: 400, h: 300, pos: at(-500, 0)}},
42+ []image.Point{{X: 0, Y: 0}, {X: -500, Y: 0}},
43+ },
44+ {
45+ "all explicit with negative position",
46+ []placeItem{{name: "A", w: 400, h: 300, pos: at(-200, 12)}, {name: "B", w: 400, h: 300, pos: at(20, -120)}},
47+ []image.Point{{X: -200, Y: 12}, {X: 20, Y: -120}},
48+ },
49+ {
50+ "automatic after explicit origins at both ends",
51+ []placeItem{{name: "A", w: 400, h: 300, pos: at(1000, 0)}, {name: "B", w: 400, h: 300, pos: at(0, 0)}, {name: "C", w: 300, h: 300}},
52+ []image.Point{{X: 1000, Y: 0}, {X: 0, Y: 0}, {X: 1400, Y: 0}},
53+ },
54+ {
55+ "automatic around an explicit origin",
56+ []placeItem{{name: "A", w: 400, h: 300}, {name: "B", w: 800, h: 300, pos: at(0, 500)}, {name: "C", w: 300, h: 300}},
57+ []image.Point{{X: 800, Y: 0}, {X: 0, Y: 500}, {X: 1200, Y: 0}},
58+ },
59+ {
60+ "right-of with offset",
61+ []placeItem{
62+ {name: "DP-1", w: 2560, h: 1440},
63+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 180)},
64+ },
65+ []image.Point{{X: 0, Y: 0}, {X: 2560, Y: 180}},
66+ },
67+ {
68+ "left-of with negative offset",
69+ []placeItem{
70+ {name: "DP-1", w: 2560, h: 1440, pos: at(100, 50)},
71+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", -30)},
72+ },
73+ []image.Point{{X: 100, Y: 50}, {X: -1820, Y: 20}},
74+ },
75+ {
76+ "above",
77+ []placeItem{
78+ {name: "DP-1", w: 2560, h: 1440},
79+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(above, "DP-1", 40)},
80+ },
81+ []image.Point{{X: 0, Y: 0}, {X: 40, Y: -1080}},
82+ },
83+ {
84+ "below",
85+ []placeItem{
86+ {name: "DP-1", w: 2560, h: 1440},
87+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(below, "DP-1", -40)},
88+ },
89+ []image.Point{{X: 0, Y: 0}, {X: -40, Y: 1440}},
90+ },
91+ {
92+ "chain resolved against item order",
93+ []placeItem{
94+ {name: "C", w: 100, h: 100, anchor: rel(rightOf, "B", 0)},
95+ {name: "B", w: 200, h: 100, anchor: rel(below, "A", 10)},
96+ {name: "A", w: 300, h: 150, pos: at(0, 0)},
97+ },
98+ []image.Point{{X: 210, Y: 150}, {X: 10, Y: 150}, {X: 0, Y: 0}},
99+ },
100+ {
101+ "two by three grid",
102+ []placeItem{
103+ {name: "DP-1", w: 2560, h: 1440},
104+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", 360)},
105+ {name: "DP-3", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 360)},
106+ {name: "HDMI-1", w: 2560, h: 1440, anchor: rel(above, "DP-1", 0)},
107+ {name: "DP-4", w: 1920, h: 1200, anchor: rel(leftOf, "HDMI-1", 240)},
108+ {name: "DP-5", w: 1920, h: 1200, anchor: rel(rightOf, "HDMI-1", 240)},
109+ },
110+ []image.Point{{X: 0, Y: 0}, {X: -1920, Y: 360}, {X: 2560, Y: 360}, {X: 0, Y: -1440}, {X: -1920, Y: -1200}, {X: 2560, Y: -1200}},
111+ },
112+ {
113+ "missing reference is automatic, after the plain automatic ones",
114+ []placeItem{
115+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "GONE", 50)},
116+ {name: "B", w: 200, h: 300},
117+ },
118+ []image.Point{{X: 200, Y: 0}, {X: 0, Y: 0}},
119+ },
120+ {
121+ "missing reference left of a negative origin stays right of zero",
122+ []placeItem{
123+ {name: "A", w: 400, h: 300, pos: at(-900, 0)},
124+ {name: "B", w: 200, h: 300, anchor: rel(above, "GONE", 0)},
125+ },
126+ []image.Point{{X: -900, Y: 0}, {X: 0, Y: 0}},
127+ },
128+ {
129+ "cycle is broken at its first output, the rest follows it",
130+ []placeItem{
131+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "B", 0)},
132+ {name: "B", w: 200, h: 300, anchor: rel(below, "A", 0)},
133+ },
134+ []image.Point{{X: 0, Y: 0}, {X: 0, Y: 300}},
135+ },
136+ {
137+ "explicit origin beats relation",
138+ []placeItem{
139+ {name: "A", w: 400, h: 300},
140+ {name: "B", w: 200, h: 300, pos: at(7, 9), anchor: rel(rightOf, "A", 0)},
141+ },
142+ []image.Point{{X: 207, Y: 0}, {X: 7, Y: 9}},
143+ },
144+ {
145+ "relation follows an explicit origin",
146+ []placeItem{
147+ {name: "A", w: 400, h: 300, pos: at(1000, 200)},
148+ {name: "B", w: 200, h: 300, anchor: rel(rightOf, "A", 5)},
149+ },
150+ []image.Point{{X: 1000, Y: 200}, {X: 1400, Y: 205}},
151+ },
152+ {
153+ "anchored output is not overlapped by a later automatic one",
154+ []placeItem{
155+ {name: "A", w: 400, h: 300},
156+ {name: "B", w: 200, h: 300},
157+ {name: "C", w: 300, h: 300, anchor: rel(rightOf, "A", 0)},
158+ },
159+ []image.Point{{X: 0, Y: 0}, {X: 700, Y: 0}, {X: 400, Y: 0}},
160+ },
161+ }
162+ for _, tc := range tests {
163+ t.Run(tc.name, func(t *testing.T) {
164+ if got := placeOutputs(tc.items); !slices.Equal(got, tc.want) {
165+ t.Fatalf("got %v, want %v", got, tc.want)
166+ }
167+ })
168+ }
169+}
M internal/core/screens.go +105 -47
@@ -7,8 +7,8 @@ import (
77 )
88
99 // screen is one connected output: its monitor tree, scale, layer surfaces and
10-// place in the global layout (ADR 011). Outputs are placed left to right, in
11-// config order (output.<name>.*), then in connection order.
10+// place in the global layout (ADR 011). Outputs are ordered by config
11+// (output.<name>.*), then by connection; order places them (placeOutputs).
1212 type screen struct {
1313 info ports.OutputInfo
1414 mon *Monitor
@@ -49,46 +49,46 @@ func (s *screen) arrange() {
4949 s.mon.SetUsable(usable)
5050 }
5151
52-// order sorts screens by config order, then connection order, and assigns x.
52+// order sorts screens by config order, then connection order, and places them
53+// in the global layout (placeOutputs).
5354 func (c *Core) order() {
5455 rank := func(s *screen) int {
55- for i, o := range c.cfg.Outputs {
56- if o.Name == s.name() {
57- return i
58- }
56+ if i := c.outputIndex(s.name()); i >= 0 {
57+ return i
5958 }
6059 return len(c.cfg.Outputs)
6160 }
6261 focused := c.cur()
6362 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
6463 c.focusScreen = slices.Index(c.screens, focused)
65- // Explicitly placed outputs reserve their position first; auto-placed
66- // outputs follow the rightmost explicit edge in configuration order.
67- x := 0
68- for _, s := range c.screens {
69- for _, o := range c.cfg.Outputs {
70- if o.Name == s.name() && o.Pos != nil {
71- s.x, s.y = o.Pos.X, o.Pos.Y
72- x = max(x, s.x+s.mon.Output().W)
73- break
74- }
64+ items := make([]placeItem, len(c.screens))
65+ for i, s := range c.screens {
66+ o := s.mon.Output()
67+ items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H
68+ if cfg, ok := c.outputConfig(s.name()); ok {
69+ items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
7570 }
7671 }
77- for _, s := range c.screens {
78- explicit := false
79- for _, o := range c.cfg.Outputs {
80- if o.Name == s.name() && o.Pos != nil {
81- explicit = true
82- break
83- }
84- }
85- if !explicit {
86- s.x, s.y = x, 0
87- x += s.mon.Output().W
88- }
72+ for i, p := range placeOutputs(items) {
73+ c.screens[i].x, c.screens[i].y = p.X, p.Y
8974 }
9075 }
9176
77+// outputIndex is the position of the output.<name> entry in the config, or
78+// -1; names are unique in the config.
79+func (c *Core) outputIndex(name string) int {
80+ return slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })
81+}
82+
83+// outputConfig returns the output.<name> entry.
84+func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
85+ i := c.outputIndex(name)
86+ if i < 0 {
87+ return ports.OutputConfig{}, false
88+ }
89+ return c.cfg.Outputs[i], true
90+}
91+
9292 // layout lists the connected outputs; the placeholder is not one.
9393 func (c *Core) layout() ports.Layout {
9494 l := make(ports.Layout, 0, len(c.screens))
@@ -129,12 +129,8 @@ func (c *Core) byName(name string) (*screen, *Workspace) {
129129
130130 // isPrimary reports output.<name>.primary.
131131 func (c *Core) isPrimary(name string) bool {
132- for _, o := range c.cfg.Outputs {
133- if o.Name == name && o.Primary {
134- return true
135- }
136- }
137- return false
132+ o, _ := c.outputConfig(name)
133+ return o.Primary
138134 }
139135
140136 // anyWindow reports whether any workspace holds a window.
@@ -152,10 +148,8 @@ func (c *Core) anyWindow() bool {
152148 // configScale is output.<name>.scale, else 1.
153149 func (c *Core) configScale(name string) float64 {
154150 v := 1.0
155- for _, o := range c.cfg.Outputs {
156- if o.Name == name && o.Scale != 0 {
157- v = o.Scale
158- }
151+ if o, ok := c.outputConfig(name); ok && o.Scale != 0 {
152+ v = o.Scale
159153 }
160154 return SnapScale(v)
161155 }
@@ -407,20 +401,84 @@ func (c *Core) settings(m *Monitor) {
407401 m.SetFollowMove(c.cfg.Focus.FollowMove)
408402 }
409403
410-// neighbor returns the screen index left (-1) or right (+1) of the focused
411-// one, or -1 at the edge.
412-func (c *Core) neighbor(dir int) int {
413- i := c.focusScreen + dir
414- if i < 0 || i >= len(c.screens) {
404+// direction is where a monitor action looks for a neighbor screen.
405+type direction uint8
406+
407+const (
408+ dirLeft direction = iota + 1
409+ dirRight
410+ dirUp
411+ dirDown
412+)
413+
414+// neighbor returns the index of the screen next to the focused one in
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
417+// the smallest gap, the largest shared edge, the closest centers and the
418+// lowest index decide. Overlapping outputs (for instance two anchored to the
419+// same reference) are reached only when no screen lies fully beyond the edge
420+// in that direction: a second pass then takes the screens whose rectangle
421+// intersects the focused one and whose center is strictly beyond the focused
422+// center, ranked the same way with a gap of 0. A screen that does have a
423+// strict candidate never falls back, so an overlapping one can stay out of
424+// reach. Screens with the same center on the axis are unreachable by direction
425+// (the pointer still reaches them). There is no wrap.
426+func (c *Core) neighbor(d direction) int {
427+ if d < dirLeft || d > dirDown {
415428 return -1
416429 }
417- return i
430+ // span is the extent of s along the move (lo, hi) and across it.
431+ span := func(s *screen) (lo, hi, clo, chi int) {
432+ o := s.mon.Output()
433+ if d == dirUp || d == dirDown {
434+ return s.y, s.y + o.H, s.x, s.x + o.W
435+ }
436+ return s.x, s.x + o.W, s.y, s.y + o.H
437+ }
438+ lo, hi, clo, chi := span(c.cur())
439+ pick := func(fallback bool) int {
440+ best, bestKey := -1, [4]int{}
441+ for i, s := range c.screens {
442+ if i == c.focusScreen || s.name() == "" {
443+ continue
444+ }
445+ l, h, cl, ch := span(s)
446+ gap := l - hi
447+ beyond := l+h > lo+hi // centers, doubled
448+ if d == dirLeft || d == dirUp {
449+ gap = lo - h
450+ beyond = l+h < lo+hi
451+ }
452+ if fallback {
453+ if !beyond || min(hi, h)-max(lo, l) <= 0 || min(chi, ch)-max(clo, cl) <= 0 {
454+ continue
455+ }
456+ gap = 0
457+ } else if gap < 0 {
458+ continue
459+ }
460+ overlap := min(chi, ch) - max(clo, cl)
461+ diagonal := 0
462+ if overlap <= 0 {
463+ diagonal = 1
464+ }
465+ key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}
466+ if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {
467+ best, bestKey = i, key
468+ }
469+ }
470+ return best
471+ }
472+ if i := pick(false); i >= 0 {
473+ return i
474+ }
475+ return pick(true)
418476 }
419477
420478 // moveWorkspace moves the workspace on screen to the neighbor screen, where
421479 // it is appended and shown; it becomes its new home (ADR 011).
422-func (c *Core) moveWorkspace(dir int) {
423- to := c.neighbor(dir)
480+func (c *Core) moveWorkspace(d direction) {
481+ to := c.neighbor(d)
424482 if to < 0 {
425483 return
426484 }
@@ -7,8 +7,8 @@ import (
7 )7 )
8 8
9 // screen is one connected output: its monitor tree, scale, layer surfaces and9 // screen is one connected output: its monitor tree, scale, layer surfaces and
10-// place in the global layout (ADR 011). Outputs are placed left to right, in10+// place in the global layout (ADR 011). Outputs are ordered by config
11-// config order (output.<name>.*), then in connection order.11+// (output.<name>.*), then by connection; order places them (placeOutputs).
12 type screen struct {12 type screen struct {
13 info ports.OutputInfo13 info ports.OutputInfo
14 mon *Monitor14 mon *Monitor
@@ -49,46 +49,46 @@ func (s *screen) arrange() {
49 s.mon.SetUsable(usable)49 s.mon.SetUsable(usable)
50 }50 }
51 51
52-// order sorts screens by config order, then connection order, and assigns x.52+// order sorts screens by config order, then connection order, and places them
53+// in the global layout (placeOutputs).
53 func (c *Core) order() {54 func (c *Core) order() {
54 rank := func(s *screen) int {55 rank := func(s *screen) int {
55- for i, o := range c.cfg.Outputs {56+ if i := c.outputIndex(s.name()); i >= 0 {
56- if o.Name == s.name() {57+ return i
57- return i
58- }
59 }58 }
60 return len(c.cfg.Outputs)59 return len(c.cfg.Outputs)
61 }60 }
62 focused := c.cur()61 focused := c.cur()
63 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })62 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
64 c.focusScreen = slices.Index(c.screens, focused)63 c.focusScreen = slices.Index(c.screens, focused)
65- // Explicitly placed outputs reserve their position first; auto-placed64+ items := make([]placeItem, len(c.screens))
66- // outputs follow the rightmost explicit edge in configuration order.65+ for i, s := range c.screens {
67- x := 066+ o := s.mon.Output()
68- for _, s := range c.screens {67+ items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H
69- for _, o := range c.cfg.Outputs {68+ if cfg, ok := c.outputConfig(s.name()); ok {
70- if o.Name == s.name() && o.Pos != nil {69+ items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
71- s.x, s.y = o.Pos.X, o.Pos.Y
72- x = max(x, s.x+s.mon.Output().W)
73- break
74- }
75 }70 }
76 }71 }
77- for _, s := range c.screens {72+ for i, p := range placeOutputs(items) {
78- explicit := false73+ c.screens[i].x, c.screens[i].y = p.X, p.Y
79- for _, o := range c.cfg.Outputs {
80- if o.Name == s.name() && o.Pos != nil {
81- explicit = true
82- break
83- }
84- }
85- if !explicit {
86- s.x, s.y = x, 0
87- x += s.mon.Output().W
88- }
89 }74 }
90 }75 }
91 76
77+// outputIndex is the position of the output.<name> entry in the config, or
78+// -1; names are unique in the config.
79+func (c *Core) outputIndex(name string) int {
80+ return slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })
81+}
82+
83+// outputConfig returns the output.<name> entry.
84+func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
85+ i := c.outputIndex(name)
86+ if i < 0 {
87+ return ports.OutputConfig{}, false
88+ }
89+ return c.cfg.Outputs[i], true
90+}
91+
92 // layout lists the connected outputs; the placeholder is not one.92 // layout lists the connected outputs; the placeholder is not one.
93 func (c *Core) layout() ports.Layout {93 func (c *Core) layout() ports.Layout {
94 l := make(ports.Layout, 0, len(c.screens))94 l := make(ports.Layout, 0, len(c.screens))
@@ -129,12 +129,8 @@ func (c *Core) byName(name string) (*screen, *Workspace) {
129 129
130 // isPrimary reports output.<name>.primary.130 // isPrimary reports output.<name>.primary.
131 func (c *Core) isPrimary(name string) bool {131 func (c *Core) isPrimary(name string) bool {
132- for _, o := range c.cfg.Outputs {132+ o, _ := c.outputConfig(name)
133- if o.Name == name && o.Primary {133+ return o.Primary
134- return true
135- }
136- }
137- return false
138 }134 }
139 135
140 // anyWindow reports whether any workspace holds a window.136 // anyWindow reports whether any workspace holds a window.
@@ -152,10 +148,8 @@ func (c *Core) anyWindow() bool {
152 // configScale is output.<name>.scale, else 1.148 // configScale is output.<name>.scale, else 1.
153 func (c *Core) configScale(name string) float64 {149 func (c *Core) configScale(name string) float64 {
154 v := 1.0150 v := 1.0
155- for _, o := range c.cfg.Outputs {151+ if o, ok := c.outputConfig(name); ok && o.Scale != 0 {
156- if o.Name == name && o.Scale != 0 {152+ v = o.Scale
157- v = o.Scale
158- }
159 }153 }
160 return SnapScale(v)154 return SnapScale(v)
161 }155 }
@@ -407,20 +401,84 @@ func (c *Core) settings(m *Monitor) {
407 m.SetFollowMove(c.cfg.Focus.FollowMove)401 m.SetFollowMove(c.cfg.Focus.FollowMove)
408 }402 }
409 403
410-// neighbor returns the screen index left (-1) or right (+1) of the focused404+// direction is where a monitor action looks for a neighbor screen.
411-// one, or -1 at the edge.405+type direction uint8
412-func (c *Core) neighbor(dir int) int {406+
413- i := c.focusScreen + dir407+const (
414- if i < 0 || i >= len(c.screens) {408+ dirLeft direction = iota + 1
409+ dirRight
410+ dirUp
411+ dirDown
412+)
413+
414+// neighbor returns the index of the screen next to the focused one in
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
417+// the smallest gap, the largest shared edge, the closest centers and the
418+// lowest index decide. Overlapping outputs (for instance two anchored to the
419+// same reference) are reached only when no screen lies fully beyond the edge
420+// in that direction: a second pass then takes the screens whose rectangle
421+// intersects the focused one and whose center is strictly beyond the focused
422+// center, ranked the same way with a gap of 0. A screen that does have a
423+// strict candidate never falls back, so an overlapping one can stay out of
424+// reach. Screens with the same center on the axis are unreachable by direction
425+// (the pointer still reaches them). There is no wrap.
426+func (c *Core) neighbor(d direction) int {
427+ if d < dirLeft || d > dirDown {
415 return -1428 return -1
416 }429 }
417- return i430+ // span is the extent of s along the move (lo, hi) and across it.
431+ span := func(s *screen) (lo, hi, clo, chi int) {
432+ o := s.mon.Output()
433+ if d == dirUp || d == dirDown {
434+ return s.y, s.y + o.H, s.x, s.x + o.W
435+ }
436+ return s.x, s.x + o.W, s.y, s.y + o.H
437+ }
438+ lo, hi, clo, chi := span(c.cur())
439+ pick := func(fallback bool) int {
440+ best, bestKey := -1, [4]int{}
441+ for i, s := range c.screens {
442+ if i == c.focusScreen || s.name() == "" {
443+ continue
444+ }
445+ l, h, cl, ch := span(s)
446+ gap := l - hi
447+ beyond := l+h > lo+hi // centers, doubled
448+ if d == dirLeft || d == dirUp {
449+ gap = lo - h
450+ beyond = l+h < lo+hi
451+ }
452+ if fallback {
453+ if !beyond || min(hi, h)-max(lo, l) <= 0 || min(chi, ch)-max(clo, cl) <= 0 {
454+ continue
455+ }
456+ gap = 0
457+ } else if gap < 0 {
458+ continue
459+ }
460+ overlap := min(chi, ch) - max(clo, cl)
461+ diagonal := 0
462+ if overlap <= 0 {
463+ diagonal = 1
464+ }
465+ key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)}
466+ if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 {
467+ best, bestKey = i, key
468+ }
469+ }
470+ return best
471+ }
472+ if i := pick(false); i >= 0 {
473+ return i
474+ }
475+ return pick(true)
418 }476 }
419 477
420 // moveWorkspace moves the workspace on screen to the neighbor screen, where478 // moveWorkspace moves the workspace on screen to the neighbor screen, where
421 // it is appended and shown; it becomes its new home (ADR 011).479 // it is appended and shown; it becomes its new home (ADR 011).
422-func (c *Core) moveWorkspace(dir int) {480+func (c *Core) moveWorkspace(d direction) {
423- to := c.neighbor(dir)481+ to := c.neighbor(d)
424 if to < 0 {482 if to < 0 {
425 return483 return
426 }484 }
M internal/core/screens_test.go +176 -0
@@ -207,6 +207,60 @@ func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
207207 }
208208 }
209209
210+func TestRelativeOutputPlacement(t *testing.T) {
211+ r := startMulti(t, func(c *ports.Config) {
212+ c.Outputs = []ports.OutputConfig{
213+ {Name: "DP-1"},
214+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1", Offset: 30}},
215+ {Name: "DP-3", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-2", Offset: -10}},
216+ }
217+ }, left, right, third)
218+ positions := func() [][2]int {
219+ t.Helper()
220+ var got [][2]int
221+ for _, o := range lastOutputs(t, r.commands).Outputs {
222+ got = append(got, [2]int{o.X, o.Y})
223+ }
224+ return got
225+ }
226+ // DP-1 is 200x100, DP-2 400x200 logical.
227+ if got, want := positions(), [][2]int{{0, 0}, {30, 100}, {430, 90}}; !slices.Equal(got, want) {
228+ t.Fatalf("positions %v, want %v", got, want)
229+ }
230+ // A reload with another offset moves the output.
231+ r.cfg.Outputs[1].Anchor.Offset = -50
232+ r.reload <- ports.ConfigChanged{Config: r.cfg}
233+ _ = receive(t, r.scenes)
234+ if got, want := positions(), [][2]int{{0, 0}, {-50, 100}, {350, 90}}; !slices.Equal(got, want) {
235+ t.Fatalf("positions after offset reload %v, want %v", got, want)
236+ }
237+ // Scaling the reference down moves the output below it.
238+ r.cfg.Outputs[0].Scale = 2
239+ r.reload <- ports.ConfigChanged{Config: r.cfg}
240+ _ = receive(t, r.scenes)
241+ if got, want := positions(), [][2]int{{0, 0}, {-50, 50}, {350, 40}}; !slices.Equal(got, want) {
242+ t.Fatalf("positions after scale reload %v, want %v", got, want)
243+ }
244+}
245+
246+func TestRelativeOutputWithoutReferenceIsAutomatic(t *testing.T) {
247+ r := startMulti(t, func(c *ports.Config) {
248+ c.Outputs = []ports.OutputConfig{{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 25}}}
249+ }, right)
250+ if out := lastOutputs(t, r.commands).Outputs; len(out) != 1 || out[0].X != 0 || out[0].Y != 0 {
251+ t.Fatalf("without reference: %+v", out)
252+ }
253+ // Plugging the reference restores the relation (DP-1 is 200 wide).
254+ r.plug(t, left)
255+ out := lastOutputs(t, r.commands).Outputs
256+ if len(out) != 2 || out[0].Info.Name != "DP-2" || out[1].Info.Name != "DP-1" {
257+ t.Fatalf("outputs %+v", out)
258+ }
259+ if out[0].X != 200 || out[0].Y != 25 || out[1].X != 0 || out[1].Y != 0 {
260+ t.Fatalf("placement %+v", out)
261+ }
262+}
263+
210264 func TestWindowsOpenOnFocusedOutput(t *testing.T) {
211265 r := startMulti(t, nil, left, right)
212266 r.mapWindow(t, 1)
@@ -297,6 +351,54 @@ func TestMoveWorkspaceToMonitor(t *testing.T) {
297351 }
298352 }
299353
354+func TestMonitorActionsFollowGeometry(t *testing.T) {
355+ r := startMulti(t, func(c *ports.Config) {
356+ c.Outputs = []ports.OutputConfig{
357+ {Name: "DP-1"},
358+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
359+ }
360+ // Without a default bind; these replace move-workspace-up/down.
361+ c.Binds["Cmd+Ctrl+Shift+Up"] = string(core.ActionMoveWorkspaceToMonitorUp)
362+ c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown)
363+ }, left, right)
364+ // SetOutputs is sent only when the focus or layout changed.
365+ current := lastOutputs(t, r.commands).Focused
366+ focused := func() string {
367+ for len(r.commands) > 0 {
368+ if v, ok := (<-r.commands).(ports.SetOutputs); ok {
369+ current = v.Focused
370+ }
371+ }
372+ return current
373+ }
374+ cmdCtrl := ports.ModAlt | ports.ModCtrl
375+ // DP-1 is focused and has no neighbor left, right or up.
376+ r.mapWindow(t, 1)
377+ for _, k := range []string{"Left", "Right", "Up"} {
378+ r.key(t, k, cmdCtrl)
379+ if got := focused(); got != "DP-1" {
380+ t.Fatalf("%s moved focus to %s", k, got)
381+ }
382+ }
383+ r.key(t, "Down", cmdCtrl)
384+ if got := focused(); got != "DP-2" {
385+ t.Fatalf("focus-monitor-down: %s", got)
386+ }
387+ r.key(t, "k", cmdCtrl)
388+ if got := focused(); got != "DP-1" {
389+ t.Fatalf("focus-monitor-up (vim twin): %s", got)
390+ }
391+ // The workspace goes to the monitor below and focus follows it.
392+ set := r.key(t, "Down", cmdCtrl|ports.ModShift)
393+ if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
394+ t.Fatal(got)
395+ }
396+ set = r.key(t, "Up", cmdCtrl|ports.ModShift)
397+ if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
398+ t.Fatalf("move-workspace-to-monitor-up: %v", got)
399+ }
400+}
401+
300402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
301403 r := startMulti(t, nil, left, right)
302404 r.mapWindow(t, 1)
@@ -1233,3 +1335,77 @@ func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) {
12331335 t.Fatal("focus not on DP-1:", got)
12341336 }
12351337 }
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 current != "DP-1" {
1402+ t.Fatalf("focused %s, want DP-1", current)
1403+ }
1404+ if out := lastOutputs(t, r.commands); !slices.Equal(out.Off, []string{"DP-2"}) {
1405+ t.Fatalf("off %v, want [DP-2]", out.Off)
1406+ }
1407+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1408+ if got := focusedOutput(r, current); got != "DP-2" {
1409+ t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)
1410+ }
1411+}
@@ -207,6 +207,60 @@ func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
207 }207 }
208 }208 }
209 209
210+func TestRelativeOutputPlacement(t *testing.T) {
211+ r := startMulti(t, func(c *ports.Config) {
212+ c.Outputs = []ports.OutputConfig{
213+ {Name: "DP-1"},
214+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1", Offset: 30}},
215+ {Name: "DP-3", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-2", Offset: -10}},
216+ }
217+ }, left, right, third)
218+ positions := func() [][2]int {
219+ t.Helper()
220+ var got [][2]int
221+ for _, o := range lastOutputs(t, r.commands).Outputs {
222+ got = append(got, [2]int{o.X, o.Y})
223+ }
224+ return got
225+ }
226+ // DP-1 is 200x100, DP-2 400x200 logical.
227+ if got, want := positions(), [][2]int{{0, 0}, {30, 100}, {430, 90}}; !slices.Equal(got, want) {
228+ t.Fatalf("positions %v, want %v", got, want)
229+ }
230+ // A reload with another offset moves the output.
231+ r.cfg.Outputs[1].Anchor.Offset = -50
232+ r.reload <- ports.ConfigChanged{Config: r.cfg}
233+ _ = receive(t, r.scenes)
234+ if got, want := positions(), [][2]int{{0, 0}, {-50, 100}, {350, 90}}; !slices.Equal(got, want) {
235+ t.Fatalf("positions after offset reload %v, want %v", got, want)
236+ }
237+ // Scaling the reference down moves the output below it.
238+ r.cfg.Outputs[0].Scale = 2
239+ r.reload <- ports.ConfigChanged{Config: r.cfg}
240+ _ = receive(t, r.scenes)
241+ if got, want := positions(), [][2]int{{0, 0}, {-50, 50}, {350, 40}}; !slices.Equal(got, want) {
242+ t.Fatalf("positions after scale reload %v, want %v", got, want)
243+ }
244+}
245+
246+func TestRelativeOutputWithoutReferenceIsAutomatic(t *testing.T) {
247+ r := startMulti(t, func(c *ports.Config) {
248+ c.Outputs = []ports.OutputConfig{{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 25}}}
249+ }, right)
250+ if out := lastOutputs(t, r.commands).Outputs; len(out) != 1 || out[0].X != 0 || out[0].Y != 0 {
251+ t.Fatalf("without reference: %+v", out)
252+ }
253+ // Plugging the reference restores the relation (DP-1 is 200 wide).
254+ r.plug(t, left)
255+ out := lastOutputs(t, r.commands).Outputs
256+ if len(out) != 2 || out[0].Info.Name != "DP-2" || out[1].Info.Name != "DP-1" {
257+ t.Fatalf("outputs %+v", out)
258+ }
259+ if out[0].X != 200 || out[0].Y != 25 || out[1].X != 0 || out[1].Y != 0 {
260+ t.Fatalf("placement %+v", out)
261+ }
262+}
263+
210 func TestWindowsOpenOnFocusedOutput(t *testing.T) {264 func TestWindowsOpenOnFocusedOutput(t *testing.T) {
211 r := startMulti(t, nil, left, right)265 r := startMulti(t, nil, left, right)
212 r.mapWindow(t, 1)266 r.mapWindow(t, 1)
@@ -297,6 +351,54 @@ func TestMoveWorkspaceToMonitor(t *testing.T) {
297 }351 }
298 }352 }
299 353
354+func TestMonitorActionsFollowGeometry(t *testing.T) {
355+ r := startMulti(t, func(c *ports.Config) {
356+ c.Outputs = []ports.OutputConfig{
357+ {Name: "DP-1"},
358+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
359+ }
360+ // Without a default bind; these replace move-workspace-up/down.
361+ c.Binds["Cmd+Ctrl+Shift+Up"] = string(core.ActionMoveWorkspaceToMonitorUp)
362+ c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown)
363+ }, left, right)
364+ // SetOutputs is sent only when the focus or layout changed.
365+ current := lastOutputs(t, r.commands).Focused
366+ focused := func() string {
367+ for len(r.commands) > 0 {
368+ if v, ok := (<-r.commands).(ports.SetOutputs); ok {
369+ current = v.Focused
370+ }
371+ }
372+ return current
373+ }
374+ cmdCtrl := ports.ModAlt | ports.ModCtrl
375+ // DP-1 is focused and has no neighbor left, right or up.
376+ r.mapWindow(t, 1)
377+ for _, k := range []string{"Left", "Right", "Up"} {
378+ r.key(t, k, cmdCtrl)
379+ if got := focused(); got != "DP-1" {
380+ t.Fatalf("%s moved focus to %s", k, got)
381+ }
382+ }
383+ r.key(t, "Down", cmdCtrl)
384+ if got := focused(); got != "DP-2" {
385+ t.Fatalf("focus-monitor-down: %s", got)
386+ }
387+ r.key(t, "k", cmdCtrl)
388+ if got := focused(); got != "DP-1" {
389+ t.Fatalf("focus-monitor-up (vim twin): %s", got)
390+ }
391+ // The workspace goes to the monitor below and focus follows it.
392+ set := r.key(t, "Down", cmdCtrl|ports.ModShift)
393+ if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
394+ t.Fatal(got)
395+ }
396+ set = r.key(t, "Up", cmdCtrl|ports.ModShift)
397+ if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
398+ t.Fatalf("move-workspace-to-monitor-up: %v", got)
399+ }
400+}
401+
300 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
301 r := startMulti(t, nil, left, right)403 r := startMulti(t, nil, left, right)
302 r.mapWindow(t, 1)404 r.mapWindow(t, 1)
@@ -1233,3 +1335,77 @@ func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) {
1233 t.Fatal("focus not on DP-1:", got)1335 t.Fatal("focus not on DP-1:", got)
1234 }1336 }
1235 }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 current != "DP-1" {
1402+ t.Fatalf("focused %s, want DP-1", current)
1403+ }
1404+ if out := lastOutputs(t, r.commands); !slices.Equal(out.Off, []string{"DP-2"}) {
1405+ t.Fatalf("off %v, want [DP-2]", out.Off)
1406+ }
1407+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1408+ if got := focusedOutput(r, current); got != "DP-2" {
1409+ t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)
1410+ }
1411+}
M internal/ports/client_commands.go +1 -1
@@ -62,7 +62,7 @@ func (o OutputPlacement) Contains(x, y float64) bool {
6262 return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
6363 }
6464
65-// Layout is the global arrangement of outputs, left to right.
65+// Layout is the global arrangement of outputs in logical pixels.
6666 type Layout []OutputPlacement
6767
6868 // At returns the output under the logical point.
@@ -62,7 +62,7 @@ func (o OutputPlacement) Contains(x, y float64) bool {
62 return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)62 return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
63 }63 }
64 64
65-// Layout is the global arrangement of outputs, left to right.65+// Layout is the global arrangement of outputs in logical pixels.
66 type Layout []OutputPlacement66 type Layout []OutputPlacement
67 67
68 // At returns the output under the logical point.68 // At returns the output under the logical point.
M internal/ports/config.go +29 -4
@@ -11,8 +11,11 @@ const DefaultSDRBrightness = 203
1111 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
1212 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
1313 // monitor's preferred mode. Off disables the connector.
14-// Every connected output that is not off is used. Outputs are placed left to
15-// right in config order, then in connection order.
14+// Every connected output that is not off is used.
15+// Placement, by precedence: Pos (set at runtime by wlr-output-management),
16+// then Anchor against another connected output, then automatic: right of
17+// everything placed so far (never left of 0), at y=0, in config order, then in
18+// connection order.
1619 // Scale is the output scale (0 means 1); layout works in logical pixels,
1720 // physical = logical × Scale.
1821 // Primary gets the focus and the pointer at startup, wherever it is placed.
@@ -23,14 +26,36 @@ type OutputConfig struct {
2326 Mode string
2427 Off bool
2528 Scale float64
26- // Pos is an explicit logical placement set at runtime; nil uses automatic layout.
27- Pos *image.Point
29+ // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
30+ Pos *image.Point
31+ // Anchor places the output against another connected one.
32+ Anchor OutputAnchor // RelationNone: automatic
2833 Primary bool
2934 HDR bool // opt-in to HDR10 on capable outputs
3035 SDRBrightness int // nits; default DefaultSDRBrightness
3136 ScaleOnly bool
3237 }
3338
39+// OutputRelation is the side of another output where an output is placed.
40+type OutputRelation uint8
41+
42+const (
43+ RelationNone OutputRelation = iota
44+ RelationRightOf
45+ RelationLeftOf
46+ RelationAbove
47+ RelationBelow
48+)
49+
50+// OutputAnchor places an output against another connected one, named by To.
51+// Offset is in logical pixels, along the shared edge: y for right-of and
52+// left-of (positive is down), x for above and below (positive is right).
53+type OutputAnchor struct {
54+ Relation OutputRelation
55+ To string
56+ Offset int
57+}
58+
3459 // LayoutRules are the layout settings shared by the defaults, screens and
3560 // named workspaces. Zero MaxColumns and an empty Overflow inherit.
3661 type LayoutRules struct {
@@ -11,8 +11,11 @@ const DefaultSDRBrightness = 203
11 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").11 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
12 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the12 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
13 // monitor's preferred mode. Off disables the connector.13 // monitor's preferred mode. Off disables the connector.
14-// Every connected output that is not off is used. Outputs are placed left to14+// Every connected output that is not off is used.
15-// right in config order, then in connection order.15+// Placement, by precedence: Pos (set at runtime by wlr-output-management),
16+// then Anchor against another connected output, then automatic: right of
17+// everything placed so far (never left of 0), at y=0, in config order, then in
18+// connection order.
16 // Scale is the output scale (0 means 1); layout works in logical pixels,19 // Scale is the output scale (0 means 1); layout works in logical pixels,
17 // physical = logical × Scale.20 // physical = logical × Scale.
18 // Primary gets the focus and the pointer at startup, wherever it is placed.21 // Primary gets the focus and the pointer at startup, wherever it is placed.
@@ -23,14 +26,36 @@ type OutputConfig struct {
23 Mode string26 Mode string
24 Off bool27 Off bool
25 Scale float6428 Scale float64
26- // Pos is an explicit logical placement set at runtime; nil uses automatic layout.29+ // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
27- Pos *image.Point30+ Pos *image.Point
31+ // Anchor places the output against another connected one.
32+ Anchor OutputAnchor // RelationNone: automatic
28 Primary bool33 Primary bool
29 HDR bool // opt-in to HDR10 on capable outputs34 HDR bool // opt-in to HDR10 on capable outputs
30 SDRBrightness int // nits; default DefaultSDRBrightness35 SDRBrightness int // nits; default DefaultSDRBrightness
31 ScaleOnly bool36 ScaleOnly bool
32 }37 }
33 38
39+// OutputRelation is the side of another output where an output is placed.
40+type OutputRelation uint8
41+
42+const (
43+ RelationNone OutputRelation = iota
44+ RelationRightOf
45+ RelationLeftOf
46+ RelationAbove
47+ RelationBelow
48+)
49+
50+// OutputAnchor places an output against another connected one, named by To.
51+// Offset is in logical pixels, along the shared edge: y for right-of and
52+// left-of (positive is down), x for above and below (positive is right).
53+type OutputAnchor struct {
54+ Relation OutputRelation
55+ To string
56+ Offset int
57+}
58+
34 // LayoutRules are the layout settings shared by the defaults, screens and59 // LayoutRules are the layout settings shared by the defaults, screens and
35 // named workspaces. Zero MaxColumns and an empty Overflow inherit.60 // named workspaces. Zero MaxColumns and an empty Overflow inherit.
36 type LayoutRules struct {61 type LayoutRules 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 25

Tags 10

↑↓ moveenter openesc close