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 Unverified
B5690EEEBB952194The 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`). | |||
| 56 | 56 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 57 | 57 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | |
| 58 | 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 | 61 | | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) | | |
| 60 | 62 | | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) | | |
| 61 | 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 | 67 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 66 | 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 | 123 | ## Workspace size | |
| 69 | 124 | ||
| 70 | 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 | 202 | | `workspace <name>` | Toggle a named workspace | | |
| 148 | 203 | | `move-column-to-workspace <N>` / `-up/-down` | Move the focused column | | |
| 149 | 204 | | `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 | | |
| 152 | 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 | 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 | 209 | | `quit` | Exit NeferWL | | |
| @@ -259,6 +314,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+ | |||
| 259 | 314 | | `cmd+shift+code:10` | `move-column-to-workspace 9` | | |
| 260 | 315 | | `cmd+ctrl+left` | `focus-monitor-left` | | |
| 261 | 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 | 319 | | `cmd+ctrl+h` | `focus-monitor-left` | | |
| 263 | 320 | | `cmd+ctrl+l` | `focus-monitor-right` | | |
| 264 | 321 | | `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 size | 123 | ## 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 }{ | |||
| 52 | 52 | {"cmd+shift+pagedown", "move-column-to-workspace-down"}, | |
| 53 | 53 | {"cmd+ctrl+left", "focus-monitor-left"}, | |
| 54 | 54 | {"cmd+ctrl+right", "focus-monitor-right"}, | |
| 55 | + | {"cmd+ctrl+up", "focus-monitor-up"}, | |
| 56 | + | {"cmd+ctrl+down", "focus-monitor-down"}, | |
| 55 | 57 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 56 | 58 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 57 | 59 | {"cmd+shift+up", "move-window-up"}, | |
| @@ -64,7 +66,47 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 64 | 66 | {"cmd+ctrl+shift+down", "move-workspace-down"}, | |
| 65 | 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 | 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 | 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 | 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 | 310 | // Parse reads `key = value` lines on top of the defaults. | |
| 192 | 311 | func Parse(r io.Reader) (ports.Config, []Warning, error) { | |
| 193 | 312 | c, _, w, err := parse(r) | |
| @@ -201,6 +320,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 201 | 320 | raw := map[string]string{} | |
| 202 | 321 | seen := map[string]int{} | |
| 203 | 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 | 325 | workspaces := map[string]int{} | |
| 205 | 326 | scanner := bufio.NewScanner(r) | |
| 206 | 327 | for n := 1; scanner.Scan(); n++ { | |
| @@ -289,6 +410,42 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 289 | 410 | entry().SDRBrightness = nits | |
| 290 | 411 | 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 | 449 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | |
| 293 | 450 | name = base | |
| 294 | 451 | s, err := parseScale(value) | |
| @@ -311,7 +468,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 311 | 468 | } | |
| 312 | 469 | override() | |
| 313 | 470 | 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 | |
| 315 | 472 | *e = o | |
| 316 | 473 | continue | |
| 317 | 474 | } | |
| @@ -353,6 +510,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 353 | 510 | if err := scanner.Err(); err != nil { | |
| 354 | 511 | return c, raw, warnings, err | |
| 355 | 512 | } | |
| 513 | + | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | |
| 356 | 514 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |
| 357 | 515 | sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | |
| 358 | 516 | // 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 = nits | 410 | entry().SDRBrightness = nits | ||
| 290 | continue | 411 | 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 = base | 450 | 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.SDRBrightness | 471 | + | o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor |
| 315 | *e = o | 472 | *e = o | ||
| 316 | continue | 473 | 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, err | 511 | 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 bound | 516 | // 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) { | |||
| 23 | 23 | func TestDefaultsAndLoad(t *testing.T) { | |
| 24 | 24 | t.Setenv("TERMINAL", "") | |
| 25 | 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 | 27 | t.Fatalf("defaults: %+v", d) | |
| 28 | 28 | } | |
| 29 | 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 | + | } | |
| @@ -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) { | |||
| 306 | 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 | + | } | |
| @@ -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 ( | |||
| 32 | 32 | ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down" | |
| 33 | 33 | ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up" | |
| 34 | 34 | 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" | |
| 40 | 45 | // Scale steps through the clean scales of the output (see CleanScales). | |
| 41 | 46 | ActionScaleUp Action = "scale-up" | |
| 42 | 47 | ActionScaleDown Action = "scale-down" | |
| @@ -53,6 +58,22 @@ const ( | |||
| 53 | 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 | 77 | // Resize axes of ResizeArg. | |
| 57 | 78 | const ( | |
| 58 | 79 | ResizeWidth = 0 | |
| @@ -173,35 +194,27 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 173 | 194 | if name, ok := NamedArg(a); ok && c.bringNamed(name) { | |
| 174 | 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 | 197 | 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 { | |
| 186 | 200 | c.focusScreen = i | |
| 187 | 201 | } | |
| 188 | 202 | return Effect{} | |
| 189 | - | case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 190 | - | c.moveWorkspace(dir) | |
| 203 | + | case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |
| 204 | + | c.moveWorkspace(monitorDirection(a)) | |
| 191 | 205 | return Effect{} | |
| 192 | 206 | case ActionFocusColumnLeft, ActionFocusColumnRight: | |
| 193 | 207 | w := c.cur().mon.Current() | |
| 208 | + | dir, d := 1, dirRight | |
| 194 | 209 | if a == ActionFocusColumnLeft { | |
| 195 | - | dir = -1 | |
| 196 | - | } else { | |
| 197 | - | dir = 1 | |
| 210 | + | dir, d = -1, dirLeft | |
| 198 | 211 | } | |
| 199 | 212 | // A covering fullscreen window is left for the window on that side; | |
| 200 | 213 | // with none (the workspace or stash edge), the move goes to the | |
| 201 | 214 | // neighbor monitor and fullscreen stays. | |
| 202 | 215 | if w.pinned() { | |
| 203 | 216 | if !w.FocusColumn(dir) { | |
| 204 | - | if i := c.neighbor(dir); i >= 0 { | |
| 217 | + | if i := c.neighbor(d); i >= 0 { | |
| 205 | 218 | c.focusScreen = i | |
| 206 | 219 | } | |
| 207 | 220 | } | |
| @@ -211,14 +224,14 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 211 | 224 | // a native float leaves for the stash or the columns, and the stash | |
| 212 | 225 | // keeps the focus at its ends. | |
| 213 | 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 | 228 | c.focusScreen = i | |
| 216 | 229 | return Effect{} | |
| 217 | 230 | } | |
| 218 | 231 | case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | |
| 219 | - | dir = 1 | |
| 232 | + | dir, d := 1, dirRight | |
| 220 | 233 | if a == ActionConsumeOrExpelLeft { | |
| 221 | - | dir = -1 | |
| 234 | + | dir, d = -1, dirLeft | |
| 222 | 235 | } | |
| 223 | 236 | from := c.cur() | |
| 224 | 237 | if from.mon.Current().ConsumeOrExpel(dir) { | |
| @@ -227,7 +240,7 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 227 | 240 | } | |
| 228 | 241 | // Full fixed workspace, stacked edge column: one hop to the | |
| 229 | 242 | // neighbor monitor, if any; focus follows the window. | |
| 230 | - | i := c.neighbor(dir) | |
| 243 | + | i := c.neighbor(d) | |
| 231 | 244 | if i < 0 { | |
| 232 | 245 | return Effect{} | |
| 233 | 246 | } | |
| @@ -244,12 +257,12 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 244 | 257 | // Past the edge the column moves to the neighbor screen, on the | |
| 245 | 258 | // side facing this one, and focus follows it. | |
| 246 | 259 | w := c.cur().mon.Current() | |
| 247 | - | dir = 1 | |
| 260 | + | d := dirRight | |
| 248 | 261 | if a == ActionMoveColumnLeft { | |
| 249 | - | dir = -1 | |
| 262 | + | d = dirLeft | |
| 250 | 263 | } | |
| 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 { | |
| 253 | 266 | col, ok := w.takeColumn() | |
| 254 | 267 | if !ok { | |
| 255 | 268 | return Effect{} | |
| @@ -258,7 +271,7 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 258 | 271 | c.focusScreen = i | |
| 259 | 272 | to := c.cur().mon.Current() | |
| 260 | 273 | at := 0 | |
| 261 | - | if dir < 0 { | |
| 274 | + | if d == dirLeft { | |
| 262 | 275 | at = len(to.Columns) | |
| 263 | 276 | } | |
| 264 | 277 | 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 = 0 | 79 | 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 = i | 200 | 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 = -1 | 210 | + | 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 the | 213 | // 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 = i | 218 | 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 stash | 224 | // 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 = i | 228 | c.focusScreen = i | ||
| 216 | return Effect{} | 229 | return Effect{} | ||
| 217 | } | 230 | } | ||
| 218 | case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | 231 | case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | ||
| 219 | - | dir = 1 | 232 | + | dir, d := 1, dirRight |
| 220 | if a == ActionConsumeOrExpelLeft { | 233 | if a == ActionConsumeOrExpelLeft { | ||
| 221 | - | dir = -1 | 234 | + | 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 the | 241 | // 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 the | 257 | // 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 = 1 | 260 | + | d := dirRight |
| 248 | if a == ActionMoveColumnLeft { | 261 | if a == ActionMoveColumnLeft { | ||
| 249 | - | dir = -1 | 262 | + | 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 = i | 271 | c.focusScreen = i | ||
| 259 | to := c.cur().mon.Current() | 272 | to := c.cur().mon.Current() | ||
| 260 | at := 0 | 273 | 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 { | |||
| 228 | 228 | continue | |
| 229 | 229 | } | |
| 230 | 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 | 232 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating: | |
| 233 | 233 | default: | |
| 234 | 234 | return fmt.Errorf("invalid action %q", a) | |
| @@ -228,7 +228,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 228 | continue | 228 | 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 { | |||
| 191 | 191 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 192 | 192 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | |
| 193 | 193 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 194 | + | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | |
| 194 | 195 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, | |
| 195 | 196 | ActionMoveWorkspaceDown, ActionToggleFloating: | |
| 196 | 197 | 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 true | 197 | return true | ||
M internal/core/overview_regression_test.go +1 -0
| @@ -127,6 +127,7 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) { | |||
| 127 | 127 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 128 | 128 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | |
| 129 | 129 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 130 | + | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | |
| 130 | 131 | } | |
| 131 | 132 | for _, a := range actions { | |
| 132 | 133 | 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 ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | // 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). | |
| 12 | 12 | type screen struct { | |
| 13 | 13 | info ports.OutputInfo | |
| 14 | 14 | mon *Monitor | |
| @@ -49,46 +49,46 @@ func (s *screen) arrange() { | |||
| 49 | 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 | 54 | func (c *Core) order() { | |
| 54 | 55 | 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 | |
| 59 | 58 | } | |
| 60 | 59 | return len(c.cfg.Outputs) | |
| 61 | 60 | } | |
| 62 | 61 | focused := c.cur() | |
| 63 | 62 | slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) }) | |
| 64 | 63 | 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 | |
| 75 | 70 | } | |
| 76 | 71 | } | |
| 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 | |
| 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 | 92 | // layout lists the connected outputs; the placeholder is not one. | |
| 93 | 93 | func (c *Core) layout() ports.Layout { | |
| 94 | 94 | l := make(ports.Layout, 0, len(c.screens)) | |
| @@ -129,12 +129,8 @@ func (c *Core) byName(name string) (*screen, *Workspace) { | |||
| 129 | 129 | ||
| 130 | 130 | // isPrimary reports output.<name>.primary. | |
| 131 | 131 | 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 | |
| 138 | 134 | } | |
| 139 | 135 | ||
| 140 | 136 | // anyWindow reports whether any workspace holds a window. | |
| @@ -152,10 +148,8 @@ func (c *Core) anyWindow() bool { | |||
| 152 | 148 | // configScale is output.<name>.scale, else 1. | |
| 153 | 149 | func (c *Core) configScale(name string) float64 { | |
| 154 | 150 | 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 | |
| 159 | 153 | } | |
| 160 | 154 | return SnapScale(v) | |
| 161 | 155 | } | |
| @@ -407,20 +401,84 @@ func (c *Core) settings(m *Monitor) { | |||
| 407 | 401 | m.SetFollowMove(c.cfg.Focus.FollowMove) | |
| 408 | 402 | } | |
| 409 | 403 | ||
| 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 { | |
| 415 | 428 | return -1 | |
| 416 | 429 | } | |
| 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) | |
| 418 | 476 | } | |
| 419 | 477 | ||
| 420 | 478 | // moveWorkspace moves the workspace on screen to the neighbor screen, where | |
| 421 | 479 | // 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) | |
| 424 | 482 | if to < 0 { | |
| 425 | 483 | return | |
| 426 | 484 | } | |
| @@ -7,8 +7,8 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | // screen is one connected output: its monitor tree, scale, layer surfaces and | 9 | // 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 | 10 | + | // 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.OutputInfo | 13 | info ports.OutputInfo | ||
| 14 | mon *Monitor | 14 | 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-placed | 64 | + | 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 := 0 | 66 | + | 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 := false | 73 | + | 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.0 | 150 | 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 focused | 404 | + | // 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 + dir | 407 | + | 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 -1 | 428 | return -1 | ||
| 416 | } | 429 | } | ||
| 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) | |||
| 418 | } | 476 | } | ||
| 419 | 477 | ||||
| 420 | // moveWorkspace moves the workspace on screen to the neighbor screen, where | 478 | // 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 | return | 483 | return | ||
| 426 | } | 484 | } | ||
M internal/core/screens_test.go +176 -0
| @@ -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 | 264 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | |
| 211 | 265 | r := startMulti(t, nil, left, right) | |
| 212 | 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 | 402 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | |
| 301 | 403 | r := startMulti(t, nil, left, right) | |
| 302 | 404 | r.mapWindow(t, 1) | |
| @@ -1233,3 +1335,77 @@ func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) { | |||
| 1233 | 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 | + | } | |
| @@ -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 { | |||
| 62 | 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 | 66 | type Layout []OutputPlacement | |
| 67 | 67 | ||
| 68 | 68 | // 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 []OutputPlacement | 66 | 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 | |||
| 11 | 11 | // OutputConfig configures one connector. Name is the connector (e.g. "DP-2"). | |
| 12 | 12 | // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the | |
| 13 | 13 | // 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. | |
| 16 | 19 | // Scale is the output scale (0 means 1); layout works in logical pixels, | |
| 17 | 20 | // physical = logical × Scale. | |
| 18 | 21 | // Primary gets the focus and the pointer at startup, wherever it is placed. | |
| @@ -23,14 +26,36 @@ type OutputConfig struct { | |||
| 23 | 26 | Mode string | |
| 24 | 27 | Off bool | |
| 25 | 28 | 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 | |
| 28 | 33 | Primary bool | |
| 29 | 34 | HDR bool // opt-in to HDR10 on capable outputs | |
| 30 | 35 | SDRBrightness int // nits; default DefaultSDRBrightness | |
| 31 | 36 | 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 | 59 | // LayoutRules are the layout settings shared by the defaults, screens and | |
| 35 | 60 | // named workspaces. Zero MaxColumns and an empty Overflow inherit. | |
| 36 | 61 | 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 the | 12 | // 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 to | 14 | + | // 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 string | 26 | Mode string | ||
| 24 | Off bool | 27 | Off bool | ||
| 25 | Scale float64 | 28 | 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.Point | 30 | + | Pos *image.Point |
| 31 | + | // Anchor places the output against another connected one. | |||
| 32 | + | Anchor OutputAnchor // RelationNone: automatic | |||
| 28 | Primary bool | 33 | Primary bool | ||
| 29 | HDR bool // opt-in to HDR10 on capable outputs | 34 | HDR bool // opt-in to HDR10 on capable outputs | ||
| 30 | SDRBrightness int // nits; default DefaultSDRBrightness | 35 | SDRBrightness int // nits; default DefaultSDRBrightness | ||
| 31 | ScaleOnly bool | 36 | 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 and | 59 | // 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 { | ||