merge: layout-binds (hjkl, move-column-to-workspace, consume-or-expel)
15 files · +705 -81
M examples/config +38 -11
| @@ -37,6 +37,9 @@ layout.overflow = scroll | |||
| 37 | 37 | # cycle-column-width steps through these, then back to the equal share. | |
| 38 | 38 | layout.presets = 1/3, 1/2, 2/3, 1 | |
| 39 | 39 | ||
| 40 | + | # on: moving a column or window to another workspace shows that workspace. | |
| 41 | + | focus.follow-move = off | |
| 42 | + | ||
| 40 | 43 | # Named workspaces. Numbered at their place like any other and never removed | |
| 41 | 44 | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | |
| 42 | 45 | # bind shows it, and pressing the bind again returns to the previous workspace. | |
| @@ -87,6 +90,18 @@ bind.cmd+up = focus-window-up | |||
| 87 | 90 | bind.cmd+down = focus-window-down | |
| 88 | 91 | bind.cmd+shift+left = move-column-left | |
| 89 | 92 | bind.cmd+shift+right = move-column-right | |
| 93 | + | # Vim keys: every arrow bind also works with h j k l. | |
| 94 | + | bind.cmd+h = focus-column-left | |
| 95 | + | bind.cmd+l = focus-column-right | |
| 96 | + | bind.cmd+k = focus-window-up | |
| 97 | + | bind.cmd+j = focus-window-down | |
| 98 | + | bind.cmd+shift+h = move-column-left | |
| 99 | + | bind.cmd+shift+l = move-column-right | |
| 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 | 105 | bind.cmd+r = cycle-column-width | |
| 91 | 106 | bind.cmd+shift+f = toggle-fullscreen | |
| 92 | 107 | bind.cmd+q = close-window | |
| @@ -97,8 +112,10 @@ bind.ctrl+cmd+space = spawn fuzzel | |||
| 97 | 112 | # waits below the last. cmd+up/down past the column edge also switch workspace. | |
| 98 | 113 | bind.cmd+pageup = focus-workspace-up | |
| 99 | 114 | 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 | |
| 102 | 119 | bind.cmd+code:2 = focus-workspace 1 | |
| 103 | 120 | bind.cmd+code:3 = focus-workspace 2 | |
| 104 | 121 | bind.cmd+code:4 = focus-workspace 3 | |
| @@ -108,15 +125,25 @@ bind.cmd+code:7 = focus-workspace 6 | |||
| 108 | 125 | bind.cmd+code:8 = focus-workspace 7 | |
| 109 | 126 | bind.cmd+code:9 = focus-workspace 8 | |
| 110 | 127 | 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 | |
| 120 | 147 | ||
| 121 | 148 | # Zoom: the whole display, live. | |
| 122 | 149 | bind.cmd+code:13 = scale-up | |
| @@ -37,6 +37,9 @@ layout.overflow = scroll | |||||
| 37 | # cycle-column-width steps through these, then back to the equal share. | 37 | # cycle-column-width steps through these, then back to the equal share. | ||
| 38 | layout.presets = 1/3, 1/2, 2/3, 1 | 38 | layout.presets = 1/3, 1/2, 2/3, 1 | ||
| 39 | 39 | ||||
| 40 | + | # on: moving a column or window to another workspace shows that workspace. | |||
| 41 | + | focus.follow-move = off | |||
| 42 | + | ||||
| 40 | # Named workspaces. Numbered at their place like any other and never removed | 43 | # Named workspaces. Numbered at their place like any other and never removed | ||
| 41 | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | 44 | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | ||
| 42 | # bind shows it, and pressing the bind again returns to the previous workspace. | 45 | # bind shows it, and pressing the bind again returns to the previous workspace. | ||
| @@ -87,6 +90,18 @@ bind.cmd+up = focus-window-up | |||||
| 87 | bind.cmd+down = focus-window-down | 90 | bind.cmd+down = focus-window-down | ||
| 88 | bind.cmd+shift+left = move-column-left | 91 | bind.cmd+shift+left = move-column-left | ||
| 89 | bind.cmd+shift+right = move-column-right | 92 | bind.cmd+shift+right = move-column-right | ||
| 93 | + | # Vim keys: every arrow bind also works with h j k l. | |||
| 94 | + | bind.cmd+h = focus-column-left | |||
| 95 | + | bind.cmd+l = focus-column-right | |||
| 96 | + | bind.cmd+k = focus-window-up | |||
| 97 | + | bind.cmd+j = focus-window-down | |||
| 98 | + | bind.cmd+shift+h = move-column-left | |||
| 99 | + | bind.cmd+shift+l = move-column-right | |||
| 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-width | 105 | bind.cmd+r = cycle-column-width | ||
| 91 | bind.cmd+shift+f = toggle-fullscreen | 106 | bind.cmd+shift+f = toggle-fullscreen | ||
| 92 | bind.cmd+q = close-window | 107 | 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-up | 113 | bind.cmd+pageup = focus-workspace-up | ||
| 99 | bind.cmd+pagedown = focus-workspace-down | 114 | bind.cmd+pagedown = focus-workspace-down | ||
| 100 | - | bind.cmd+shift+pageup = move-to-workspace-up | 115 | + | # move-column-to-workspace moves the whole column; move-window-to-workspace |
| 101 | - | bind.cmd+shift+pagedown = move-to-workspace-down | 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 | |||
| 102 | bind.cmd+code:2 = focus-workspace 1 | 119 | bind.cmd+code:2 = focus-workspace 1 | ||
| 103 | bind.cmd+code:3 = focus-workspace 2 | 120 | bind.cmd+code:3 = focus-workspace 2 | ||
| 104 | bind.cmd+code:4 = focus-workspace 3 | 121 | 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 7 | 125 | bind.cmd+code:8 = focus-workspace 7 | ||
| 109 | bind.cmd+code:9 = focus-workspace 8 | 126 | bind.cmd+code:9 = focus-workspace 8 | ||
| 110 | bind.cmd+code:10 = focus-workspace 9 | 127 | bind.cmd+code:10 = focus-workspace 9 | ||
| 111 | - | bind.cmd+shift+code:2 = move-to-workspace 1 | 128 | + | bind.cmd+shift+code:2 = move-column-to-workspace 1 |
| 112 | - | bind.cmd+shift+code:3 = move-to-workspace 2 | 129 | + | bind.cmd+shift+code:3 = move-column-to-workspace 2 |
| 113 | - | bind.cmd+shift+code:4 = move-to-workspace 3 | 130 | + | bind.cmd+shift+code:4 = move-column-to-workspace 3 |
| 114 | - | bind.cmd+shift+code:5 = move-to-workspace 4 | 131 | + | bind.cmd+shift+code:5 = move-column-to-workspace 4 |
| 115 | - | bind.cmd+shift+code:6 = move-to-workspace 5 | 132 | + | bind.cmd+shift+code:6 = move-column-to-workspace 5 |
| 116 | - | bind.cmd+shift+code:7 = move-to-workspace 6 | 133 | + | bind.cmd+shift+code:7 = move-column-to-workspace 6 |
| 117 | - | bind.cmd+shift+code:8 = move-to-workspace 7 | 134 | + | bind.cmd+shift+code:8 = move-column-to-workspace 7 |
| 118 | - | bind.cmd+shift+code:9 = move-to-workspace 8 | 135 | + | bind.cmd+shift+code:9 = move-column-to-workspace 8 |
| 119 | - | bind.cmd+shift+code:10 = move-to-workspace 9 | 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 | |||
| 120 | 147 | ||||
| 121 | # Zoom: the whole display, live. | 148 | # Zoom: the whole display, live. | ||
| 122 | bind.cmd+code:13 = scale-up | 149 | bind.cmd+code:13 = scale-up | ||
M internal/adapters/config/config.go +26 -9
| @@ -27,6 +27,8 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 27 | 27 | {"cmd+down", "focus-window-down"}, | |
| 28 | 28 | {"cmd+shift+left", "move-column-left"}, | |
| 29 | 29 | {"cmd+shift+right", "move-column-right"}, | |
| 30 | + | {"cmd+bracketleft", "consume-or-expel-window-left"}, | |
| 31 | + | {"cmd+bracketright", "consume-or-expel-window-right"}, | |
| 30 | 32 | {"cmd+r", "cycle-column-width"}, | |
| 31 | 33 | {"cmd+shift+f", "toggle-fullscreen"}, | |
| 32 | 34 | {"cmd+q", "close-window"}, | |
| @@ -34,14 +36,14 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 34 | 36 | {"ctrl+cmd+space", "spawn fuzzel"}, | |
| 35 | 37 | {"cmd+pageup", "focus-workspace-up"}, | |
| 36 | 38 | {"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"}, | |
| 39 | 41 | {"cmd+ctrl+left", "focus-monitor-left"}, | |
| 40 | 42 | {"cmd+ctrl+right", "focus-monitor-right"}, | |
| 41 | 43 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 42 | 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 | 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 | 48 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 47 | 49 | ||
| @@ -87,11 +89,20 @@ func Defaults() ports.Config { | |||
| 87 | 89 | c.Layout.Overflow = "scroll" | |
| 88 | 90 | c.Binds = map[string]string{} | |
| 89 | 91 | 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 | |
| 93 | 105 | } | |
| 94 | - | c.Binds[combo] = b.action | |
| 95 | 106 | } | |
| 96 | 107 | // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they | |
| 97 | 108 | // work the same on AZERTY, QWERTZ or Dvorak. | |
| @@ -103,7 +114,7 @@ func Defaults() ports.Config { | |||
| 103 | 114 | focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1)) | |
| 104 | 115 | move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1)) | |
| 105 | 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 | 119 | c.Render.DirectScanout = true | |
| 109 | 120 | c.Render.Tearing = true | |
| @@ -447,6 +458,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 447 | 458 | } | |
| 448 | 459 | } | |
| 449 | 460 | 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 | 467 | case "render.direct-scanout": | |
| 451 | 468 | b, err := onOff(v) | |
| 452 | 469 | if err != nil { | |
| @@ -622,7 +639,7 @@ func checkAction(v string) error { | |||
| 622 | 639 | } | |
| 623 | 640 | 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 | 643 | if rest, ok := strings.CutPrefix(v, prefix); ok { | |
| 627 | 644 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | |
| 628 | 645 | 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 they | 107 | // 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 = true | 119 | c.Render.DirectScanout = true | ||
| 109 | c.Render.Tearing = true | 120 | 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 = list | 460 | 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 nil | 640 | 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) { | |||
| 21 | 21 | ||
| 22 | 22 | func TestDefaultsAndLoad(t *testing.T) { | |
| 23 | 23 | d := Defaults() | |
| 24 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 40 || d.Layout.MaxColumns != 2 { | |
| 24 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 52 || d.Layout.MaxColumns != 2 { | |
| 25 | 25 | t.Fatalf("defaults: %+v", d) | |
| 26 | 26 | } | |
| 27 | + | if d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove { | |
| 28 | + | t.Fatal(d.Binds) | |
| 29 | + | } | |
| 27 | 30 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | |
| 28 | 31 | t.Fatal(d.Binds) | |
| 29 | 32 | } | |
| @@ -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 | 61 | func TestParse(t *testing.T) { | |
| 48 | 62 | c, w := parseString(t, ` | |
| 49 | 63 | # 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 | # comment | 63 | # comment | ||
M internal/core/action.go +76 -32
| @@ -20,38 +20,57 @@ const ( | |||
| 20 | 20 | ActionCloseWindow Action = "close-window" | |
| 21 | 21 | ActionQuit Action = "quit" | |
| 22 | 22 | // Workspaces stack vertically; up/down stop at the ends. | |
| 23 | - | ActionFocusWorkspaceUp Action = "focus-workspace-up" | |
| 24 | - | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| 25 | - | ActionMoveToWorkspaceUp Action = "move-to-workspace-up" | |
| 26 | - | ActionMoveToWorkspaceDown Action = "move-to-workspace-down" | |
| 23 | + | ActionFocusWorkspaceUp Action = "focus-workspace-up" | |
| 24 | + | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| 25 | + | // Move the focused column, or only the focused window, one workspace | |
| 26 | + | // up or down. | |
| 27 | + | ActionMoveColumnToWorkspaceUp Action = "move-column-to-workspace-up" | |
| 28 | + | ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down" | |
| 29 | + | ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up" | |
| 30 | + | ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down" | |
| 27 | 31 | // Monitors are ordered left to right (ADR 011). | |
| 28 | 32 | ActionFocusMonitorLeft Action = "focus-monitor-left" | |
| 29 | 33 | ActionFocusMonitorRight Action = "focus-monitor-right" | |
| 30 | 34 | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | |
| 31 | 35 | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | |
| 32 | 36 | // Scale steps through the clean scales of the output (see CleanScales). | |
| 33 | - | ActionScaleUp Action = "scale-up" | |
| 34 | - | ActionScaleDown Action = "scale-down" | |
| 35 | - | actionFocusWorkspace = "focus-workspace " | |
| 36 | - | actionMoveToWorkspace = "move-to-workspace " | |
| 37 | + | ActionScaleUp Action = "scale-up" | |
| 38 | + | ActionScaleDown Action = "scale-down" | |
| 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" | |
| 37 | 42 | ) | |
| 38 | 43 | ||
| 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 | |
| 53 | 72 | } | |
| 54 | - | return n, move, true | |
| 73 | + | return 0, 0, false | |
| 55 | 74 | } | |
| 56 | 75 | ||
| 57 | 76 | // namedPrefix toggles a named workspace: "workspace dev". | |
| @@ -114,6 +133,31 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 114 | 133 | c.focusScreen = i | |
| 115 | 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 | 161 | case ActionMoveColumnLeft, ActionMoveColumnRight: | |
| 118 | 162 | // Past the edge the column moves to the neighbor screen, on the | |
| 119 | 163 | // side facing this one, and focus follows it. | |
| @@ -140,7 +184,7 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 140 | 184 | if dir < 0 { | |
| 141 | 185 | at = len(to.Columns) | |
| 142 | 186 | } | |
| 143 | - | to.insertColumn(at, col) | |
| 187 | + | to.receive(col, at) | |
| 144 | 188 | from.mon.normalize() | |
| 145 | 189 | c.cur().mon.normalize() | |
| 146 | 190 | return Effect{} | |
| @@ -151,11 +195,11 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 151 | 195 | ||
| 152 | 196 | // Apply runs a bind action on the monitor. | |
| 153 | 197 | 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 { | |
| 158 | 200 | m.FocusNumber(n) | |
| 201 | + | } else { | |
| 202 | + | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | |
| 159 | 203 | } | |
| 160 | 204 | return Effect{} | |
| 161 | 205 | } | |
| @@ -185,14 +229,14 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 185 | 229 | m.Focus(m.Active + 1) | |
| 186 | 230 | } | |
| 187 | 231 | return Effect{} | |
| 188 | - | case ActionMoveToWorkspaceUp: | |
| 232 | + | case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp: | |
| 189 | 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 | 236 | return Effect{} | |
| 193 | - | case ActionMoveToWorkspaceDown: | |
| 237 | + | case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown: | |
| 194 | 238 | if m.shown == nil { | |
| 195 | - | m.MoveToWorkspace(m.Active + 1) | |
| 239 | + | m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown) | |
| 196 | 240 | } | |
| 197 | 241 | return Effect{} | |
| 198 | 242 | } | |
| @@ -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 = true | 50 | + | MoveWindowToWorkspace |
| 46 | - | } | 51 | + | ) |
| 47 | - | if !found { | 52 | + | |
| 48 | - | return 0, false, false | 53 | + | 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, false | 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 | |||
| 53 | } | 72 | } | ||
| 54 | - | return n, move, true | 73 | + | 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 = i | 133 | 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 the | 162 | // 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 { | |||
| 181 | 181 | continue | |
| 182 | 182 | } | |
| 183 | 183 | switch Action(a) { | |
| 184 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 184 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | |
| 185 | 185 | default: | |
| 186 | 186 | return fmt.Errorf("invalid action %q", a) | |
| 187 | 187 | } | |
| @@ -181,7 +181,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 181 | continue | 181 | continue | ||
| 182 | } | 182 | } | ||
| 183 | switch Action(a) { | 183 | switch Action(a) { | ||
| 184 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | 184 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, 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) { | |||
| 496 | 496 | for len(commands) > 0 { | |
| 497 | 497 | <-commands | |
| 498 | 498 | } | |
| 499 | - | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1. | |
| 499 | + | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-column-to-workspace 1. | |
| 500 | 500 | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | |
| 501 | 501 | scene(t, scenes) | |
| 502 | 502 | // Shift goes up first, so the release reports "1": still swallowed. | |
| @@ -496,7 +496,7 @@ func TestShiftReleasedFirst(t *testing.T) { | |||||
| 496 | for len(commands) > 0 { | 496 | for len(commands) > 0 { | ||
| 497 | <-commands | 497 | <-commands | ||
| 498 | } | 498 | } | ||
| 499 | - | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1. | 499 | + | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-column-to-workspace 1. |
| 500 | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | 500 | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | ||
| 501 | scene(t, scenes) | 501 | scene(t, scenes) | ||
| 502 | // Shift goes up first, so the release reports "1": still swallowed. | 502 | // Shift goes up first, so the release reports "1": still swallowed. | ||
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) { | |||
| 229 | 229 | if id == 0 || w.has(id) { | |
| 230 | 230 | return | |
| 231 | 231 | } | |
| 232 | + | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // addColumn places a new column like a new window and focuses it. | |
| 236 | + | func (w *Workspace) addColumn(col Column) { | |
| 232 | 237 | at := 0 | |
| 233 | 238 | if w.Overflow == OverflowFixed { | |
| 234 | 239 | // The spiral depends only on window order: new windows go last. | |
| @@ -238,7 +243,7 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 238 | 243 | } | |
| 239 | 244 | w.Columns = append(w.Columns, Column{}) | |
| 240 | 245 | copy(w.Columns[at+1:], w.Columns[at:]) | |
| 241 | - | w.Columns[at] = Column{Windows: []WindowID{id}} | |
| 246 | + | w.Columns[at] = col | |
| 242 | 247 | w.Focus = at | |
| 243 | 248 | w.scroll() | |
| 244 | 249 | } | |
| @@ -229,6 +229,11 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 229 | if id == 0 || w.has(id) { | 229 | if id == 0 || w.has(id) { | ||
| 230 | return | 230 | return | ||
| 231 | } | 231 | } | ||
| 232 | + | w.addColumn(Column{Windows: []WindowID{id}}) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // addColumn places a new column like a new window and focuses it. | |||
| 236 | + | func (w *Workspace) addColumn(col Column) { | |||
| 232 | at := 0 | 237 | at := 0 | ||
| 233 | if w.Overflow == OverflowFixed { | 238 | if w.Overflow == OverflowFixed { | ||
| 234 | // The spiral depends only on window order: new windows go last. | 239 | // The spiral depends only on window order: new windows go last. | ||
| @@ -238,7 +243,7 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 238 | } | 243 | } | ||
| 239 | w.Columns = append(w.Columns, Column{}) | 244 | w.Columns = append(w.Columns, Column{}) | ||
| 240 | copy(w.Columns[at+1:], w.Columns[at:]) | 245 | copy(w.Columns[at+1:], w.Columns[at:]) | ||
| 241 | - | w.Columns[at] = Column{Windows: []WindowID{id}} | 246 | + | w.Columns[at] = col |
| 242 | w.Focus = at | 247 | w.Focus = at | ||
| 243 | w.scroll() | 248 | w.scroll() | ||
| 244 | } | 249 | } | ||
M internal/core/monitor.go +47 -11
| @@ -22,6 +22,8 @@ type Monitor struct { | |||
| 22 | 22 | back *Workspace // where a named workspace toggle returns to | |
| 23 | 23 | template Workspace | |
| 24 | 24 | named []NamedWorkspace | |
| 25 | + | // followMove shows the target workspace after a move to it. | |
| 26 | + | followMove bool | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | |
| @@ -477,29 +479,60 @@ func (m *Monitor) fullscreenHome() { | |||
| 477 | 479 | } | |
| 478 | 480 | } | |
| 479 | 481 | ||
| 480 | - | // MoveToWorkspace moves the focused window to numbered workspace index i; | |
| 481 | - | // focus stays here. | |
| 482 | - | func (m *Monitor) MoveToWorkspace(i int) { | |
| 482 | + | // MoveToWorkspace moves the focused column (or only the focused window) to | |
| 483 | + | // numbered workspace index i. A floating window always moves alone. Focus | |
| 484 | + | // stays here unless followMove is set (ADR 017). | |
| 485 | + | func (m *Monitor) MoveToWorkspace(i int, column bool) { | |
| 483 | 486 | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 484 | - | cur := m.Current() | |
| 487 | + | cur, to := m.Current(), m.Workspaces[i] | |
| 485 | 488 | id, ok := cur.Focused() | |
| 486 | - | if !ok || m.Workspaces[i] == cur { | |
| 489 | + | if !ok || to == cur { | |
| 487 | 490 | return | |
| 488 | 491 | } | |
| 489 | 492 | if f := cur.floatIndex(id); f >= 0 { | |
| 490 | 493 | fl := cur.Floats[f] | |
| 491 | 494 | cur.RemoveWindow(id) | |
| 492 | - | m.Workspaces[i].AddFloating(id, fl.W, fl.H) | |
| 493 | - | } else if to := m.Workspaces[i]; to.origin != nil { | |
| 494 | - | cur.RemoveWindow(id) | |
| 495 | - | to.joinFullscreen(id) | |
| 495 | + | to.AddFloating(id, fl.W, fl.H) | |
| 496 | + | to.FocusID(id) | |
| 496 | 497 | } 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 | |
| 499 | 513 | } | |
| 500 | 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 | 536 | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats | |
| 504 | 537 | // above the fullscreen window, which would hide it (fixed overflow), and | |
| 505 | 538 | // tiles again once home. | |
| @@ -545,6 +578,9 @@ func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps( | |||
| 545 | 578 | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | |
| 546 | 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 | 584 | // SetMaxColumns sets the default; named workspaces may override it. | |
| 549 | 585 | func (m *Monitor) SetMaxColumns(n int) { | |
| 550 | 586 | m.template.MaxColumns = n | |
| @@ -22,6 +22,8 @@ type Monitor struct { | |||||
| 22 | back *Workspace // where a named workspace toggle returns to | 22 | back *Workspace // where a named workspace toggle returns to | ||
| 23 | template Workspace | 23 | template Workspace | ||
| 24 | named []NamedWorkspace | 24 | named []NamedWorkspace | ||
| 25 | + | // followMove shows the target workspace after a move to it. | |||
| 26 | + | followMove bool | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | 29 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | ||
| @@ -477,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 | return | 490 | return | ||
| 488 | } | 491 | } | ||
| 489 | if f := cur.floatIndex(id); f >= 0 { | 492 | if f := cur.floatIndex(id); f >= 0 { | ||
| 490 | fl := cur.Floats[f] | 493 | fl := cur.Floats[f] | ||
| 491 | cur.RemoveWindow(id) | 494 | cur.RemoveWindow(id) | ||
| 492 | - | m.Workspaces[i].AddFloating(id, fl.W, fl.H) | 495 | + | to.AddFloating(id, fl.W, fl.H) |
| 493 | - | } else if to := m.Workspaces[i]; to.origin != nil { | 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 floats | 536 | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats | ||
| 504 | // above the fullscreen window, which would hide it (fixed overflow), and | 537 | // 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 = n | 586 | m.template.MaxColumns = n | ||
M internal/core/monitor_test.go +107 -13
| @@ -154,11 +154,11 @@ func TestMonitorActions(t *testing.T) { | |||
| 154 | 154 | t.Fatal(m.Active) | |
| 155 | 155 | } | |
| 156 | 156 | // Move the focused window (2) down: focus stays on workspace 1. | |
| 157 | - | m.Apply(ActionMoveToWorkspaceDown) | |
| 157 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 158 | 158 | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 { | |
| 159 | 159 | t.Fatal(windows(m), m.Active) | |
| 160 | 160 | } | |
| 161 | - | m.Apply("move-to-workspace 3") | |
| 161 | + | m.Apply("move-window-to-workspace 3") | |
| 162 | 162 | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 { | |
| 163 | 163 | t.Fatal(windows(m), m.Active) | |
| 164 | 164 | } | |
| @@ -202,15 +202,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 | 278 | func TestWorkspaceArg(t *testing.T) { | |
| 206 | 279 | 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) | |
| 214 | 288 | } | |
| 215 | 289 | } | |
| 216 | 290 | } | |
| @@ -278,8 +352,8 @@ func TestHiddenWorkspaceToggle(t *testing.T) { | |||
| 278 | 352 | } | |
| 279 | 353 | // Moving a window up/down from a hidden workspace does nothing. | |
| 280 | 354 | m.Apply("workspace dev") | |
| 281 | - | m.Apply(ActionMoveToWorkspaceDown) | |
| 282 | - | m.Apply(ActionMoveToWorkspaceUp) | |
| 355 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 356 | + | m.Apply(ActionMoveWindowToWorkspaceUp) | |
| 283 | 357 | if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" { | |
| 284 | 358 | t.Fatal(id, windows(m)) | |
| 285 | 359 | } | |
| @@ -541,11 +615,31 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 541 | 615 | m.ToggleFullscreen() | |
| 542 | 616 | m.Apply(ActionFocusWorkspaceUp) | |
| 543 | 617 | m.Current().FocusID(1) | |
| 544 | - | m.Apply(ActionMoveToWorkspaceDown) | |
| 618 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 545 | 619 | if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil { | |
| 546 | 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 | 643 | t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) { | |
| 550 | 644 | m := fixed() | |
| 551 | 645 | m.Current().FocusID(2) | |
| @@ -603,7 +697,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 603 | 697 | m := fixed() | |
| 604 | 698 | m.Current().FocusID(2) | |
| 605 | 699 | m.ToggleFullscreen() | |
| 606 | - | m.Apply(ActionMoveToWorkspaceUp) | |
| 700 | + | m.Apply(ActionMoveWindowToWorkspaceUp) | |
| 607 | 701 | m.AddWindow(4) | |
| 608 | 702 | m.RemoveWindow(4) | |
| 609 | 703 | 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 Action | 280 | + | a Action |
| 208 | - | n int | 281 | + | n int |
| 209 | - | move, ok bool | 282 | + | 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) { | |||
| 304 | 304 | m.SetPresets(c.presets) | |
| 305 | 305 | m.SetGaps(c.cfg.Layout.Gaps) | |
| 306 | 306 | m.SetBorder(c.cfg.Border.Width) | |
| 307 | + | m.SetFollowMove(c.cfg.Focus.FollowMove) | |
| 307 | 308 | } | |
| 308 | 309 | ||
| 309 | 310 | // neighbor returns the screen index left (-1) or right (+1) of the focused | |
| @@ -304,6 +304,7 @@ func (c *Core) settings(m *Monitor) { | |||||
| 304 | m.SetPresets(c.presets) | 304 | m.SetPresets(c.presets) | ||
| 305 | m.SetGaps(c.cfg.Layout.Gaps) | 305 | m.SetGaps(c.cfg.Layout.Gaps) | ||
| 306 | m.SetBorder(c.cfg.Border.Width) | 306 | m.SetBorder(c.cfg.Border.Width) | ||
| 307 | + | m.SetFollowMove(c.cfg.Focus.FollowMove) | |||
| 307 | } | 308 | } | ||
| 308 | 309 | ||||
| 309 | // neighbor returns the screen index left (-1) or right (+1) of the focused | 310 | // neighbor returns the screen index left (-1) or right (+1) of the focused | ||
M internal/core/screens_test.go +97 -0
| @@ -3,6 +3,7 @@ package core_test | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "reflect" | |
| 6 | + | "slices" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/nefertty/internal/adapters/config" | |
| @@ -504,3 +505,99 @@ func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) { | |||
| 504 | 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 | + | } | |
| @@ -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) { | |||
| 343 | 343 | r.fill(t) | |
| 344 | 344 | r.press(t, "d") // on dev, focus on code (slot 1) | |
| 345 | 345 | r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true} | |
| 346 | - | scene(t, r.scenes) // move-to-workspace-up: code goes to workspace 1 | |
| 346 | + | scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1 | |
| 347 | 347 | noSpawn(t, r.spawn) | |
| 348 | 348 | r.press(t, "d") // back to workspace 1 | |
| 349 | 349 | r.press(t, "d") // dev again: slot 1 is empty | |
| @@ -391,3 +391,22 @@ func TestSlotKeptWhileFullscreenAway(t *testing.T) { | |||
| 391 | 391 | r.press(t, "d") | |
| 392 | 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 | + | } | |
| @@ -343,7 +343,7 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) { | |||||
| 343 | r.fill(t) | 343 | r.fill(t) | ||
| 344 | r.press(t, "d") // on dev, focus on code (slot 1) | 344 | r.press(t, "d") // on dev, focus on code (slot 1) | ||
| 345 | r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true} | 345 | r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true} | ||
| 346 | - | scene(t, r.scenes) // move-to-workspace-up: code goes to workspace 1 | 346 | + | scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1 |
| 347 | noSpawn(t, r.spawn) | 347 | noSpawn(t, r.spawn) | ||
| 348 | r.press(t, "d") // back to workspace 1 | 348 | r.press(t, "d") // back to workspace 1 | ||
| 349 | r.press(t, "d") // dev again: slot 1 is empty | 349 | r.press(t, "d") // dev again: slot 1 is empty | ||
| @@ -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 { | |||
| 48 | 48 | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | |
| 49 | 49 | Overflow string | |
| 50 | 50 | } | |
| 51 | + | Focus struct { | |
| 52 | + | // FollowMove shows the target workspace after a column or window | |
| 53 | + | // moves to it. | |
| 54 | + | FollowMove bool | |
| 55 | + | } | |
| 51 | 56 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 52 | 57 | Workspaces []WorkspaceConfig | |
| 53 | 58 | // Outputs selects and configures physical displays (drm backend). | |
| @@ -48,6 +48,11 @@ type Config struct { | |||||
| 48 | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | 48 | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | ||
| 49 | Overflow string | 49 | Overflow string | ||
| 50 | } | 50 | } | ||
| 51 | + | Focus struct { | |||
| 52 | + | // FollowMove shows the target workspace after a column or window | |||
| 53 | + | // moves to it. | |||
| 54 | + | FollowMove bool | |||
| 55 | + | } | |||
| 51 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | 56 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | ||
| 52 | Workspaces []WorkspaceConfig | 57 | Workspaces []WorkspaceConfig | ||
| 53 | // Outputs selects and configures physical displays (drm backend). | 58 | // Outputs selects and configures physical displays (drm backend). | ||