detached 23657a0

merge: layout-binds (hjkl, move-column-to-workspace, consume-or-expel)

commit
23657a0102907bf95a66d3594211b0f6139b60a5
author
brice · · 2026-09-26 14:55 UTC
merge of
33db215 + 188d6b5 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +705 -81
M examples/config +38 -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,18 @@ 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
100+# A window alone in its column joins the neighbor column; a stacked one
101+# leaves for a new column. A full fixed workspace stacks it into the
102+# neighbor column instead, past the edge on the neighbor monitor.
103+bind.cmd+bracketleft = consume-or-expel-window-left
104+bind.cmd+bracketright = consume-or-expel-window-right
90105 bind.cmd+r = cycle-column-width
91106 bind.cmd+shift+f = toggle-fullscreen
92107 bind.cmd+q = close-window
@@ -97,8 +112,10 @@ bind.ctrl+cmd+space = spawn fuzzel
97112 # waits below the last. cmd+up/down past the column edge also switch workspace.
98113 bind.cmd+pageup = focus-workspace-up
99114 bind.cmd+pagedown = focus-workspace-down
100-bind.cmd+shift+pageup = move-to-workspace-up
101-bind.cmd+shift+pagedown = move-to-workspace-down
115+# move-column-to-workspace moves the whole column; move-window-to-workspace
116+# (N, -up, -down) moves only the focused window and has no default bind.
117+bind.cmd+shift+pageup = move-column-to-workspace-up
118+bind.cmd+shift+pagedown = move-column-to-workspace-down
102119 bind.cmd+code:2 = focus-workspace 1
103120 bind.cmd+code:3 = focus-workspace 2
104121 bind.cmd+code:4 = focus-workspace 3
@@ -108,15 +125,25 @@ bind.cmd+code:7 = focus-workspace 6
108125 bind.cmd+code:8 = focus-workspace 7
109126 bind.cmd+code:9 = focus-workspace 8
110127 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
128+bind.cmd+shift+code:2 = move-column-to-workspace 1
129+bind.cmd+shift+code:3 = move-column-to-workspace 2
130+bind.cmd+shift+code:4 = move-column-to-workspace 3
131+bind.cmd+shift+code:5 = move-column-to-workspace 4
132+bind.cmd+shift+code:6 = move-column-to-workspace 5
133+bind.cmd+shift+code:7 = move-column-to-workspace 6
134+bind.cmd+shift+code:8 = move-column-to-workspace 7
135+bind.cmd+shift+code:9 = move-column-to-workspace 8
136+bind.cmd+shift+code:10 = move-column-to-workspace 9
137+
138+# Monitors, left to right: focus the neighbor, or move the workspace there.
139+bind.cmd+ctrl+left = focus-monitor-left
140+bind.cmd+ctrl+right = focus-monitor-right
141+bind.cmd+ctrl+h = focus-monitor-left
142+bind.cmd+ctrl+l = focus-monitor-right
143+bind.cmd+ctrl+shift+left = move-workspace-to-monitor-left
144+bind.cmd+ctrl+shift+right = move-workspace-to-monitor-right
145+bind.cmd+ctrl+shift+h = move-workspace-to-monitor-left
146+bind.cmd+ctrl+shift+l = move-workspace-to-monitor-right
120147
121148 # Zoom: the whole display, live.
122149 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,18 @@ 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
100+# A window alone in its column joins the neighbor column; a stacked one
101+# leaves for a new column. A full fixed workspace stacks it into the
102+# neighbor column instead, past the edge on the neighbor monitor.
103+bind.cmd+bracketleft = consume-or-expel-window-left
104+bind.cmd+bracketright = consume-or-expel-window-right
90 bind.cmd+r = cycle-column-width105 bind.cmd+r = cycle-column-width
91 bind.cmd+shift+f = toggle-fullscreen106 bind.cmd+shift+f = toggle-fullscreen
92 bind.cmd+q = close-window107 bind.cmd+q = close-window
@@ -97,8 +112,10 @@ bind.ctrl+cmd+space = spawn fuzzel
97 # waits below the last. cmd+up/down past the column edge also switch workspace.112 # waits below the last. cmd+up/down past the column edge also switch workspace.
98 bind.cmd+pageup = focus-workspace-up113 bind.cmd+pageup = focus-workspace-up
99 bind.cmd+pagedown = focus-workspace-down114 bind.cmd+pagedown = focus-workspace-down
100-bind.cmd+shift+pageup = move-to-workspace-up115+# move-column-to-workspace moves the whole column; move-window-to-workspace
101-bind.cmd+shift+pagedown = move-to-workspace-down116+# (N, -up, -down) moves only the focused window and has no default bind.
117+bind.cmd+shift+pageup = move-column-to-workspace-up
118+bind.cmd+shift+pagedown = move-column-to-workspace-down
102 bind.cmd+code:2 = focus-workspace 1119 bind.cmd+code:2 = focus-workspace 1
103 bind.cmd+code:3 = focus-workspace 2120 bind.cmd+code:3 = focus-workspace 2
104 bind.cmd+code:4 = focus-workspace 3121 bind.cmd+code:4 = focus-workspace 3
@@ -108,15 +125,25 @@ bind.cmd+code:7 = focus-workspace 6
108 bind.cmd+code:8 = focus-workspace 7125 bind.cmd+code:8 = focus-workspace 7
109 bind.cmd+code:9 = focus-workspace 8126 bind.cmd+code:9 = focus-workspace 8
110 bind.cmd+code:10 = focus-workspace 9127 bind.cmd+code:10 = focus-workspace 9
111-bind.cmd+shift+code:2 = move-to-workspace 1128+bind.cmd+shift+code:2 = move-column-to-workspace 1
112-bind.cmd+shift+code:3 = move-to-workspace 2129+bind.cmd+shift+code:3 = move-column-to-workspace 2
113-bind.cmd+shift+code:4 = move-to-workspace 3130+bind.cmd+shift+code:4 = move-column-to-workspace 3
114-bind.cmd+shift+code:5 = move-to-workspace 4131+bind.cmd+shift+code:5 = move-column-to-workspace 4
115-bind.cmd+shift+code:6 = move-to-workspace 5132+bind.cmd+shift+code:6 = move-column-to-workspace 5
116-bind.cmd+shift+code:7 = move-to-workspace 6133+bind.cmd+shift+code:7 = move-column-to-workspace 6
117-bind.cmd+shift+code:8 = move-to-workspace 7134+bind.cmd+shift+code:8 = move-column-to-workspace 7
118-bind.cmd+shift+code:9 = move-to-workspace 8135+bind.cmd+shift+code:9 = move-column-to-workspace 8
119-bind.cmd+shift+code:10 = move-to-workspace 9136+bind.cmd+shift+code:10 = move-column-to-workspace 9
137+
138+# Monitors, left to right: focus the neighbor, or move the workspace there.
139+bind.cmd+ctrl+left = focus-monitor-left
140+bind.cmd+ctrl+right = focus-monitor-right
141+bind.cmd+ctrl+h = focus-monitor-left
142+bind.cmd+ctrl+l = focus-monitor-right
143+bind.cmd+ctrl+shift+left = move-workspace-to-monitor-left
144+bind.cmd+ctrl+shift+right = move-workspace-to-monitor-right
145+bind.cmd+ctrl+shift+h = move-workspace-to-monitor-left
146+bind.cmd+ctrl+shift+l = move-workspace-to-monitor-right
120 147
121 # Zoom: the whole display, live.148 # Zoom: the whole display, live.
122 bind.cmd+code:13 = scale-up149 bind.cmd+code:13 = scale-up
M internal/adapters/config/config.go +26 -9
@@ -27,6 +27,8 @@ var defaultBinds = []struct{ combo, action string }{
2727 {"cmd+down", "focus-window-down"},
2828 {"cmd+shift+left", "move-column-left"},
2929 {"cmd+shift+right", "move-column-right"},
30+ {"cmd+bracketleft", "consume-or-expel-window-left"},
31+ {"cmd+bracketright", "consume-or-expel-window-right"},
3032 {"cmd+r", "cycle-column-width"},
3133 {"cmd+shift+f", "toggle-fullscreen"},
3234 {"cmd+q", "close-window"},
@@ -34,14 +36,14 @@ var defaultBinds = []struct{ combo, action string }{
3436 {"ctrl+cmd+space", "spawn fuzzel"},
3537 {"cmd+pageup", "focus-workspace-up"},
3638 {"cmd+pagedown", "focus-workspace-down"},
37- {"cmd+shift+pageup", "move-to-workspace-up"},
38- {"cmd+shift+pagedown", "move-to-workspace-down"},
39+ {"cmd+shift+pageup", "move-column-to-workspace-up"},
40+ {"cmd+shift+pagedown", "move-column-to-workspace-down"},
3941 {"cmd+ctrl+left", "focus-monitor-left"},
4042 {"cmd+ctrl+right", "focus-monitor-right"},
4143 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
4244 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
4345 }
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}
46+var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
4547 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
4648 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
4749
@@ -87,11 +89,20 @@ func Defaults() ports.Config {
8789 c.Layout.Overflow = "scroll"
8890 c.Binds = map[string]string{}
8991 for _, b := range defaultBinds {
90- combo, err := parseCombo(b.combo)
91- if err != nil {
92- panic(err)
92+ combos := []string{b.combo}
93+ // Every arrow bind has a vim twin: h j k l.
94+ for arrow, vim := range map[string]string{"left": "h", "down": "j", "up": "k", "right": "l"} {
95+ if base, ok := strings.CutSuffix(b.combo, "+"+arrow); ok {
96+ combos = append(combos, base+"+"+vim)
97+ }
98+ }
99+ for _, s := range combos {
100+ combo, err := parseCombo(s)
101+ if err != nil {
102+ panic(err)
103+ }
104+ c.Binds[combo] = b.action
93105 }
94- c.Binds[combo] = b.action
95106 }
96107 // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they
97108 // work the same on AZERTY, QWERTZ or Dvorak.
@@ -103,7 +114,7 @@ func Defaults() ports.Config {
103114 focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1))
104115 move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1))
105116 c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n)
106- c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n)
117+ c.Binds[move] = fmt.Sprintf("move-column-to-workspace %d", n)
107118 }
108119 c.Render.DirectScanout = true
109120 c.Render.Tearing = true
@@ -447,6 +458,12 @@ func set(c *ports.Config, key, v string) error {
447458 }
448459 }
449460 c.Layout.Presets = list
461+ case "focus.follow-move":
462+ b, err := onOff(v)
463+ if err != nil {
464+ return err
465+ }
466+ c.Focus.FollowMove = b
450467 case "render.direct-scanout":
451468 b, err := onOff(v)
452469 if err != nil {
@@ -622,7 +639,7 @@ func checkAction(v string) error {
622639 }
623640 return nil
624641 }
625- for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
642+ for _, prefix := range []string{"focus-workspace ", "move-column-to-workspace ", "move-window-to-workspace "} {
626643 if rest, ok := strings.CutPrefix(v, prefix); ok {
627644 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
628645 return fmt.Errorf("%snumber must be between 1 and 99", prefix)
@@ -27,6 +27,8 @@ var defaultBinds = []struct{ combo, action string }{
27 {"cmd+down", "focus-window-down"},27 {"cmd+down", "focus-window-down"},
28 {"cmd+shift+left", "move-column-left"},28 {"cmd+shift+left", "move-column-left"},
29 {"cmd+shift+right", "move-column-right"},29 {"cmd+shift+right", "move-column-right"},
30+ {"cmd+bracketleft", "consume-or-expel-window-left"},
31+ {"cmd+bracketright", "consume-or-expel-window-right"},
30 {"cmd+r", "cycle-column-width"},32 {"cmd+r", "cycle-column-width"},
31 {"cmd+shift+f", "toggle-fullscreen"},33 {"cmd+shift+f", "toggle-fullscreen"},
32 {"cmd+q", "close-window"},34 {"cmd+q", "close-window"},
@@ -34,14 +36,14 @@ var defaultBinds = []struct{ combo, action string }{
34 {"ctrl+cmd+space", "spawn fuzzel"},36 {"ctrl+cmd+space", "spawn fuzzel"},
35 {"cmd+pageup", "focus-workspace-up"},37 {"cmd+pageup", "focus-workspace-up"},
36 {"cmd+pagedown", "focus-workspace-down"},38 {"cmd+pagedown", "focus-workspace-down"},
37- {"cmd+shift+pageup", "move-to-workspace-up"},39+ {"cmd+shift+pageup", "move-column-to-workspace-up"},
38- {"cmd+shift+pagedown", "move-to-workspace-down"},40+ {"cmd+shift+pagedown", "move-column-to-workspace-down"},
39 {"cmd+ctrl+left", "focus-monitor-left"},41 {"cmd+ctrl+left", "focus-monitor-left"},
40 {"cmd+ctrl+right", "focus-monitor-right"},42 {"cmd+ctrl+right", "focus-monitor-right"},
41 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},43 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
42 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},44 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
43 }45 }
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}46+var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "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}47 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}$`)48 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
47 49
@@ -87,11 +89,20 @@ func Defaults() ports.Config {
87 c.Layout.Overflow = "scroll"89 c.Layout.Overflow = "scroll"
88 c.Binds = map[string]string{}90 c.Binds = map[string]string{}
89 for _, b := range defaultBinds {91 for _, b := range defaultBinds {
90- combo, err := parseCombo(b.combo)92+ combos := []string{b.combo}
91- if err != nil {93+ // Every arrow bind has a vim twin: h j k l.
92- panic(err)94+ for arrow, vim := range map[string]string{"left": "h", "down": "j", "up": "k", "right": "l"} {
95+ if base, ok := strings.CutSuffix(b.combo, "+"+arrow); ok {
96+ combos = append(combos, base+"+"+vim)
97+ }
98+ }
99+ for _, s := range combos {
100+ combo, err := parseCombo(s)
101+ if err != nil {
102+ panic(err)
103+ }
104+ c.Binds[combo] = b.action
93 }105 }
94- c.Binds[combo] = b.action
95 }106 }
96 // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they107 // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they
97 // work the same on AZERTY, QWERTZ or Dvorak.108 // work the same on AZERTY, QWERTZ or Dvorak.
@@ -103,7 +114,7 @@ func Defaults() ports.Config {
103 focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1))114 focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1))
104 move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1))115 move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1))
105 c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n)116 c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n)
106- c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n)117+ c.Binds[move] = fmt.Sprintf("move-column-to-workspace %d", n)
107 }118 }
108 c.Render.DirectScanout = true119 c.Render.DirectScanout = true
109 c.Render.Tearing = true120 c.Render.Tearing = true
@@ -447,6 +458,12 @@ func set(c *ports.Config, key, v string) error {
447 }458 }
448 }459 }
449 c.Layout.Presets = list460 c.Layout.Presets = list
461+ case "focus.follow-move":
462+ b, err := onOff(v)
463+ if err != nil {
464+ return err
465+ }
466+ c.Focus.FollowMove = b
450 case "render.direct-scanout":467 case "render.direct-scanout":
451 b, err := onOff(v)468 b, err := onOff(v)
452 if err != nil {469 if err != nil {
@@ -622,7 +639,7 @@ func checkAction(v string) error {
622 }639 }
623 return nil640 return nil
624 }641 }
625- for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {642+ for _, prefix := range []string{"focus-workspace ", "move-column-to-workspace ", "move-window-to-workspace "} {
626 if rest, ok := strings.CutPrefix(v, prefix); ok {643 if rest, ok := strings.CutPrefix(v, prefix); ok {
627 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {644 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)645 return fmt.Errorf("%snumber must be between 1 and 99", prefix)
M internal/adapters/config/config_test.go +15 -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) != 52 || 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 }
@@ -44,6 +47,17 @@ func TestDefaultsAndLoad(t *testing.T) {
4447 }
4548 }
4649
50+func TestFollowMove(t *testing.T) {
51+ c, w := parseString(t, "focus.follow-move = on\n")
52+ if !c.Focus.FollowMove || len(w) != 0 {
53+ t.Fatal(c.Focus, w)
54+ }
55+ c, w = parseString(t, "focus.follow-move = yes\n")
56+ if c.Focus.FollowMove || len(w) != 1 {
57+ t.Fatal(c.Focus, w)
58+ }
59+}
60+
4761 func TestParse(t *testing.T) {
4862 c, w := parseString(t, `
4963 # comment
@@ -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) != 52 || 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 }
@@ -44,6 +47,17 @@ func TestDefaultsAndLoad(t *testing.T) {
44 }47 }
45 }48 }
46 49
50+func TestFollowMove(t *testing.T) {
51+ c, w := parseString(t, "focus.follow-move = on\n")
52+ if !c.Focus.FollowMove || len(w) != 0 {
53+ t.Fatal(c.Focus, w)
54+ }
55+ c, w = parseString(t, "focus.follow-move = yes\n")
56+ if c.Focus.FollowMove || len(w) != 1 {
57+ t.Fatal(c.Focus, w)
58+ }
59+}
60+
47 func TestParse(t *testing.T) {61 func TestParse(t *testing.T) {
48 c, w := parseString(t, `62 c, w := parseString(t, `
49 # comment63 # comment
M internal/core/action.go +76 -32
@@ -20,38 +20,57 @@ 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"
39+ // Consume or expel the focused window (ADR 016).
40+ ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left"
41+ ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
3742 )
3843
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
44+// WorkspaceOp is what a numbered workspace action does.
45+type WorkspaceOp int
46+
47+const (
48+ FocusWorkspace WorkspaceOp = iota
49+ MoveColumnToWorkspace
50+ MoveWindowToWorkspace
51+)
52+
53+var workspacePrefixes = [...]string{
54+ FocusWorkspace: "focus-workspace ",
55+ MoveColumnToWorkspace: "move-column-to-workspace ",
56+ MoveWindowToWorkspace: "move-window-to-workspace ",
57+}
58+
59+// WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and
60+// "move-window-to-workspace N" (N from 1).
61+func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) {
62+ for i, prefix := range workspacePrefixes {
63+ rest, found := strings.CutPrefix(string(a), prefix)
64+ if !found {
65+ continue
66+ }
67+ n, err := strconv.Atoi(strings.TrimSpace(rest))
68+ if err != nil || n < 1 || n > 99 {
69+ return 0, 0, false
70+ }
71+ return n, WorkspaceOp(i), true
5372 }
54- return n, move, true
73+ return 0, 0, false
5574 }
5675
5776 // namedPrefix toggles a named workspace: "workspace dev".
@@ -114,6 +133,31 @@ func (c *Core) applyAction(a Action) Effect {
114133 c.focusScreen = i
115134 return Effect{}
116135 }
136+ case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
137+ dir = 1
138+ if a == ActionConsumeOrExpelLeft {
139+ dir = -1
140+ }
141+ from := c.cur()
142+ if from.mon.Current().ConsumeOrExpel(dir) {
143+ from.mon.normalize()
144+ return Effect{}
145+ }
146+ // Full fixed workspace, stacked edge column: one hop to the
147+ // neighbor monitor, if any; focus follows the window.
148+ i := c.neighbor(dir)
149+ if i < 0 {
150+ return Effect{}
151+ }
152+ id, ok := from.mon.Current().takeWindow()
153+ if !ok {
154+ return Effect{}
155+ }
156+ c.focusScreen = i
157+ c.cur().mon.Current().expelTo(id, dir)
158+ from.mon.normalize()
159+ c.cur().mon.normalize()
160+ return Effect{}
117161 case ActionMoveColumnLeft, ActionMoveColumnRight:
118162 // Past the edge the column moves to the neighbor screen, on the
119163 // side facing this one, and focus follows it.
@@ -140,7 +184,7 @@ func (c *Core) applyAction(a Action) Effect {
140184 if dir < 0 {
141185 at = len(to.Columns)
142186 }
143- to.insertColumn(at, col)
187+ to.receive(col, at)
144188 from.mon.normalize()
145189 c.cur().mon.normalize()
146190 return Effect{}
@@ -151,11 +195,11 @@ func (c *Core) applyAction(a Action) Effect {
151195
152196 // Apply runs a bind action on the monitor.
153197 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 {
198+ if n, op, ok := WorkspaceArg(a); ok {
199+ if op == FocusWorkspace {
158200 m.FocusNumber(n)
201+ } else {
202+ m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace)
159203 }
160204 return Effect{}
161205 }
@@ -185,14 +229,14 @@ func (m *Monitor) Apply(a Action) Effect {
185229 m.Focus(m.Active + 1)
186230 }
187231 return Effect{}
188- case ActionMoveToWorkspaceUp:
232+ case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp:
189233 if m.shown == nil && m.Active > 0 {
190- m.MoveToWorkspace(m.Active - 1)
234+ m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp)
191235 }
192236 return Effect{}
193- case ActionMoveToWorkspaceDown:
237+ case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown:
194238 if m.shown == nil {
195- m.MoveToWorkspace(m.Active + 1)
239+ m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown)
196240 }
197241 return Effect{}
198242 }
@@ -20,38 +20,57 @@ 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 "39+ // Consume or expel the focused window (ADR 016).
36- actionMoveToWorkspace = "move-to-workspace "40+ ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left"
41+ ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
37 )42 )
38 43
39-// WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1).44+// WorkspaceOp is what a numbered workspace action does.
40-// move is true for move-to-workspace.45+type WorkspaceOp int
41-func WorkspaceArg(a Action) (n int, move bool, ok bool) {46+
42- rest, found := strings.CutPrefix(string(a), actionFocusWorkspace)47+const (
43- if !found {48+ FocusWorkspace WorkspaceOp = iota
44- rest, found = strings.CutPrefix(string(a), actionMoveToWorkspace)49+ MoveColumnToWorkspace
45- move = true50+ MoveWindowToWorkspace
46- }51+)
47- if !found {52+
48- return 0, false, false53+var workspacePrefixes = [...]string{
49- }54+ FocusWorkspace: "focus-workspace ",
50- n, err := strconv.Atoi(strings.TrimSpace(rest))55+ MoveColumnToWorkspace: "move-column-to-workspace ",
51- if err != nil || n < 1 || n > 99 {56+ MoveWindowToWorkspace: "move-window-to-workspace ",
52- return 0, false, false57+}
58+
59+// WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and
60+// "move-window-to-workspace N" (N from 1).
61+func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) {
62+ for i, prefix := range workspacePrefixes {
63+ rest, found := strings.CutPrefix(string(a), prefix)
64+ if !found {
65+ continue
66+ }
67+ n, err := strconv.Atoi(strings.TrimSpace(rest))
68+ if err != nil || n < 1 || n > 99 {
69+ return 0, 0, false
70+ }
71+ return n, WorkspaceOp(i), true
53 }72 }
54- return n, move, true73+ return 0, 0, false
55 }74 }
56 75
57 // namedPrefix toggles a named workspace: "workspace dev".76 // namedPrefix toggles a named workspace: "workspace dev".
@@ -114,6 +133,31 @@ func (c *Core) applyAction(a Action) Effect {
114 c.focusScreen = i133 c.focusScreen = i
115 return Effect{}134 return Effect{}
116 }135 }
136+ case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
137+ dir = 1
138+ if a == ActionConsumeOrExpelLeft {
139+ dir = -1
140+ }
141+ from := c.cur()
142+ if from.mon.Current().ConsumeOrExpel(dir) {
143+ from.mon.normalize()
144+ return Effect{}
145+ }
146+ // Full fixed workspace, stacked edge column: one hop to the
147+ // neighbor monitor, if any; focus follows the window.
148+ i := c.neighbor(dir)
149+ if i < 0 {
150+ return Effect{}
151+ }
152+ id, ok := from.mon.Current().takeWindow()
153+ if !ok {
154+ return Effect{}
155+ }
156+ c.focusScreen = i
157+ c.cur().mon.Current().expelTo(id, dir)
158+ from.mon.normalize()
159+ c.cur().mon.normalize()
160+ return Effect{}
117 case ActionMoveColumnLeft, ActionMoveColumnRight:161 case ActionMoveColumnLeft, ActionMoveColumnRight:
118 // Past the edge the column moves to the neighbor screen, on the162 // Past the edge the column moves to the neighbor screen, on the
119 // side facing this one, and focus follows it.163 // side facing this one, and focus follows it.
@@ -140,7 +184,7 @@ func (c *Core) applyAction(a Action) Effect {
140 if dir < 0 {184 if dir < 0 {
141 at = len(to.Columns)185 at = len(to.Columns)
142 }186 }
143- to.insertColumn(at, col)187+ to.receive(col, at)
144 from.mon.normalize()188 from.mon.normalize()
145 c.cur().mon.normalize()189 c.cur().mon.normalize()
146 return Effect{}190 return Effect{}
@@ -151,11 +195,11 @@ func (c *Core) applyAction(a Action) Effect {
151 195
152 // Apply runs a bind action on the monitor.196 // Apply runs a bind action on the monitor.
153 func (m *Monitor) Apply(a Action) Effect {197 func (m *Monitor) Apply(a Action) Effect {
154- if n, move, ok := WorkspaceArg(a); ok {198+ if n, op, ok := WorkspaceArg(a); ok {
155- if move {199+ if op == FocusWorkspace {
156- m.MoveToWorkspace(n - 1)
157- } else {
158 m.FocusNumber(n)200 m.FocusNumber(n)
201+ } else {
202+ m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace)
159 }203 }
160 return Effect{}204 return Effect{}
161 }205 }
@@ -185,14 +229,14 @@ func (m *Monitor) Apply(a Action) Effect {
185 m.Focus(m.Active + 1)229 m.Focus(m.Active + 1)
186 }230 }
187 return Effect{}231 return Effect{}
188- case ActionMoveToWorkspaceUp:232+ case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp:
189 if m.shown == nil && m.Active > 0 {233 if m.shown == nil && m.Active > 0 {
190- m.MoveToWorkspace(m.Active - 1)234+ m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp)
191 }235 }
192 return Effect{}236 return Effect{}
193- case ActionMoveToWorkspaceDown:237+ case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown:
194 if m.shown == nil {238 if m.shown == nil {
195- m.MoveToWorkspace(m.Active + 1)239+ m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown)
196 }240 }
197 return Effect{}241 return Effect{}
198 }242 }
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, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
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, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
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.
A internal/core/expel.go +108 -0
@@ -0,0 +1,108 @@
1+package core
2+
3+import "slices"
4+
5+// Consume or expel (ADR 016): Cmd+[ and Cmd+] move the focused tiled window
6+// one column left or right. Alone in its column it joins the bottom of the
7+// neighbor column (consume); in a stacked column it leaves for a new column
8+// (expel). A full fixed workspace never gets a new column: the window stacks
9+// into the neighbor column instead, past the edge on the neighbor monitor.
10+//
11+// A slot column's slot window is its first window: windows only join a
12+// column at the bottom, so the slot follows Windows[0].
13+
14+// full reports whether a fixed-overflow workspace has no room for a column.
15+func (w *Workspace) full() bool {
16+ return w.Overflow == OverflowFixed && len(w.Columns) >= max(w.MaxColumns, 1)
17+}
18+
19+// ConsumeOrExpel moves the focused window in direction dir (-1 or 1). It
20+// returns false when the window must leave for the neighbor monitor: a full
21+// workspace with the window stacked in the edge column.
22+func (w *Workspace) ConsumeOrExpel(dir int) bool {
23+ if len(w.Columns) == 0 || w.floatFocus || (dir != -1 && dir != 1) {
24+ return true
25+ }
26+ // A fullscreen window hides the others (fixed) or its column (scroll):
27+ // moving would break its way back.
28+ if w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) {
29+ return true
30+ }
31+ src := w.Focus
32+ col := w.Columns[src]
33+ id := col.Windows[col.Focus]
34+ next := src + dir
35+ edge := next < 0 || next >= len(w.Columns)
36+ switch {
37+ case len(col.Windows) == 1 && edge:
38+ // Consume at the edge: move-column already crosses monitors.
39+ case len(col.Windows) == 1:
40+ // The column goes away, and a slot with it.
41+ w.stack(id, w.Columns[next].Windows[0])
42+ case !w.full():
43+ c := Column{Windows: []WindowID{id}, Width: col.Width}
44+ if col.Focus == 0 && col.Slot != 0 {
45+ // The slot and its width follow the slot window.
46+ c.Slot = col.Slot
47+ w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}
48+ } else if col.Slot != 0 {
49+ c.Width = Width{}
50+ }
51+ w.RemoveWindow(id)
52+ w.insertColumn(src+max(dir, 0), c)
53+ case edge:
54+ return false
55+ default:
56+ w.stack(id, w.Columns[next].Windows[0])
57+ }
58+ return true
59+}
60+
61+// stack moves tiled window id to the bottom of the column holding target
62+// and focuses it. Leaving a slot column's first row releases the slot.
63+func (w *Workspace) stack(id, target WindowID) {
64+ w.dropSlotOf(id)
65+ w.RemoveWindow(id)
66+ i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })
67+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
68+ // Activate leaves a fullscreen of the target column, which would hide it.
69+ w.Activate(id)
70+}
71+
72+// dropSlotOf clears the slot of the column whose slot window is id.
73+func (w *Workspace) dropSlotOf(id WindowID) {
74+ for i := range w.Columns {
75+ if w.Columns[i].Slot != 0 && w.Columns[i].Windows[0] == id {
76+ w.Columns[i].Slot = 0
77+ }
78+ }
79+}
80+
81+// takeWindow removes the focused tiled window from its column for another
82+// workspace; its slot, if any, is released.
83+func (w *Workspace) takeWindow() (WindowID, bool) {
84+ id, ok := w.Focused()
85+ if !ok || w.floatFocus {
86+ return 0, false
87+ }
88+ w.dropSlotOf(id)
89+ w.RemoveWindow(id)
90+ return id, true
91+}
92+
93+// expelTo puts a window expelled from the neighbor monitor on the side
94+// facing it (dir > 0: it came from the left): as a new edge column, or at
95+// the bottom of the edge column when the workspace is full.
96+func (w *Workspace) expelTo(id WindowID, dir int) {
97+ at := 0
98+ if dir < 0 {
99+ at = len(w.Columns)
100+ }
101+ if !w.full() || w.origin != nil {
102+ w.receive(Column{Windows: []WindowID{id}}, at)
103+ return
104+ }
105+ i := min(at, len(w.Columns)-1)
106+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
107+ w.Activate(id)
108+}
@@ -0,0 +1,108 @@
1+package core
2+
3+import "slices"
4+
5+// Consume or expel (ADR 016): Cmd+[ and Cmd+] move the focused tiled window
6+// one column left or right. Alone in its column it joins the bottom of the
7+// neighbor column (consume); in a stacked column it leaves for a new column
8+// (expel). A full fixed workspace never gets a new column: the window stacks
9+// into the neighbor column instead, past the edge on the neighbor monitor.
10+//
11+// A slot column's slot window is its first window: windows only join a
12+// column at the bottom, so the slot follows Windows[0].
13+
14+// full reports whether a fixed-overflow workspace has no room for a column.
15+func (w *Workspace) full() bool {
16+ return w.Overflow == OverflowFixed && len(w.Columns) >= max(w.MaxColumns, 1)
17+}
18+
19+// ConsumeOrExpel moves the focused window in direction dir (-1 or 1). It
20+// returns false when the window must leave for the neighbor monitor: a full
21+// workspace with the window stacked in the edge column.
22+func (w *Workspace) ConsumeOrExpel(dir int) bool {
23+ if len(w.Columns) == 0 || w.floatFocus || (dir != -1 && dir != 1) {
24+ return true
25+ }
26+ // A fullscreen window hides the others (fixed) or its column (scroll):
27+ // moving would break its way back.
28+ if w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) {
29+ return true
30+ }
31+ src := w.Focus
32+ col := w.Columns[src]
33+ id := col.Windows[col.Focus]
34+ next := src + dir
35+ edge := next < 0 || next >= len(w.Columns)
36+ switch {
37+ case len(col.Windows) == 1 && edge:
38+ // Consume at the edge: move-column already crosses monitors.
39+ case len(col.Windows) == 1:
40+ // The column goes away, and a slot with it.
41+ w.stack(id, w.Columns[next].Windows[0])
42+ case !w.full():
43+ c := Column{Windows: []WindowID{id}, Width: col.Width}
44+ if col.Focus == 0 && col.Slot != 0 {
45+ // The slot and its width follow the slot window.
46+ c.Slot = col.Slot
47+ w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}
48+ } else if col.Slot != 0 {
49+ c.Width = Width{}
50+ }
51+ w.RemoveWindow(id)
52+ w.insertColumn(src+max(dir, 0), c)
53+ case edge:
54+ return false
55+ default:
56+ w.stack(id, w.Columns[next].Windows[0])
57+ }
58+ return true
59+}
60+
61+// stack moves tiled window id to the bottom of the column holding target
62+// and focuses it. Leaving a slot column's first row releases the slot.
63+func (w *Workspace) stack(id, target WindowID) {
64+ w.dropSlotOf(id)
65+ w.RemoveWindow(id)
66+ i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })
67+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
68+ // Activate leaves a fullscreen of the target column, which would hide it.
69+ w.Activate(id)
70+}
71+
72+// dropSlotOf clears the slot of the column whose slot window is id.
73+func (w *Workspace) dropSlotOf(id WindowID) {
74+ for i := range w.Columns {
75+ if w.Columns[i].Slot != 0 && w.Columns[i].Windows[0] == id {
76+ w.Columns[i].Slot = 0
77+ }
78+ }
79+}
80+
81+// takeWindow removes the focused tiled window from its column for another
82+// workspace; its slot, if any, is released.
83+func (w *Workspace) takeWindow() (WindowID, bool) {
84+ id, ok := w.Focused()
85+ if !ok || w.floatFocus {
86+ return 0, false
87+ }
88+ w.dropSlotOf(id)
89+ w.RemoveWindow(id)
90+ return id, true
91+}
92+
93+// expelTo puts a window expelled from the neighbor monitor on the side
94+// facing it (dir > 0: it came from the left): as a new edge column, or at
95+// the bottom of the edge column when the workspace is full.
96+func (w *Workspace) expelTo(id WindowID, dir int) {
97+ at := 0
98+ if dir < 0 {
99+ at = len(w.Columns)
100+ }
101+ if !w.full() || w.origin != nil {
102+ w.receive(Column{Windows: []WindowID{id}}, at)
103+ return
104+ }
105+ i := min(at, len(w.Columns)-1)
106+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
107+ w.Activate(id)
108+}
A internal/core/expel_test.go +157 -0
@@ -0,0 +1,157 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+// cols builds a workspace with the given columns, focus on the last window
9+// of column focus.
10+func cols(overflow Overflow, max, focus int, columns ...[]WindowID) *Workspace {
11+ w := &Workspace{Overflow: overflow, MaxColumns: max}
12+ w.SetOutput(300, 100)
13+ for _, c := range columns {
14+ w.Columns = append(w.Columns, Column{Windows: c, Focus: len(c) - 1})
15+ }
16+ w.Focus = focus
17+ return w
18+}
19+
20+func ids(w *Workspace) [][]WindowID {
21+ var out [][]WindowID
22+ for _, c := range w.Columns {
23+ out = append(out, c.Windows)
24+ }
25+ return out
26+}
27+
28+func TestConsumeOrExpel(t *testing.T) {
29+ for _, tc := range []struct {
30+ name string
31+ w *Workspace
32+ dir int
33+ want [][]WindowID
34+ stays bool
35+ focusCol int
36+ }{
37+ {"consume right", cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{2, 1}}, true, 0},
38+ {"consume left", cols(OverflowScroll, 2, 1, []WindowID{1}, []WindowID{2}), -1, [][]WindowID{{1, 2}}, true, 0},
39+ {"consume at the edge does nothing", cols(OverflowFixed, 3, 1, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{1}, {2}}, true, 1},
40+ {"expel right in scroll", cols(OverflowScroll, 1, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
41+ {"expel left in scroll", cols(OverflowScroll, 1, 1, []WindowID{1}, []WindowID{2, 3}), -1, [][]WindowID{{1}, {3}, {2}}, true, 1},
42+ {"expel in fixed under the limit", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
43+ {"full fixed stacks into the next column", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
44+ {"full fixed at the edge leaves", cols(OverflowFixed, 3, 2, []WindowID{1}, []WindowID{2}, []WindowID{3, 4}), 1, [][]WindowID{{1}, {2}, {3, 4}}, false, 2},
45+ {"past the limit counts as full", cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ stays := tc.w.ConsumeOrExpel(tc.dir)
49+ if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol {
50+ t.Fatal(got, stays, tc.w.Focus)
51+ }
52+ })
53+ }
54+}
55+
56+func TestConsumeOrExpelFocusFollows(t *testing.T) {
57+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
58+ w.ConsumeOrExpel(1)
59+ if id, _ := w.Focused(); id != 2 {
60+ t.Fatal(id)
61+ }
62+}
63+
64+func TestConsumeOrExpelSkips(t *testing.T) {
65+ t.Run("floating focus", func(t *testing.T) {
66+ w := cols(OverflowScroll, 2, 0, []WindowID{1, 2})
67+ w.AddFloating(9, 10, 10)
68+ w.FocusID(9)
69+ w.ConsumeOrExpel(1)
70+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
71+ t.Fatal(got)
72+ }
73+ })
74+ t.Run("fullscreen", func(t *testing.T) {
75+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2})
76+ w.fullscreen = 2
77+ w.ConsumeOrExpel(1)
78+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
79+ t.Fatal(got)
80+ }
81+ })
82+}
83+
84+func TestConsumeIntoFullscreenColumnShows(t *testing.T) {
85+ w := cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2})
86+ w.fullscreen = 2
87+ w.ConsumeOrExpel(1)
88+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{2, 1}}) || id != 1 || w.fullscreen != 0 {
89+ t.Fatal(ids(w), id, w.fullscreen)
90+ }
91+}
92+
93+func TestConsumeOrExpelSlots(t *testing.T) {
94+ slots := func(w *Workspace) []int {
95+ var out []int
96+ for _, c := range w.Columns {
97+ out = append(out, c.Slot)
98+ }
99+ return out
100+ }
101+ t.Run("the slot follows its window", func(t *testing.T) {
102+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
103+ w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}
104+ w.Columns[0].Focus = 0
105+ w.ConsumeOrExpel(1)
106+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {
107+ t.Fatal(got, slots(w), w.Columns)
108+ }
109+ })
110+ t.Run("another window leaves the slot", func(t *testing.T) {
111+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
112+ w.Columns[0].Slot = 1
113+ w.ConsumeOrExpel(1)
114+ if !reflect.DeepEqual(slots(w), []int{1, 0}) {
115+ t.Fatal(slots(w))
116+ }
117+ })
118+ t.Run("stacking the slot window releases it", func(t *testing.T) {
119+ w := cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3})
120+ w.Columns[0].Slot, w.Columns[0].Focus = 1, 0
121+ w.ConsumeOrExpel(1)
122+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {3, 1}}) || !reflect.DeepEqual(slots(w), []int{0, 0}) {
123+ t.Fatal(got, slots(w))
124+ }
125+ })
126+}
127+
128+func TestExpelTo(t *testing.T) {
129+ t.Run("new edge column", func(t *testing.T) {
130+ w := cols(OverflowFixed, 3, 0, []WindowID{1}, []WindowID{2})
131+ w.expelTo(9, 1)
132+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{9}, {1}, {2}}) || w.Focus != 0 {
133+ t.Fatal(got)
134+ }
135+ })
136+ t.Run("full stacks into the first column", func(t *testing.T) {
137+ w := cols(OverflowFixed, 2, 1, []WindowID{1}, []WindowID{2})
138+ w.expelTo(9, 1)
139+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1, 9}, {2}}) || id != 9 {
140+ t.Fatal(ids(w), id)
141+ }
142+ })
143+ t.Run("full from the right stacks into the last column", func(t *testing.T) {
144+ w := cols(OverflowFixed, 2, 0, []WindowID{1}, []WindowID{2}, []WindowID{3})
145+ w.expelTo(9, -1)
146+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1}, {2}, {3, 9}}) || id != 9 {
147+ t.Fatal(ids(w), id)
148+ }
149+ })
150+ t.Run("empty workspace", func(t *testing.T) {
151+ w := cols(OverflowFixed, 2, 0)
152+ w.expelTo(9, -1)
153+ if !reflect.DeepEqual(ids(w), [][]WindowID{{9}}) {
154+ t.Fatal(ids(w))
155+ }
156+ })
157+}
@@ -0,0 +1,157 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+// cols builds a workspace with the given columns, focus on the last window
9+// of column focus.
10+func cols(overflow Overflow, max, focus int, columns ...[]WindowID) *Workspace {
11+ w := &Workspace{Overflow: overflow, MaxColumns: max}
12+ w.SetOutput(300, 100)
13+ for _, c := range columns {
14+ w.Columns = append(w.Columns, Column{Windows: c, Focus: len(c) - 1})
15+ }
16+ w.Focus = focus
17+ return w
18+}
19+
20+func ids(w *Workspace) [][]WindowID {
21+ var out [][]WindowID
22+ for _, c := range w.Columns {
23+ out = append(out, c.Windows)
24+ }
25+ return out
26+}
27+
28+func TestConsumeOrExpel(t *testing.T) {
29+ for _, tc := range []struct {
30+ name string
31+ w *Workspace
32+ dir int
33+ want [][]WindowID
34+ stays bool
35+ focusCol int
36+ }{
37+ {"consume right", cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{2, 1}}, true, 0},
38+ {"consume left", cols(OverflowScroll, 2, 1, []WindowID{1}, []WindowID{2}), -1, [][]WindowID{{1, 2}}, true, 0},
39+ {"consume at the edge does nothing", cols(OverflowFixed, 3, 1, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{1}, {2}}, true, 1},
40+ {"expel right in scroll", cols(OverflowScroll, 1, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
41+ {"expel left in scroll", cols(OverflowScroll, 1, 1, []WindowID{1}, []WindowID{2, 3}), -1, [][]WindowID{{1}, {3}, {2}}, true, 1},
42+ {"expel in fixed under the limit", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
43+ {"full fixed stacks into the next column", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
44+ {"full fixed at the edge leaves", cols(OverflowFixed, 3, 2, []WindowID{1}, []WindowID{2}, []WindowID{3, 4}), 1, [][]WindowID{{1}, {2}, {3, 4}}, false, 2},
45+ {"past the limit counts as full", cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ stays := tc.w.ConsumeOrExpel(tc.dir)
49+ if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol {
50+ t.Fatal(got, stays, tc.w.Focus)
51+ }
52+ })
53+ }
54+}
55+
56+func TestConsumeOrExpelFocusFollows(t *testing.T) {
57+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
58+ w.ConsumeOrExpel(1)
59+ if id, _ := w.Focused(); id != 2 {
60+ t.Fatal(id)
61+ }
62+}
63+
64+func TestConsumeOrExpelSkips(t *testing.T) {
65+ t.Run("floating focus", func(t *testing.T) {
66+ w := cols(OverflowScroll, 2, 0, []WindowID{1, 2})
67+ w.AddFloating(9, 10, 10)
68+ w.FocusID(9)
69+ w.ConsumeOrExpel(1)
70+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
71+ t.Fatal(got)
72+ }
73+ })
74+ t.Run("fullscreen", func(t *testing.T) {
75+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2})
76+ w.fullscreen = 2
77+ w.ConsumeOrExpel(1)
78+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
79+ t.Fatal(got)
80+ }
81+ })
82+}
83+
84+func TestConsumeIntoFullscreenColumnShows(t *testing.T) {
85+ w := cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2})
86+ w.fullscreen = 2
87+ w.ConsumeOrExpel(1)
88+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{2, 1}}) || id != 1 || w.fullscreen != 0 {
89+ t.Fatal(ids(w), id, w.fullscreen)
90+ }
91+}
92+
93+func TestConsumeOrExpelSlots(t *testing.T) {
94+ slots := func(w *Workspace) []int {
95+ var out []int
96+ for _, c := range w.Columns {
97+ out = append(out, c.Slot)
98+ }
99+ return out
100+ }
101+ t.Run("the slot follows its window", func(t *testing.T) {
102+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
103+ w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}
104+ w.Columns[0].Focus = 0
105+ w.ConsumeOrExpel(1)
106+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {
107+ t.Fatal(got, slots(w), w.Columns)
108+ }
109+ })
110+ t.Run("another window leaves the slot", func(t *testing.T) {
111+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
112+ w.Columns[0].Slot = 1
113+ w.ConsumeOrExpel(1)
114+ if !reflect.DeepEqual(slots(w), []int{1, 0}) {
115+ t.Fatal(slots(w))
116+ }
117+ })
118+ t.Run("stacking the slot window releases it", func(t *testing.T) {
119+ w := cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3})
120+ w.Columns[0].Slot, w.Columns[0].Focus = 1, 0
121+ w.ConsumeOrExpel(1)
122+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {3, 1}}) || !reflect.DeepEqual(slots(w), []int{0, 0}) {
123+ t.Fatal(got, slots(w))
124+ }
125+ })
126+}
127+
128+func TestExpelTo(t *testing.T) {
129+ t.Run("new edge column", func(t *testing.T) {
130+ w := cols(OverflowFixed, 3, 0, []WindowID{1}, []WindowID{2})
131+ w.expelTo(9, 1)
132+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{9}, {1}, {2}}) || w.Focus != 0 {
133+ t.Fatal(got)
134+ }
135+ })
136+ t.Run("full stacks into the first column", func(t *testing.T) {
137+ w := cols(OverflowFixed, 2, 1, []WindowID{1}, []WindowID{2})
138+ w.expelTo(9, 1)
139+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1, 9}, {2}}) || id != 9 {
140+ t.Fatal(ids(w), id)
141+ }
142+ })
143+ t.Run("full from the right stacks into the last column", func(t *testing.T) {
144+ w := cols(OverflowFixed, 2, 0, []WindowID{1}, []WindowID{2}, []WindowID{3})
145+ w.expelTo(9, -1)
146+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1}, {2}, {3, 9}}) || id != 9 {
147+ t.Fatal(ids(w), id)
148+ }
149+ })
150+ t.Run("empty workspace", func(t *testing.T) {
151+ w := cols(OverflowFixed, 2, 0)
152+ w.expelTo(9, -1)
153+ if !reflect.DeepEqual(ids(w), [][]WindowID{{9}}) {
154+ t.Fatal(ids(w))
155+ }
156+ })
157+}
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 +47 -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,29 +479,60 @@ 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)
496+ to.FocusID(id)
496497 } else {
497- cur.RemoveWindow(id)
498- to.AddWindow(id)
498+ col := Column{Windows: []WindowID{id}}
499+ ok := true
500+ if column {
501+ col, ok = cur.takeColumn()
502+ } else {
503+ _, ok = cur.takeWindow()
504+ }
505+ if !ok {
506+ return
507+ }
508+ to.receive(col, -1)
509+ }
510+ if m.followMove {
511+ m.Active = i
512+ m.shown = nil
499513 }
500514 m.normalize()
501515 }
502516
517+// receive adds a column moved in from another workspace and focuses it: at
518+// index at, or where a new window goes when at < 0. It stays visible: on a
519+// fixed-overflow fullscreen workspace its windows float above, and an
520+// in-place fullscreen is left.
521+func (w *Workspace) receive(col Column, at int) {
522+ id := col.Windows[col.Focus]
523+ switch {
524+ case w.origin != nil:
525+ for _, v := range col.Windows {
526+ w.joinFullscreen(v)
527+ }
528+ case at < 0:
529+ w.addColumn(col)
530+ default:
531+ w.insertColumn(at, col)
532+ }
533+ w.Activate(id)
534+}
535+
503536 // joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats
504537 // above the fullscreen window, which would hide it (fixed overflow), and
505538 // tiles again once home.
@@ -545,6 +578,9 @@ func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(
545578 func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) }
546579 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
547580
581+// SetFollowMove makes moves to another workspace show it (focus.follow-move).
582+func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
583+
548584 // SetMaxColumns sets the default; named workspaces may override it.
549585 func (m *Monitor) SetMaxColumns(n int) {
550586 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,29 +479,60 @@ 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 {496+ to.FocusID(id)
494- cur.RemoveWindow(id)
495- to.joinFullscreen(id)
496 } else {497 } else {
497- cur.RemoveWindow(id)498+ col := Column{Windows: []WindowID{id}}
498- to.AddWindow(id)499+ ok := true
500+ if column {
501+ col, ok = cur.takeColumn()
502+ } else {
503+ _, ok = cur.takeWindow()
504+ }
505+ if !ok {
506+ return
507+ }
508+ to.receive(col, -1)
509+ }
510+ if m.followMove {
511+ m.Active = i
512+ m.shown = nil
499 }513 }
500 m.normalize()514 m.normalize()
501 }515 }
502 516
517+// receive adds a column moved in from another workspace and focuses it: at
518+// index at, or where a new window goes when at < 0. It stays visible: on a
519+// fixed-overflow fullscreen workspace its windows float above, and an
520+// in-place fullscreen is left.
521+func (w *Workspace) receive(col Column, at int) {
522+ id := col.Windows[col.Focus]
523+ switch {
524+ case w.origin != nil:
525+ for _, v := range col.Windows {
526+ w.joinFullscreen(v)
527+ }
528+ case at < 0:
529+ w.addColumn(col)
530+ default:
531+ w.insertColumn(at, col)
532+ }
533+ w.Activate(id)
534+}
535+
503 // joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats536 // joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats
504 // above the fullscreen window, which would hide it (fixed overflow), and537 // above the fullscreen window, which would hide it (fixed overflow), and
505 // tiles again once home.538 // tiles again once home.
@@ -545,6 +578,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) }) }578 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) }) }579 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
547 580
581+// SetFollowMove makes moves to another workspace show it (focus.follow-move).
582+func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
583+
548 // SetMaxColumns sets the default; named workspaces may override it.584 // SetMaxColumns sets the default; named workspaces may override it.
549 func (m *Monitor) SetMaxColumns(n int) {585 func (m *Monitor) SetMaxColumns(n int) {
550 m.template.MaxColumns = n586 m.template.MaxColumns = n
M internal/core/monitor_test.go +107 -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,89 @@ 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("the slot window alone releases its slot", func(t *testing.T) {
250+ m := stack()
251+ c := &m.Current().Columns[1]
252+ c.Slot, c.Focus = 2, 0
253+ m.Apply(ActionMoveWindowToWorkspaceDown)
254+ if c := m.Current().Columns[1]; c.Slot != 0 || c.Windows[0] != 3 {
255+ t.Fatal(c)
256+ }
257+ })
258+ t.Run("follow a floating window", func(t *testing.T) {
259+ m := stack()
260+ m.SetFollowMove(true)
261+ m.AddFloating(9, 10, 10)
262+ m.Current().FocusID(9)
263+ m.Apply(ActionMoveWindowToWorkspaceDown)
264+ if id, _ := m.Focused(); m.Active != 1 || id != 9 {
265+ t.Fatal(m.Active, id)
266+ }
267+ })
268+ t.Run("slot column loses its slot", func(t *testing.T) {
269+ m := stack()
270+ m.Current().Columns[1].Slot = 2
271+ m.Apply(ActionMoveColumnToWorkspaceDown)
272+ if c := m.Workspaces[1].Columns[0]; c.Slot != 0 {
273+ t.Fatal(c)
274+ }
275+ })
276+}
277+
205278 func TestWorkspaceArg(t *testing.T) {
206279 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)
280+ a Action
281+ n int
282+ op WorkspaceOp
283+ ok bool
284+ }{{"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}} {
285+ n, op, ok := WorkspaceArg(tc.a)
286+ if n != tc.n || op != tc.op || ok != tc.ok {
287+ t.Errorf("%s: %d %v %v", tc.a, n, op, ok)
214288 }
215289 }
216290 }
@@ -278,8 +352,8 @@ func TestHiddenWorkspaceToggle(t *testing.T) {
278352 }
279353 // Moving a window up/down from a hidden workspace does nothing.
280354 m.Apply("workspace dev")
281- m.Apply(ActionMoveToWorkspaceDown)
282- m.Apply(ActionMoveToWorkspaceUp)
355+ m.Apply(ActionMoveWindowToWorkspaceDown)
356+ m.Apply(ActionMoveWindowToWorkspaceUp)
283357 if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
284358 t.Fatal(id, windows(m))
285359 }
@@ -541,11 +615,31 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
541615 m.ToggleFullscreen()
542616 m.Apply(ActionFocusWorkspaceUp)
543617 m.Current().FocusID(1)
544- m.Apply(ActionMoveToWorkspaceDown)
618+ m.Apply(ActionMoveWindowToWorkspaceDown)
545619 if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
546620 t.Fatal(fs.Floats)
547621 }
548622 })
623+ t.Run("a moved column floats window by window", func(t *testing.T) {
624+ m := fixed()
625+ m.Current().FocusID(2)
626+ m.ToggleFullscreen()
627+ m.Apply(ActionFocusWorkspaceUp)
628+ w := m.Current()
629+ ids := w.windows()
630+ w.Columns = []Column{{Windows: ids}}
631+ w.Focus = 0
632+ m.Apply(ActionMoveColumnToWorkspaceDown)
633+ fs := m.Workspaces[1]
634+ for _, id := range ids {
635+ if fs.floatIndex(id) < 0 {
636+ t.Fatal(id, fs.Floats)
637+ }
638+ }
639+ if fs.origin == nil || len(ids) < 2 {
640+ t.Fatal(fs.origin, ids)
641+ }
642+ })
549643 t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
550644 m := fixed()
551645 m.Current().FocusID(2)
@@ -603,7 +697,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
603697 m := fixed()
604698 m.Current().FocusID(2)
605699 m.ToggleFullscreen()
606- m.Apply(ActionMoveToWorkspaceUp)
700+ m.Apply(ActionMoveWindowToWorkspaceUp)
607701 m.AddWindow(4)
608702 m.RemoveWindow(4)
609703 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,89 @@ 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("the slot window alone releases its slot", func(t *testing.T) {
250+ m := stack()
251+ c := &m.Current().Columns[1]
252+ c.Slot, c.Focus = 2, 0
253+ m.Apply(ActionMoveWindowToWorkspaceDown)
254+ if c := m.Current().Columns[1]; c.Slot != 0 || c.Windows[0] != 3 {
255+ t.Fatal(c)
256+ }
257+ })
258+ t.Run("follow a floating window", func(t *testing.T) {
259+ m := stack()
260+ m.SetFollowMove(true)
261+ m.AddFloating(9, 10, 10)
262+ m.Current().FocusID(9)
263+ m.Apply(ActionMoveWindowToWorkspaceDown)
264+ if id, _ := m.Focused(); m.Active != 1 || id != 9 {
265+ t.Fatal(m.Active, id)
266+ }
267+ })
268+ t.Run("slot column loses its slot", func(t *testing.T) {
269+ m := stack()
270+ m.Current().Columns[1].Slot = 2
271+ m.Apply(ActionMoveColumnToWorkspaceDown)
272+ if c := m.Workspaces[1].Columns[0]; c.Slot != 0 {
273+ t.Fatal(c)
274+ }
275+ })
276+}
277+
205 func TestWorkspaceArg(t *testing.T) {278 func TestWorkspaceArg(t *testing.T) {
206 for _, tc := range []struct {279 for _, tc := range []struct {
207- a Action280+ a Action
208- n int281+ n int
209- move, ok bool282+ 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}} {283+ ok bool
211- n, move, ok := WorkspaceArg(tc.a)284+ }{{"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 {285+ n, op, ok := WorkspaceArg(tc.a)
213- t.Errorf("%s: %d %v %v", tc.a, n, move, ok)286+ if n != tc.n || op != tc.op || ok != tc.ok {
287+ t.Errorf("%s: %d %v %v", tc.a, n, op, ok)
214 }288 }
215 }289 }
216 }290 }
@@ -278,8 +352,8 @@ func TestHiddenWorkspaceToggle(t *testing.T) {
278 }352 }
279 // Moving a window up/down from a hidden workspace does nothing.353 // Moving a window up/down from a hidden workspace does nothing.
280 m.Apply("workspace dev")354 m.Apply("workspace dev")
281- m.Apply(ActionMoveToWorkspaceDown)355+ m.Apply(ActionMoveWindowToWorkspaceDown)
282- m.Apply(ActionMoveToWorkspaceUp)356+ m.Apply(ActionMoveWindowToWorkspaceUp)
283 if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {357 if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
284 t.Fatal(id, windows(m))358 t.Fatal(id, windows(m))
285 }359 }
@@ -541,11 +615,31 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
541 m.ToggleFullscreen()615 m.ToggleFullscreen()
542 m.Apply(ActionFocusWorkspaceUp)616 m.Apply(ActionFocusWorkspaceUp)
543 m.Current().FocusID(1)617 m.Current().FocusID(1)
544- m.Apply(ActionMoveToWorkspaceDown)618+ m.Apply(ActionMoveWindowToWorkspaceDown)
545 if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {619 if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
546 t.Fatal(fs.Floats)620 t.Fatal(fs.Floats)
547 }621 }
548 })622 })
623+ t.Run("a moved column floats window by window", func(t *testing.T) {
624+ m := fixed()
625+ m.Current().FocusID(2)
626+ m.ToggleFullscreen()
627+ m.Apply(ActionFocusWorkspaceUp)
628+ w := m.Current()
629+ ids := w.windows()
630+ w.Columns = []Column{{Windows: ids}}
631+ w.Focus = 0
632+ m.Apply(ActionMoveColumnToWorkspaceDown)
633+ fs := m.Workspaces[1]
634+ for _, id := range ids {
635+ if fs.floatIndex(id) < 0 {
636+ t.Fatal(id, fs.Floats)
637+ }
638+ }
639+ if fs.origin == nil || len(ids) < 2 {
640+ t.Fatal(fs.origin, ids)
641+ }
642+ })
549 t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {643 t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
550 m := fixed()644 m := fixed()
551 m.Current().FocusID(2)645 m.Current().FocusID(2)
@@ -603,7 +697,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
603 m := fixed()697 m := fixed()
604 m.Current().FocusID(2)698 m.Current().FocusID(2)
605 m.ToggleFullscreen()699 m.ToggleFullscreen()
606- m.Apply(ActionMoveToWorkspaceUp)700+ m.Apply(ActionMoveWindowToWorkspaceUp)
607 m.AddWindow(4)701 m.AddWindow(4)
608 m.RemoveWindow(4)702 m.RemoveWindow(4)
609 if m.Active != 1 {703 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/screens_test.go +97 -0
@@ -3,6 +3,7 @@ package core_test
33 import (
44 "context"
55 "reflect"
6+ "slices"
67 "testing"
78
89 "github.com/bnema/nefertty/internal/adapters/config"
@@ -504,3 +505,99 @@ func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
504505 t.Fatalf("%+v", st)
505506 }
506507 }
508+
509+func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
510+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
511+ r.mapWindow(t, 1)
512+ r.mapWindow(t, 2)
513+ // Window 2 goes to DP-2, alone, and goes fullscreen in place.
514+ r.key(t, "Right", ports.ModAlt|ports.ModShift)
515+ r.key(t, "f", ports.ModAlt|ports.ModShift)
516+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
517+ // Window 1 joins from DP-1: the fullscreen ends so it is visible and focused.
518+ set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
519+ for _, s := range set {
520+ for _, w := range s.Windows {
521+ if w.ID == 1 && (s.Output != "DP-2" || w.Hidden || !w.Focused) {
522+ t.Fatalf("%s %+v", s.Output, w)
523+ }
524+ }
525+ }
526+}
527+
528+// windowsOf lists the tiled windows of an output in scene order, with the
529+// focused one.
530+func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
531+ for _, s := range set {
532+ if s.Output != out {
533+ continue
534+ }
535+ for _, w := range s.Windows {
536+ if !w.Hidden {
537+ ids = append(ids, w.ID)
538+ }
539+ if w.Focused {
540+ focused = w.ID
541+ }
542+ }
543+ }
544+ return ids, focused
545+}
546+
547+func TestExpelCrossesToFullNeighbor(t *testing.T) {
548+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
549+ // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
550+ // [1] [2 3], focus on 3 at the bottom of the edge column.
551+ r.mapWindow(t, 1)
552+ r.mapWindow(t, 2)
553+ r.mapWindow(t, 3)
554+ r.key(t, "bracketleft", ports.ModAlt)
555+ // DP-2: [4] [5], full too.
556+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
557+ r.mapWindow(t, 4)
558+ r.mapWindow(t, 5)
559+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
560+ // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
561+ set := r.key(t, "bracketright", ports.ModAlt)
562+ if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
563+ t.Fatal(got)
564+ }
565+ got, focused := windowsOf(set, "DP-2")
566+ if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
567+ t.Fatal(got, focused)
568+ }
569+ if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
570+ t.Fatal(out.Focused)
571+ }
572+ // 3 is not in the edge column of DP-2: it stacks into [5].
573+ set = r.key(t, "bracketright", ports.ModAlt)
574+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
575+ t.Fatal(got, focused)
576+ }
577+ // Now at the edge with no right monitor: nothing moves.
578+ set = r.key(t, "bracketright", ports.ModAlt)
579+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
580+ t.Fatal(got, focused)
581+ }
582+}
583+
584+func TestExpelOntoFixedFullscreenFloats(t *testing.T) {
585+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
586+ // DP-1: [1] [2 3], focus on 3.
587+ r.mapWindow(t, 1)
588+ r.mapWindow(t, 2)
589+ r.mapWindow(t, 3)
590+ r.key(t, "bracketleft", ports.ModAlt)
591+ // DP-2: [4] [5]; 5 goes fullscreen on its own workspace.
592+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
593+ r.mapWindow(t, 4)
594+ r.mapWindow(t, 5)
595+ r.key(t, "f", ports.ModAlt|ports.ModShift)
596+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
597+ // 3 floats above the fullscreen window, visible and focused.
598+ set := r.key(t, "bracketright", ports.ModAlt)
599+ got, focused := windowsOf(set, "DP-2")
600+ if !slices.Contains(got, 3) || focused != 3 {
601+ t.Fatal(got, focused)
602+ }
603+}
@@ -3,6 +3,7 @@ package core_test
3 import (3 import (
4 "context"4 "context"
5 "reflect"5 "reflect"
6+ "slices"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/nefertty/internal/adapters/config"9 "github.com/bnema/nefertty/internal/adapters/config"
@@ -504,3 +505,99 @@ func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
504 t.Fatalf("%+v", st)505 t.Fatalf("%+v", st)
505 }506 }
506 }507 }
508+
509+func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
510+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
511+ r.mapWindow(t, 1)
512+ r.mapWindow(t, 2)
513+ // Window 2 goes to DP-2, alone, and goes fullscreen in place.
514+ r.key(t, "Right", ports.ModAlt|ports.ModShift)
515+ r.key(t, "f", ports.ModAlt|ports.ModShift)
516+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
517+ // Window 1 joins from DP-1: the fullscreen ends so it is visible and focused.
518+ set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
519+ for _, s := range set {
520+ for _, w := range s.Windows {
521+ if w.ID == 1 && (s.Output != "DP-2" || w.Hidden || !w.Focused) {
522+ t.Fatalf("%s %+v", s.Output, w)
523+ }
524+ }
525+ }
526+}
527+
528+// windowsOf lists the tiled windows of an output in scene order, with the
529+// focused one.
530+func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
531+ for _, s := range set {
532+ if s.Output != out {
533+ continue
534+ }
535+ for _, w := range s.Windows {
536+ if !w.Hidden {
537+ ids = append(ids, w.ID)
538+ }
539+ if w.Focused {
540+ focused = w.ID
541+ }
542+ }
543+ }
544+ return ids, focused
545+}
546+
547+func TestExpelCrossesToFullNeighbor(t *testing.T) {
548+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
549+ // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
550+ // [1] [2 3], focus on 3 at the bottom of the edge column.
551+ r.mapWindow(t, 1)
552+ r.mapWindow(t, 2)
553+ r.mapWindow(t, 3)
554+ r.key(t, "bracketleft", ports.ModAlt)
555+ // DP-2: [4] [5], full too.
556+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
557+ r.mapWindow(t, 4)
558+ r.mapWindow(t, 5)
559+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
560+ // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
561+ set := r.key(t, "bracketright", ports.ModAlt)
562+ if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
563+ t.Fatal(got)
564+ }
565+ got, focused := windowsOf(set, "DP-2")
566+ if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
567+ t.Fatal(got, focused)
568+ }
569+ if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
570+ t.Fatal(out.Focused)
571+ }
572+ // 3 is not in the edge column of DP-2: it stacks into [5].
573+ set = r.key(t, "bracketright", ports.ModAlt)
574+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
575+ t.Fatal(got, focused)
576+ }
577+ // Now at the edge with no right monitor: nothing moves.
578+ set = r.key(t, "bracketright", ports.ModAlt)
579+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
580+ t.Fatal(got, focused)
581+ }
582+}
583+
584+func TestExpelOntoFixedFullscreenFloats(t *testing.T) {
585+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
586+ // DP-1: [1] [2 3], focus on 3.
587+ r.mapWindow(t, 1)
588+ r.mapWindow(t, 2)
589+ r.mapWindow(t, 3)
590+ r.key(t, "bracketleft", ports.ModAlt)
591+ // DP-2: [4] [5]; 5 goes fullscreen on its own workspace.
592+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
593+ r.mapWindow(t, 4)
594+ r.mapWindow(t, 5)
595+ r.key(t, "f", ports.ModAlt|ports.ModShift)
596+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
597+ // 3 floats above the fullscreen window, visible and focused.
598+ set := r.key(t, "bracketright", ports.ModAlt)
599+ got, focused := windowsOf(set, "DP-2")
600+ if !slices.Contains(got, 3) || focused != 3 {
601+ t.Fatal(got, focused)
602+ }
603+}
M internal/core/slots_test.go +20 -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
@@ -391,3 +391,22 @@ func TestSlotKeptWhileFullscreenAway(t *testing.T) {
391391 r.press(t, "d")
392392 noSpawn(t, r.spawn)
393393 }
394+
395+// Expelling a slot window from a stacked column keeps one column per slot:
396+// the slot and its width follow the window, and nothing is respawned.
397+func TestSlotFollowsExpelledWindow(t *testing.T) {
398+ r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
399+ r.fill(t)
400+ r.press(t, "d") // dev: [code] [foot], focus on code
401+ r.client <- ports.WindowMapped{ID: 9}
402+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
403+ r.press(t, "bracketleft") // 9 joins code: [code 9] [foot]
404+ r.press(t, "Up") // focus code, the slot window
405+ r.press(t, "bracketright") // code leaves: [9] [code] [foot]
406+ r.press(t, "d")
407+ s := r.press(t, "d") // shown again: no slot is empty
408+ noSpawn(t, r.spawn)
409+ if got := visible(s); got[2].W != 70 {
410+ t.Fatalf("slots: %v", got)
411+ }
412+}
@@ -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
@@ -391,3 +391,22 @@ func TestSlotKeptWhileFullscreenAway(t *testing.T) {
391 r.press(t, "d")391 r.press(t, "d")
392 noSpawn(t, r.spawn)392 noSpawn(t, r.spawn)
393 }393 }
394+
395+// Expelling a slot window from a stacked column keeps one column per slot:
396+// the slot and its width follow the window, and nothing is respawned.
397+func TestSlotFollowsExpelledWindow(t *testing.T) {
398+ r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
399+ r.fill(t)
400+ r.press(t, "d") // dev: [code] [foot], focus on code
401+ r.client <- ports.WindowMapped{ID: 9}
402+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
403+ r.press(t, "bracketleft") // 9 joins code: [code 9] [foot]
404+ r.press(t, "Up") // focus code, the slot window
405+ r.press(t, "bracketright") // code leaves: [9] [code] [foot]
406+ r.press(t, "d")
407+ s := r.press(t, "d") // shown again: no slot is empty
408+ noSpawn(t, r.spawn)
409+ if got := visible(s); got[2].W != 70 {
410+ t.Fatalf("slots: %v", got)
411+ }
412+}
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