feat(core): add consume-or-expel-window-left/right on Cmd+[ and Cmd+]
A lone window joins the neighbor column; a stacked one leaves for a new column. A full fixed workspace stacks it into the neighbor column instead, and past the edge onto the neighbor monitor (new edge column, or stacked when that one is full too). Slots follow their window (ADR 016).
- commit
- ade7c5a513c0d296a58d337c8608d5bdd6972a78
- author
- brice · · 2026-09-26 14:44 UTC
- parent
- 1ddb3e5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +350 -3
M examples/config +5 -0
| @@ -97,6 +97,11 @@ bind.cmd+k = focus-window-up | |||
| 97 | 97 | bind.cmd+j = focus-window-down | |
| 98 | 98 | bind.cmd+shift+h = move-column-left | |
| 99 | 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 | |
| 100 | 105 | bind.cmd+r = cycle-column-width | |
| 101 | 106 | bind.cmd+shift+f = toggle-fullscreen | |
| 102 | 107 | bind.cmd+q = close-window | |
| @@ -97,6 +97,11 @@ bind.cmd+k = focus-window-up | |||||
| 97 | bind.cmd+j = focus-window-down | 97 | bind.cmd+j = focus-window-down | ||
| 98 | bind.cmd+shift+h = move-column-left | 98 | bind.cmd+shift+h = move-column-left | ||
| 99 | bind.cmd+shift+l = move-column-right | 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 | |||
| 100 | bind.cmd+r = cycle-column-width | 105 | bind.cmd+r = cycle-column-width | ||
| 101 | bind.cmd+shift+f = toggle-fullscreen | 106 | bind.cmd+shift+f = toggle-fullscreen | ||
| 102 | bind.cmd+q = close-window | 107 | bind.cmd+q = close-window | ||
M internal/adapters/config/config.go +3 -1
| @@ -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"}, | |
| @@ -41,7 +43,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 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-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | |
| 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 | ||
| @@ -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"}, | ||
| @@ -41,7 +43,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 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-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | 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 | ||||
M internal/adapters/config/config_test.go +1 -1
| @@ -21,7 +21,7 @@ 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) != 50 || 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 | 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 { | |
| @@ -21,7 +21,7 @@ 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) != 50 || 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 { | 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 { | ||
M internal/core/action.go +28 -0
| @@ -36,6 +36,9 @@ const ( | |||
| 36 | 36 | // Scale steps through the clean scales of the output (see CleanScales). | |
| 37 | 37 | ActionScaleUp Action = "scale-up" | |
| 38 | 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" | |
| 39 | 42 | ) | |
| 40 | 43 | ||
| 41 | 44 | // WorkspaceOp is what a numbered workspace action does. | |
| @@ -130,6 +133,31 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 130 | 133 | c.focusScreen = i | |
| 131 | 134 | return Effect{} | |
| 132 | 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{} | |
| 133 | 161 | case ActionMoveColumnLeft, ActionMoveColumnRight: | |
| 134 | 162 | // Past the edge the column moves to the neighbor screen, on the | |
| 135 | 163 | // side facing this one, and focus follows it. | |
| @@ -36,6 +36,9 @@ const ( | |||||
| 36 | // Scale steps through the clean scales of the output (see CleanScales). | 36 | // Scale steps through the clean scales of the output (see CleanScales). | ||
| 37 | ActionScaleUp Action = "scale-up" | 37 | ActionScaleUp Action = "scale-up" | ||
| 38 | ActionScaleDown Action = "scale-down" | 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" | |||
| 39 | ) | 42 | ) | ||
| 40 | 43 | ||||
| 41 | // WorkspaceOp is what a numbered workspace action does. | 44 | // WorkspaceOp is what a numbered workspace action does. | ||
| @@ -130,6 +133,31 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 130 | c.focusScreen = i | 133 | c.focusScreen = i | ||
| 131 | return Effect{} | 134 | return Effect{} | ||
| 132 | } | 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{} | |||
| 133 | case ActionMoveColumnLeft, ActionMoveColumnRight: | 161 | case ActionMoveColumnLeft, ActionMoveColumnRight: | ||
| 134 | // 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 | ||
| 135 | // side facing this one, and focus follows it. | 163 | // side facing this one, and focus follows it. | ||
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, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, 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, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, 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 | } | ||
A internal/core/expel.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 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 | + | w.FocusID(id) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // dropSlotOf clears the slot of the column whose slot window is id. | |
| 72 | + | func (w *Workspace) dropSlotOf(id WindowID) { | |
| 73 | + | for i := range w.Columns { | |
| 74 | + | if w.Columns[i].Slot != 0 && w.Columns[i].Windows[0] == id { | |
| 75 | + | w.Columns[i].Slot = 0 | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // takeWindow removes the focused tiled window from its column for another | |
| 81 | + | // workspace; its slot, if any, is released. | |
| 82 | + | func (w *Workspace) takeWindow() (WindowID, bool) { | |
| 83 | + | id, ok := w.Focused() | |
| 84 | + | if !ok || w.floatFocus { | |
| 85 | + | return 0, false | |
| 86 | + | } | |
| 87 | + | w.dropSlotOf(id) | |
| 88 | + | w.RemoveWindow(id) | |
| 89 | + | return id, true | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // expelTo puts a window expelled from the neighbor monitor on the side | |
| 93 | + | // facing it (dir > 0: it came from the left): as a new edge column, or at | |
| 94 | + | // the bottom of the edge column when the workspace is full. | |
| 95 | + | func (w *Workspace) expelTo(id WindowID, dir int) { | |
| 96 | + | at := 0 | |
| 97 | + | if dir < 0 { | |
| 98 | + | at = len(w.Columns) | |
| 99 | + | } | |
| 100 | + | if !w.full() || w.origin != nil { | |
| 101 | + | w.receive(Column{Windows: []WindowID{id}}, at) | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | i := min(at, len(w.Columns)-1) | |
| 105 | + | w.Columns[i].Windows = append(w.Columns[i].Windows, id) | |
| 106 | + | w.Activate(id) | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 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 | + | w.FocusID(id) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // dropSlotOf clears the slot of the column whose slot window is id. | |||
| 72 | + | func (w *Workspace) dropSlotOf(id WindowID) { | |||
| 73 | + | for i := range w.Columns { | |||
| 74 | + | if w.Columns[i].Slot != 0 && w.Columns[i].Windows[0] == id { | |||
| 75 | + | w.Columns[i].Slot = 0 | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // takeWindow removes the focused tiled window from its column for another | |||
| 81 | + | // workspace; its slot, if any, is released. | |||
| 82 | + | func (w *Workspace) takeWindow() (WindowID, bool) { | |||
| 83 | + | id, ok := w.Focused() | |||
| 84 | + | if !ok || w.floatFocus { | |||
| 85 | + | return 0, false | |||
| 86 | + | } | |||
| 87 | + | w.dropSlotOf(id) | |||
| 88 | + | w.RemoveWindow(id) | |||
| 89 | + | return id, true | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // expelTo puts a window expelled from the neighbor monitor on the side | |||
| 93 | + | // facing it (dir > 0: it came from the left): as a new edge column, or at | |||
| 94 | + | // the bottom of the edge column when the workspace is full. | |||
| 95 | + | func (w *Workspace) expelTo(id WindowID, dir int) { | |||
| 96 | + | at := 0 | |||
| 97 | + | if dir < 0 { | |||
| 98 | + | at = len(w.Columns) | |||
| 99 | + | } | |||
| 100 | + | if !w.full() || w.origin != nil { | |||
| 101 | + | w.receive(Column{Windows: []WindowID{id}}, at) | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | i := min(at, len(w.Columns)-1) | |||
| 105 | + | w.Columns[i].Windows = append(w.Columns[i].Windows, id) | |||
| 106 | + | w.Activate(id) | |||
| 107 | + | } | |||
A internal/core/expel_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 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 | + | ||
| 57 | + | func TestConsumeOrExpelFocusFollows(t *testing.T) { | |
| 58 | + | w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}) | |
| 59 | + | w.ConsumeOrExpel(1) | |
| 60 | + | if id, _ := w.Focused(); id != 2 { | |
| 61 | + | t.Fatal(id) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestConsumeOrExpelSkips(t *testing.T) { | |
| 66 | + | t.Run("floating focus", func(t *testing.T) { | |
| 67 | + | w := cols(OverflowScroll, 2, 0, []WindowID{1, 2}) | |
| 68 | + | w.AddFloating(9, 10, 10) | |
| 69 | + | w.FocusID(9) | |
| 70 | + | w.ConsumeOrExpel(1) | |
| 71 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) { | |
| 72 | + | t.Fatal(got) | |
| 73 | + | } | |
| 74 | + | }) | |
| 75 | + | t.Run("fullscreen", func(t *testing.T) { | |
| 76 | + | w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}) | |
| 77 | + | w.fullscreen = 2 | |
| 78 | + | w.ConsumeOrExpel(1) | |
| 79 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) { | |
| 80 | + | t.Fatal(got) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestConsumeOrExpelSlots(t *testing.T) { | |
| 86 | + | slots := func(w *Workspace) []int { | |
| 87 | + | var out []int | |
| 88 | + | for _, c := range w.Columns { | |
| 89 | + | out = append(out, c.Slot) | |
| 90 | + | } | |
| 91 | + | return out | |
| 92 | + | } | |
| 93 | + | t.Run("the slot follows its window", func(t *testing.T) { | |
| 94 | + | w := cols(OverflowScroll, 1, 0, []WindowID{1, 2}) | |
| 95 | + | w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3} | |
| 96 | + | w.Columns[0].Focus = 0 | |
| 97 | + | w.ConsumeOrExpel(1) | |
| 98 | + | 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{}) { | |
| 99 | + | t.Fatal(got, slots(w), w.Columns) | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | t.Run("another window leaves the slot", func(t *testing.T) { | |
| 103 | + | w := cols(OverflowScroll, 1, 0, []WindowID{1, 2}) | |
| 104 | + | w.Columns[0].Slot = 1 | |
| 105 | + | w.ConsumeOrExpel(1) | |
| 106 | + | if !reflect.DeepEqual(slots(w), []int{1, 0}) { | |
| 107 | + | t.Fatal(slots(w)) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | t.Run("stacking the slot window releases it", func(t *testing.T) { | |
| 111 | + | w := cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3}) | |
| 112 | + | w.Columns[0].Slot, w.Columns[0].Focus = 1, 0 | |
| 113 | + | w.ConsumeOrExpel(1) | |
| 114 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {3, 1}}) || !reflect.DeepEqual(slots(w), []int{0, 0}) { | |
| 115 | + | t.Fatal(got, slots(w)) | |
| 116 | + | } | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestExpelTo(t *testing.T) { | |
| 121 | + | t.Run("new edge column", func(t *testing.T) { | |
| 122 | + | w := cols(OverflowFixed, 3, 0, []WindowID{1}, []WindowID{2}) | |
| 123 | + | w.expelTo(9, 1) | |
| 124 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{9}, {1}, {2}}) || w.Focus != 0 { | |
| 125 | + | t.Fatal(got) | |
| 126 | + | } | |
| 127 | + | }) | |
| 128 | + | t.Run("full stacks into the first column", func(t *testing.T) { | |
| 129 | + | w := cols(OverflowFixed, 2, 1, []WindowID{1}, []WindowID{2}) | |
| 130 | + | w.expelTo(9, 1) | |
| 131 | + | if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1, 9}, {2}}) || id != 9 { | |
| 132 | + | t.Fatal(ids(w), id) | |
| 133 | + | } | |
| 134 | + | }) | |
| 135 | + | t.Run("full from the right stacks into the last column", func(t *testing.T) { | |
| 136 | + | w := cols(OverflowFixed, 2, 0, []WindowID{1}, []WindowID{2}, []WindowID{3}) | |
| 137 | + | w.expelTo(9, -1) | |
| 138 | + | if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1}, {2}, {3, 9}}) || id != 9 { | |
| 139 | + | t.Fatal(ids(w), id) | |
| 140 | + | } | |
| 141 | + | }) | |
| 142 | + | t.Run("empty workspace", func(t *testing.T) { | |
| 143 | + | w := cols(OverflowFixed, 2, 0) | |
| 144 | + | w.expelTo(9, -1) | |
| 145 | + | if !reflect.DeepEqual(ids(w), [][]WindowID{{9}}) { | |
| 146 | + | t.Fatal(ids(w)) | |
| 147 | + | } | |
| 148 | + | }) | |
| 149 | + | } | |
| @@ -0,0 +1,149 @@ | |||||
| 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 | + | ||||
| 57 | + | func TestConsumeOrExpelFocusFollows(t *testing.T) { | |||
| 58 | + | w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}) | |||
| 59 | + | w.ConsumeOrExpel(1) | |||
| 60 | + | if id, _ := w.Focused(); id != 2 { | |||
| 61 | + | t.Fatal(id) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestConsumeOrExpelSkips(t *testing.T) { | |||
| 66 | + | t.Run("floating focus", func(t *testing.T) { | |||
| 67 | + | w := cols(OverflowScroll, 2, 0, []WindowID{1, 2}) | |||
| 68 | + | w.AddFloating(9, 10, 10) | |||
| 69 | + | w.FocusID(9) | |||
| 70 | + | w.ConsumeOrExpel(1) | |||
| 71 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) { | |||
| 72 | + | t.Fatal(got) | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | t.Run("fullscreen", func(t *testing.T) { | |||
| 76 | + | w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}) | |||
| 77 | + | w.fullscreen = 2 | |||
| 78 | + | w.ConsumeOrExpel(1) | |||
| 79 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) { | |||
| 80 | + | t.Fatal(got) | |||
| 81 | + | } | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestConsumeOrExpelSlots(t *testing.T) { | |||
| 86 | + | slots := func(w *Workspace) []int { | |||
| 87 | + | var out []int | |||
| 88 | + | for _, c := range w.Columns { | |||
| 89 | + | out = append(out, c.Slot) | |||
| 90 | + | } | |||
| 91 | + | return out | |||
| 92 | + | } | |||
| 93 | + | t.Run("the slot follows its window", func(t *testing.T) { | |||
| 94 | + | w := cols(OverflowScroll, 1, 0, []WindowID{1, 2}) | |||
| 95 | + | w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3} | |||
| 96 | + | w.Columns[0].Focus = 0 | |||
| 97 | + | w.ConsumeOrExpel(1) | |||
| 98 | + | 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{}) { | |||
| 99 | + | t.Fatal(got, slots(w), w.Columns) | |||
| 100 | + | } | |||
| 101 | + | }) | |||
| 102 | + | t.Run("another window leaves the slot", func(t *testing.T) { | |||
| 103 | + | w := cols(OverflowScroll, 1, 0, []WindowID{1, 2}) | |||
| 104 | + | w.Columns[0].Slot = 1 | |||
| 105 | + | w.ConsumeOrExpel(1) | |||
| 106 | + | if !reflect.DeepEqual(slots(w), []int{1, 0}) { | |||
| 107 | + | t.Fatal(slots(w)) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | t.Run("stacking the slot window releases it", func(t *testing.T) { | |||
| 111 | + | w := cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3}) | |||
| 112 | + | w.Columns[0].Slot, w.Columns[0].Focus = 1, 0 | |||
| 113 | + | w.ConsumeOrExpel(1) | |||
| 114 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {3, 1}}) || !reflect.DeepEqual(slots(w), []int{0, 0}) { | |||
| 115 | + | t.Fatal(got, slots(w)) | |||
| 116 | + | } | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestExpelTo(t *testing.T) { | |||
| 121 | + | t.Run("new edge column", func(t *testing.T) { | |||
| 122 | + | w := cols(OverflowFixed, 3, 0, []WindowID{1}, []WindowID{2}) | |||
| 123 | + | w.expelTo(9, 1) | |||
| 124 | + | if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{9}, {1}, {2}}) || w.Focus != 0 { | |||
| 125 | + | t.Fatal(got) | |||
| 126 | + | } | |||
| 127 | + | }) | |||
| 128 | + | t.Run("full stacks into the first column", func(t *testing.T) { | |||
| 129 | + | w := cols(OverflowFixed, 2, 1, []WindowID{1}, []WindowID{2}) | |||
| 130 | + | w.expelTo(9, 1) | |||
| 131 | + | if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1, 9}, {2}}) || id != 9 { | |||
| 132 | + | t.Fatal(ids(w), id) | |||
| 133 | + | } | |||
| 134 | + | }) | |||
| 135 | + | t.Run("full from the right stacks into the last column", func(t *testing.T) { | |||
| 136 | + | w := cols(OverflowFixed, 2, 0, []WindowID{1}, []WindowID{2}, []WindowID{3}) | |||
| 137 | + | w.expelTo(9, -1) | |||
| 138 | + | if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1}, {2}, {3, 9}}) || id != 9 { | |||
| 139 | + | t.Fatal(ids(w), id) | |||
| 140 | + | } | |||
| 141 | + | }) | |||
| 142 | + | t.Run("empty workspace", func(t *testing.T) { | |||
| 143 | + | w := cols(OverflowFixed, 2, 0) | |||
| 144 | + | w.expelTo(9, -1) | |||
| 145 | + | if !reflect.DeepEqual(ids(w), [][]WindowID{{9}}) { | |||
| 146 | + | t.Fatal(ids(w)) | |||
| 147 | + | } | |||
| 148 | + | }) | |||
| 149 | + | } | |||
M internal/core/screens_test.go +56 -0
| @@ -523,3 +523,59 @@ func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) { | |||
| 523 | 523 | } | |
| 524 | 524 | } | |
| 525 | 525 | } | |
| 526 | + | ||
| 527 | + | // windowsOf lists the tiled windows of an output in scene order, with the | |
| 528 | + | // focused one. | |
| 529 | + | func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) { | |
| 530 | + | for _, s := range set { | |
| 531 | + | if s.Output != out { | |
| 532 | + | continue | |
| 533 | + | } | |
| 534 | + | for _, w := range s.Windows { | |
| 535 | + | if !w.Hidden { | |
| 536 | + | ids = append(ids, w.ID) | |
| 537 | + | } | |
| 538 | + | if w.Focused { | |
| 539 | + | focused = w.ID | |
| 540 | + | } | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | return ids, focused | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | func TestExpelCrossesToFullNeighbor(t *testing.T) { | |
| 547 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |
| 548 | + | // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives | |
| 549 | + | // [1] [2 3], focus on 3 at the bottom of the edge column. | |
| 550 | + | r.mapWindow(t, 1) | |
| 551 | + | r.mapWindow(t, 2) | |
| 552 | + | r.mapWindow(t, 3) | |
| 553 | + | r.key(t, "bracketleft", ports.ModAlt) | |
| 554 | + | // DP-2: [4] [5], full too. | |
| 555 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 556 | + | r.mapWindow(t, 4) | |
| 557 | + | r.mapWindow(t, 5) | |
| 558 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 559 | + | // Expel right at the edge: 3 stacks under 4, the first column of DP-2. | |
| 560 | + | set := r.key(t, "bracketright", ports.ModAlt) | |
| 561 | + | if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) { | |
| 562 | + | t.Fatal(got) | |
| 563 | + | } | |
| 564 | + | got, focused := windowsOf(set, "DP-2") | |
| 565 | + | if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 { | |
| 566 | + | t.Fatal(got, focused) | |
| 567 | + | } | |
| 568 | + | if out := lastOutputs(t, r.commands); out.Focused != "DP-2" { | |
| 569 | + | t.Fatal(out.Focused) | |
| 570 | + | } | |
| 571 | + | // 3 is not in the edge column of DP-2: it stacks into [5]. | |
| 572 | + | set = r.key(t, "bracketright", ports.ModAlt) | |
| 573 | + | if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 { | |
| 574 | + | t.Fatal(got, focused) | |
| 575 | + | } | |
| 576 | + | // Now at the edge with no right monitor: nothing moves. | |
| 577 | + | set = r.key(t, "bracketright", ports.ModAlt) | |
| 578 | + | if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 { | |
| 579 | + | t.Fatal(got, focused) | |
| 580 | + | } | |
| 581 | + | } | |
| @@ -523,3 +523,59 @@ func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) { | |||||
| 523 | } | 523 | } | ||
| 524 | } | 524 | } | ||
| 525 | } | 525 | } | ||
| 526 | + | ||||
| 527 | + | // windowsOf lists the tiled windows of an output in scene order, with the | |||
| 528 | + | // focused one. | |||
| 529 | + | func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) { | |||
| 530 | + | for _, s := range set { | |||
| 531 | + | if s.Output != out { | |||
| 532 | + | continue | |||
| 533 | + | } | |||
| 534 | + | for _, w := range s.Windows { | |||
| 535 | + | if !w.Hidden { | |||
| 536 | + | ids = append(ids, w.ID) | |||
| 537 | + | } | |||
| 538 | + | if w.Focused { | |||
| 539 | + | focused = w.ID | |||
| 540 | + | } | |||
| 541 | + | } | |||
| 542 | + | } | |||
| 543 | + | return ids, focused | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | func TestExpelCrossesToFullNeighbor(t *testing.T) { | |||
| 547 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |||
| 548 | + | // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives | |||
| 549 | + | // [1] [2 3], focus on 3 at the bottom of the edge column. | |||
| 550 | + | r.mapWindow(t, 1) | |||
| 551 | + | r.mapWindow(t, 2) | |||
| 552 | + | r.mapWindow(t, 3) | |||
| 553 | + | r.key(t, "bracketleft", ports.ModAlt) | |||
| 554 | + | // DP-2: [4] [5], full too. | |||
| 555 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 556 | + | r.mapWindow(t, 4) | |||
| 557 | + | r.mapWindow(t, 5) | |||
| 558 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 559 | + | // Expel right at the edge: 3 stacks under 4, the first column of DP-2. | |||
| 560 | + | set := r.key(t, "bracketright", ports.ModAlt) | |||
| 561 | + | if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) { | |||
| 562 | + | t.Fatal(got) | |||
| 563 | + | } | |||
| 564 | + | got, focused := windowsOf(set, "DP-2") | |||
| 565 | + | if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 { | |||
| 566 | + | t.Fatal(got, focused) | |||
| 567 | + | } | |||
| 568 | + | if out := lastOutputs(t, r.commands); out.Focused != "DP-2" { | |||
| 569 | + | t.Fatal(out.Focused) | |||
| 570 | + | } | |||
| 571 | + | // 3 is not in the edge column of DP-2: it stacks into [5]. | |||
| 572 | + | set = r.key(t, "bracketright", ports.ModAlt) | |||
| 573 | + | if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 { | |||
| 574 | + | t.Fatal(got, focused) | |||
| 575 | + | } | |||
| 576 | + | // Now at the edge with no right monitor: nothing moves. | |||
| 577 | + | set = r.key(t, "bracketright", ports.ModAlt) | |||
| 578 | + | if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 { | |||
| 579 | + | t.Fatal(got, focused) | |||
| 580 | + | } | |||
| 581 | + | } | |||