feat(core): add window, column width and workspace order actions (#80)
Add move-window-up/down, set-column-width +/-N%, move-workspace-up/down and reserve toggle-floating and set-window-height, with default binds and docs.
- commit
- 186314effb447d06b3a0b642bca11c2d5626927e
- author
- bnema · · 2026-10-01 16:26 UTC
- committer
- GitHub · · 2026-10-01 16:26 UTC
- parent
- 01dbe2a
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
17 files · +548 -17
M docs/config.md +11 -0
| @@ -126,6 +126,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 126 | 126 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays | | |
| 127 | 127 | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends | | |
| 128 | 128 | | `move-column-left/right` | Move the focused column | | |
| 129 | + | | `move-window-up/down` | Move the focused window up or down in its column; focus follows it | | |
| 130 | + | | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow | | |
| 129 | 131 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | |
| 130 | 132 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | |
| 131 | 133 | | `workspace <name>` | Toggle a named workspace | | |
| @@ -133,6 +135,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 133 | 135 | | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window | | |
| 134 | 136 | | `focus-monitor-left/right` | Focus the neighbor monitor | | |
| 135 | 137 | | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home | | |
| 138 | + | | `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 | | |
| 136 | 139 | | `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`) | | |
| 137 | 140 | | `quit` | Exit NeferWL | | |
| 138 | 141 | ||
| @@ -165,6 +168,8 @@ Return or a click on a front tile or peeking card commits it; card changes remai | |||
| 165 | 168 | ||
| 166 | 169 | ### Default binds | |
| 167 | 170 | ||
| 171 | + | With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+…` and are dropped with a warning; with `keyboard.cmd = ctrl`, so are the `cmd+ctrl+…` ones. Bind those actions to other keys. | |
| 172 | + | ||
| 168 | 173 | | Keys | Action | | |
| 169 | 174 | |---|---| | |
| 170 | 175 | | `cmd+return` | `spawn-terminal` | | |
| @@ -182,6 +187,10 @@ Return or a click on a front tile or peeking card commits it; card changes remai | |||
| 182 | 187 | | `cmd+shift+l` | `move-column-right` | | |
| 183 | 188 | | `cmd+bracketleft` | `consume-or-expel-window-left` | | |
| 184 | 189 | | `cmd+bracketright` | `consume-or-expel-window-right` | | |
| 190 | + | | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` | | |
| 191 | + | | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` | | |
| 192 | + | | `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` | | |
| 193 | + | | `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` | | |
| 185 | 194 | | `cmd+r` | `cycle-column-width` | | |
| 186 | 195 | | `cmd+f` | `maximize-column` | | |
| 187 | 196 | | `cmd+shift+f` | `toggle-fullscreen` | | |
| @@ -221,5 +230,7 @@ Return or a click on a front tile or peeking card commits it; card changes remai | |||
| 221 | 230 | | `cmd+ctrl+shift+right` | `move-workspace-to-monitor-right` | | |
| 222 | 231 | | `cmd+ctrl+shift+h` | `move-workspace-to-monitor-left` | | |
| 223 | 232 | | `cmd+ctrl+shift+l` | `move-workspace-to-monitor-right` | | |
| 233 | + | | `cmd+ctrl+shift+up` / `cmd+ctrl+shift+k` | `move-workspace-up` | | |
| 234 | + | | `cmd+ctrl+shift+down` / `cmd+ctrl+shift+j` | `move-workspace-down` | | |
| 224 | 235 | | `cmd+code:13` | `scale-up` | | |
| 225 | 236 | | `cmd+code:12` | `scale-down` | | |
| @@ -126,6 +126,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 126 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays | | 126 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays | | ||
| 127 | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends | | 127 | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends | | ||
| 128 | | `move-column-left/right` | Move the focused column | | 128 | | `move-column-left/right` | Move the focused column | | ||
| 129 | + | | `move-window-up/down` | Move the focused window up or down in its column; focus follows it | | |||
| 130 | + | | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow | | |||
| 129 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | 131 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | ||
| 130 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | 132 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | ||
| 131 | | `workspace <name>` | Toggle a named workspace | | 133 | | `workspace <name>` | Toggle a named workspace | | ||
| @@ -133,6 +135,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 133 | | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window | | 135 | | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window | | ||
| 134 | | `focus-monitor-left/right` | Focus the neighbor monitor | | 136 | | `focus-monitor-left/right` | Focus the neighbor monitor | | ||
| 135 | | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home | | 137 | | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home | | ||
| 138 | + | | `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 | | |||
| 136 | | `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`) | | 139 | | `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`) | | ||
| 137 | | `quit` | Exit NeferWL | | 140 | | `quit` | Exit NeferWL | | ||
| 138 | 141 | ||||
| @@ -165,6 +168,8 @@ Return or a click on a front tile or peeking card commits it; card changes remai | |||||
| 165 | 168 | ||||
| 166 | ### Default binds | 169 | ### Default binds | ||
| 167 | 170 | ||||
| 171 | + | With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+…` and are dropped with a warning; with `keyboard.cmd = ctrl`, so are the `cmd+ctrl+…` ones. Bind those actions to other keys. | |||
| 172 | + | ||||
| 168 | | Keys | Action | | 173 | | Keys | Action | | ||
| 169 | |---|---| | 174 | |---|---| | ||
| 170 | | `cmd+return` | `spawn-terminal` | | 175 | | `cmd+return` | `spawn-terminal` | | ||
| @@ -182,6 +187,10 @@ Return or a click on a front tile or peeking card commits it; card changes remai | |||||
| 182 | | `cmd+shift+l` | `move-column-right` | | 187 | | `cmd+shift+l` | `move-column-right` | | ||
| 183 | | `cmd+bracketleft` | `consume-or-expel-window-left` | | 188 | | `cmd+bracketleft` | `consume-or-expel-window-left` | | ||
| 184 | | `cmd+bracketright` | `consume-or-expel-window-right` | | 189 | | `cmd+bracketright` | `consume-or-expel-window-right` | | ||
| 190 | + | | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` | | |||
| 191 | + | | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` | | |||
| 192 | + | | `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` | | |||
| 193 | + | | `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` | | |||
| 185 | | `cmd+r` | `cycle-column-width` | | 194 | | `cmd+r` | `cycle-column-width` | | ||
| 186 | | `cmd+f` | `maximize-column` | | 195 | | `cmd+f` | `maximize-column` | | ||
| 187 | | `cmd+shift+f` | `toggle-fullscreen` | | 196 | | `cmd+shift+f` | `toggle-fullscreen` | | ||
| @@ -221,5 +230,7 @@ Return or a click on a front tile or peeking card commits it; card changes remai | |||||
| 221 | | `cmd+ctrl+shift+right` | `move-workspace-to-monitor-right` | | 230 | | `cmd+ctrl+shift+right` | `move-workspace-to-monitor-right` | | ||
| 222 | | `cmd+ctrl+shift+h` | `move-workspace-to-monitor-left` | | 231 | | `cmd+ctrl+shift+h` | `move-workspace-to-monitor-left` | | ||
| 223 | | `cmd+ctrl+shift+l` | `move-workspace-to-monitor-right` | | 232 | | `cmd+ctrl+shift+l` | `move-workspace-to-monitor-right` | | ||
| 233 | + | | `cmd+ctrl+shift+up` / `cmd+ctrl+shift+k` | `move-workspace-up` | | |||
| 234 | + | | `cmd+ctrl+shift+down` / `cmd+ctrl+shift+j` | `move-workspace-down` | | |||
| 224 | | `cmd+code:13` | `scale-up` | | 235 | | `cmd+code:13` | `scale-up` | | ||
| 225 | | `cmd+code:12` | `scale-down` | | 236 | | `cmd+code:12` | `scale-down` | | ||
M examples/config +21 -0
| @@ -125,6 +125,22 @@ bind.cmd+shift+l = move-column-right | |||
| 125 | 125 | # neighbor column instead, past the edge on the neighbor monitor. | |
| 126 | 126 | bind.cmd+bracketleft = consume-or-expel-window-left | |
| 127 | 127 | bind.cmd+bracketright = consume-or-expel-window-right | |
| 128 | + | # Reorder the focused window inside its column. | |
| 129 | + | bind.cmd+shift+up = move-window-up | |
| 130 | + | bind.cmd+shift+down = move-window-down | |
| 131 | + | bind.cmd+shift+k = move-window-up | |
| 132 | + | bind.cmd+shift+j = move-window-down | |
| 133 | + | # Resize by a step of the usable width or height: +N% or -N% (1-100). | |
| 134 | + | bind.cmd+alt+left = set-column-width -10% | |
| 135 | + | bind.cmd+alt+right = set-column-width +10% | |
| 136 | + | bind.cmd+alt+up = set-window-height -10% | |
| 137 | + | bind.cmd+alt+down = set-window-height +10% | |
| 138 | + | bind.cmd+alt+h = set-column-width -10% | |
| 139 | + | bind.cmd+alt+l = set-column-width +10% | |
| 140 | + | bind.cmd+alt+k = set-window-height -10% | |
| 141 | + | bind.cmd+alt+j = set-window-height +10% | |
| 142 | + | # A tile becomes a free floating window, or returns to its column. | |
| 143 | + | bind.cmd+shift+space = toggle-floating | |
| 128 | 144 | bind.cmd+r = cycle-column-width | |
| 129 | 145 | bind.cmd+f = maximize-column | |
| 130 | 146 | bind.cmd+shift+f = toggle-fullscreen | |
| @@ -143,6 +159,11 @@ bind.cmd+pagedown = focus-workspace-down | |||
| 143 | 159 | # (N, -up, -down) moves only the focused window and has no default bind. | |
| 144 | 160 | bind.cmd+shift+pageup = move-column-to-workspace-up | |
| 145 | 161 | bind.cmd+shift+pagedown = move-column-to-workspace-down | |
| 162 | + | # Reorder workspaces: swap the current one with its neighbor. | |
| 163 | + | bind.cmd+ctrl+shift+up = move-workspace-up | |
| 164 | + | bind.cmd+ctrl+shift+down = move-workspace-down | |
| 165 | + | bind.cmd+ctrl+shift+k = move-workspace-up | |
| 166 | + | bind.cmd+ctrl+shift+j = move-workspace-down | |
| 146 | 167 | bind.cmd+code:2 = focus-workspace 1 | |
| 147 | 168 | bind.cmd+code:3 = focus-workspace 2 | |
| 148 | 169 | bind.cmd+code:4 = focus-workspace 3 | |
| @@ -125,6 +125,22 @@ bind.cmd+shift+l = move-column-right | |||||
| 125 | # neighbor column instead, past the edge on the neighbor monitor. | 125 | # neighbor column instead, past the edge on the neighbor monitor. | ||
| 126 | bind.cmd+bracketleft = consume-or-expel-window-left | 126 | bind.cmd+bracketleft = consume-or-expel-window-left | ||
| 127 | bind.cmd+bracketright = consume-or-expel-window-right | 127 | bind.cmd+bracketright = consume-or-expel-window-right | ||
| 128 | + | # Reorder the focused window inside its column. | |||
| 129 | + | bind.cmd+shift+up = move-window-up | |||
| 130 | + | bind.cmd+shift+down = move-window-down | |||
| 131 | + | bind.cmd+shift+k = move-window-up | |||
| 132 | + | bind.cmd+shift+j = move-window-down | |||
| 133 | + | # Resize by a step of the usable width or height: +N% or -N% (1-100). | |||
| 134 | + | bind.cmd+alt+left = set-column-width -10% | |||
| 135 | + | bind.cmd+alt+right = set-column-width +10% | |||
| 136 | + | bind.cmd+alt+up = set-window-height -10% | |||
| 137 | + | bind.cmd+alt+down = set-window-height +10% | |||
| 138 | + | bind.cmd+alt+h = set-column-width -10% | |||
| 139 | + | bind.cmd+alt+l = set-column-width +10% | |||
| 140 | + | bind.cmd+alt+k = set-window-height -10% | |||
| 141 | + | bind.cmd+alt+j = set-window-height +10% | |||
| 142 | + | # A tile becomes a free floating window, or returns to its column. | |||
| 143 | + | bind.cmd+shift+space = toggle-floating | |||
| 128 | bind.cmd+r = cycle-column-width | 144 | bind.cmd+r = cycle-column-width | ||
| 129 | bind.cmd+f = maximize-column | 145 | bind.cmd+f = maximize-column | ||
| 130 | bind.cmd+shift+f = toggle-fullscreen | 146 | bind.cmd+shift+f = toggle-fullscreen | ||
| @@ -143,6 +159,11 @@ bind.cmd+pagedown = focus-workspace-down | |||||
| 143 | # (N, -up, -down) moves only the focused window and has no default bind. | 159 | # (N, -up, -down) moves only the focused window and has no default bind. | ||
| 144 | bind.cmd+shift+pageup = move-column-to-workspace-up | 160 | bind.cmd+shift+pageup = move-column-to-workspace-up | ||
| 145 | bind.cmd+shift+pagedown = move-column-to-workspace-down | 161 | bind.cmd+shift+pagedown = move-column-to-workspace-down | ||
| 162 | + | # Reorder workspaces: swap the current one with its neighbor. | |||
| 163 | + | bind.cmd+ctrl+shift+up = move-workspace-up | |||
| 164 | + | bind.cmd+ctrl+shift+down = move-workspace-down | |||
| 165 | + | bind.cmd+ctrl+shift+k = move-workspace-up | |||
| 166 | + | bind.cmd+ctrl+shift+j = move-workspace-down | |||
| 146 | bind.cmd+code:2 = focus-workspace 1 | 167 | bind.cmd+code:2 = focus-workspace 1 | ||
| 147 | bind.cmd+code:3 = focus-workspace 2 | 168 | bind.cmd+code:3 = focus-workspace 2 | ||
| 148 | bind.cmd+code:4 = focus-workspace 3 | 169 | bind.cmd+code:4 = focus-workspace 3 | ||
M internal/adapters/config/config.go +22 -1
| @@ -51,11 +51,24 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 51 | 51 | {"cmd+ctrl+right", "focus-monitor-right"}, | |
| 52 | 52 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 53 | 53 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 54 | + | {"cmd+shift+up", "move-window-up"}, | |
| 55 | + | {"cmd+shift+down", "move-window-down"}, | |
| 56 | + | {"cmd+alt+left", "set-column-width -10%"}, | |
| 57 | + | {"cmd+alt+right", "set-column-width +10%"}, | |
| 58 | + | {"cmd+alt+up", "set-window-height -10%"}, | |
| 59 | + | {"cmd+alt+down", "set-window-height +10%"}, | |
| 60 | + | {"cmd+ctrl+shift+up", "move-workspace-up"}, | |
| 61 | + | {"cmd+ctrl+shift+down", "move-workspace-down"}, | |
| 62 | + | {"cmd+shift+space", "toggle-floating"}, | |
| 54 | 63 | } | |
| 55 | - | 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} | |
| 64 | + | 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} | |
| 56 | 65 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| 57 | 66 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 58 | 67 | ||
| 68 | + | // resizeStep is the +N% or -N% step of set-column-width and | |
| 69 | + | // set-window-height, N from 1 to 100. | |
| 70 | + | var resizeStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`) | |
| 71 | + | ||
| 59 | 72 | // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive. | |
| 60 | 73 | var namedKeys = map[string]string{ | |
| 61 | 74 | "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape", | |
| @@ -812,6 +825,14 @@ func checkAction(v string) error { | |||
| 812 | 825 | return nil | |
| 813 | 826 | } | |
| 814 | 827 | } | |
| 828 | + | for _, prefix := range []string{"set-column-width ", "set-window-height "} { | |
| 829 | + | if rest, ok := strings.CutPrefix(v, prefix); ok { | |
| 830 | + | if !resizeStep.MatchString(strings.TrimSpace(rest)) { | |
| 831 | + | return fmt.Errorf("%sneeds +N%% or -N%% (1-100)", prefix) | |
| 832 | + | } | |
| 833 | + | return nil | |
| 834 | + | } | |
| 835 | + | } | |
| 815 | 836 | if !actions[v] { | |
| 816 | 837 | return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v) | |
| 817 | 838 | } | |
| @@ -51,11 +51,24 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 51 | {"cmd+ctrl+right", "focus-monitor-right"}, | 51 | {"cmd+ctrl+right", "focus-monitor-right"}, | ||
| 52 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | 52 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | ||
| 53 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | 53 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | ||
| 54 | + | {"cmd+shift+up", "move-window-up"}, | |||
| 55 | + | {"cmd+shift+down", "move-window-down"}, | |||
| 56 | + | {"cmd+alt+left", "set-column-width -10%"}, | |||
| 57 | + | {"cmd+alt+right", "set-column-width +10%"}, | |||
| 58 | + | {"cmd+alt+up", "set-window-height -10%"}, | |||
| 59 | + | {"cmd+alt+down", "set-window-height +10%"}, | |||
| 60 | + | {"cmd+ctrl+shift+up", "move-workspace-up"}, | |||
| 61 | + | {"cmd+ctrl+shift+down", "move-workspace-down"}, | |||
| 62 | + | {"cmd+shift+space", "toggle-floating"}, | |||
| 54 | } | 63 | } | ||
| 55 | - | 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} | 64 | + | 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} |
| 56 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | 65 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | ||
| 57 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 66 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | ||
| 58 | 67 | ||||
| 68 | + | // resizeStep is the +N% or -N% step of set-column-width and | |||
| 69 | + | // set-window-height, N from 1 to 100. | |||
| 70 | + | var resizeStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`) | |||
| 71 | + | ||||
| 59 | // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive. | 72 | // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive. | ||
| 60 | var namedKeys = map[string]string{ | 73 | var namedKeys = map[string]string{ | ||
| 61 | "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape", | 74 | "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape", | ||
| @@ -812,6 +825,14 @@ func checkAction(v string) error { | |||||
| 812 | return nil | 825 | return nil | ||
| 813 | } | 826 | } | ||
| 814 | } | 827 | } | ||
| 828 | + | for _, prefix := range []string{"set-column-width ", "set-window-height "} { | |||
| 829 | + | if rest, ok := strings.CutPrefix(v, prefix); ok { | |||
| 830 | + | if !resizeStep.MatchString(strings.TrimSpace(rest)) { | |||
| 831 | + | return fmt.Errorf("%sneeds +N%% or -N%% (1-100)", prefix) | |||
| 832 | + | } | |||
| 833 | + | return nil | |||
| 834 | + | } | |||
| 835 | + | } | |||
| 815 | if !actions[v] { | 836 | if !actions[v] { | ||
| 816 | return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v) | 837 | return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v) | ||
| 817 | } | 838 | } | ||
M internal/adapters/config/config_test.go +8 -1
| @@ -23,12 +23,15 @@ 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) != 56 || 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) != 73 || 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 { | |
| 30 | 30 | t.Fatal(d.Binds) | |
| 31 | 31 | } | |
| 32 | + | if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-down" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-up" || d.Binds["Cmd+Shift+space"] != "toggle-floating" { | |
| 33 | + | t.Fatal(d.Binds) | |
| 34 | + | } | |
| 32 | 35 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | |
| 33 | 36 | t.Fatal(d.Binds) | |
| 34 | 37 | } | |
| @@ -193,6 +196,10 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 193 | 196 | {"bind.cmd+ = quit", "missing key"}, | |
| 194 | 197 | {"bind.hyper+x = quit", "unknown modifier"}, | |
| 195 | 198 | {"bind.cmd+x = bogus", "unknown action"}, | |
| 199 | + | {"bind.cmd+x = set-column-width 10%", "set-column-width needs"}, | |
| 200 | + | {"bind.cmd+x = set-column-width +0%", "set-column-width needs"}, | |
| 201 | + | {"bind.cmd+x = set-window-height -101%", "set-window-height needs"}, | |
| 202 | + | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | |
| 196 | 203 | {"bind.cmd+x = spawn ", "unknown action"}, | |
| 197 | 204 | } | |
| 198 | 205 | for _, tc := range cases { | |
| @@ -23,12 +23,15 @@ 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) != 56 || 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) != 73 || 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 { | ||
| 30 | t.Fatal(d.Binds) | 30 | t.Fatal(d.Binds) | ||
| 31 | } | 31 | } | ||
| 32 | + | if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-down" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-up" || d.Binds["Cmd+Shift+space"] != "toggle-floating" { | |||
| 33 | + | t.Fatal(d.Binds) | |||
| 34 | + | } | |||
| 32 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | 35 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | ||
| 33 | t.Fatal(d.Binds) | 36 | t.Fatal(d.Binds) | ||
| 34 | } | 37 | } | ||
| @@ -193,6 +196,10 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||||
| 193 | {"bind.cmd+ = quit", "missing key"}, | 196 | {"bind.cmd+ = quit", "missing key"}, | ||
| 194 | {"bind.hyper+x = quit", "unknown modifier"}, | 197 | {"bind.hyper+x = quit", "unknown modifier"}, | ||
| 195 | {"bind.cmd+x = bogus", "unknown action"}, | 198 | {"bind.cmd+x = bogus", "unknown action"}, | ||
| 199 | + | {"bind.cmd+x = set-column-width 10%", "set-column-width needs"}, | |||
| 200 | + | {"bind.cmd+x = set-column-width +0%", "set-column-width needs"}, | |||
| 201 | + | {"bind.cmd+x = set-window-height -101%", "set-window-height needs"}, | |||
| 202 | + | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | |||
| 196 | {"bind.cmd+x = spawn ", "unknown action"}, | 203 | {"bind.cmd+x = spawn ", "unknown action"}, | ||
| 197 | } | 204 | } | ||
| 198 | for _, tc := range cases { | 205 | for _, tc := range cases { | ||
M internal/core/action.go +67 -0
| @@ -43,8 +43,59 @@ const ( | |||
| 43 | 43 | // Consume or expel the focused window (ADR 016). | |
| 44 | 44 | ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left" | |
| 45 | 45 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | |
| 46 | + | // Move the focused window up or down inside its column. | |
| 47 | + | ActionMoveWindowUp Action = "move-window-up" | |
| 48 | + | ActionMoveWindowDown Action = "move-window-down" | |
| 49 | + | // Swap the active numbered workspace with its neighbor. | |
| 50 | + | ActionMoveWorkspaceUp Action = "move-workspace-up" | |
| 51 | + | ActionMoveWorkspaceDown Action = "move-workspace-down" | |
| 52 | + | // Turn the focused tile into a free floating window, or back. | |
| 53 | + | ActionToggleFloating Action = "toggle-floating" | |
| 46 | 54 | ) | |
| 47 | 55 | ||
| 56 | + | // Resize axes of ResizeArg. | |
| 57 | + | const ( | |
| 58 | + | ResizeWidth = 0 | |
| 59 | + | ResizeHeight = 1 | |
| 60 | + | ) | |
| 61 | + | ||
| 62 | + | var resizePrefixes = [...]string{ | |
| 63 | + | ResizeWidth: "set-column-width ", | |
| 64 | + | ResizeHeight: "set-window-height ", | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // ResizeArg parses "set-column-width +N%" (axis ResizeWidth) and | |
| 68 | + | // "set-window-height -N%" (axis ResizeHeight); N is 1 to 100 and the sign | |
| 69 | + | // is required. | |
| 70 | + | func ResizeArg(a Action) (axis, pct int, ok bool) { | |
| 71 | + | for i, prefix := range resizePrefixes { | |
| 72 | + | rest, found := strings.CutPrefix(string(a), prefix) | |
| 73 | + | if !found { | |
| 74 | + | continue | |
| 75 | + | } | |
| 76 | + | if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok { | |
| 77 | + | return i, p, true | |
| 78 | + | } | |
| 79 | + | return 0, 0, false | |
| 80 | + | } | |
| 81 | + | return 0, 0, false | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // parseResizeStep parses "+N%" or "-N%" with N from 1 to 100. | |
| 85 | + | func parseResizeStep(s string) (int, bool) { | |
| 86 | + | if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' { | |
| 87 | + | return 0, false | |
| 88 | + | } | |
| 89 | + | n, err := strconv.Atoi(s[1 : len(s)-1]) | |
| 90 | + | if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' { | |
| 91 | + | return 0, false | |
| 92 | + | } | |
| 93 | + | if s[0] == '-' { | |
| 94 | + | n = -n | |
| 95 | + | } | |
| 96 | + | return n, true | |
| 97 | + | } | |
| 98 | + | ||
| 48 | 99 | // WorkspaceOp is what a numbered workspace action does. | |
| 49 | 100 | type WorkspaceOp int | |
| 50 | 101 | ||
| @@ -283,6 +334,12 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 283 | 334 | m.Focus(m.Active + 1) | |
| 284 | 335 | } | |
| 285 | 336 | return Effect{} | |
| 337 | + | case ActionMoveWorkspaceUp: | |
| 338 | + | m.MoveWorkspace(-1) | |
| 339 | + | return Effect{} | |
| 340 | + | case ActionMoveWorkspaceDown: | |
| 341 | + | m.MoveWorkspace(1) | |
| 342 | + | return Effect{} | |
| 286 | 343 | } | |
| 287 | 344 | return m.Current().Apply(a) | |
| 288 | 345 | } | |
| @@ -324,6 +381,12 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 324 | 381 | if argv, ok := SpawnArgv(a); ok { | |
| 325 | 382 | return Effect{Spawn: true, Argv: argv} | |
| 326 | 383 | } | |
| 384 | + | if axis, pct, ok := ResizeArg(a); ok { | |
| 385 | + | if axis == ResizeWidth { | |
| 386 | + | w.ResizeColumn(pct) | |
| 387 | + | } | |
| 388 | + | return Effect{} | |
| 389 | + | } | |
| 327 | 390 | switch a { | |
| 328 | 391 | case ActionSpawnTerminal: | |
| 329 | 392 | return Effect{Spawn: true} | |
| @@ -339,6 +402,10 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 339 | 402 | w.MoveColumn(-1) | |
| 340 | 403 | case ActionMoveColumnRight: | |
| 341 | 404 | w.MoveColumn(1) | |
| 405 | + | case ActionMoveWindowUp: | |
| 406 | + | w.MoveWindow(-1) | |
| 407 | + | case ActionMoveWindowDown: | |
| 408 | + | w.MoveWindow(1) | |
| 342 | 409 | case ActionCycleColumnWidth: | |
| 343 | 410 | w.CycleWidth() | |
| 344 | 411 | case ActionMaximizeColumn: | |
| @@ -43,8 +43,59 @@ const ( | |||||
| 43 | // Consume or expel the focused window (ADR 016). | 43 | // Consume or expel the focused window (ADR 016). | ||
| 44 | ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left" | 44 | ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left" | ||
| 45 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | 45 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | ||
| 46 | + | // Move the focused window up or down inside its column. | |||
| 47 | + | ActionMoveWindowUp Action = "move-window-up" | |||
| 48 | + | ActionMoveWindowDown Action = "move-window-down" | |||
| 49 | + | // Swap the active numbered workspace with its neighbor. | |||
| 50 | + | ActionMoveWorkspaceUp Action = "move-workspace-up" | |||
| 51 | + | ActionMoveWorkspaceDown Action = "move-workspace-down" | |||
| 52 | + | // Turn the focused tile into a free floating window, or back. | |||
| 53 | + | ActionToggleFloating Action = "toggle-floating" | |||
| 46 | ) | 54 | ) | ||
| 47 | 55 | ||||
| 56 | + | // Resize axes of ResizeArg. | |||
| 57 | + | const ( | |||
| 58 | + | ResizeWidth = 0 | |||
| 59 | + | ResizeHeight = 1 | |||
| 60 | + | ) | |||
| 61 | + | ||||
| 62 | + | var resizePrefixes = [...]string{ | |||
| 63 | + | ResizeWidth: "set-column-width ", | |||
| 64 | + | ResizeHeight: "set-window-height ", | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // ResizeArg parses "set-column-width +N%" (axis ResizeWidth) and | |||
| 68 | + | // "set-window-height -N%" (axis ResizeHeight); N is 1 to 100 and the sign | |||
| 69 | + | // is required. | |||
| 70 | + | func ResizeArg(a Action) (axis, pct int, ok bool) { | |||
| 71 | + | for i, prefix := range resizePrefixes { | |||
| 72 | + | rest, found := strings.CutPrefix(string(a), prefix) | |||
| 73 | + | if !found { | |||
| 74 | + | continue | |||
| 75 | + | } | |||
| 76 | + | if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok { | |||
| 77 | + | return i, p, true | |||
| 78 | + | } | |||
| 79 | + | return 0, 0, false | |||
| 80 | + | } | |||
| 81 | + | return 0, 0, false | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // parseResizeStep parses "+N%" or "-N%" with N from 1 to 100. | |||
| 85 | + | func parseResizeStep(s string) (int, bool) { | |||
| 86 | + | if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' { | |||
| 87 | + | return 0, false | |||
| 88 | + | } | |||
| 89 | + | n, err := strconv.Atoi(s[1 : len(s)-1]) | |||
| 90 | + | if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' { | |||
| 91 | + | return 0, false | |||
| 92 | + | } | |||
| 93 | + | if s[0] == '-' { | |||
| 94 | + | n = -n | |||
| 95 | + | } | |||
| 96 | + | return n, true | |||
| 97 | + | } | |||
| 98 | + | ||||
| 48 | // WorkspaceOp is what a numbered workspace action does. | 99 | // WorkspaceOp is what a numbered workspace action does. | ||
| 49 | type WorkspaceOp int | 100 | type WorkspaceOp int | ||
| 50 | 101 | ||||
| @@ -283,6 +334,12 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 283 | m.Focus(m.Active + 1) | 334 | m.Focus(m.Active + 1) | ||
| 284 | } | 335 | } | ||
| 285 | return Effect{} | 336 | return Effect{} | ||
| 337 | + | case ActionMoveWorkspaceUp: | |||
| 338 | + | m.MoveWorkspace(-1) | |||
| 339 | + | return Effect{} | |||
| 340 | + | case ActionMoveWorkspaceDown: | |||
| 341 | + | m.MoveWorkspace(1) | |||
| 342 | + | return Effect{} | |||
| 286 | } | 343 | } | ||
| 287 | return m.Current().Apply(a) | 344 | return m.Current().Apply(a) | ||
| 288 | } | 345 | } | ||
| @@ -324,6 +381,12 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 324 | if argv, ok := SpawnArgv(a); ok { | 381 | if argv, ok := SpawnArgv(a); ok { | ||
| 325 | return Effect{Spawn: true, Argv: argv} | 382 | return Effect{Spawn: true, Argv: argv} | ||
| 326 | } | 383 | } | ||
| 384 | + | if axis, pct, ok := ResizeArg(a); ok { | |||
| 385 | + | if axis == ResizeWidth { | |||
| 386 | + | w.ResizeColumn(pct) | |||
| 387 | + | } | |||
| 388 | + | return Effect{} | |||
| 389 | + | } | |||
| 327 | switch a { | 390 | switch a { | ||
| 328 | case ActionSpawnTerminal: | 391 | case ActionSpawnTerminal: | ||
| 329 | return Effect{Spawn: true} | 392 | return Effect{Spawn: true} | ||
| @@ -339,6 +402,10 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 339 | w.MoveColumn(-1) | 402 | w.MoveColumn(-1) | ||
| 340 | case ActionMoveColumnRight: | 403 | case ActionMoveColumnRight: | ||
| 341 | w.MoveColumn(1) | 404 | w.MoveColumn(1) | ||
| 405 | + | case ActionMoveWindowUp: | |||
| 406 | + | w.MoveWindow(-1) | |||
| 407 | + | case ActionMoveWindowDown: | |||
| 408 | + | w.MoveWindow(1) | |||
| 342 | case ActionCycleColumnWidth: | 409 | case ActionCycleColumnWidth: | ||
| 343 | w.CycleWidth() | 410 | w.CycleWidth() | ||
| 344 | case ActionMaximizeColumn: | 411 | case ActionMaximizeColumn: | ||
A internal/core/action_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 9 | + | "github.com/bnema/neferwl/internal/core" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // altCmdDefaults is the default config with keyboard.cmd = alt. The loader | |
| 14 | + | // drops binds that use both cmd and alt then; so does this helper. | |
| 15 | + | func altCmdDefaults() ports.Config { | |
| 16 | + | cfg := config.Defaults() | |
| 17 | + | cfg.Keyboard.CmdKey = "alt" | |
| 18 | + | for combo := range cfg.Binds { | |
| 19 | + | parts := strings.Split(combo, "+") | |
| 20 | + | mods := parts[:len(parts)-1] | |
| 21 | + | if slices.Contains(mods, "Alt") && slices.Contains(mods, "Cmd") { | |
| 22 | + | delete(cfg.Binds, combo) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | return cfg | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestDefaultBindsAccepted(t *testing.T) { | |
| 29 | + | for _, cmd := range []string{"super", "alt"} { | |
| 30 | + | cfg := config.Defaults() | |
| 31 | + | if cmd == "alt" { | |
| 32 | + | cfg = altCmdDefaults() | |
| 33 | + | } | |
| 34 | + | if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}); err != nil { | |
| 35 | + | t.Fatalf("cmd=%s: %v", cmd, err) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestResizeArg(t *testing.T) { | |
| 41 | + | for _, tc := range []struct { | |
| 42 | + | a core.Action | |
| 43 | + | axis, pct int | |
| 44 | + | ok bool | |
| 45 | + | }{ | |
| 46 | + | {"set-column-width +10%", core.ResizeWidth, 10, true}, | |
| 47 | + | {"set-column-width -5%", core.ResizeWidth, -5, true}, | |
| 48 | + | {"set-window-height +100%", core.ResizeHeight, 100, true}, | |
| 49 | + | {"set-window-height -1%", core.ResizeHeight, -1, true}, | |
| 50 | + | {"set-column-width 10%", 0, 0, false}, | |
| 51 | + | {"set-column-width +0%", 0, 0, false}, | |
| 52 | + | {"set-column-width +101%", 0, 0, false}, | |
| 53 | + | {"set-column-width +10", 0, 0, false}, | |
| 54 | + | {"set-column-width +-1%", 0, 0, false}, | |
| 55 | + | {"set-column-width ++1%", 0, 0, false}, | |
| 56 | + | {"set-window-height", 0, 0, false}, | |
| 57 | + | {"cycle-column-width", 0, 0, false}, | |
| 58 | + | } { | |
| 59 | + | axis, pct, ok := core.ResizeArg(tc.a) | |
| 60 | + | if axis != tc.axis || pct != tc.pct || ok != tc.ok { | |
| 61 | + | t.Errorf("%q: %d %d %v", tc.a, axis, pct, ok) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 9 | + | "github.com/bnema/neferwl/internal/core" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // altCmdDefaults is the default config with keyboard.cmd = alt. The loader | |||
| 14 | + | // drops binds that use both cmd and alt then; so does this helper. | |||
| 15 | + | func altCmdDefaults() ports.Config { | |||
| 16 | + | cfg := config.Defaults() | |||
| 17 | + | cfg.Keyboard.CmdKey = "alt" | |||
| 18 | + | for combo := range cfg.Binds { | |||
| 19 | + | parts := strings.Split(combo, "+") | |||
| 20 | + | mods := parts[:len(parts)-1] | |||
| 21 | + | if slices.Contains(mods, "Alt") && slices.Contains(mods, "Cmd") { | |||
| 22 | + | delete(cfg.Binds, combo) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | return cfg | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestDefaultBindsAccepted(t *testing.T) { | |||
| 29 | + | for _, cmd := range []string{"super", "alt"} { | |||
| 30 | + | cfg := config.Defaults() | |||
| 31 | + | if cmd == "alt" { | |||
| 32 | + | cfg = altCmdDefaults() | |||
| 33 | + | } | |||
| 34 | + | if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}); err != nil { | |||
| 35 | + | t.Fatalf("cmd=%s: %v", cmd, err) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestResizeArg(t *testing.T) { | |||
| 41 | + | for _, tc := range []struct { | |||
| 42 | + | a core.Action | |||
| 43 | + | axis, pct int | |||
| 44 | + | ok bool | |||
| 45 | + | }{ | |||
| 46 | + | {"set-column-width +10%", core.ResizeWidth, 10, true}, | |||
| 47 | + | {"set-column-width -5%", core.ResizeWidth, -5, true}, | |||
| 48 | + | {"set-window-height +100%", core.ResizeHeight, 100, true}, | |||
| 49 | + | {"set-window-height -1%", core.ResizeHeight, -1, true}, | |||
| 50 | + | {"set-column-width 10%", 0, 0, false}, | |||
| 51 | + | {"set-column-width +0%", 0, 0, false}, | |||
| 52 | + | {"set-column-width +101%", 0, 0, false}, | |||
| 53 | + | {"set-column-width +10", 0, 0, false}, | |||
| 54 | + | {"set-column-width +-1%", 0, 0, false}, | |||
| 55 | + | {"set-column-width ++1%", 0, 0, false}, | |||
| 56 | + | {"set-window-height", 0, 0, false}, | |||
| 57 | + | {"cycle-column-width", 0, 0, false}, | |||
| 58 | + | } { | |||
| 59 | + | axis, pct, ok := core.ResizeArg(tc.a) | |||
| 60 | + | if axis != tc.axis || pct != tc.pct || ok != tc.ok { | |||
| 61 | + | t.Errorf("%q: %d %d %v", tc.a, axis, pct, ok) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
M internal/core/core.go +6 -1
| @@ -204,12 +204,17 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 204 | 204 | binds[b] = Action(a) | |
| 205 | 205 | continue | |
| 206 | 206 | } | |
| 207 | + | if _, _, ok := ResizeArg(Action(a)); ok { | |
| 208 | + | binds[b] = Action(a) | |
| 209 | + | continue | |
| 210 | + | } | |
| 207 | 211 | if _, ok := NamedArg(Action(a)); ok { | |
| 208 | 212 | binds[b] = Action(a) | |
| 209 | 213 | continue | |
| 210 | 214 | } | |
| 211 | 215 | switch Action(a) { | |
| 212 | - | 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: | |
| 216 | + | 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, | |
| 217 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating: | |
| 213 | 218 | default: | |
| 214 | 219 | return fmt.Errorf("invalid action %q", a) | |
| 215 | 220 | } | |
| @@ -204,12 +204,17 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 204 | binds[b] = Action(a) | 204 | binds[b] = Action(a) | ||
| 205 | continue | 205 | continue | ||
| 206 | } | 206 | } | ||
| 207 | + | if _, _, ok := ResizeArg(Action(a)); ok { | |||
| 208 | + | binds[b] = Action(a) | |||
| 209 | + | continue | |||
| 210 | + | } | |||
| 207 | if _, ok := NamedArg(Action(a)); ok { | 211 | if _, ok := NamedArg(Action(a)); ok { | ||
| 208 | binds[b] = Action(a) | 212 | binds[b] = Action(a) | ||
| 209 | continue | 213 | continue | ||
| 210 | } | 214 | } | ||
| 211 | switch Action(a) { | 215 | switch Action(a) { | ||
| 212 | - | 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: | 216 | + | 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, |
| 217 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating: | |||
| 213 | default: | 218 | default: | ||
| 214 | return fmt.Errorf("invalid action %q", a) | 219 | return fmt.Errorf("invalid action %q", a) | ||
| 215 | } | 220 | } | ||
M internal/core/core_test.go +1 -2
| @@ -47,8 +47,7 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | func TestOwner(t *testing.T) { | |
| 50 | - | cfg := config.Defaults() | |
| 51 | - | cfg.Keyboard.CmdKey = "alt" | |
| 50 | + | cfg := altCmdDefaults() | |
| 52 | 51 | cfg.Border.Width = 0 | |
| 53 | 52 | cfg.Layout.Gaps = 8 | |
| 54 | 53 | client := make(chan ports.ClientEvent, 128) | |
| @@ -47,8 +47,7 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | func TestOwner(t *testing.T) { | 49 | func TestOwner(t *testing.T) { | ||
| 50 | - | cfg := config.Defaults() | 50 | + | cfg := altCmdDefaults() |
| 51 | - | cfg.Keyboard.CmdKey = "alt" | |||
| 52 | cfg.Border.Width = 0 | 51 | cfg.Border.Width = 0 | ||
| 53 | cfg.Layout.Gaps = 8 | 52 | cfg.Layout.Gaps = 8 | ||
| 54 | client := make(chan ports.ClientEvent, 128) | 53 | client := make(chan ports.ClientEvent, 128) | ||
M internal/core/expel.go +1 -1
| @@ -25,7 +25,7 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool { | |||
| 25 | 25 | } | |
| 26 | 26 | // A fullscreen window hides the others (fixed) or its column (scroll): | |
| 27 | 27 | // moving would break its way back. | |
| 28 | - | if w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) { | |
| 28 | + | if w.fullscreenHides() { | |
| 29 | 29 | return true | |
| 30 | 30 | } | |
| 31 | 31 | src := w.Focus | |
| @@ -25,7 +25,7 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool { | |||||
| 25 | } | 25 | } | ||
| 26 | // A fullscreen window hides the others (fixed) or its column (scroll): | 26 | // A fullscreen window hides the others (fixed) or its column (scroll): | ||
| 27 | // moving would break its way back. | 27 | // moving would break its way back. | ||
| 28 | - | if w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) { | 28 | + | if w.fullscreenHides() { |
| 29 | return true | 29 | return true | ||
| 30 | } | 30 | } | ||
| 31 | src := w.Focus | 31 | src := w.Focus | ||
M internal/core/layout.go +57 -3
| @@ -44,6 +44,14 @@ func ParseWidth(s string) (Width, error) { | |||
| 44 | 44 | return Width{}, fmt.Errorf("invalid width %q", s) | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | // same reports whether v and o are the same width: 1/2 and 50/100 are. | |
| 48 | + | func (v Width) same(o Width) bool { | |
| 49 | + | if v.Pixels > 0 || o.Pixels > 0 || v.Den == 0 || o.Den == 0 { | |
| 50 | + | return v == o | |
| 51 | + | } | |
| 52 | + | return v.Num*o.Den == o.Num*v.Den | |
| 53 | + | } | |
| 54 | + | ||
| 47 | 55 | // Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps. | |
| 48 | 56 | // Widths are clamped to the available area (zero if no space remains). | |
| 49 | 57 | func (v Width) Resolve(usableW, gaps int) int { | |
| @@ -665,6 +673,44 @@ func (w *Workspace) MoveColumn(dir int) { | |||
| 665 | 673 | } | |
| 666 | 674 | } | |
| 667 | 675 | ||
| 676 | + | // MoveWindow moves the focused tile up or down (dir -1/1) inside its | |
| 677 | + | // column; focus follows it. A slot window leaving row 0 releases the slot. | |
| 678 | + | func (w *Workspace) MoveWindow(dir int) { | |
| 679 | + | if w.onFloat() || len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.fullscreenHides() { | |
| 680 | + | return | |
| 681 | + | } | |
| 682 | + | c := &w.Columns[w.Focus] | |
| 683 | + | to := c.Focus + dir | |
| 684 | + | if to < 0 || to >= len(c.Windows) { | |
| 685 | + | return | |
| 686 | + | } | |
| 687 | + | if c.Focus == 0 || to == 0 { | |
| 688 | + | w.dropSlotOf(c.Windows[0]) | |
| 689 | + | } | |
| 690 | + | c.Windows[c.Focus], c.Windows[to] = c.Windows[to], c.Windows[c.Focus] | |
| 691 | + | c.Focus = to | |
| 692 | + | w.scroll() | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | // ResizeColumn changes the focused column's width by pct percent of the | |
| 696 | + | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | |
| 697 | + | // overflow shares the width equally: nothing changes there. | |
| 698 | + | func (w *Workspace) ResizeColumn(pct int) { | |
| 699 | + | if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() { | |
| 700 | + | return | |
| 701 | + | } | |
| 702 | + | w.unmaximize() | |
| 703 | + | g := w.gap() | |
| 704 | + | avail := w.Usable.W - g | |
| 705 | + | if avail <= 0 { | |
| 706 | + | return | |
| 707 | + | } | |
| 708 | + | // Inverse of Width.Resolve: width = round(p/100 * avail) - g. | |
| 709 | + | cur := (200*(w.columnWidth(w.Focus)+g) + avail) / (2 * avail) | |
| 710 | + | w.Columns[w.Focus].Width = Width{Num: min(max(cur+pct, 10), 100), Den: 100} | |
| 711 | + | w.scroll() | |
| 712 | + | } | |
| 713 | + | ||
| 668 | 714 | // takeColumn removes the focused column and returns it, as a normal column. | |
| 669 | 715 | func (w *Workspace) takeColumn() (Column, bool) { | |
| 670 | 716 | if len(w.Columns) == 0 || w.onFloat() { | |
| @@ -706,14 +752,15 @@ func (w *Workspace) CycleWidth() { | |||
| 706 | 752 | if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() { | |
| 707 | 753 | return | |
| 708 | 754 | } | |
| 709 | - | // auto → presets in order → auto. | |
| 755 | + | // auto → presets in order → auto; a width set by set-column-width | |
| 756 | + | // restarts at the first preset. | |
| 710 | 757 | c := &w.Columns[w.Focus] | |
| 711 | 758 | next := Width{} | |
| 712 | - | if c.Width == next { | |
| 759 | + | if c.Width == next || !slices.ContainsFunc(w.presets, c.Width.same) { | |
| 713 | 760 | next = w.presets[0] | |
| 714 | 761 | } | |
| 715 | 762 | for i, v := range w.presets { | |
| 716 | - | if v == c.Width && i+1 < len(w.presets) { | |
| 763 | + | if v.same(c.Width) && i+1 < len(w.presets) { | |
| 717 | 764 | next = w.presets[i+1] | |
| 718 | 765 | break | |
| 719 | 766 | } | |
| @@ -1004,6 +1051,13 @@ func (w *Workspace) cover() WindowID { | |||
| 1004 | 1051 | return 0 | |
| 1005 | 1052 | } | |
| 1006 | 1053 | ||
| 1054 | + | // fullscreenHides reports whether a fullscreen window hides the focused | |
| 1055 | + | // column's layout: the others (fixed) or its own column (scroll). Column | |
| 1056 | + | // edits would change what the user cannot see. | |
| 1057 | + | func (w *Workspace) fullscreenHides() bool { | |
| 1058 | + | return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) | |
| 1059 | + | } | |
| 1060 | + | ||
| 1007 | 1061 | func (w *Workspace) fullscreenColumn(i int) bool { | |
| 1008 | 1062 | for _, id := range w.Columns[i].Windows { | |
| 1009 | 1063 | if id == w.fullscreen && id != 0 { | |
| @@ -44,6 +44,14 @@ func ParseWidth(s string) (Width, error) { | |||||
| 44 | return Width{}, fmt.Errorf("invalid width %q", s) | 44 | return Width{}, fmt.Errorf("invalid width %q", s) | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | // same reports whether v and o are the same width: 1/2 and 50/100 are. | |||
| 48 | + | func (v Width) same(o Width) bool { | |||
| 49 | + | if v.Pixels > 0 || o.Pixels > 0 || v.Den == 0 || o.Den == 0 { | |||
| 50 | + | return v == o | |||
| 51 | + | } | |||
| 52 | + | return v.Num*o.Den == o.Num*v.Den | |||
| 53 | + | } | |||
| 54 | + | ||||
| 47 | // Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps. | 55 | // Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps. | ||
| 48 | // Widths are clamped to the available area (zero if no space remains). | 56 | // Widths are clamped to the available area (zero if no space remains). | ||
| 49 | func (v Width) Resolve(usableW, gaps int) int { | 57 | func (v Width) Resolve(usableW, gaps int) int { | ||
| @@ -665,6 +673,44 @@ func (w *Workspace) MoveColumn(dir int) { | |||||
| 665 | } | 673 | } | ||
| 666 | } | 674 | } | ||
| 667 | 675 | ||||
| 676 | + | // MoveWindow moves the focused tile up or down (dir -1/1) inside its | |||
| 677 | + | // column; focus follows it. A slot window leaving row 0 releases the slot. | |||
| 678 | + | func (w *Workspace) MoveWindow(dir int) { | |||
| 679 | + | if w.onFloat() || len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.fullscreenHides() { | |||
| 680 | + | return | |||
| 681 | + | } | |||
| 682 | + | c := &w.Columns[w.Focus] | |||
| 683 | + | to := c.Focus + dir | |||
| 684 | + | if to < 0 || to >= len(c.Windows) { | |||
| 685 | + | return | |||
| 686 | + | } | |||
| 687 | + | if c.Focus == 0 || to == 0 { | |||
| 688 | + | w.dropSlotOf(c.Windows[0]) | |||
| 689 | + | } | |||
| 690 | + | c.Windows[c.Focus], c.Windows[to] = c.Windows[to], c.Windows[c.Focus] | |||
| 691 | + | c.Focus = to | |||
| 692 | + | w.scroll() | |||
| 693 | + | } | |||
| 694 | + | ||||
| 695 | + | // ResizeColumn changes the focused column's width by pct percent of the | |||
| 696 | + | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | |||
| 697 | + | // overflow shares the width equally: nothing changes there. | |||
| 698 | + | func (w *Workspace) ResizeColumn(pct int) { | |||
| 699 | + | if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() { | |||
| 700 | + | return | |||
| 701 | + | } | |||
| 702 | + | w.unmaximize() | |||
| 703 | + | g := w.gap() | |||
| 704 | + | avail := w.Usable.W - g | |||
| 705 | + | if avail <= 0 { | |||
| 706 | + | return | |||
| 707 | + | } | |||
| 708 | + | // Inverse of Width.Resolve: width = round(p/100 * avail) - g. | |||
| 709 | + | cur := (200*(w.columnWidth(w.Focus)+g) + avail) / (2 * avail) | |||
| 710 | + | w.Columns[w.Focus].Width = Width{Num: min(max(cur+pct, 10), 100), Den: 100} | |||
| 711 | + | w.scroll() | |||
| 712 | + | } | |||
| 713 | + | ||||
| 668 | // takeColumn removes the focused column and returns it, as a normal column. | 714 | // takeColumn removes the focused column and returns it, as a normal column. | ||
| 669 | func (w *Workspace) takeColumn() (Column, bool) { | 715 | func (w *Workspace) takeColumn() (Column, bool) { | ||
| 670 | if len(w.Columns) == 0 || w.onFloat() { | 716 | if len(w.Columns) == 0 || w.onFloat() { | ||
| @@ -706,14 +752,15 @@ func (w *Workspace) CycleWidth() { | |||||
| 706 | if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() { | 752 | if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() { | ||
| 707 | return | 753 | return | ||
| 708 | } | 754 | } | ||
| 709 | - | // auto → presets in order → auto. | 755 | + | // auto → presets in order → auto; a width set by set-column-width |
| 756 | + | // restarts at the first preset. | |||
| 710 | c := &w.Columns[w.Focus] | 757 | c := &w.Columns[w.Focus] | ||
| 711 | next := Width{} | 758 | next := Width{} | ||
| 712 | - | if c.Width == next { | 759 | + | if c.Width == next || !slices.ContainsFunc(w.presets, c.Width.same) { |
| 713 | next = w.presets[0] | 760 | next = w.presets[0] | ||
| 714 | } | 761 | } | ||
| 715 | for i, v := range w.presets { | 762 | for i, v := range w.presets { | ||
| 716 | - | if v == c.Width && i+1 < len(w.presets) { | 763 | + | if v.same(c.Width) && i+1 < len(w.presets) { |
| 717 | next = w.presets[i+1] | 764 | next = w.presets[i+1] | ||
| 718 | break | 765 | break | ||
| 719 | } | 766 | } | ||
| @@ -1004,6 +1051,13 @@ func (w *Workspace) cover() WindowID { | |||||
| 1004 | return 0 | 1051 | return 0 | ||
| 1005 | } | 1052 | } | ||
| 1006 | 1053 | ||||
| 1054 | + | // fullscreenHides reports whether a fullscreen window hides the focused | |||
| 1055 | + | // column's layout: the others (fixed) or its own column (scroll). Column | |||
| 1056 | + | // edits would change what the user cannot see. | |||
| 1057 | + | func (w *Workspace) fullscreenHides() bool { | |||
| 1058 | + | return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) | |||
| 1059 | + | } | |||
| 1060 | + | ||||
| 1007 | func (w *Workspace) fullscreenColumn(i int) bool { | 1061 | func (w *Workspace) fullscreenColumn(i int) bool { | ||
| 1008 | for _, id := range w.Columns[i].Windows { | 1062 | for _, id := range w.Columns[i].Windows { | ||
| 1009 | if id == w.fullscreen && id != 0 { | 1063 | if id == w.fullscreen && id != 0 { | ||
M internal/core/monitor.go +14 -0
| @@ -199,6 +199,20 @@ func (m *Monitor) Focus(i int) { | |||
| 199 | 199 | m.normalize() | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | + | // MoveWorkspace swaps the active numbered workspace with its neighbor up | |
| 203 | + | // (dir -1) or down (dir 1); the view follows it. It never passes the | |
| 204 | + | // trailing empty workspace. | |
| 205 | + | func (m *Monitor) MoveWorkspace(dir int) { | |
| 206 | + | to := m.Active + dir | |
| 207 | + | if m.shown != nil || (dir != -1 && dir != 1) || to < 0 || to >= len(m.Workspaces)-1 || m.Active >= len(m.Workspaces)-1 { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | m.Workspaces[m.Active], m.Workspaces[to] = m.Workspaces[to], m.Workspaces[m.Active] | |
| 211 | + | m.stopSwitch() | |
| 212 | + | m.Active = to | |
| 213 | + | m.normalize() | |
| 214 | + | } | |
| 215 | + | ||
| 202 | 216 | // FocusNumber switches to workspace n (1-based); past the end means the last. | |
| 203 | 217 | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | |
| 204 | 218 | ||
| @@ -199,6 +199,20 @@ func (m *Monitor) Focus(i int) { | |||||
| 199 | m.normalize() | 199 | m.normalize() | ||
| 200 | } | 200 | } | ||
| 201 | 201 | ||||
| 202 | + | // MoveWorkspace swaps the active numbered workspace with its neighbor up | |||
| 203 | + | // (dir -1) or down (dir 1); the view follows it. It never passes the | |||
| 204 | + | // trailing empty workspace. | |||
| 205 | + | func (m *Monitor) MoveWorkspace(dir int) { | |||
| 206 | + | to := m.Active + dir | |||
| 207 | + | if m.shown != nil || (dir != -1 && dir != 1) || to < 0 || to >= len(m.Workspaces)-1 || m.Active >= len(m.Workspaces)-1 { | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | m.Workspaces[m.Active], m.Workspaces[to] = m.Workspaces[to], m.Workspaces[m.Active] | |||
| 211 | + | m.stopSwitch() | |||
| 212 | + | m.Active = to | |||
| 213 | + | m.normalize() | |||
| 214 | + | } | |||
| 215 | + | ||||
| 202 | // FocusNumber switches to workspace n (1-based); past the end means the last. | 216 | // FocusNumber switches to workspace n (1-based); past the end means the last. | ||
| 203 | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | 217 | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | ||
| 204 | 218 | ||||
M internal/core/overview.go +6 -1
| @@ -181,11 +181,16 @@ func (m *Monitor) overviewTarget() WindowID { | |||
| 181 | 181 | // selection is provisional. Workspace and monitor navigation and moves to | |
| 182 | 182 | // another workspace remain active. | |
| 183 | 183 | func overviewBlocks(a Action) bool { | |
| 184 | + | if _, _, ok := ResizeArg(a); ok { | |
| 185 | + | return true | |
| 186 | + | } | |
| 184 | 187 | switch a { | |
| 185 | 188 | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | |
| 186 | 189 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 187 | 190 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | |
| 188 | - | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 191 | + | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 192 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, | |
| 193 | + | ActionMoveWorkspaceDown, ActionToggleFloating: | |
| 189 | 194 | return true | |
| 190 | 195 | } | |
| 191 | 196 | return false | |
| @@ -181,11 +181,16 @@ func (m *Monitor) overviewTarget() WindowID { | |||||
| 181 | // selection is provisional. Workspace and monitor navigation and moves to | 181 | // selection is provisional. Workspace and monitor navigation and moves to | ||
| 182 | // another workspace remain active. | 182 | // another workspace remain active. | ||
| 183 | func overviewBlocks(a Action) bool { | 183 | func overviewBlocks(a Action) bool { | ||
| 184 | + | if _, _, ok := ResizeArg(a); ok { | |||
| 185 | + | return true | |||
| 186 | + | } | |||
| 184 | switch a { | 187 | switch a { | ||
| 185 | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | 188 | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | ||
| 186 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | 189 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | ||
| 187 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | 190 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | ||
| 188 | - | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | 191 | + | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, |
| 192 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, | |||
| 193 | + | ActionMoveWorkspaceDown, ActionToggleFloating: | |||
| 189 | return true | 194 | return true | ||
| 190 | } | 195 | } | ||
| 191 | return false | 196 | return false | ||
M internal/core/screens_test.go +1 -2
| @@ -41,8 +41,7 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo | |||
| 41 | 41 | // startRig is startMulti; terminal enables the configured automatic-open policy. | |
| 42 | 42 | func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | |
| 43 | 43 | t.Helper() | |
| 44 | - | cfg := config.Defaults() | |
| 45 | - | cfg.Keyboard.CmdKey = "alt" | |
| 44 | + | cfg := altCmdDefaults() | |
| 46 | 45 | cfg.Border.Width = 0 | |
| 47 | 46 | if edit != nil { | |
| 48 | 47 | edit(&cfg) | |
| @@ -41,8 +41,7 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo | |||||
| 41 | // startRig is startMulti; terminal enables the configured automatic-open policy. | 41 | // startRig is startMulti; terminal enables the configured automatic-open policy. | ||
| 42 | func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | 42 | func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | ||
| 43 | t.Helper() | 43 | t.Helper() | ||
| 44 | - | cfg := config.Defaults() | 44 | + | cfg := altCmdDefaults() |
| 45 | - | cfg.Keyboard.CmdKey = "alt" | |||
| 46 | cfg.Border.Width = 0 | 45 | cfg.Border.Width = 0 | ||
| 47 | if edit != nil { | 46 | if edit != nil { | ||
| 48 | edit(&cfg) | 47 | edit(&cfg) | ||
M internal/core/slots_test.go +1 -2
| @@ -28,8 +28,7 @@ type slotRig struct { | |||
| 28 | 28 | // fixed overflow. | |
| 29 | 29 | func startSlots(t *testing.T, numbered ...bool) *slotRig { | |
| 30 | 30 | t.Helper() | |
| 31 | - | cfg := config.Defaults() | |
| 32 | - | cfg.Keyboard.CmdKey = "alt" | |
| 31 | + | cfg := altCmdDefaults() | |
| 33 | 32 | cfg.Border.Width = 0 | |
| 34 | 33 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{ | |
| 35 | 34 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | |
| @@ -28,8 +28,7 @@ type slotRig struct { | |||||
| 28 | // fixed overflow. | 28 | // fixed overflow. | ||
| 29 | func startSlots(t *testing.T, numbered ...bool) *slotRig { | 29 | func startSlots(t *testing.T, numbered ...bool) *slotRig { | ||
| 30 | t.Helper() | 30 | t.Helper() | ||
| 31 | - | cfg := config.Defaults() | 31 | + | cfg := altCmdDefaults() |
| 32 | - | cfg.Keyboard.CmdKey = "alt" | |||
| 33 | cfg.Border.Width = 0 | 32 | cfg.Border.Width = 0 | ||
| 34 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{ | 33 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{ | ||
| 35 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | 34 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | ||
M internal/core/swipe_test.go +1 -3
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/neferwl/internal/adapters/config" | |
| 10 | 9 | "github.com/bnema/neferwl/internal/core" | |
| 11 | 10 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 12 | 11 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -28,8 +27,7 @@ var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: | |||
| 28 | 27 | ||
| 29 | 28 | func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig { | |
| 30 | 29 | t.Helper() | |
| 31 | - | cfg := config.Defaults() | |
| 32 | - | cfg.Keyboard.CmdKey = "alt" | |
| 30 | + | cfg := altCmdDefaults() | |
| 33 | 31 | cfg.Border.Width = 0 | |
| 34 | 32 | cfg.Layout.Gaps = 0 | |
| 35 | 33 | cfg.Terminal.AutoOpen = "never" | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 10 | "github.com/bnema/neferwl/internal/core" | 9 | "github.com/bnema/neferwl/internal/core" | ||
| 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | 10 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | ||
| 12 | "github.com/bnema/neferwl/internal/ports" | 11 | "github.com/bnema/neferwl/internal/ports" | ||
| @@ -28,8 +27,7 @@ var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: | |||||
| 28 | 27 | ||||
| 29 | func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig { | 28 | func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig { | ||
| 30 | t.Helper() | 29 | t.Helper() | ||
| 31 | - | cfg := config.Defaults() | 30 | + | cfg := altCmdDefaults() |
| 32 | - | cfg.Keyboard.CmdKey = "alt" | |||
| 33 | cfg.Border.Width = 0 | 31 | cfg.Border.Width = 0 | ||
| 34 | cfg.Layout.Gaps = 0 | 32 | cfg.Layout.Gaps = 0 | ||
| 35 | cfg.Terminal.AutoOpen = "never" | 33 | cfg.Terminal.AutoOpen = "never" | ||
A internal/core/window_actions_test.go +209 -0
| @@ -0,0 +1,209 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestMoveWindowInColumn(t *testing.T) { | |
| 10 | + | for _, o := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 11 | + | w := workspace() | |
| 12 | + | w.Overflow = o | |
| 13 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}} | |
| 14 | + | w.Apply(ActionMoveWindowUp) | |
| 15 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{2, 1, 3}) || w.Columns[0].Focus != 0 { | |
| 16 | + | t.Fatal(o, w.Columns) | |
| 17 | + | } | |
| 18 | + | // Top end: nothing moves. | |
| 19 | + | w.Apply(ActionMoveWindowUp) | |
| 20 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{2, 1, 3}) || w.Columns[0].Focus != 0 { | |
| 21 | + | t.Fatal(o, w.Columns) | |
| 22 | + | } | |
| 23 | + | w.Apply(ActionMoveWindowDown) | |
| 24 | + | w.Apply(ActionMoveWindowDown) | |
| 25 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{1, 3, 2}) || w.Columns[0].Focus != 2 { | |
| 26 | + | t.Fatal(o, w.Columns) | |
| 27 | + | } | |
| 28 | + | w.Apply(ActionMoveWindowDown) | |
| 29 | + | if id, _ := w.Focused(); id != 2 || w.Columns[0].Focus != 2 { | |
| 30 | + | t.Fatal(o, w.Columns) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestMoveWindowReleasesSlot(t *testing.T) { | |
| 36 | + | w := workspace() | |
| 37 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Slot: 7}} | |
| 38 | + | w.Apply(ActionMoveWindowDown) | |
| 39 | + | if w.Columns[0].Slot != 0 || !slices.Equal(w.Columns[0].Windows, []WindowID{2, 1}) { | |
| 40 | + | t.Fatal(w.Columns) | |
| 41 | + | } | |
| 42 | + | // A window moving into row 0 takes the place of the slot window: the | |
| 43 | + | // slot is released too. | |
| 44 | + | w = workspace() | |
| 45 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Slot: 7, Focus: 1}} | |
| 46 | + | w.Apply(ActionMoveWindowUp) | |
| 47 | + | if w.Columns[0].Slot != 0 { | |
| 48 | + | t.Fatal(w.Columns) | |
| 49 | + | } | |
| 50 | + | // Rows below keep it. | |
| 51 | + | w = workspace() | |
| 52 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Slot: 7, Focus: 1}} | |
| 53 | + | w.Apply(ActionMoveWindowDown) | |
| 54 | + | if w.Columns[0].Slot != 7 { | |
| 55 | + | t.Fatal(w.Columns) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestMoveWindowOnFloat(t *testing.T) { | |
| 60 | + | w := workspace() | |
| 61 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}}} | |
| 62 | + | w.AddFloating(3, 20, 20) | |
| 63 | + | w.Apply(ActionMoveWindowDown) | |
| 64 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{1, 2}) { | |
| 65 | + | t.Fatal(w.Columns) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestResizeColumn(t *testing.T) { | |
| 70 | + | w := workspace() | |
| 71 | + | w.SetOutput(1000, 800) | |
| 72 | + | w.SetGaps(10) | |
| 73 | + | w.AddWindow(1) | |
| 74 | + | w.AddWindow(2) | |
| 75 | + | before := w.columnWidth(w.Focus) | |
| 76 | + | w.Apply("set-column-width +10%") | |
| 77 | + | if w.Columns[w.Focus].Width.Den != 100 || w.columnWidth(w.Focus) <= before { | |
| 78 | + | t.Fatal(w.Columns[w.Focus].Width, w.columnWidth(w.Focus), before) | |
| 79 | + | } | |
| 80 | + | w.Apply("set-column-width -10%") | |
| 81 | + | if d := w.columnWidth(w.Focus) - before; d < -1 || d > 1 { | |
| 82 | + | t.Fatal(w.columnWidth(w.Focus), before) | |
| 83 | + | } | |
| 84 | + | for range 20 { | |
| 85 | + | w.Apply("set-column-width +10%") | |
| 86 | + | } | |
| 87 | + | if w.Columns[w.Focus].Width != (Width{Num: 100, Den: 100}) { | |
| 88 | + | t.Fatal(w.Columns[w.Focus].Width) | |
| 89 | + | } | |
| 90 | + | for range 20 { | |
| 91 | + | w.Apply("set-column-width -10%") | |
| 92 | + | } | |
| 93 | + | if w.Columns[w.Focus].Width != (Width{Num: 10, Den: 100}) { | |
| 94 | + | t.Fatal(w.Columns[w.Focus].Width) | |
| 95 | + | } | |
| 96 | + | // A preset snaps to the nearest whole percent once; steps then | |
| 97 | + | // round-trip exactly. | |
| 98 | + | for _, p := range []Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 2, Den: 3}} { | |
| 99 | + | w.Columns[w.Focus].Width = p | |
| 100 | + | px := w.columnWidth(w.Focus) | |
| 101 | + | w.Apply("set-column-width +7%") | |
| 102 | + | w.Apply("set-column-width -7%") | |
| 103 | + | snapped := w.columnWidth(w.Focus) | |
| 104 | + | if d := snapped - px; d < -w.Usable.W/100 || d > w.Usable.W/100 { | |
| 105 | + | t.Fatal(p, snapped, px) | |
| 106 | + | } | |
| 107 | + | w.Apply("set-column-width +7%") | |
| 108 | + | w.Apply("set-column-width -7%") | |
| 109 | + | if w.columnWidth(w.Focus) != snapped { | |
| 110 | + | t.Fatal(p, w.columnWidth(w.Focus), snapped) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestResizeColumnUnmaximizesAndCycles(t *testing.T) { | |
| 116 | + | w := workspace() | |
| 117 | + | w.SetOutput(1000, 800) | |
| 118 | + | w.SetPresets([]Width{{Num: 1, Den: 2}, {Num: 1, Den: 1}}) | |
| 119 | + | w.AddWindow(1) | |
| 120 | + | w.ToggleFullWidth() | |
| 121 | + | w.Apply("set-column-width -10%") | |
| 122 | + | if w.Columns[0].FullWidth { | |
| 123 | + | t.Fatal("still maximized") | |
| 124 | + | } | |
| 125 | + | // A width outside the presets restarts the cycle at the first preset. | |
| 126 | + | w.CycleWidth() | |
| 127 | + | if w.Columns[0].Width != (Width{Num: 1, Den: 2}) { | |
| 128 | + | t.Fatal(w.Columns[0].Width) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestResizeColumnSameValueAsPreset(t *testing.T) { | |
| 133 | + | w := workspace() | |
| 134 | + | w.SetOutput(1000, 800) | |
| 135 | + | w.SetPresets([]Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 1, Den: 1}}) | |
| 136 | + | w.AddWindow(1) | |
| 137 | + | w.Columns[0].Width = Width{Num: 50, Den: 100} | |
| 138 | + | w.CycleWidth() | |
| 139 | + | if w.Columns[0].Width != (Width{Num: 1, Den: 1}) { | |
| 140 | + | t.Fatal(w.Columns[0].Width) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestColumnEditsUnderFullscreen(t *testing.T) { | |
| 145 | + | w := workspace() | |
| 146 | + | w.SetOutput(1000, 800) | |
| 147 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: Width{Num: 1, Den: 2}}} | |
| 148 | + | w.SetFullscreen(1, true) | |
| 149 | + | w.Apply("set-column-width -10%") | |
| 150 | + | w.Apply(ActionMoveWindowDown) | |
| 151 | + | c := w.Columns[0] | |
| 152 | + | if c.Width != (Width{Num: 1, Den: 2}) || !slices.Equal(c.Windows, []WindowID{1, 2}) { | |
| 153 | + | t.Fatal(c) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestResizeColumnFixedNoop(t *testing.T) { | |
| 158 | + | w := workspace() | |
| 159 | + | w.Overflow = OverflowFixed | |
| 160 | + | w.AddWindow(1) | |
| 161 | + | w.Apply("set-column-width +10%") | |
| 162 | + | if w.Columns[0].Width != (Width{}) { | |
| 163 | + | t.Fatal(w.Columns[0].Width) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestMoveWorkspace(t *testing.T) { | |
| 168 | + | m := monitor() | |
| 169 | + | m.AddWindow(1) | |
| 170 | + | m.FocusNumber(2) | |
| 171 | + | m.AddWindow(2) | |
| 172 | + | m.FocusNumber(3) | |
| 173 | + | m.AddWindow(3) | |
| 174 | + | // [1:{1}] [2:{2}] [3:{3}] [4:{}] | |
| 175 | + | m.Apply(ActionMoveWorkspaceUp) | |
| 176 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {2}, {}}) || m.Active != 1 { | |
| 177 | + | t.Fatal(windows(m), m.Active) | |
| 178 | + | } | |
| 179 | + | m.Apply(ActionMoveWorkspaceUp) | |
| 180 | + | m.Apply(ActionMoveWorkspaceUp) | |
| 181 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{3}, {1}, {2}, {}}) || m.Active != 0 { | |
| 182 | + | t.Fatal(windows(m), m.Active) | |
| 183 | + | } | |
| 184 | + | m.Apply(ActionMoveWorkspaceDown) | |
| 185 | + | m.Apply(ActionMoveWorkspaceDown) | |
| 186 | + | // Never past the trailing empty workspace. | |
| 187 | + | m.Apply(ActionMoveWorkspaceDown) | |
| 188 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {3}, {}}) || m.Active != 2 { | |
| 189 | + | t.Fatal(windows(m), m.Active) | |
| 190 | + | } | |
| 191 | + | // The trailing empty workspace does not move. | |
| 192 | + | m.FocusNumber(4) | |
| 193 | + | m.Apply(ActionMoveWorkspaceUp) | |
| 194 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {3}, {}}) || m.Active != 3 { | |
| 195 | + | t.Fatal(windows(m), m.Active) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestMoveWorkspaceHidden(t *testing.T) { | |
| 200 | + | m := named(monitor(), NamedWorkspace{Name: "dev"}) | |
| 201 | + | m.AddWindow(1) | |
| 202 | + | m.FocusNumber(2) | |
| 203 | + | m.AddWindow(2) | |
| 204 | + | m.ToggleNamed("dev") | |
| 205 | + | m.Apply(ActionMoveWorkspaceUp) | |
| 206 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) { | |
| 207 | + | t.Fatal(windows(m)) | |
| 208 | + | } | |
| 209 | + | } | |
| @@ -0,0 +1,209 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestMoveWindowInColumn(t *testing.T) { | |||
| 10 | + | for _, o := range []Overflow{OverflowScroll, OverflowFixed} { | |||
| 11 | + | w := workspace() | |||
| 12 | + | w.Overflow = o | |||
| 13 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}} | |||
| 14 | + | w.Apply(ActionMoveWindowUp) | |||
| 15 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{2, 1, 3}) || w.Columns[0].Focus != 0 { | |||
| 16 | + | t.Fatal(o, w.Columns) | |||
| 17 | + | } | |||
| 18 | + | // Top end: nothing moves. | |||
| 19 | + | w.Apply(ActionMoveWindowUp) | |||
| 20 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{2, 1, 3}) || w.Columns[0].Focus != 0 { | |||
| 21 | + | t.Fatal(o, w.Columns) | |||
| 22 | + | } | |||
| 23 | + | w.Apply(ActionMoveWindowDown) | |||
| 24 | + | w.Apply(ActionMoveWindowDown) | |||
| 25 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{1, 3, 2}) || w.Columns[0].Focus != 2 { | |||
| 26 | + | t.Fatal(o, w.Columns) | |||
| 27 | + | } | |||
| 28 | + | w.Apply(ActionMoveWindowDown) | |||
| 29 | + | if id, _ := w.Focused(); id != 2 || w.Columns[0].Focus != 2 { | |||
| 30 | + | t.Fatal(o, w.Columns) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestMoveWindowReleasesSlot(t *testing.T) { | |||
| 36 | + | w := workspace() | |||
| 37 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Slot: 7}} | |||
| 38 | + | w.Apply(ActionMoveWindowDown) | |||
| 39 | + | if w.Columns[0].Slot != 0 || !slices.Equal(w.Columns[0].Windows, []WindowID{2, 1}) { | |||
| 40 | + | t.Fatal(w.Columns) | |||
| 41 | + | } | |||
| 42 | + | // A window moving into row 0 takes the place of the slot window: the | |||
| 43 | + | // slot is released too. | |||
| 44 | + | w = workspace() | |||
| 45 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Slot: 7, Focus: 1}} | |||
| 46 | + | w.Apply(ActionMoveWindowUp) | |||
| 47 | + | if w.Columns[0].Slot != 0 { | |||
| 48 | + | t.Fatal(w.Columns) | |||
| 49 | + | } | |||
| 50 | + | // Rows below keep it. | |||
| 51 | + | w = workspace() | |||
| 52 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Slot: 7, Focus: 1}} | |||
| 53 | + | w.Apply(ActionMoveWindowDown) | |||
| 54 | + | if w.Columns[0].Slot != 7 { | |||
| 55 | + | t.Fatal(w.Columns) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestMoveWindowOnFloat(t *testing.T) { | |||
| 60 | + | w := workspace() | |||
| 61 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}}} | |||
| 62 | + | w.AddFloating(3, 20, 20) | |||
| 63 | + | w.Apply(ActionMoveWindowDown) | |||
| 64 | + | if !slices.Equal(w.Columns[0].Windows, []WindowID{1, 2}) { | |||
| 65 | + | t.Fatal(w.Columns) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestResizeColumn(t *testing.T) { | |||
| 70 | + | w := workspace() | |||
| 71 | + | w.SetOutput(1000, 800) | |||
| 72 | + | w.SetGaps(10) | |||
| 73 | + | w.AddWindow(1) | |||
| 74 | + | w.AddWindow(2) | |||
| 75 | + | before := w.columnWidth(w.Focus) | |||
| 76 | + | w.Apply("set-column-width +10%") | |||
| 77 | + | if w.Columns[w.Focus].Width.Den != 100 || w.columnWidth(w.Focus) <= before { | |||
| 78 | + | t.Fatal(w.Columns[w.Focus].Width, w.columnWidth(w.Focus), before) | |||
| 79 | + | } | |||
| 80 | + | w.Apply("set-column-width -10%") | |||
| 81 | + | if d := w.columnWidth(w.Focus) - before; d < -1 || d > 1 { | |||
| 82 | + | t.Fatal(w.columnWidth(w.Focus), before) | |||
| 83 | + | } | |||
| 84 | + | for range 20 { | |||
| 85 | + | w.Apply("set-column-width +10%") | |||
| 86 | + | } | |||
| 87 | + | if w.Columns[w.Focus].Width != (Width{Num: 100, Den: 100}) { | |||
| 88 | + | t.Fatal(w.Columns[w.Focus].Width) | |||
| 89 | + | } | |||
| 90 | + | for range 20 { | |||
| 91 | + | w.Apply("set-column-width -10%") | |||
| 92 | + | } | |||
| 93 | + | if w.Columns[w.Focus].Width != (Width{Num: 10, Den: 100}) { | |||
| 94 | + | t.Fatal(w.Columns[w.Focus].Width) | |||
| 95 | + | } | |||
| 96 | + | // A preset snaps to the nearest whole percent once; steps then | |||
| 97 | + | // round-trip exactly. | |||
| 98 | + | for _, p := range []Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 2, Den: 3}} { | |||
| 99 | + | w.Columns[w.Focus].Width = p | |||
| 100 | + | px := w.columnWidth(w.Focus) | |||
| 101 | + | w.Apply("set-column-width +7%") | |||
| 102 | + | w.Apply("set-column-width -7%") | |||
| 103 | + | snapped := w.columnWidth(w.Focus) | |||
| 104 | + | if d := snapped - px; d < -w.Usable.W/100 || d > w.Usable.W/100 { | |||
| 105 | + | t.Fatal(p, snapped, px) | |||
| 106 | + | } | |||
| 107 | + | w.Apply("set-column-width +7%") | |||
| 108 | + | w.Apply("set-column-width -7%") | |||
| 109 | + | if w.columnWidth(w.Focus) != snapped { | |||
| 110 | + | t.Fatal(p, w.columnWidth(w.Focus), snapped) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestResizeColumnUnmaximizesAndCycles(t *testing.T) { | |||
| 116 | + | w := workspace() | |||
| 117 | + | w.SetOutput(1000, 800) | |||
| 118 | + | w.SetPresets([]Width{{Num: 1, Den: 2}, {Num: 1, Den: 1}}) | |||
| 119 | + | w.AddWindow(1) | |||
| 120 | + | w.ToggleFullWidth() | |||
| 121 | + | w.Apply("set-column-width -10%") | |||
| 122 | + | if w.Columns[0].FullWidth { | |||
| 123 | + | t.Fatal("still maximized") | |||
| 124 | + | } | |||
| 125 | + | // A width outside the presets restarts the cycle at the first preset. | |||
| 126 | + | w.CycleWidth() | |||
| 127 | + | if w.Columns[0].Width != (Width{Num: 1, Den: 2}) { | |||
| 128 | + | t.Fatal(w.Columns[0].Width) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestResizeColumnSameValueAsPreset(t *testing.T) { | |||
| 133 | + | w := workspace() | |||
| 134 | + | w.SetOutput(1000, 800) | |||
| 135 | + | w.SetPresets([]Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 1, Den: 1}}) | |||
| 136 | + | w.AddWindow(1) | |||
| 137 | + | w.Columns[0].Width = Width{Num: 50, Den: 100} | |||
| 138 | + | w.CycleWidth() | |||
| 139 | + | if w.Columns[0].Width != (Width{Num: 1, Den: 1}) { | |||
| 140 | + | t.Fatal(w.Columns[0].Width) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestColumnEditsUnderFullscreen(t *testing.T) { | |||
| 145 | + | w := workspace() | |||
| 146 | + | w.SetOutput(1000, 800) | |||
| 147 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: Width{Num: 1, Den: 2}}} | |||
| 148 | + | w.SetFullscreen(1, true) | |||
| 149 | + | w.Apply("set-column-width -10%") | |||
| 150 | + | w.Apply(ActionMoveWindowDown) | |||
| 151 | + | c := w.Columns[0] | |||
| 152 | + | if c.Width != (Width{Num: 1, Den: 2}) || !slices.Equal(c.Windows, []WindowID{1, 2}) { | |||
| 153 | + | t.Fatal(c) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestResizeColumnFixedNoop(t *testing.T) { | |||
| 158 | + | w := workspace() | |||
| 159 | + | w.Overflow = OverflowFixed | |||
| 160 | + | w.AddWindow(1) | |||
| 161 | + | w.Apply("set-column-width +10%") | |||
| 162 | + | if w.Columns[0].Width != (Width{}) { | |||
| 163 | + | t.Fatal(w.Columns[0].Width) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestMoveWorkspace(t *testing.T) { | |||
| 168 | + | m := monitor() | |||
| 169 | + | m.AddWindow(1) | |||
| 170 | + | m.FocusNumber(2) | |||
| 171 | + | m.AddWindow(2) | |||
| 172 | + | m.FocusNumber(3) | |||
| 173 | + | m.AddWindow(3) | |||
| 174 | + | // [1:{1}] [2:{2}] [3:{3}] [4:{}] | |||
| 175 | + | m.Apply(ActionMoveWorkspaceUp) | |||
| 176 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {2}, {}}) || m.Active != 1 { | |||
| 177 | + | t.Fatal(windows(m), m.Active) | |||
| 178 | + | } | |||
| 179 | + | m.Apply(ActionMoveWorkspaceUp) | |||
| 180 | + | m.Apply(ActionMoveWorkspaceUp) | |||
| 181 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{3}, {1}, {2}, {}}) || m.Active != 0 { | |||
| 182 | + | t.Fatal(windows(m), m.Active) | |||
| 183 | + | } | |||
| 184 | + | m.Apply(ActionMoveWorkspaceDown) | |||
| 185 | + | m.Apply(ActionMoveWorkspaceDown) | |||
| 186 | + | // Never past the trailing empty workspace. | |||
| 187 | + | m.Apply(ActionMoveWorkspaceDown) | |||
| 188 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {3}, {}}) || m.Active != 2 { | |||
| 189 | + | t.Fatal(windows(m), m.Active) | |||
| 190 | + | } | |||
| 191 | + | // The trailing empty workspace does not move. | |||
| 192 | + | m.FocusNumber(4) | |||
| 193 | + | m.Apply(ActionMoveWorkspaceUp) | |||
| 194 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {3}, {}}) || m.Active != 3 { | |||
| 195 | + | t.Fatal(windows(m), m.Active) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestMoveWorkspaceHidden(t *testing.T) { | |||
| 200 | + | m := named(monitor(), NamedWorkspace{Name: "dev"}) | |||
| 201 | + | m.AddWindow(1) | |||
| 202 | + | m.FocusNumber(2) | |||
| 203 | + | m.AddWindow(2) | |||
| 204 | + | m.ToggleNamed("dev") | |||
| 205 | + | m.Apply(ActionMoveWorkspaceUp) | |||
| 206 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) { | |||
| 207 | + | t.Fatal(windows(m)) | |||
| 208 | + | } | |||
| 209 | + | } | |||
M internal/core/workspaces_test.go +58 -0
| @@ -104,3 +104,61 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) { | |||
| 104 | 104 | t.Fatal(moved) | |
| 105 | 105 | } | |
| 106 | 106 | } | |
| 107 | + | ||
| 108 | + | func TestMoveWorkspaceReordersSnapshot(t *testing.T) { | |
| 109 | + | cfg := config.Defaults() | |
| 110 | + | client := make(chan ports.ClientEvent, 16) | |
| 111 | + | input := make(chan ports.InputEvent, 8) | |
| 112 | + | output := make(chan ports.OutputEvent, 8) | |
| 113 | + | snapshots := make(chan ports.Workspaces, 1) | |
| 114 | + | scenes := make(chan []ports.Scene, 1) | |
| 115 | + | spawn := make(chan ports.SpawnRequest, 8) | |
| 116 | + | commands := make(chan ports.ClientCommand, 32) | |
| 117 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn}) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 122 | + | done := make(chan error, 1) | |
| 123 | + | go func() { done <- c.Run(ctx) }() | |
| 124 | + | go func() { | |
| 125 | + | for { | |
| 126 | + | select { | |
| 127 | + | case <-scenes: | |
| 128 | + | case <-commands: | |
| 129 | + | case <-spawn: | |
| 130 | + | case <-ctx.Done(): | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | }() | |
| 135 | + | t.Cleanup(func() { | |
| 136 | + | cancel() | |
| 137 | + | if err := <-done; err != nil { | |
| 138 | + | t.Error(err) | |
| 139 | + | } | |
| 140 | + | }) | |
| 141 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}} | |
| 142 | + | until := func(ok func(ws []ports.WorkspaceInfo) bool) []ports.WorkspaceInfo { | |
| 143 | + | t.Helper() | |
| 144 | + | for { | |
| 145 | + | v := receive(t, snapshots) | |
| 146 | + | if len(v.Outputs) > 0 && ok(v.Outputs[0].Workspaces) { | |
| 147 | + | return v.Outputs[0].Workspaces | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | client <- ports.WindowMapped{ID: 1} | |
| 152 | + | until(func(ws []ports.WorkspaceInfo) bool { return len(ws) >= 2 }) | |
| 153 | + | input <- ports.KeyEvent{Keysym: "Next", Mods: ports.ModSuper, Pressed: true} | |
| 154 | + | until(func(ws []ports.WorkspaceInfo) bool { return ws[1].Active }) | |
| 155 | + | client <- ports.WindowMapped{ID: 2} | |
| 156 | + | ws := until(func(ws []ports.WorkspaceInfo) bool { return len(ws) >= 3 && ws[1].Active }) | |
| 157 | + | before := ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Workspaces: ws}}} | |
| 158 | + | first, second := before.Outputs[0].Workspaces[0].ID, before.Outputs[0].Workspaces[1].ID | |
| 159 | + | input <- ports.KeyEvent{Keysym: "Up", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true} | |
| 160 | + | ws = until(func(ws []ports.WorkspaceInfo) bool { return ws[0].Active }) | |
| 161 | + | if ws[0].ID != second || ws[1].ID != first || !ws[0].Active || ws[0].Index != 0 || ws[1].Index != 1 { | |
| 162 | + | t.Fatalf("%+v", ws) | |
| 163 | + | } | |
| 164 | + | } | |
| @@ -104,3 +104,61 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) { | |||||
| 104 | t.Fatal(moved) | 104 | t.Fatal(moved) | ||
| 105 | } | 105 | } | ||
| 106 | } | 106 | } | ||
| 107 | + | ||||
| 108 | + | func TestMoveWorkspaceReordersSnapshot(t *testing.T) { | |||
| 109 | + | cfg := config.Defaults() | |||
| 110 | + | client := make(chan ports.ClientEvent, 16) | |||
| 111 | + | input := make(chan ports.InputEvent, 8) | |||
| 112 | + | output := make(chan ports.OutputEvent, 8) | |||
| 113 | + | snapshots := make(chan ports.Workspaces, 1) | |||
| 114 | + | scenes := make(chan []ports.Scene, 1) | |||
| 115 | + | spawn := make(chan ports.SpawnRequest, 8) | |||
| 116 | + | commands := make(chan ports.ClientCommand, 32) | |||
| 117 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn}) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 122 | + | done := make(chan error, 1) | |||
| 123 | + | go func() { done <- c.Run(ctx) }() | |||
| 124 | + | go func() { | |||
| 125 | + | for { | |||
| 126 | + | select { | |||
| 127 | + | case <-scenes: | |||
| 128 | + | case <-commands: | |||
| 129 | + | case <-spawn: | |||
| 130 | + | case <-ctx.Done(): | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | }() | |||
| 135 | + | t.Cleanup(func() { | |||
| 136 | + | cancel() | |||
| 137 | + | if err := <-done; err != nil { | |||
| 138 | + | t.Error(err) | |||
| 139 | + | } | |||
| 140 | + | }) | |||
| 141 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}} | |||
| 142 | + | until := func(ok func(ws []ports.WorkspaceInfo) bool) []ports.WorkspaceInfo { | |||
| 143 | + | t.Helper() | |||
| 144 | + | for { | |||
| 145 | + | v := receive(t, snapshots) | |||
| 146 | + | if len(v.Outputs) > 0 && ok(v.Outputs[0].Workspaces) { | |||
| 147 | + | return v.Outputs[0].Workspaces | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | client <- ports.WindowMapped{ID: 1} | |||
| 152 | + | until(func(ws []ports.WorkspaceInfo) bool { return len(ws) >= 2 }) | |||
| 153 | + | input <- ports.KeyEvent{Keysym: "Next", Mods: ports.ModSuper, Pressed: true} | |||
| 154 | + | until(func(ws []ports.WorkspaceInfo) bool { return ws[1].Active }) | |||
| 155 | + | client <- ports.WindowMapped{ID: 2} | |||
| 156 | + | ws := until(func(ws []ports.WorkspaceInfo) bool { return len(ws) >= 3 && ws[1].Active }) | |||
| 157 | + | before := ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Workspaces: ws}}} | |||
| 158 | + | first, second := before.Outputs[0].Workspaces[0].ID, before.Outputs[0].Workspaces[1].ID | |||
| 159 | + | input <- ports.KeyEvent{Keysym: "Up", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true} | |||
| 160 | + | ws = until(func(ws []ports.WorkspaceInfo) bool { return ws[0].Active }) | |||
| 161 | + | if ws[0].ID != second || ws[1].ID != first || !ws[0].Active || ws[0].Index != 0 || ws[1].Index != 1 { | |||
| 162 | + | t.Fatalf("%+v", ws) | |||
| 163 | + | } | |||
| 164 | + | } | |||