detached 186314e

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 UnverifiedB5690EEEBB952194 The 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
126126 | `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 |
127127 | `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 |
128128 | `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 |
129131 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
130132 | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace |
131133 | `workspace <name>` | Toggle a named workspace |
@@ -133,6 +135,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
133135 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |
134136 | `focus-monitor-left/right` | Focus the neighbor monitor |
135137 | `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 |
136139 | `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`) |
137140 | `quit` | Exit NeferWL |
138141
@@ -165,6 +168,8 @@ Return or a click on a front tile or peeking card commits it; card changes remai
165168
166169 ### Default binds
167170
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+
168173 | Keys | Action |
169174 |---|---|
170175 | `cmd+return` | `spawn-terminal` |
@@ -182,6 +187,10 @@ Return or a click on a front tile or peeking card commits it; card changes remai
182187 | `cmd+shift+l` | `move-column-right` |
183188 | `cmd+bracketleft` | `consume-or-expel-window-left` |
184189 | `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%` |
185194 | `cmd+r` | `cycle-column-width` |
186195 | `cmd+f` | `maximize-column` |
187196 | `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
221230 | `cmd+ctrl+shift+right` | `move-workspace-to-monitor-right` |
222231 | `cmd+ctrl+shift+h` | `move-workspace-to-monitor-left` |
223232 | `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` |
224235 | `cmd+code:13` | `scale-up` |
225236 | `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 binds169 ### 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
125125 # neighbor column instead, past the edge on the neighbor monitor.
126126 bind.cmd+bracketleft = consume-or-expel-window-left
127127 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
128144 bind.cmd+r = cycle-column-width
129145 bind.cmd+f = maximize-column
130146 bind.cmd+shift+f = toggle-fullscreen
@@ -143,6 +159,11 @@ bind.cmd+pagedown = focus-workspace-down
143159 # (N, -up, -down) moves only the focused window and has no default bind.
144160 bind.cmd+shift+pageup = move-column-to-workspace-up
145161 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
146167 bind.cmd+code:2 = focus-workspace 1
147168 bind.cmd+code:3 = focus-workspace 2
148169 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-left126 bind.cmd+bracketleft = consume-or-expel-window-left
127 bind.cmd+bracketright = consume-or-expel-window-right127 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-width144 bind.cmd+r = cycle-column-width
129 bind.cmd+f = maximize-column145 bind.cmd+f = maximize-column
130 bind.cmd+shift+f = toggle-fullscreen146 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-up160 bind.cmd+shift+pageup = move-column-to-workspace-up
145 bind.cmd+shift+pagedown = move-column-to-workspace-down161 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 1167 bind.cmd+code:2 = focus-workspace 1
147 bind.cmd+code:3 = focus-workspace 2168 bind.cmd+code:3 = focus-workspace 2
148 bind.cmd+code:4 = focus-workspace 3169 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 }{
5151 {"cmd+ctrl+right", "focus-monitor-right"},
5252 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
5353 {"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"},
5463 }
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}
5665 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
5766 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
5867
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+
5972 // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
6073 var namedKeys = map[string]string{
6174 "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape",
@@ -812,6 +825,14 @@ func checkAction(v string) error {
812825 return nil
813826 }
814827 }
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+ }
815836 if !actions[v] {
816837 return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
817838 }
@@ -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 nil825 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) {
2323 func TestDefaultsAndLoad(t *testing.T) {
2424 t.Setenv("TERMINAL", "")
2525 d := Defaults()
26- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 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 {
2727 t.Fatalf("defaults: %+v", d)
2828 }
2929 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
3030 t.Fatal(d.Binds)
3131 }
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+ }
3235 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
3336 t.Fatal(d.Binds)
3437 }
@@ -193,6 +196,10 @@ func TestWarningsKeepDefaults(t *testing.T) {
193196 {"bind.cmd+ = quit", "missing key"},
194197 {"bind.hyper+x = quit", "unknown modifier"},
195198 {"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"},
196203 {"bind.cmd+x = spawn ", "unknown action"},
197204 }
198205 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 (
4343 // Consume or expel the focused window (ADR 016).
4444 ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left"
4545 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"
4654 )
4755
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+
4899 // WorkspaceOp is what a numbered workspace action does.
49100 type WorkspaceOp int
50101
@@ -283,6 +334,12 @@ func (m *Monitor) Apply(a Action) Effect {
283334 m.Focus(m.Active + 1)
284335 }
285336 return Effect{}
337+ case ActionMoveWorkspaceUp:
338+ m.MoveWorkspace(-1)
339+ return Effect{}
340+ case ActionMoveWorkspaceDown:
341+ m.MoveWorkspace(1)
342+ return Effect{}
286343 }
287344 return m.Current().Apply(a)
288345 }
@@ -324,6 +381,12 @@ func (w *Workspace) Apply(a Action) Effect {
324381 if argv, ok := SpawnArgv(a); ok {
325382 return Effect{Spawn: true, Argv: argv}
326383 }
384+ if axis, pct, ok := ResizeArg(a); ok {
385+ if axis == ResizeWidth {
386+ w.ResizeColumn(pct)
387+ }
388+ return Effect{}
389+ }
327390 switch a {
328391 case ActionSpawnTerminal:
329392 return Effect{Spawn: true}
@@ -339,6 +402,10 @@ func (w *Workspace) Apply(a Action) Effect {
339402 w.MoveColumn(-1)
340403 case ActionMoveColumnRight:
341404 w.MoveColumn(1)
405+ case ActionMoveWindowUp:
406+ w.MoveWindow(-1)
407+ case ActionMoveWindowDown:
408+ w.MoveWindow(1)
342409 case ActionCycleColumnWidth:
343410 w.CycleWidth()
344411 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 int100 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 {
204204 binds[b] = Action(a)
205205 continue
206206 }
207+ if _, _, ok := ResizeArg(Action(a)); ok {
208+ binds[b] = Action(a)
209+ continue
210+ }
207211 if _, ok := NamedArg(Action(a)); ok {
208212 binds[b] = Action(a)
209213 continue
210214 }
211215 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:
213218 default:
214219 return fmt.Errorf("invalid action %q", a)
215220 }
@@ -204,12 +204,17 @@ func (c *Core) apply(cfg ports.Config) error {
204 binds[b] = Action(a)204 binds[b] = Action(a)
205 continue205 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 continue213 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 {
4747 }
4848
4949 func TestOwner(t *testing.T) {
50- cfg := config.Defaults()
51- cfg.Keyboard.CmdKey = "alt"
50+ cfg := altCmdDefaults()
5251 cfg.Border.Width = 0
5352 cfg.Layout.Gaps = 8
5453 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 = 051 cfg.Border.Width = 0
53 cfg.Layout.Gaps = 852 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 {
2525 }
2626 // A fullscreen window hides the others (fixed) or its column (scroll):
2727 // moving would break its way back.
28- if w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) {
28+ if w.fullscreenHides() {
2929 return true
3030 }
3131 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 true29 return true
30 }30 }
31 src := w.Focus31 src := w.Focus
M internal/core/layout.go +57 -3
@@ -44,6 +44,14 @@ func ParseWidth(s string) (Width, error) {
4444 return Width{}, fmt.Errorf("invalid width %q", s)
4545 }
4646
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+
4755 // Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
4856 // Widths are clamped to the available area (zero if no space remains).
4957 func (v Width) Resolve(usableW, gaps int) int {
@@ -665,6 +673,44 @@ func (w *Workspace) MoveColumn(dir int) {
665673 }
666674 }
667675
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+
668714 // takeColumn removes the focused column and returns it, as a normal column.
669715 func (w *Workspace) takeColumn() (Column, bool) {
670716 if len(w.Columns) == 0 || w.onFloat() {
@@ -706,14 +752,15 @@ func (w *Workspace) CycleWidth() {
706752 if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() {
707753 return
708754 }
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.
710757 c := &w.Columns[w.Focus]
711758 next := Width{}
712- if c.Width == next {
759+ if c.Width == next || !slices.ContainsFunc(w.presets, c.Width.same) {
713760 next = w.presets[0]
714761 }
715762 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) {
717764 next = w.presets[i+1]
718765 break
719766 }
@@ -1004,6 +1051,13 @@ func (w *Workspace) cover() WindowID {
10041051 return 0
10051052 }
10061053
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+
10071061 func (w *Workspace) fullscreenColumn(i int) bool {
10081062 for _, id := range w.Columns[i].Windows {
10091063 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 return753 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 break765 break
719 }766 }
@@ -1004,6 +1051,13 @@ func (w *Workspace) cover() WindowID {
1004 return 01051 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) {
199199 m.normalize()
200200 }
201201
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+
202216 // FocusNumber switches to workspace n (1-based); past the end means the last.
203217 func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
204218
@@ -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 {
181181 // selection is provisional. Workspace and monitor navigation and moves to
182182 // another workspace remain active.
183183 func overviewBlocks(a Action) bool {
184+ if _, _, ok := ResizeArg(a); ok {
185+ return true
186+ }
184187 switch a {
185188 case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
186189 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
187190 ActionToggleStashVisible, ActionConsumeOrExpelLeft,
188- ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
191+ ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
192+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp,
193+ ActionMoveWorkspaceDown, ActionToggleFloating:
189194 return true
190195 }
191196 return false
@@ -181,11 +181,16 @@ func (m *Monitor) overviewTarget() WindowID {
181 // selection is provisional. Workspace and monitor navigation and moves to181 // 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 true194 return true
190 }195 }
191 return false196 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
4141 // startRig is startMulti; terminal enables the configured automatic-open policy.
4242 func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
4343 t.Helper()
44- cfg := config.Defaults()
45- cfg.Keyboard.CmdKey = "alt"
44+ cfg := altCmdDefaults()
4645 cfg.Border.Width = 0
4746 if edit != nil {
4847 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 = 045 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 {
2828 // fixed overflow.
2929 func startSlots(t *testing.T, numbered ...bool) *slotRig {
3030 t.Helper()
31- cfg := config.Defaults()
32- cfg.Keyboard.CmdKey = "alt"
31+ cfg := altCmdDefaults()
3332 cfg.Border.Width = 0
3433 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
3534 {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 = 032 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 (
66 "testing"
77 "time"
88
9- "github.com/bnema/neferwl/internal/adapters/config"
109 "github.com/bnema/neferwl/internal/core"
1110 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1211 "github.com/bnema/neferwl/internal/ports"
@@ -28,8 +27,7 @@ var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli:
2827
2928 func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
3029 t.Helper()
31- cfg := config.Defaults()
32- cfg.Keyboard.CmdKey = "alt"
30+ cfg := altCmdDefaults()
3331 cfg.Border.Width = 0
3432 cfg.Layout.Gaps = 0
3533 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 = 031 cfg.Border.Width = 0
34 cfg.Layout.Gaps = 032 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) {
104104 t.Fatal(moved)
105105 }
106106 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close