feat(binds): add hjkl binds and move-column/window-to-workspace actions
Every arrow bind gets an h/j/k/l twin. Cmd+Shift+N and Cmd+Shift+PageUp/Down now move the whole focused column; move-window-to-workspace keeps the old single-window move without a default bind. move-to-workspace is removed. focus.follow-move = on shows the target workspace after a move (ADR 017).
- commit
- 503de12590b33008e27a7f2474e7e8ce7ba94aa2
- author
- brice · · 2026-09-26 14:38 UTC
- parent
- 1e24fe6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +241 -80
M examples/config +33 -11
| @@ -37,6 +37,9 @@ layout.overflow = scroll | |||
| 37 | 37 | # cycle-column-width steps through these, then back to the equal share. | |
| 38 | 38 | layout.presets = 1/3, 1/2, 2/3, 1 | |
| 39 | 39 | ||
| 40 | + | # on: moving a column or window to another workspace shows that workspace. | |
| 41 | + | focus.follow-move = off | |
| 42 | + | ||
| 40 | 43 | # Named workspaces. Numbered at their place like any other and never removed | |
| 41 | 44 | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | |
| 42 | 45 | # bind shows it, and pressing the bind again returns to the previous workspace. | |
| @@ -87,6 +90,13 @@ bind.cmd+up = focus-window-up | |||
| 87 | 90 | bind.cmd+down = focus-window-down | |
| 88 | 91 | bind.cmd+shift+left = move-column-left | |
| 89 | 92 | bind.cmd+shift+right = move-column-right | |
| 93 | + | # Vim keys: every arrow bind also works with h j k l. | |
| 94 | + | bind.cmd+h = focus-column-left | |
| 95 | + | bind.cmd+l = focus-column-right | |
| 96 | + | bind.cmd+k = focus-window-up | |
| 97 | + | bind.cmd+j = focus-window-down | |
| 98 | + | bind.cmd+shift+h = move-column-left | |
| 99 | + | bind.cmd+shift+l = move-column-right | |
| 90 | 100 | bind.cmd+r = cycle-column-width | |
| 91 | 101 | bind.cmd+shift+f = toggle-fullscreen | |
| 92 | 102 | bind.cmd+q = close-window | |
| @@ -97,8 +107,10 @@ bind.ctrl+cmd+space = spawn fuzzel | |||
| 97 | 107 | # waits below the last. cmd+up/down past the column edge also switch workspace. | |
| 98 | 108 | bind.cmd+pageup = focus-workspace-up | |
| 99 | 109 | bind.cmd+pagedown = focus-workspace-down | |
| 100 | - | bind.cmd+shift+pageup = move-to-workspace-up | |
| 101 | - | bind.cmd+shift+pagedown = move-to-workspace-down | |
| 110 | + | # move-column-to-workspace moves the whole column; move-window-to-workspace | |
| 111 | + | # (N, -up, -down) moves only the focused window and has no default bind. | |
| 112 | + | bind.cmd+shift+pageup = move-column-to-workspace-up | |
| 113 | + | bind.cmd+shift+pagedown = move-column-to-workspace-down | |
| 102 | 114 | bind.cmd+code:2 = focus-workspace 1 | |
| 103 | 115 | bind.cmd+code:3 = focus-workspace 2 | |
| 104 | 116 | bind.cmd+code:4 = focus-workspace 3 | |
| @@ -108,15 +120,25 @@ bind.cmd+code:7 = focus-workspace 6 | |||
| 108 | 120 | bind.cmd+code:8 = focus-workspace 7 | |
| 109 | 121 | bind.cmd+code:9 = focus-workspace 8 | |
| 110 | 122 | bind.cmd+code:10 = focus-workspace 9 | |
| 111 | - | bind.cmd+shift+code:2 = move-to-workspace 1 | |
| 112 | - | bind.cmd+shift+code:3 = move-to-workspace 2 | |
| 113 | - | bind.cmd+shift+code:4 = move-to-workspace 3 | |
| 114 | - | bind.cmd+shift+code:5 = move-to-workspace 4 | |
| 115 | - | bind.cmd+shift+code:6 = move-to-workspace 5 | |
| 116 | - | bind.cmd+shift+code:7 = move-to-workspace 6 | |
| 117 | - | bind.cmd+shift+code:8 = move-to-workspace 7 | |
| 118 | - | bind.cmd+shift+code:9 = move-to-workspace 8 | |
| 119 | - | bind.cmd+shift+code:10 = move-to-workspace 9 | |
| 123 | + | bind.cmd+shift+code:2 = move-column-to-workspace 1 | |
| 124 | + | bind.cmd+shift+code:3 = move-column-to-workspace 2 | |
| 125 | + | bind.cmd+shift+code:4 = move-column-to-workspace 3 | |
| 126 | + | bind.cmd+shift+code:5 = move-column-to-workspace 4 | |
| 127 | + | bind.cmd+shift+code:6 = move-column-to-workspace 5 | |
| 128 | + | bind.cmd+shift+code:7 = move-column-to-workspace 6 | |
| 129 | + | bind.cmd+shift+code:8 = move-column-to-workspace 7 | |
| 130 | + | bind.cmd+shift+code:9 = move-column-to-workspace 8 | |
| 131 | + | bind.cmd+shift+code:10 = move-column-to-workspace 9 | |
| 132 | + | ||
| 133 | + | # Monitors, left to right: focus the neighbor, or move the workspace there. | |
| 134 | + | bind.cmd+ctrl+left = focus-monitor-left | |
| 135 | + | bind.cmd+ctrl+right = focus-monitor-right | |
| 136 | + | bind.cmd+ctrl+h = focus-monitor-left | |
| 137 | + | bind.cmd+ctrl+l = focus-monitor-right | |
| 138 | + | bind.cmd+ctrl+shift+left = move-workspace-to-monitor-left | |
| 139 | + | bind.cmd+ctrl+shift+right = move-workspace-to-monitor-right | |
| 140 | + | bind.cmd+ctrl+shift+h = move-workspace-to-monitor-left | |
| 141 | + | bind.cmd+ctrl+shift+l = move-workspace-to-monitor-right | |
| 120 | 142 | ||
| 121 | 143 | # Zoom: the whole display, live. | |
| 122 | 144 | bind.cmd+code:13 = scale-up | |
| @@ -37,6 +37,9 @@ layout.overflow = scroll | |||||
| 37 | # cycle-column-width steps through these, then back to the equal share. | 37 | # cycle-column-width steps through these, then back to the equal share. | ||
| 38 | layout.presets = 1/3, 1/2, 2/3, 1 | 38 | layout.presets = 1/3, 1/2, 2/3, 1 | ||
| 39 | 39 | ||||
| 40 | + | # on: moving a column or window to another workspace shows that workspace. | |||
| 41 | + | focus.follow-move = off | |||
| 42 | + | ||||
| 40 | # Named workspaces. Numbered at their place like any other and never removed | 43 | # Named workspaces. Numbered at their place like any other and never removed | ||
| 41 | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | 44 | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | ||
| 42 | # bind shows it, and pressing the bind again returns to the previous workspace. | 45 | # bind shows it, and pressing the bind again returns to the previous workspace. | ||
| @@ -87,6 +90,13 @@ bind.cmd+up = focus-window-up | |||||
| 87 | bind.cmd+down = focus-window-down | 90 | bind.cmd+down = focus-window-down | ||
| 88 | bind.cmd+shift+left = move-column-left | 91 | bind.cmd+shift+left = move-column-left | ||
| 89 | bind.cmd+shift+right = move-column-right | 92 | bind.cmd+shift+right = move-column-right | ||
| 93 | + | # Vim keys: every arrow bind also works with h j k l. | |||
| 94 | + | bind.cmd+h = focus-column-left | |||
| 95 | + | bind.cmd+l = focus-column-right | |||
| 96 | + | bind.cmd+k = focus-window-up | |||
| 97 | + | bind.cmd+j = focus-window-down | |||
| 98 | + | bind.cmd+shift+h = move-column-left | |||
| 99 | + | bind.cmd+shift+l = move-column-right | |||
| 90 | bind.cmd+r = cycle-column-width | 100 | bind.cmd+r = cycle-column-width | ||
| 91 | bind.cmd+shift+f = toggle-fullscreen | 101 | bind.cmd+shift+f = toggle-fullscreen | ||
| 92 | bind.cmd+q = close-window | 102 | bind.cmd+q = close-window | ||
| @@ -97,8 +107,10 @@ bind.ctrl+cmd+space = spawn fuzzel | |||||
| 97 | # waits below the last. cmd+up/down past the column edge also switch workspace. | 107 | # waits below the last. cmd+up/down past the column edge also switch workspace. | ||
| 98 | bind.cmd+pageup = focus-workspace-up | 108 | bind.cmd+pageup = focus-workspace-up | ||
| 99 | bind.cmd+pagedown = focus-workspace-down | 109 | bind.cmd+pagedown = focus-workspace-down | ||
| 100 | - | bind.cmd+shift+pageup = move-to-workspace-up | 110 | + | # move-column-to-workspace moves the whole column; move-window-to-workspace |
| 101 | - | bind.cmd+shift+pagedown = move-to-workspace-down | 111 | + | # (N, -up, -down) moves only the focused window and has no default bind. |
| 112 | + | bind.cmd+shift+pageup = move-column-to-workspace-up | |||
| 113 | + | bind.cmd+shift+pagedown = move-column-to-workspace-down | |||
| 102 | bind.cmd+code:2 = focus-workspace 1 | 114 | bind.cmd+code:2 = focus-workspace 1 | ||
| 103 | bind.cmd+code:3 = focus-workspace 2 | 115 | bind.cmd+code:3 = focus-workspace 2 | ||
| 104 | bind.cmd+code:4 = focus-workspace 3 | 116 | bind.cmd+code:4 = focus-workspace 3 | ||
| @@ -108,15 +120,25 @@ bind.cmd+code:7 = focus-workspace 6 | |||||
| 108 | bind.cmd+code:8 = focus-workspace 7 | 120 | bind.cmd+code:8 = focus-workspace 7 | ||
| 109 | bind.cmd+code:9 = focus-workspace 8 | 121 | bind.cmd+code:9 = focus-workspace 8 | ||
| 110 | bind.cmd+code:10 = focus-workspace 9 | 122 | bind.cmd+code:10 = focus-workspace 9 | ||
| 111 | - | bind.cmd+shift+code:2 = move-to-workspace 1 | 123 | + | bind.cmd+shift+code:2 = move-column-to-workspace 1 |
| 112 | - | bind.cmd+shift+code:3 = move-to-workspace 2 | 124 | + | bind.cmd+shift+code:3 = move-column-to-workspace 2 |
| 113 | - | bind.cmd+shift+code:4 = move-to-workspace 3 | 125 | + | bind.cmd+shift+code:4 = move-column-to-workspace 3 |
| 114 | - | bind.cmd+shift+code:5 = move-to-workspace 4 | 126 | + | bind.cmd+shift+code:5 = move-column-to-workspace 4 |
| 115 | - | bind.cmd+shift+code:6 = move-to-workspace 5 | 127 | + | bind.cmd+shift+code:6 = move-column-to-workspace 5 |
| 116 | - | bind.cmd+shift+code:7 = move-to-workspace 6 | 128 | + | bind.cmd+shift+code:7 = move-column-to-workspace 6 |
| 117 | - | bind.cmd+shift+code:8 = move-to-workspace 7 | 129 | + | bind.cmd+shift+code:8 = move-column-to-workspace 7 |
| 118 | - | bind.cmd+shift+code:9 = move-to-workspace 8 | 130 | + | bind.cmd+shift+code:9 = move-column-to-workspace 8 |
| 119 | - | bind.cmd+shift+code:10 = move-to-workspace 9 | 131 | + | bind.cmd+shift+code:10 = move-column-to-workspace 9 |
| 132 | + | ||||
| 133 | + | # Monitors, left to right: focus the neighbor, or move the workspace there. | |||
| 134 | + | bind.cmd+ctrl+left = focus-monitor-left | |||
| 135 | + | bind.cmd+ctrl+right = focus-monitor-right | |||
| 136 | + | bind.cmd+ctrl+h = focus-monitor-left | |||
| 137 | + | bind.cmd+ctrl+l = focus-monitor-right | |||
| 138 | + | bind.cmd+ctrl+shift+left = move-workspace-to-monitor-left | |||
| 139 | + | bind.cmd+ctrl+shift+right = move-workspace-to-monitor-right | |||
| 140 | + | bind.cmd+ctrl+shift+h = move-workspace-to-monitor-left | |||
| 141 | + | bind.cmd+ctrl+shift+l = move-workspace-to-monitor-right | |||
| 120 | 142 | ||||
| 121 | # Zoom: the whole display, live. | 143 | # Zoom: the whole display, live. | ||
| 122 | bind.cmd+code:13 = scale-up | 144 | bind.cmd+code:13 = scale-up | ||
M internal/adapters/config/config.go +24 -9
| @@ -34,14 +34,14 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 34 | 34 | {"ctrl+cmd+space", "spawn fuzzel"}, | |
| 35 | 35 | {"cmd+pageup", "focus-workspace-up"}, | |
| 36 | 36 | {"cmd+pagedown", "focus-workspace-down"}, | |
| 37 | - | {"cmd+shift+pageup", "move-to-workspace-up"}, | |
| 38 | - | {"cmd+shift+pagedown", "move-to-workspace-down"}, | |
| 37 | + | {"cmd+shift+pageup", "move-column-to-workspace-up"}, | |
| 38 | + | {"cmd+shift+pagedown", "move-column-to-workspace-down"}, | |
| 39 | 39 | {"cmd+ctrl+left", "focus-monitor-left"}, | |
| 40 | 40 | {"cmd+ctrl+right", "focus-monitor-right"}, | |
| 41 | 41 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 42 | 42 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 43 | 43 | } | |
| 44 | - | 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-to-workspace-up": true, "move-to-workspace-down": true, "none": 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, "toggle-fullscreen": true, "close-window": true, "quit": true} | |
| 44 | + | 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, "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, "toggle-fullscreen": true, "close-window": true, "quit": true} | |
| 45 | 45 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| 46 | 46 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 47 | 47 | ||
| @@ -87,11 +87,20 @@ func Defaults() ports.Config { | |||
| 87 | 87 | c.Layout.Overflow = "scroll" | |
| 88 | 88 | c.Binds = map[string]string{} | |
| 89 | 89 | for _, b := range defaultBinds { | |
| 90 | - | combo, err := parseCombo(b.combo) | |
| 91 | - | if err != nil { | |
| 92 | - | panic(err) | |
| 90 | + | combos := []string{b.combo} | |
| 91 | + | // Every arrow bind has a vim twin: h j k l. | |
| 92 | + | for arrow, vim := range map[string]string{"left": "h", "down": "j", "up": "k", "right": "l"} { | |
| 93 | + | if base, ok := strings.CutSuffix(b.combo, "+"+arrow); ok { | |
| 94 | + | combos = append(combos, base+"+"+vim) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | for _, s := range combos { | |
| 98 | + | combo, err := parseCombo(s) | |
| 99 | + | if err != nil { | |
| 100 | + | panic(err) | |
| 101 | + | } | |
| 102 | + | c.Binds[combo] = b.action | |
| 93 | 103 | } | |
| 94 | - | c.Binds[combo] = b.action | |
| 95 | 104 | } | |
| 96 | 105 | // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they | |
| 97 | 106 | // work the same on AZERTY, QWERTZ or Dvorak. | |
| @@ -103,7 +112,7 @@ func Defaults() ports.Config { | |||
| 103 | 112 | focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1)) | |
| 104 | 113 | move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1)) | |
| 105 | 114 | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | |
| 106 | - | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | |
| 115 | + | c.Binds[move] = fmt.Sprintf("move-column-to-workspace %d", n) | |
| 107 | 116 | } | |
| 108 | 117 | c.Render.DirectScanout = true | |
| 109 | 118 | c.Render.Tearing = true | |
| @@ -447,6 +456,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 447 | 456 | } | |
| 448 | 457 | } | |
| 449 | 458 | c.Layout.Presets = list | |
| 459 | + | case "focus.follow-move": | |
| 460 | + | b, err := onOff(v) | |
| 461 | + | if err != nil { | |
| 462 | + | return err | |
| 463 | + | } | |
| 464 | + | c.Focus.FollowMove = b | |
| 450 | 465 | case "render.direct-scanout": | |
| 451 | 466 | b, err := onOff(v) | |
| 452 | 467 | if err != nil { | |
| @@ -622,7 +637,7 @@ func checkAction(v string) error { | |||
| 622 | 637 | } | |
| 623 | 638 | return nil | |
| 624 | 639 | } | |
| 625 | - | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | |
| 640 | + | for _, prefix := range []string{"focus-workspace ", "move-column-to-workspace ", "move-window-to-workspace "} { | |
| 626 | 641 | if rest, ok := strings.CutPrefix(v, prefix); ok { | |
| 627 | 642 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | |
| 628 | 643 | return fmt.Errorf("%snumber must be between 1 and 99", prefix) | |
| @@ -34,14 +34,14 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 34 | {"ctrl+cmd+space", "spawn fuzzel"}, | 34 | {"ctrl+cmd+space", "spawn fuzzel"}, | ||
| 35 | {"cmd+pageup", "focus-workspace-up"}, | 35 | {"cmd+pageup", "focus-workspace-up"}, | ||
| 36 | {"cmd+pagedown", "focus-workspace-down"}, | 36 | {"cmd+pagedown", "focus-workspace-down"}, | ||
| 37 | - | {"cmd+shift+pageup", "move-to-workspace-up"}, | 37 | + | {"cmd+shift+pageup", "move-column-to-workspace-up"}, |
| 38 | - | {"cmd+shift+pagedown", "move-to-workspace-down"}, | 38 | + | {"cmd+shift+pagedown", "move-column-to-workspace-down"}, |
| 39 | {"cmd+ctrl+left", "focus-monitor-left"}, | 39 | {"cmd+ctrl+left", "focus-monitor-left"}, | ||
| 40 | {"cmd+ctrl+right", "focus-monitor-right"}, | 40 | {"cmd+ctrl+right", "focus-monitor-right"}, | ||
| 41 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | 41 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | ||
| 42 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | 42 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | ||
| 43 | } | 43 | } | ||
| 44 | - | 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-to-workspace-up": true, "move-to-workspace-down": true, "none": 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, "toggle-fullscreen": true, "close-window": true, "quit": true} | 44 | + | 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, "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, "toggle-fullscreen": true, "close-window": true, "quit": true} |
| 45 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | 45 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | ||
| 46 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 46 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | ||
| 47 | 47 | ||||
| @@ -87,11 +87,20 @@ func Defaults() ports.Config { | |||||
| 87 | c.Layout.Overflow = "scroll" | 87 | c.Layout.Overflow = "scroll" | ||
| 88 | c.Binds = map[string]string{} | 88 | c.Binds = map[string]string{} | ||
| 89 | for _, b := range defaultBinds { | 89 | for _, b := range defaultBinds { | ||
| 90 | - | combo, err := parseCombo(b.combo) | 90 | + | combos := []string{b.combo} |
| 91 | - | if err != nil { | 91 | + | // Every arrow bind has a vim twin: h j k l. |
| 92 | - | panic(err) | 92 | + | for arrow, vim := range map[string]string{"left": "h", "down": "j", "up": "k", "right": "l"} { |
| 93 | + | if base, ok := strings.CutSuffix(b.combo, "+"+arrow); ok { | |||
| 94 | + | combos = append(combos, base+"+"+vim) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | for _, s := range combos { | |||
| 98 | + | combo, err := parseCombo(s) | |||
| 99 | + | if err != nil { | |||
| 100 | + | panic(err) | |||
| 101 | + | } | |||
| 102 | + | c.Binds[combo] = b.action | |||
| 93 | } | 103 | } | ||
| 94 | - | c.Binds[combo] = b.action | |||
| 95 | } | 104 | } | ||
| 96 | // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they | 105 | // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they | ||
| 97 | // work the same on AZERTY, QWERTZ or Dvorak. | 106 | // work the same on AZERTY, QWERTZ or Dvorak. | ||
| @@ -103,7 +112,7 @@ func Defaults() ports.Config { | |||||
| 103 | focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1)) | 112 | focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1)) | ||
| 104 | move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1)) | 113 | move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1)) | ||
| 105 | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | 114 | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | ||
| 106 | - | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | 115 | + | c.Binds[move] = fmt.Sprintf("move-column-to-workspace %d", n) |
| 107 | } | 116 | } | ||
| 108 | c.Render.DirectScanout = true | 117 | c.Render.DirectScanout = true | ||
| 109 | c.Render.Tearing = true | 118 | c.Render.Tearing = true | ||
| @@ -447,6 +456,12 @@ func set(c *ports.Config, key, v string) error { | |||||
| 447 | } | 456 | } | ||
| 448 | } | 457 | } | ||
| 449 | c.Layout.Presets = list | 458 | c.Layout.Presets = list | ||
| 459 | + | case "focus.follow-move": | |||
| 460 | + | b, err := onOff(v) | |||
| 461 | + | if err != nil { | |||
| 462 | + | return err | |||
| 463 | + | } | |||
| 464 | + | c.Focus.FollowMove = b | |||
| 450 | case "render.direct-scanout": | 465 | case "render.direct-scanout": | ||
| 451 | b, err := onOff(v) | 466 | b, err := onOff(v) | ||
| 452 | if err != nil { | 467 | if err != nil { | ||
| @@ -622,7 +637,7 @@ func checkAction(v string) error { | |||||
| 622 | } | 637 | } | ||
| 623 | return nil | 638 | return nil | ||
| 624 | } | 639 | } | ||
| 625 | - | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | 640 | + | for _, prefix := range []string{"focus-workspace ", "move-column-to-workspace ", "move-window-to-workspace "} { |
| 626 | if rest, ok := strings.CutPrefix(v, prefix); ok { | 641 | if rest, ok := strings.CutPrefix(v, prefix); ok { | ||
| 627 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | 642 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | ||
| 628 | return fmt.Errorf("%snumber must be between 1 and 99", prefix) | 643 | return fmt.Errorf("%snumber must be between 1 and 99", prefix) | ||
M internal/adapters/config/config_test.go +4 -1
| @@ -21,9 +21,12 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) { | |||
| 21 | 21 | ||
| 22 | 22 | func TestDefaultsAndLoad(t *testing.T) { | |
| 23 | 23 | d := Defaults() | |
| 24 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 40 || d.Layout.MaxColumns != 2 { | |
| 24 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 50 || d.Layout.MaxColumns != 2 { | |
| 25 | 25 | t.Fatalf("defaults: %+v", d) | |
| 26 | 26 | } | |
| 27 | + | if 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 { | |
| 28 | + | t.Fatal(d.Binds) | |
| 29 | + | } | |
| 27 | 30 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | |
| 28 | 31 | t.Fatal(d.Binds) | |
| 29 | 32 | } | |
| @@ -21,9 +21,12 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) { | |||||
| 21 | 21 | ||||
| 22 | func TestDefaultsAndLoad(t *testing.T) { | 22 | func TestDefaultsAndLoad(t *testing.T) { | ||
| 23 | d := Defaults() | 23 | d := Defaults() | ||
| 24 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 40 || d.Layout.MaxColumns != 2 { | 24 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 50 || d.Layout.MaxColumns != 2 { |
| 25 | t.Fatalf("defaults: %+v", d) | 25 | t.Fatalf("defaults: %+v", d) | ||
| 26 | } | 26 | } | ||
| 27 | + | if 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 { | |||
| 28 | + | t.Fatal(d.Binds) | |||
| 29 | + | } | |||
| 27 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | 30 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | ||
| 28 | t.Fatal(d.Binds) | 31 | t.Fatal(d.Binds) | ||
| 29 | } | 32 | } | ||
M internal/core/action.go +47 -31
| @@ -20,38 +20,54 @@ const ( | |||
| 20 | 20 | ActionCloseWindow Action = "close-window" | |
| 21 | 21 | ActionQuit Action = "quit" | |
| 22 | 22 | // Workspaces stack vertically; up/down stop at the ends. | |
| 23 | - | ActionFocusWorkspaceUp Action = "focus-workspace-up" | |
| 24 | - | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| 25 | - | ActionMoveToWorkspaceUp Action = "move-to-workspace-up" | |
| 26 | - | ActionMoveToWorkspaceDown Action = "move-to-workspace-down" | |
| 23 | + | ActionFocusWorkspaceUp Action = "focus-workspace-up" | |
| 24 | + | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| 25 | + | // Move the focused column, or only the focused window, one workspace | |
| 26 | + | // up or down. | |
| 27 | + | ActionMoveColumnToWorkspaceUp Action = "move-column-to-workspace-up" | |
| 28 | + | ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down" | |
| 29 | + | ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up" | |
| 30 | + | ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down" | |
| 27 | 31 | // Monitors are ordered left to right (ADR 011). | |
| 28 | 32 | ActionFocusMonitorLeft Action = "focus-monitor-left" | |
| 29 | 33 | ActionFocusMonitorRight Action = "focus-monitor-right" | |
| 30 | 34 | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | |
| 31 | 35 | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | |
| 32 | 36 | // Scale steps through the clean scales of the output (see CleanScales). | |
| 33 | - | ActionScaleUp Action = "scale-up" | |
| 34 | - | ActionScaleDown Action = "scale-down" | |
| 35 | - | actionFocusWorkspace = "focus-workspace " | |
| 36 | - | actionMoveToWorkspace = "move-to-workspace " | |
| 37 | + | ActionScaleUp Action = "scale-up" | |
| 38 | + | ActionScaleDown Action = "scale-down" | |
| 37 | 39 | ) | |
| 38 | 40 | ||
| 39 | - | // WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1). | |
| 40 | - | // move is true for move-to-workspace. | |
| 41 | - | func WorkspaceArg(a Action) (n int, move bool, ok bool) { | |
| 42 | - | rest, found := strings.CutPrefix(string(a), actionFocusWorkspace) | |
| 43 | - | if !found { | |
| 44 | - | rest, found = strings.CutPrefix(string(a), actionMoveToWorkspace) | |
| 45 | - | move = true | |
| 46 | - | } | |
| 47 | - | if !found { | |
| 48 | - | return 0, false, false | |
| 49 | - | } | |
| 50 | - | n, err := strconv.Atoi(strings.TrimSpace(rest)) | |
| 51 | - | if err != nil || n < 1 || n > 99 { | |
| 52 | - | return 0, false, false | |
| 41 | + | // WorkspaceOp is what a numbered workspace action does. | |
| 42 | + | type WorkspaceOp int | |
| 43 | + | ||
| 44 | + | const ( | |
| 45 | + | FocusWorkspace WorkspaceOp = iota | |
| 46 | + | MoveColumnToWorkspace | |
| 47 | + | MoveWindowToWorkspace | |
| 48 | + | ) | |
| 49 | + | ||
| 50 | + | var workspacePrefixes = [...]string{ | |
| 51 | + | FocusWorkspace: "focus-workspace ", | |
| 52 | + | MoveColumnToWorkspace: "move-column-to-workspace ", | |
| 53 | + | MoveWindowToWorkspace: "move-window-to-workspace ", | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and | |
| 57 | + | // "move-window-to-workspace N" (N from 1). | |
| 58 | + | func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) { | |
| 59 | + | for i, prefix := range workspacePrefixes { | |
| 60 | + | rest, found := strings.CutPrefix(string(a), prefix) | |
| 61 | + | if !found { | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | n, err := strconv.Atoi(strings.TrimSpace(rest)) | |
| 65 | + | if err != nil || n < 1 || n > 99 { | |
| 66 | + | return 0, 0, false | |
| 67 | + | } | |
| 68 | + | return n, WorkspaceOp(i), true | |
| 53 | 69 | } | |
| 54 | - | return n, move, true | |
| 70 | + | return 0, 0, false | |
| 55 | 71 | } | |
| 56 | 72 | ||
| 57 | 73 | // namedPrefix toggles a named workspace: "workspace dev". | |
| @@ -151,11 +167,11 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 151 | 167 | ||
| 152 | 168 | // Apply runs a bind action on the monitor. | |
| 153 | 169 | func (m *Monitor) Apply(a Action) Effect { | |
| 154 | - | if n, move, ok := WorkspaceArg(a); ok { | |
| 155 | - | if move { | |
| 156 | - | m.MoveToWorkspace(n - 1) | |
| 157 | - | } else { | |
| 170 | + | if n, op, ok := WorkspaceArg(a); ok { | |
| 171 | + | if op == FocusWorkspace { | |
| 158 | 172 | m.FocusNumber(n) | |
| 173 | + | } else { | |
| 174 | + | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | |
| 159 | 175 | } | |
| 160 | 176 | return Effect{} | |
| 161 | 177 | } | |
| @@ -185,14 +201,14 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 185 | 201 | m.Focus(m.Active + 1) | |
| 186 | 202 | } | |
| 187 | 203 | return Effect{} | |
| 188 | - | case ActionMoveToWorkspaceUp: | |
| 204 | + | case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp: | |
| 189 | 205 | if m.shown == nil && m.Active > 0 { | |
| 190 | - | m.MoveToWorkspace(m.Active - 1) | |
| 206 | + | m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp) | |
| 191 | 207 | } | |
| 192 | 208 | return Effect{} | |
| 193 | - | case ActionMoveToWorkspaceDown: | |
| 209 | + | case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown: | |
| 194 | 210 | if m.shown == nil { | |
| 195 | - | m.MoveToWorkspace(m.Active + 1) | |
| 211 | + | m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown) | |
| 196 | 212 | } | |
| 197 | 213 | return Effect{} | |
| 198 | 214 | } | |
| @@ -20,38 +20,54 @@ const ( | |||||
| 20 | ActionCloseWindow Action = "close-window" | 20 | ActionCloseWindow Action = "close-window" | ||
| 21 | ActionQuit Action = "quit" | 21 | ActionQuit Action = "quit" | ||
| 22 | // Workspaces stack vertically; up/down stop at the ends. | 22 | // Workspaces stack vertically; up/down stop at the ends. | ||
| 23 | - | ActionFocusWorkspaceUp Action = "focus-workspace-up" | 23 | + | ActionFocusWorkspaceUp Action = "focus-workspace-up" |
| 24 | - | ActionFocusWorkspaceDown Action = "focus-workspace-down" | 24 | + | ActionFocusWorkspaceDown Action = "focus-workspace-down" |
| 25 | - | ActionMoveToWorkspaceUp Action = "move-to-workspace-up" | 25 | + | // Move the focused column, or only the focused window, one workspace |
| 26 | - | ActionMoveToWorkspaceDown Action = "move-to-workspace-down" | 26 | + | // up or down. |
| 27 | + | ActionMoveColumnToWorkspaceUp Action = "move-column-to-workspace-up" | |||
| 28 | + | ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down" | |||
| 29 | + | ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up" | |||
| 30 | + | ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down" | |||
| 27 | // Monitors are ordered left to right (ADR 011). | 31 | // Monitors are ordered left to right (ADR 011). | ||
| 28 | ActionFocusMonitorLeft Action = "focus-monitor-left" | 32 | ActionFocusMonitorLeft Action = "focus-monitor-left" | ||
| 29 | ActionFocusMonitorRight Action = "focus-monitor-right" | 33 | ActionFocusMonitorRight Action = "focus-monitor-right" | ||
| 30 | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | 34 | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | ||
| 31 | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | 35 | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | ||
| 32 | // Scale steps through the clean scales of the output (see CleanScales). | 36 | // Scale steps through the clean scales of the output (see CleanScales). | ||
| 33 | - | ActionScaleUp Action = "scale-up" | 37 | + | ActionScaleUp Action = "scale-up" |
| 34 | - | ActionScaleDown Action = "scale-down" | 38 | + | ActionScaleDown Action = "scale-down" |
| 35 | - | actionFocusWorkspace = "focus-workspace " | |||
| 36 | - | actionMoveToWorkspace = "move-to-workspace " | |||
| 37 | ) | 39 | ) | ||
| 38 | 40 | ||||
| 39 | - | // WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1). | 41 | + | // WorkspaceOp is what a numbered workspace action does. |
| 40 | - | // move is true for move-to-workspace. | 42 | + | type WorkspaceOp int |
| 41 | - | func WorkspaceArg(a Action) (n int, move bool, ok bool) { | 43 | + | |
| 42 | - | rest, found := strings.CutPrefix(string(a), actionFocusWorkspace) | 44 | + | const ( |
| 43 | - | if !found { | 45 | + | FocusWorkspace WorkspaceOp = iota |
| 44 | - | rest, found = strings.CutPrefix(string(a), actionMoveToWorkspace) | 46 | + | MoveColumnToWorkspace |
| 45 | - | move = true | 47 | + | MoveWindowToWorkspace |
| 46 | - | } | 48 | + | ) |
| 47 | - | if !found { | 49 | + | |
| 48 | - | return 0, false, false | 50 | + | var workspacePrefixes = [...]string{ |
| 49 | - | } | 51 | + | FocusWorkspace: "focus-workspace ", |
| 50 | - | n, err := strconv.Atoi(strings.TrimSpace(rest)) | 52 | + | MoveColumnToWorkspace: "move-column-to-workspace ", |
| 51 | - | if err != nil || n < 1 || n > 99 { | 53 | + | MoveWindowToWorkspace: "move-window-to-workspace ", |
| 52 | - | return 0, false, false | 54 | + | } |
| 55 | + | ||||
| 56 | + | // WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and | |||
| 57 | + | // "move-window-to-workspace N" (N from 1). | |||
| 58 | + | func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) { | |||
| 59 | + | for i, prefix := range workspacePrefixes { | |||
| 60 | + | rest, found := strings.CutPrefix(string(a), prefix) | |||
| 61 | + | if !found { | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | n, err := strconv.Atoi(strings.TrimSpace(rest)) | |||
| 65 | + | if err != nil || n < 1 || n > 99 { | |||
| 66 | + | return 0, 0, false | |||
| 67 | + | } | |||
| 68 | + | return n, WorkspaceOp(i), true | |||
| 53 | } | 69 | } | ||
| 54 | - | return n, move, true | 70 | + | return 0, 0, false |
| 55 | } | 71 | } | ||
| 56 | 72 | ||||
| 57 | // namedPrefix toggles a named workspace: "workspace dev". | 73 | // namedPrefix toggles a named workspace: "workspace dev". | ||
| @@ -151,11 +167,11 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 151 | 167 | ||||
| 152 | // Apply runs a bind action on the monitor. | 168 | // Apply runs a bind action on the monitor. | ||
| 153 | func (m *Monitor) Apply(a Action) Effect { | 169 | func (m *Monitor) Apply(a Action) Effect { | ||
| 154 | - | if n, move, ok := WorkspaceArg(a); ok { | 170 | + | if n, op, ok := WorkspaceArg(a); ok { |
| 155 | - | if move { | 171 | + | if op == FocusWorkspace { |
| 156 | - | m.MoveToWorkspace(n - 1) | |||
| 157 | - | } else { | |||
| 158 | m.FocusNumber(n) | 172 | m.FocusNumber(n) | ||
| 173 | + | } else { | |||
| 174 | + | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | |||
| 159 | } | 175 | } | ||
| 160 | return Effect{} | 176 | return Effect{} | ||
| 161 | } | 177 | } | ||
| @@ -185,14 +201,14 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 185 | m.Focus(m.Active + 1) | 201 | m.Focus(m.Active + 1) | ||
| 186 | } | 202 | } | ||
| 187 | return Effect{} | 203 | return Effect{} | ||
| 188 | - | case ActionMoveToWorkspaceUp: | 204 | + | case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp: |
| 189 | if m.shown == nil && m.Active > 0 { | 205 | if m.shown == nil && m.Active > 0 { | ||
| 190 | - | m.MoveToWorkspace(m.Active - 1) | 206 | + | m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp) |
| 191 | } | 207 | } | ||
| 192 | return Effect{} | 208 | return Effect{} | ||
| 193 | - | case ActionMoveToWorkspaceDown: | 209 | + | case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown: |
| 194 | if m.shown == nil { | 210 | if m.shown == nil { | ||
| 195 | - | m.MoveToWorkspace(m.Active + 1) | 211 | + | m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown) |
| 196 | } | 212 | } | ||
| 197 | return Effect{} | 213 | return Effect{} | ||
| 198 | } | 214 | } | ||
M internal/core/core.go +1 -1
| @@ -181,7 +181,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 181 | 181 | continue | |
| 182 | 182 | } | |
| 183 | 183 | switch Action(a) { | |
| 184 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 184 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 185 | 185 | default: | |
| 186 | 186 | return fmt.Errorf("invalid action %q", a) | |
| 187 | 187 | } | |
| @@ -181,7 +181,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 181 | continue | 181 | continue | ||
| 182 | } | 182 | } | ||
| 183 | switch Action(a) { | 183 | switch Action(a) { | ||
| 184 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | 184 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: |
| 185 | default: | 185 | default: | ||
| 186 | return fmt.Errorf("invalid action %q", a) | 186 | return fmt.Errorf("invalid action %q", a) | ||
| 187 | } | 187 | } | ||
M internal/core/core_test.go +1 -1
| @@ -496,7 +496,7 @@ func TestShiftReleasedFirst(t *testing.T) { | |||
| 496 | 496 | for len(commands) > 0 { | |
| 497 | 497 | <-commands | |
| 498 | 498 | } | |
| 499 | - | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1. | |
| 499 | + | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-column-to-workspace 1. | |
| 500 | 500 | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | |
| 501 | 501 | scene(t, scenes) | |
| 502 | 502 | // Shift goes up first, so the release reports "1": still swallowed. | |
| @@ -496,7 +496,7 @@ func TestShiftReleasedFirst(t *testing.T) { | |||||
| 496 | for len(commands) > 0 { | 496 | for len(commands) > 0 { | ||
| 497 | <-commands | 497 | <-commands | ||
| 498 | } | 498 | } | ||
| 499 | - | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1. | 499 | + | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-column-to-workspace 1. |
| 500 | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | 500 | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | ||
| 501 | scene(t, scenes) | 501 | scene(t, scenes) | ||
| 502 | // Shift goes up first, so the release reports "1": still swallowed. | 502 | // Shift goes up first, so the release reports "1": still swallowed. | ||
M internal/core/layout.go +6 -1
| @@ -229,6 +229,11 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 229 | 229 | if id == 0 || w.has(id) { | |
| 230 | 230 | return | |
| 231 | 231 | } | |
| 232 | + | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // addColumn places a new column like a new window and focuses it. | |
| 236 | + | func (w *Workspace) addColumn(col Column) { | |
| 232 | 237 | at := 0 | |
| 233 | 238 | if w.Overflow == OverflowFixed { | |
| 234 | 239 | // The spiral depends only on window order: new windows go last. | |
| @@ -238,7 +243,7 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 238 | 243 | } | |
| 239 | 244 | w.Columns = append(w.Columns, Column{}) | |
| 240 | 245 | copy(w.Columns[at+1:], w.Columns[at:]) | |
| 241 | - | w.Columns[at] = Column{Windows: []WindowID{id}} | |
| 246 | + | w.Columns[at] = col | |
| 242 | 247 | w.Focus = at | |
| 243 | 248 | w.scroll() | |
| 244 | 249 | } | |
| @@ -229,6 +229,11 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 229 | if id == 0 || w.has(id) { | 229 | if id == 0 || w.has(id) { | ||
| 230 | return | 230 | return | ||
| 231 | } | 231 | } | ||
| 232 | + | w.addColumn(Column{Windows: []WindowID{id}}) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // addColumn places a new column like a new window and focuses it. | |||
| 236 | + | func (w *Workspace) addColumn(col Column) { | |||
| 232 | at := 0 | 237 | at := 0 | ||
| 233 | if w.Overflow == OverflowFixed { | 238 | if w.Overflow == OverflowFixed { | ||
| 234 | // The spiral depends only on window order: new windows go last. | 239 | // The spiral depends only on window order: new windows go last. | ||
| @@ -238,7 +243,7 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 238 | } | 243 | } | ||
| 239 | w.Columns = append(w.Columns, Column{}) | 244 | w.Columns = append(w.Columns, Column{}) | ||
| 240 | copy(w.Columns[at+1:], w.Columns[at:]) | 245 | copy(w.Columns[at+1:], w.Columns[at:]) | ||
| 241 | - | w.Columns[at] = Column{Windows: []WindowID{id}} | 246 | + | w.Columns[at] = col |
| 242 | w.Focus = at | 247 | w.Focus = at | ||
| 243 | w.scroll() | 248 | w.scroll() | ||
| 244 | } | 249 | } | ||
M internal/core/monitor.go +30 -11
| @@ -22,6 +22,8 @@ type Monitor struct { | |||
| 22 | 22 | back *Workspace // where a named workspace toggle returns to | |
| 23 | 23 | template Workspace | |
| 24 | 24 | named []NamedWorkspace | |
| 25 | + | // followMove shows the target workspace after a move to it. | |
| 26 | + | followMove bool | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | |
| @@ -477,25 +479,39 @@ func (m *Monitor) fullscreenHome() { | |||
| 477 | 479 | } | |
| 478 | 480 | } | |
| 479 | 481 | ||
| 480 | - | // MoveToWorkspace moves the focused window to numbered workspace index i; | |
| 481 | - | // focus stays here. | |
| 482 | - | func (m *Monitor) MoveToWorkspace(i int) { | |
| 482 | + | // MoveToWorkspace moves the focused column (or only the focused window) to | |
| 483 | + | // numbered workspace index i. A floating window always moves alone. Focus | |
| 484 | + | // stays here unless followMove is set (ADR 017). | |
| 485 | + | func (m *Monitor) MoveToWorkspace(i int, column bool) { | |
| 483 | 486 | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 484 | - | cur := m.Current() | |
| 487 | + | cur, to := m.Current(), m.Workspaces[i] | |
| 485 | 488 | id, ok := cur.Focused() | |
| 486 | - | if !ok || m.Workspaces[i] == cur { | |
| 489 | + | if !ok || to == cur { | |
| 487 | 490 | return | |
| 488 | 491 | } | |
| 489 | 492 | if f := cur.floatIndex(id); f >= 0 { | |
| 490 | 493 | fl := cur.Floats[f] | |
| 491 | 494 | cur.RemoveWindow(id) | |
| 492 | - | m.Workspaces[i].AddFloating(id, fl.W, fl.H) | |
| 493 | - | } else if to := m.Workspaces[i]; to.origin != nil { | |
| 494 | - | cur.RemoveWindow(id) | |
| 495 | - | to.joinFullscreen(id) | |
| 495 | + | to.AddFloating(id, fl.W, fl.H) | |
| 496 | 496 | } else { | |
| 497 | - | cur.RemoveWindow(id) | |
| 498 | - | to.AddWindow(id) | |
| 497 | + | col := Column{Windows: []WindowID{id}} | |
| 498 | + | if column { | |
| 499 | + | col, _ = cur.takeColumn() | |
| 500 | + | } else { | |
| 501 | + | cur.RemoveWindow(id) | |
| 502 | + | } | |
| 503 | + | if to.origin != nil { | |
| 504 | + | for _, w := range col.Windows { | |
| 505 | + | to.joinFullscreen(w) | |
| 506 | + | } | |
| 507 | + | } else { | |
| 508 | + | to.addColumn(col) | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | if m.followMove { | |
| 512 | + | m.Active = i | |
| 513 | + | m.shown = nil | |
| 514 | + | to.FocusID(id) | |
| 499 | 515 | } | |
| 500 | 516 | m.normalize() | |
| 501 | 517 | } | |
| @@ -545,6 +561,9 @@ func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps( | |||
| 545 | 561 | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | |
| 546 | 562 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | |
| 547 | 563 | ||
| 564 | + | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | |
| 565 | + | func (m *Monitor) SetFollowMove(on bool) { m.followMove = on } | |
| 566 | + | ||
| 548 | 567 | // SetMaxColumns sets the default; named workspaces may override it. | |
| 549 | 568 | func (m *Monitor) SetMaxColumns(n int) { | |
| 550 | 569 | m.template.MaxColumns = n | |
| @@ -22,6 +22,8 @@ type Monitor struct { | |||||
| 22 | back *Workspace // where a named workspace toggle returns to | 22 | back *Workspace // where a named workspace toggle returns to | ||
| 23 | template Workspace | 23 | template Workspace | ||
| 24 | named []NamedWorkspace | 24 | named []NamedWorkspace | ||
| 25 | + | // followMove shows the target workspace after a move to it. | |||
| 26 | + | followMove bool | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | 29 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | ||
| @@ -477,25 +479,39 @@ func (m *Monitor) fullscreenHome() { | |||||
| 477 | } | 479 | } | ||
| 478 | } | 480 | } | ||
| 479 | 481 | ||||
| 480 | - | // MoveToWorkspace moves the focused window to numbered workspace index i; | 482 | + | // MoveToWorkspace moves the focused column (or only the focused window) to |
| 481 | - | // focus stays here. | 483 | + | // numbered workspace index i. A floating window always moves alone. Focus |
| 482 | - | func (m *Monitor) MoveToWorkspace(i int) { | 484 | + | // stays here unless followMove is set (ADR 017). |
| 485 | + | func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 483 | i = min(max(i, 0), len(m.Workspaces)-1) | 486 | i = min(max(i, 0), len(m.Workspaces)-1) | ||
| 484 | - | cur := m.Current() | 487 | + | cur, to := m.Current(), m.Workspaces[i] |
| 485 | id, ok := cur.Focused() | 488 | id, ok := cur.Focused() | ||
| 486 | - | if !ok || m.Workspaces[i] == cur { | 489 | + | if !ok || to == cur { |
| 487 | return | 490 | return | ||
| 488 | } | 491 | } | ||
| 489 | if f := cur.floatIndex(id); f >= 0 { | 492 | if f := cur.floatIndex(id); f >= 0 { | ||
| 490 | fl := cur.Floats[f] | 493 | fl := cur.Floats[f] | ||
| 491 | cur.RemoveWindow(id) | 494 | cur.RemoveWindow(id) | ||
| 492 | - | m.Workspaces[i].AddFloating(id, fl.W, fl.H) | 495 | + | to.AddFloating(id, fl.W, fl.H) |
| 493 | - | } else if to := m.Workspaces[i]; to.origin != nil { | |||
| 494 | - | cur.RemoveWindow(id) | |||
| 495 | - | to.joinFullscreen(id) | |||
| 496 | } else { | 496 | } else { | ||
| 497 | - | cur.RemoveWindow(id) | 497 | + | col := Column{Windows: []WindowID{id}} |
| 498 | - | to.AddWindow(id) | 498 | + | if column { |
| 499 | + | col, _ = cur.takeColumn() | |||
| 500 | + | } else { | |||
| 501 | + | cur.RemoveWindow(id) | |||
| 502 | + | } | |||
| 503 | + | if to.origin != nil { | |||
| 504 | + | for _, w := range col.Windows { | |||
| 505 | + | to.joinFullscreen(w) | |||
| 506 | + | } | |||
| 507 | + | } else { | |||
| 508 | + | to.addColumn(col) | |||
| 509 | + | } | |||
| 510 | + | } | |||
| 511 | + | if m.followMove { | |||
| 512 | + | m.Active = i | |||
| 513 | + | m.shown = nil | |||
| 514 | + | to.FocusID(id) | |||
| 499 | } | 515 | } | ||
| 500 | m.normalize() | 516 | m.normalize() | ||
| 501 | } | 517 | } | ||
| @@ -545,6 +561,9 @@ func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps( | |||||
| 545 | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | 561 | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | ||
| 546 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | 562 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | ||
| 547 | 563 | ||||
| 564 | + | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | |||
| 565 | + | func (m *Monitor) SetFollowMove(on bool) { m.followMove = on } | |||
| 566 | + | ||||
| 548 | // SetMaxColumns sets the default; named workspaces may override it. | 567 | // SetMaxColumns sets the default; named workspaces may override it. | ||
| 549 | func (m *Monitor) SetMaxColumns(n int) { | 568 | func (m *Monitor) SetMaxColumns(n int) { | ||
| 550 | m.template.MaxColumns = n | 569 | m.template.MaxColumns = n | ||
M internal/core/monitor_test.go +88 -13
| @@ -154,11 +154,11 @@ func TestMonitorActions(t *testing.T) { | |||
| 154 | 154 | t.Fatal(m.Active) | |
| 155 | 155 | } | |
| 156 | 156 | // Move the focused window (2) down: focus stays on workspace 1. | |
| 157 | - | m.Apply(ActionMoveToWorkspaceDown) | |
| 157 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 158 | 158 | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 { | |
| 159 | 159 | t.Fatal(windows(m), m.Active) | |
| 160 | 160 | } | |
| 161 | - | m.Apply("move-to-workspace 3") | |
| 161 | + | m.Apply("move-window-to-workspace 3") | |
| 162 | 162 | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 { | |
| 163 | 163 | t.Fatal(windows(m), m.Active) | |
| 164 | 164 | } | |
| @@ -202,15 +202,70 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||
| 202 | 202 | } | |
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | + | // stack returns a monitor with columns [1] [2 3], focus on window 3. | |
| 206 | + | func stack() *Monitor { | |
| 207 | + | m := monitor() | |
| 208 | + | m.AddWindow(1) | |
| 209 | + | m.AddWindow(2) | |
| 210 | + | m.AddWindow(3) | |
| 211 | + | w := m.Current() | |
| 212 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1}} | |
| 213 | + | w.Focus = 1 | |
| 214 | + | return m | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestMoveColumnToWorkspace(t *testing.T) { | |
| 218 | + | t.Run("whole column, focus stays", func(t *testing.T) { | |
| 219 | + | m := stack() | |
| 220 | + | m.Apply("move-column-to-workspace 2") | |
| 221 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) || m.Active != 0 { | |
| 222 | + | t.Fatal(got, m.Active) | |
| 223 | + | } | |
| 224 | + | if w := m.Workspaces[1]; len(w.Columns) != 1 || w.Columns[0].Focus != 1 { | |
| 225 | + | t.Fatal(w.Columns) | |
| 226 | + | } | |
| 227 | + | if id, _ := m.Focused(); id != 1 { | |
| 228 | + | t.Fatal(id) | |
| 229 | + | } | |
| 230 | + | }) | |
| 231 | + | t.Run("window only", func(t *testing.T) { | |
| 232 | + | m := stack() | |
| 233 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 234 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) { | |
| 235 | + | t.Fatal(got) | |
| 236 | + | } | |
| 237 | + | }) | |
| 238 | + | t.Run("follow shows the target", func(t *testing.T) { | |
| 239 | + | m := stack() | |
| 240 | + | m.SetFollowMove(true) | |
| 241 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 242 | + | if id, _ := m.Focused(); m.Active != 1 || id != 3 { | |
| 243 | + | t.Fatal(m.Active, id) | |
| 244 | + | } | |
| 245 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) { | |
| 246 | + | t.Fatal(got) | |
| 247 | + | } | |
| 248 | + | }) | |
| 249 | + | t.Run("slot column loses its slot", func(t *testing.T) { | |
| 250 | + | m := stack() | |
| 251 | + | m.Current().Columns[1].Slot = 2 | |
| 252 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 253 | + | if c := m.Workspaces[1].Columns[0]; c.Slot != 0 { | |
| 254 | + | t.Fatal(c) | |
| 255 | + | } | |
| 256 | + | }) | |
| 257 | + | } | |
| 258 | + | ||
| 205 | 259 | func TestWorkspaceArg(t *testing.T) { | |
| 206 | 260 | for _, tc := range []struct { | |
| 207 | - | a Action | |
| 208 | - | n int | |
| 209 | - | move, ok bool | |
| 210 | - | }{{"focus-workspace 3", 3, false, true}, {"move-to-workspace 12", 12, true, true}, {"focus-workspace 0", 0, false, false}, {"focus-workspace x", 0, false, false}, {"quit", 0, false, false}} { | |
| 211 | - | n, move, ok := WorkspaceArg(tc.a) | |
| 212 | - | if n != tc.n || move != tc.move || ok != tc.ok { | |
| 213 | - | t.Errorf("%s: %d %v %v", tc.a, n, move, ok) | |
| 261 | + | a Action | |
| 262 | + | n int | |
| 263 | + | op WorkspaceOp | |
| 264 | + | ok bool | |
| 265 | + | }{{"focus-workspace 3", 3, FocusWorkspace, true}, {"move-column-to-workspace 12", 12, MoveColumnToWorkspace, true}, {"move-window-to-workspace 2", 2, MoveWindowToWorkspace, true}, {"move-to-workspace 2", 0, 0, false}, {"focus-workspace 0", 0, 0, false}, {"focus-workspace x", 0, 0, false}, {"quit", 0, 0, false}} { | |
| 266 | + | n, op, ok := WorkspaceArg(tc.a) | |
| 267 | + | if n != tc.n || op != tc.op || ok != tc.ok { | |
| 268 | + | t.Errorf("%s: %d %v %v", tc.a, n, op, ok) | |
| 214 | 269 | } | |
| 215 | 270 | } | |
| 216 | 271 | } | |
| @@ -278,8 +333,8 @@ func TestHiddenWorkspaceToggle(t *testing.T) { | |||
| 278 | 333 | } | |
| 279 | 334 | // Moving a window up/down from a hidden workspace does nothing. | |
| 280 | 335 | m.Apply("workspace dev") | |
| 281 | - | m.Apply(ActionMoveToWorkspaceDown) | |
| 282 | - | m.Apply(ActionMoveToWorkspaceUp) | |
| 336 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 337 | + | m.Apply(ActionMoveWindowToWorkspaceUp) | |
| 283 | 338 | if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" { | |
| 284 | 339 | t.Fatal(id, windows(m)) | |
| 285 | 340 | } | |
| @@ -541,11 +596,31 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 541 | 596 | m.ToggleFullscreen() | |
| 542 | 597 | m.Apply(ActionFocusWorkspaceUp) | |
| 543 | 598 | m.Current().FocusID(1) | |
| 544 | - | m.Apply(ActionMoveToWorkspaceDown) | |
| 599 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 545 | 600 | if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil { | |
| 546 | 601 | t.Fatal(fs.Floats) | |
| 547 | 602 | } | |
| 548 | 603 | }) | |
| 604 | + | t.Run("a moved column floats window by window", func(t *testing.T) { | |
| 605 | + | m := fixed() | |
| 606 | + | m.Current().FocusID(2) | |
| 607 | + | m.ToggleFullscreen() | |
| 608 | + | m.Apply(ActionFocusWorkspaceUp) | |
| 609 | + | w := m.Current() | |
| 610 | + | ids := w.windows() | |
| 611 | + | w.Columns = []Column{{Windows: ids}} | |
| 612 | + | w.Focus = 0 | |
| 613 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 614 | + | fs := m.Workspaces[1] | |
| 615 | + | for _, id := range ids { | |
| 616 | + | if fs.floatIndex(id) < 0 { | |
| 617 | + | t.Fatal(id, fs.Floats) | |
| 618 | + | } | |
| 619 | + | } | |
| 620 | + | if fs.origin == nil || len(ids) < 2 { | |
| 621 | + | t.Fatal(fs.origin, ids) | |
| 622 | + | } | |
| 623 | + | }) | |
| 549 | 624 | t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) { | |
| 550 | 625 | m := fixed() | |
| 551 | 626 | m.Current().FocusID(2) | |
| @@ -603,7 +678,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 603 | 678 | m := fixed() | |
| 604 | 679 | m.Current().FocusID(2) | |
| 605 | 680 | m.ToggleFullscreen() | |
| 606 | - | m.Apply(ActionMoveToWorkspaceUp) | |
| 681 | + | m.Apply(ActionMoveWindowToWorkspaceUp) | |
| 607 | 682 | m.AddWindow(4) | |
| 608 | 683 | m.RemoveWindow(4) | |
| 609 | 684 | if m.Active != 1 { | |
| @@ -154,11 +154,11 @@ func TestMonitorActions(t *testing.T) { | |||||
| 154 | t.Fatal(m.Active) | 154 | t.Fatal(m.Active) | ||
| 155 | } | 155 | } | ||
| 156 | // Move the focused window (2) down: focus stays on workspace 1. | 156 | // Move the focused window (2) down: focus stays on workspace 1. | ||
| 157 | - | m.Apply(ActionMoveToWorkspaceDown) | 157 | + | m.Apply(ActionMoveWindowToWorkspaceDown) |
| 158 | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 { | 158 | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 { | ||
| 159 | t.Fatal(windows(m), m.Active) | 159 | t.Fatal(windows(m), m.Active) | ||
| 160 | } | 160 | } | ||
| 161 | - | m.Apply("move-to-workspace 3") | 161 | + | m.Apply("move-window-to-workspace 3") |
| 162 | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 { | 162 | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 { | ||
| 163 | t.Fatal(windows(m), m.Active) | 163 | t.Fatal(windows(m), m.Active) | ||
| 164 | } | 164 | } | ||
| @@ -202,15 +202,70 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||||
| 202 | } | 202 | } | ||
| 203 | } | 203 | } | ||
| 204 | 204 | ||||
| 205 | + | // stack returns a monitor with columns [1] [2 3], focus on window 3. | |||
| 206 | + | func stack() *Monitor { | |||
| 207 | + | m := monitor() | |||
| 208 | + | m.AddWindow(1) | |||
| 209 | + | m.AddWindow(2) | |||
| 210 | + | m.AddWindow(3) | |||
| 211 | + | w := m.Current() | |||
| 212 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1}} | |||
| 213 | + | w.Focus = 1 | |||
| 214 | + | return m | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestMoveColumnToWorkspace(t *testing.T) { | |||
| 218 | + | t.Run("whole column, focus stays", func(t *testing.T) { | |||
| 219 | + | m := stack() | |||
| 220 | + | m.Apply("move-column-to-workspace 2") | |||
| 221 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) || m.Active != 0 { | |||
| 222 | + | t.Fatal(got, m.Active) | |||
| 223 | + | } | |||
| 224 | + | if w := m.Workspaces[1]; len(w.Columns) != 1 || w.Columns[0].Focus != 1 { | |||
| 225 | + | t.Fatal(w.Columns) | |||
| 226 | + | } | |||
| 227 | + | if id, _ := m.Focused(); id != 1 { | |||
| 228 | + | t.Fatal(id) | |||
| 229 | + | } | |||
| 230 | + | }) | |||
| 231 | + | t.Run("window only", func(t *testing.T) { | |||
| 232 | + | m := stack() | |||
| 233 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |||
| 234 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) { | |||
| 235 | + | t.Fatal(got) | |||
| 236 | + | } | |||
| 237 | + | }) | |||
| 238 | + | t.Run("follow shows the target", func(t *testing.T) { | |||
| 239 | + | m := stack() | |||
| 240 | + | m.SetFollowMove(true) | |||
| 241 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 242 | + | if id, _ := m.Focused(); m.Active != 1 || id != 3 { | |||
| 243 | + | t.Fatal(m.Active, id) | |||
| 244 | + | } | |||
| 245 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) { | |||
| 246 | + | t.Fatal(got) | |||
| 247 | + | } | |||
| 248 | + | }) | |||
| 249 | + | t.Run("slot column loses its slot", func(t *testing.T) { | |||
| 250 | + | m := stack() | |||
| 251 | + | m.Current().Columns[1].Slot = 2 | |||
| 252 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 253 | + | if c := m.Workspaces[1].Columns[0]; c.Slot != 0 { | |||
| 254 | + | t.Fatal(c) | |||
| 255 | + | } | |||
| 256 | + | }) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 205 | func TestWorkspaceArg(t *testing.T) { | 259 | func TestWorkspaceArg(t *testing.T) { | ||
| 206 | for _, tc := range []struct { | 260 | for _, tc := range []struct { | ||
| 207 | - | a Action | 261 | + | a Action |
| 208 | - | n int | 262 | + | n int |
| 209 | - | move, ok bool | 263 | + | op WorkspaceOp |
| 210 | - | }{{"focus-workspace 3", 3, false, true}, {"move-to-workspace 12", 12, true, true}, {"focus-workspace 0", 0, false, false}, {"focus-workspace x", 0, false, false}, {"quit", 0, false, false}} { | 264 | + | ok bool |
| 211 | - | n, move, ok := WorkspaceArg(tc.a) | 265 | + | }{{"focus-workspace 3", 3, FocusWorkspace, true}, {"move-column-to-workspace 12", 12, MoveColumnToWorkspace, true}, {"move-window-to-workspace 2", 2, MoveWindowToWorkspace, true}, {"move-to-workspace 2", 0, 0, false}, {"focus-workspace 0", 0, 0, false}, {"focus-workspace x", 0, 0, false}, {"quit", 0, 0, false}} { |
| 212 | - | if n != tc.n || move != tc.move || ok != tc.ok { | 266 | + | n, op, ok := WorkspaceArg(tc.a) |
| 213 | - | t.Errorf("%s: %d %v %v", tc.a, n, move, ok) | 267 | + | if n != tc.n || op != tc.op || ok != tc.ok { |
| 268 | + | t.Errorf("%s: %d %v %v", tc.a, n, op, ok) | |||
| 214 | } | 269 | } | ||
| 215 | } | 270 | } | ||
| 216 | } | 271 | } | ||
| @@ -278,8 +333,8 @@ func TestHiddenWorkspaceToggle(t *testing.T) { | |||||
| 278 | } | 333 | } | ||
| 279 | // Moving a window up/down from a hidden workspace does nothing. | 334 | // Moving a window up/down from a hidden workspace does nothing. | ||
| 280 | m.Apply("workspace dev") | 335 | m.Apply("workspace dev") | ||
| 281 | - | m.Apply(ActionMoveToWorkspaceDown) | 336 | + | m.Apply(ActionMoveWindowToWorkspaceDown) |
| 282 | - | m.Apply(ActionMoveToWorkspaceUp) | 337 | + | m.Apply(ActionMoveWindowToWorkspaceUp) |
| 283 | if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" { | 338 | if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" { | ||
| 284 | t.Fatal(id, windows(m)) | 339 | t.Fatal(id, windows(m)) | ||
| 285 | } | 340 | } | ||
| @@ -541,11 +596,31 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||||
| 541 | m.ToggleFullscreen() | 596 | m.ToggleFullscreen() | ||
| 542 | m.Apply(ActionFocusWorkspaceUp) | 597 | m.Apply(ActionFocusWorkspaceUp) | ||
| 543 | m.Current().FocusID(1) | 598 | m.Current().FocusID(1) | ||
| 544 | - | m.Apply(ActionMoveToWorkspaceDown) | 599 | + | m.Apply(ActionMoveWindowToWorkspaceDown) |
| 545 | if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil { | 600 | if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil { | ||
| 546 | t.Fatal(fs.Floats) | 601 | t.Fatal(fs.Floats) | ||
| 547 | } | 602 | } | ||
| 548 | }) | 603 | }) | ||
| 604 | + | t.Run("a moved column floats window by window", func(t *testing.T) { | |||
| 605 | + | m := fixed() | |||
| 606 | + | m.Current().FocusID(2) | |||
| 607 | + | m.ToggleFullscreen() | |||
| 608 | + | m.Apply(ActionFocusWorkspaceUp) | |||
| 609 | + | w := m.Current() | |||
| 610 | + | ids := w.windows() | |||
| 611 | + | w.Columns = []Column{{Windows: ids}} | |||
| 612 | + | w.Focus = 0 | |||
| 613 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 614 | + | fs := m.Workspaces[1] | |||
| 615 | + | for _, id := range ids { | |||
| 616 | + | if fs.floatIndex(id) < 0 { | |||
| 617 | + | t.Fatal(id, fs.Floats) | |||
| 618 | + | } | |||
| 619 | + | } | |||
| 620 | + | if fs.origin == nil || len(ids) < 2 { | |||
| 621 | + | t.Fatal(fs.origin, ids) | |||
| 622 | + | } | |||
| 623 | + | }) | |||
| 549 | t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) { | 624 | t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) { | ||
| 550 | m := fixed() | 625 | m := fixed() | ||
| 551 | m.Current().FocusID(2) | 626 | m.Current().FocusID(2) | ||
| @@ -603,7 +678,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||||
| 603 | m := fixed() | 678 | m := fixed() | ||
| 604 | m.Current().FocusID(2) | 679 | m.Current().FocusID(2) | ||
| 605 | m.ToggleFullscreen() | 680 | m.ToggleFullscreen() | ||
| 606 | - | m.Apply(ActionMoveToWorkspaceUp) | 681 | + | m.Apply(ActionMoveWindowToWorkspaceUp) |
| 607 | m.AddWindow(4) | 682 | m.AddWindow(4) | ||
| 608 | m.RemoveWindow(4) | 683 | m.RemoveWindow(4) | ||
| 609 | if m.Active != 1 { | 684 | if m.Active != 1 { | ||
M internal/core/screens.go +1 -0
| @@ -304,6 +304,7 @@ func (c *Core) settings(m *Monitor) { | |||
| 304 | 304 | m.SetPresets(c.presets) | |
| 305 | 305 | m.SetGaps(c.cfg.Layout.Gaps) | |
| 306 | 306 | m.SetBorder(c.cfg.Border.Width) | |
| 307 | + | m.SetFollowMove(c.cfg.Focus.FollowMove) | |
| 307 | 308 | } | |
| 308 | 309 | ||
| 309 | 310 | // neighbor returns the screen index left (-1) or right (+1) of the focused | |
| @@ -304,6 +304,7 @@ func (c *Core) settings(m *Monitor) { | |||||
| 304 | m.SetPresets(c.presets) | 304 | m.SetPresets(c.presets) | ||
| 305 | m.SetGaps(c.cfg.Layout.Gaps) | 305 | m.SetGaps(c.cfg.Layout.Gaps) | ||
| 306 | m.SetBorder(c.cfg.Border.Width) | 306 | m.SetBorder(c.cfg.Border.Width) | ||
| 307 | + | m.SetFollowMove(c.cfg.Focus.FollowMove) | |||
| 307 | } | 308 | } | ||
| 308 | 309 | ||||
| 309 | // neighbor returns the screen index left (-1) or right (+1) of the focused | 310 | // neighbor returns the screen index left (-1) or right (+1) of the focused | ||
M internal/core/slots_test.go +1 -1
| @@ -343,7 +343,7 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) { | |||
| 343 | 343 | r.fill(t) | |
| 344 | 344 | r.press(t, "d") // on dev, focus on code (slot 1) | |
| 345 | 345 | r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true} | |
| 346 | - | scene(t, r.scenes) // move-to-workspace-up: code goes to workspace 1 | |
| 346 | + | scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1 | |
| 347 | 347 | noSpawn(t, r.spawn) | |
| 348 | 348 | r.press(t, "d") // back to workspace 1 | |
| 349 | 349 | r.press(t, "d") // dev again: slot 1 is empty | |
| @@ -343,7 +343,7 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) { | |||||
| 343 | r.fill(t) | 343 | r.fill(t) | ||
| 344 | r.press(t, "d") // on dev, focus on code (slot 1) | 344 | r.press(t, "d") // on dev, focus on code (slot 1) | ||
| 345 | r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true} | 345 | r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true} | ||
| 346 | - | scene(t, r.scenes) // move-to-workspace-up: code goes to workspace 1 | 346 | + | scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1 |
| 347 | noSpawn(t, r.spawn) | 347 | noSpawn(t, r.spawn) | ||
| 348 | r.press(t, "d") // back to workspace 1 | 348 | r.press(t, "d") // back to workspace 1 | ||
| 349 | r.press(t, "d") // dev again: slot 1 is empty | 349 | r.press(t, "d") // dev again: slot 1 is empty | ||
M internal/ports/config.go +5 -0
| @@ -48,6 +48,11 @@ type Config struct { | |||
| 48 | 48 | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | |
| 49 | 49 | Overflow string | |
| 50 | 50 | } | |
| 51 | + | Focus struct { | |
| 52 | + | // FollowMove shows the target workspace after a column or window | |
| 53 | + | // moves to it. | |
| 54 | + | FollowMove bool | |
| 55 | + | } | |
| 51 | 56 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 52 | 57 | Workspaces []WorkspaceConfig | |
| 53 | 58 | // Outputs selects and configures physical displays (drm backend). | |
| @@ -48,6 +48,11 @@ type Config struct { | |||||
| 48 | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | 48 | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | ||
| 49 | Overflow string | 49 | Overflow string | ||
| 50 | } | 50 | } | ||
| 51 | + | Focus struct { | |||
| 52 | + | // FollowMove shows the target workspace after a column or window | |||
| 53 | + | // moves to it. | |||
| 54 | + | FollowMove bool | |||
| 55 | + | } | |||
| 51 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | 56 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | ||
| 52 | Workspaces []WorkspaceConfig | 57 | Workspaces []WorkspaceConfig | ||
| 53 | // Outputs selects and configures physical displays (drm backend). | 58 | // Outputs selects and configures physical displays (drm backend). | ||