detached 503de12

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +241 -80
M examples/config +33 -11
@@ -37,6 +37,9 @@ layout.overflow = scroll
3737 # cycle-column-width steps through these, then back to the equal share.
3838 layout.presets = 1/3, 1/2, 2/3, 1
3939
40+# on: moving a column or window to another workspace shows that workspace.
41+focus.follow-move = off
42+
4043 # Named workspaces. Numbered at their place like any other and never removed
4144 # when empty. hidden = on keeps one out of the numbers and of up/down: only its
4245 # bind shows it, and pressing the bind again returns to the previous workspace.
@@ -87,6 +90,13 @@ bind.cmd+up = focus-window-up
8790 bind.cmd+down = focus-window-down
8891 bind.cmd+shift+left = move-column-left
8992 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
90100 bind.cmd+r = cycle-column-width
91101 bind.cmd+shift+f = toggle-fullscreen
92102 bind.cmd+q = close-window
@@ -97,8 +107,10 @@ bind.ctrl+cmd+space = spawn fuzzel
97107 # waits below the last. cmd+up/down past the column edge also switch workspace.
98108 bind.cmd+pageup = focus-workspace-up
99109 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
102114 bind.cmd+code:2 = focus-workspace 1
103115 bind.cmd+code:3 = focus-workspace 2
104116 bind.cmd+code:4 = focus-workspace 3
@@ -108,15 +120,25 @@ bind.cmd+code:7 = focus-workspace 6
108120 bind.cmd+code:8 = focus-workspace 7
109121 bind.cmd+code:9 = focus-workspace 8
110122 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
120142
121143 # Zoom: the whole display, live.
122144 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, 138 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 removed43 # 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 its44 # 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-down90 bind.cmd+down = focus-window-down
88 bind.cmd+shift+left = move-column-left91 bind.cmd+shift+left = move-column-left
89 bind.cmd+shift+right = move-column-right92 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-width100 bind.cmd+r = cycle-column-width
91 bind.cmd+shift+f = toggle-fullscreen101 bind.cmd+shift+f = toggle-fullscreen
92 bind.cmd+q = close-window102 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-up108 bind.cmd+pageup = focus-workspace-up
99 bind.cmd+pagedown = focus-workspace-down109 bind.cmd+pagedown = focus-workspace-down
100-bind.cmd+shift+pageup = move-to-workspace-up110+# move-column-to-workspace moves the whole column; move-window-to-workspace
101-bind.cmd+shift+pagedown = move-to-workspace-down111+# (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 1114 bind.cmd+code:2 = focus-workspace 1
103 bind.cmd+code:3 = focus-workspace 2115 bind.cmd+code:3 = focus-workspace 2
104 bind.cmd+code:4 = focus-workspace 3116 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 7120 bind.cmd+code:8 = focus-workspace 7
109 bind.cmd+code:9 = focus-workspace 8121 bind.cmd+code:9 = focus-workspace 8
110 bind.cmd+code:10 = focus-workspace 9122 bind.cmd+code:10 = focus-workspace 9
111-bind.cmd+shift+code:2 = move-to-workspace 1123+bind.cmd+shift+code:2 = move-column-to-workspace 1
112-bind.cmd+shift+code:3 = move-to-workspace 2124+bind.cmd+shift+code:3 = move-column-to-workspace 2
113-bind.cmd+shift+code:4 = move-to-workspace 3125+bind.cmd+shift+code:4 = move-column-to-workspace 3
114-bind.cmd+shift+code:5 = move-to-workspace 4126+bind.cmd+shift+code:5 = move-column-to-workspace 4
115-bind.cmd+shift+code:6 = move-to-workspace 5127+bind.cmd+shift+code:6 = move-column-to-workspace 5
116-bind.cmd+shift+code:7 = move-to-workspace 6128+bind.cmd+shift+code:7 = move-column-to-workspace 6
117-bind.cmd+shift+code:8 = move-to-workspace 7129+bind.cmd+shift+code:8 = move-column-to-workspace 7
118-bind.cmd+shift+code:9 = move-to-workspace 8130+bind.cmd+shift+code:9 = move-column-to-workspace 8
119-bind.cmd+shift+code:10 = move-to-workspace 9131+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-up144 bind.cmd+code:13 = scale-up
M internal/adapters/config/config.go +24 -9
@@ -34,14 +34,14 @@ var defaultBinds = []struct{ combo, action string }{
3434 {"ctrl+cmd+space", "spawn fuzzel"},
3535 {"cmd+pageup", "focus-workspace-up"},
3636 {"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"},
3939 {"cmd+ctrl+left", "focus-monitor-left"},
4040 {"cmd+ctrl+right", "focus-monitor-right"},
4141 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
4242 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
4343 }
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}
4545 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
4646 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
4747
@@ -87,11 +87,20 @@ func Defaults() ports.Config {
8787 c.Layout.Overflow = "scroll"
8888 c.Binds = map[string]string{}
8989 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
93103 }
94- c.Binds[combo] = b.action
95104 }
96105 // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they
97106 // work the same on AZERTY, QWERTZ or Dvorak.
@@ -103,7 +112,7 @@ func Defaults() ports.Config {
103112 focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1))
104113 move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1))
105114 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)
107116 }
108117 c.Render.DirectScanout = true
109118 c.Render.Tearing = true
@@ -447,6 +456,12 @@ func set(c *ports.Config, key, v string) error {
447456 }
448457 }
449458 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
450465 case "render.direct-scanout":
451466 b, err := onOff(v)
452467 if err != nil {
@@ -622,7 +637,7 @@ func checkAction(v string) error {
622637 }
623638 return nil
624639 }
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 "} {
626641 if rest, ok := strings.CutPrefix(v, prefix); ok {
627642 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
628643 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 they105 // 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 = true117 c.Render.DirectScanout = true
109 c.Render.Tearing = true118 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 = list458 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 nil638 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) {
2121
2222 func TestDefaultsAndLoad(t *testing.T) {
2323 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 {
2525 t.Fatalf("defaults: %+v", d)
2626 }
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+ }
2730 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
2831 t.Fatal(d.Binds)
2932 }
@@ -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 (
2020 ActionCloseWindow Action = "close-window"
2121 ActionQuit Action = "quit"
2222 // 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"
2731 // Monitors are ordered left to right (ADR 011).
2832 ActionFocusMonitorLeft Action = "focus-monitor-left"
2933 ActionFocusMonitorRight Action = "focus-monitor-right"
3034 ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"
3135 ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"
3236 // 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"
3739 )
3840
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
5369 }
54- return n, move, true
70+ return 0, 0, false
5571 }
5672
5773 // namedPrefix toggles a named workspace: "workspace dev".
@@ -151,11 +167,11 @@ func (c *Core) applyAction(a Action) Effect {
151167
152168 // Apply runs a bind action on the monitor.
153169 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 {
158172 m.FocusNumber(n)
173+ } else {
174+ m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace)
159175 }
160176 return Effect{}
161177 }
@@ -185,14 +201,14 @@ func (m *Monitor) Apply(a Action) Effect {
185201 m.Focus(m.Active + 1)
186202 }
187203 return Effect{}
188- case ActionMoveToWorkspaceUp:
204+ case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp:
189205 if m.shown == nil && m.Active > 0 {
190- m.MoveToWorkspace(m.Active - 1)
206+ m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp)
191207 }
192208 return Effect{}
193- case ActionMoveToWorkspaceDown:
209+ case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown:
194210 if m.shown == nil {
195- m.MoveToWorkspace(m.Active + 1)
211+ m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown)
196212 }
197213 return Effect{}
198214 }
@@ -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 = true47+ MoveWindowToWorkspace
46- }48+)
47- if !found {49+
48- return 0, false, false50+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, false54+}
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, true70+ 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 {
181181 continue
182182 }
183183 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:
185185 default:
186186 return fmt.Errorf("invalid action %q", a)
187187 }
@@ -181,7 +181,7 @@ func (c *Core) apply(cfg ports.Config) error {
181 continue181 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) {
496496 for len(commands) > 0 {
497497 <-commands
498498 }
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.
500500 input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true}
501501 scene(t, scenes)
502502 // 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 <-commands497 <-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) {
229229 if id == 0 || w.has(id) {
230230 return
231231 }
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) {
232237 at := 0
233238 if w.Overflow == OverflowFixed {
234239 // The spiral depends only on window order: new windows go last.
@@ -238,7 +243,7 @@ func (w *Workspace) AddWindow(id WindowID) {
238243 }
239244 w.Columns = append(w.Columns, Column{})
240245 copy(w.Columns[at+1:], w.Columns[at:])
241- w.Columns[at] = Column{Windows: []WindowID{id}}
246+ w.Columns[at] = col
242247 w.Focus = at
243248 w.scroll()
244249 }
@@ -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 return230 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 := 0237 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 = at247 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 {
2222 back *Workspace // where a named workspace toggle returns to
2323 template Workspace
2424 named []NamedWorkspace
25+ // followMove shows the target workspace after a move to it.
26+ followMove bool
2527 }
2628
2729 // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
@@ -477,25 +479,39 @@ func (m *Monitor) fullscreenHome() {
477479 }
478480 }
479481
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) {
483486 i = min(max(i, 0), len(m.Workspaces)-1)
484- cur := m.Current()
487+ cur, to := m.Current(), m.Workspaces[i]
485488 id, ok := cur.Focused()
486- if !ok || m.Workspaces[i] == cur {
489+ if !ok || to == cur {
487490 return
488491 }
489492 if f := cur.floatIndex(id); f >= 0 {
490493 fl := cur.Floats[f]
491494 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)
496496 } 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)
499515 }
500516 m.normalize()
501517 }
@@ -545,6 +561,9 @@ func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(
545561 func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) }
546562 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
547563
564+// SetFollowMove makes moves to another workspace show it (focus.follow-move).
565+func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
566+
548567 // SetMaxColumns sets the default; named workspaces may override it.
549568 func (m *Monitor) SetMaxColumns(n int) {
550569 m.template.MaxColumns = n
@@ -22,6 +22,8 @@ type Monitor struct {
22 back *Workspace // where a named workspace toggle returns to22 back *Workspace // where a named workspace toggle returns to
23 template Workspace23 template Workspace
24 named []NamedWorkspace24 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 empty29 // 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 return490 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 = n569 m.template.MaxColumns = n
M internal/core/monitor_test.go +88 -13
@@ -154,11 +154,11 @@ func TestMonitorActions(t *testing.T) {
154154 t.Fatal(m.Active)
155155 }
156156 // Move the focused window (2) down: focus stays on workspace 1.
157- m.Apply(ActionMoveToWorkspaceDown)
157+ m.Apply(ActionMoveWindowToWorkspaceDown)
158158 if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 {
159159 t.Fatal(windows(m), m.Active)
160160 }
161- m.Apply("move-to-workspace 3")
161+ m.Apply("move-window-to-workspace 3")
162162 if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 {
163163 t.Fatal(windows(m), m.Active)
164164 }
@@ -202,15 +202,70 @@ func TestEqualSharesAndBorderless(t *testing.T) {
202202 }
203203 }
204204
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+
205259 func TestWorkspaceArg(t *testing.T) {
206260 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)
214269 }
215270 }
216271 }
@@ -278,8 +333,8 @@ func TestHiddenWorkspaceToggle(t *testing.T) {
278333 }
279334 // Moving a window up/down from a hidden workspace does nothing.
280335 m.Apply("workspace dev")
281- m.Apply(ActionMoveToWorkspaceDown)
282- m.Apply(ActionMoveToWorkspaceUp)
336+ m.Apply(ActionMoveWindowToWorkspaceDown)
337+ m.Apply(ActionMoveWindowToWorkspaceUp)
283338 if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
284339 t.Fatal(id, windows(m))
285340 }
@@ -541,11 +596,31 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
541596 m.ToggleFullscreen()
542597 m.Apply(ActionFocusWorkspaceUp)
543598 m.Current().FocusID(1)
544- m.Apply(ActionMoveToWorkspaceDown)
599+ m.Apply(ActionMoveWindowToWorkspaceDown)
545600 if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
546601 t.Fatal(fs.Floats)
547602 }
548603 })
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+ })
549624 t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
550625 m := fixed()
551626 m.Current().FocusID(2)
@@ -603,7 +678,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
603678 m := fixed()
604679 m.Current().FocusID(2)
605680 m.ToggleFullscreen()
606- m.Apply(ActionMoveToWorkspaceUp)
681+ m.Apply(ActionMoveWindowToWorkspaceUp)
607682 m.AddWindow(4)
608683 m.RemoveWindow(4)
609684 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 Action261+ a Action
208- n int262+ n int
209- move, ok bool263+ 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) {
304304 m.SetPresets(c.presets)
305305 m.SetGaps(c.cfg.Layout.Gaps)
306306 m.SetBorder(c.cfg.Border.Width)
307+ m.SetFollowMove(c.cfg.Focus.FollowMove)
307308 }
308309
309310 // 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 focused310 // 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) {
343343 r.fill(t)
344344 r.press(t, "d") // on dev, focus on code (slot 1)
345345 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
347347 noSpawn(t, r.spawn)
348348 r.press(t, "d") // back to workspace 1
349349 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 1346+ 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 1348 r.press(t, "d") // back to workspace 1
349 r.press(t, "d") // dev again: slot 1 is empty349 r.press(t, "d") // dev again: slot 1 is empty
M internal/ports/config.go +5 -0
@@ -48,6 +48,11 @@ type Config struct {
4848 // Overflow is "scroll" or "fixed" (see WorkspaceConfig).
4949 Overflow string
5050 }
51+ Focus struct {
52+ // FollowMove shows the target workspace after a column or window
53+ // moves to it.
54+ FollowMove bool
55+ }
5156 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
5257 Workspaces []WorkspaceConfig
5358 // 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 string49 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 []WorkspaceConfig57 Workspaces []WorkspaceConfig
53 // Outputs selects and configures physical displays (drm backend).58 // Outputs selects and configures physical displays (drm backend).

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 26

Tags 10

↑↓ moveenter openesc close