feat(core): navigate monitors by geometry, add up/down monitor actions
- commit
- 0e21ae4f92e756883ede8f632be6dcf04608ca21
- author
- brice · · 2026-10-03 14:08 UTC
- parent
- 43b7e36
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +253 -30
M internal/adapters/config/config.go +6 -0
| @@ -52,6 +52,8 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 52 | 52 | {"cmd+shift+pagedown", "move-column-to-workspace-down"}, | |
| 53 | 53 | {"cmd+ctrl+left", "focus-monitor-left"}, | |
| 54 | 54 | {"cmd+ctrl+right", "focus-monitor-right"}, | |
| 55 | + | {"cmd+ctrl+up", "focus-monitor-up"}, | |
| 56 | + | {"cmd+ctrl+down", "focus-monitor-down"}, | |
| 55 | 57 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 56 | 58 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 57 | 59 | {"cmd+shift+up", "move-window-up"}, | |
| @@ -69,6 +71,10 @@ var actions = map[string]bool{ | |||
| 69 | 71 | "focus-monitor-right": true, | |
| 70 | 72 | "move-workspace-to-monitor-left": true, | |
| 71 | 73 | "move-workspace-to-monitor-right": true, | |
| 74 | + | "focus-monitor-up": true, | |
| 75 | + | "focus-monitor-down": true, | |
| 76 | + | "move-workspace-to-monitor-up": true, | |
| 77 | + | "move-workspace-to-monitor-down": true, | |
| 72 | 78 | "scale-up": true, | |
| 73 | 79 | "scale-down": true, | |
| 74 | 80 | "focus-workspace-up": true, | |
| @@ -52,6 +52,8 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 52 | {"cmd+shift+pagedown", "move-column-to-workspace-down"}, | 52 | {"cmd+shift+pagedown", "move-column-to-workspace-down"}, | ||
| 53 | {"cmd+ctrl+left", "focus-monitor-left"}, | 53 | {"cmd+ctrl+left", "focus-monitor-left"}, | ||
| 54 | {"cmd+ctrl+right", "focus-monitor-right"}, | 54 | {"cmd+ctrl+right", "focus-monitor-right"}, | ||
| 55 | + | {"cmd+ctrl+up", "focus-monitor-up"}, | |||
| 56 | + | {"cmd+ctrl+down", "focus-monitor-down"}, | |||
| 55 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | 57 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | ||
| 56 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | 58 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | ||
| 57 | {"cmd+shift+up", "move-window-up"}, | 59 | {"cmd+shift+up", "move-window-up"}, | ||
| @@ -69,6 +71,10 @@ var actions = map[string]bool{ | |||||
| 69 | "focus-monitor-right": true, | 71 | "focus-monitor-right": true, | ||
| 70 | "move-workspace-to-monitor-left": true, | 72 | "move-workspace-to-monitor-left": true, | ||
| 71 | "move-workspace-to-monitor-right": true, | 73 | "move-workspace-to-monitor-right": true, | ||
| 74 | + | "focus-monitor-up": true, | |||
| 75 | + | "focus-monitor-down": true, | |||
| 76 | + | "move-workspace-to-monitor-up": true, | |||
| 77 | + | "move-workspace-to-monitor-down": true, | |||
| 72 | "scale-up": true, | 78 | "scale-up": true, | ||
| 73 | "scale-down": true, | 79 | "scale-down": true, | ||
| 74 | "focus-workspace-up": true, | 80 | "focus-workspace-up": true, | ||
M internal/adapters/config/config_test.go +20 -1
| @@ -23,7 +23,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) { | |||
| 23 | 23 | func TestDefaultsAndLoad(t *testing.T) { | |
| 24 | 24 | t.Setenv("TERMINAL", "") | |
| 25 | 25 | d := Defaults() | |
| 26 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 73 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | |
| 26 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 77 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | |
| 27 | 27 | t.Fatalf("defaults: %+v", d) | |
| 28 | 28 | } | |
| 29 | 29 | if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || 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 { | |
| @@ -697,6 +697,25 @@ func TestOutputRelationWarnings(t *testing.T) { | |||
| 697 | 697 | } | |
| 698 | 698 | } | |
| 699 | 699 | ||
| 700 | + | func TestDefaultMonitorBinds(t *testing.T) { | |
| 701 | + | d := Defaults() | |
| 702 | + | for combo, action := range map[string]string{ | |
| 703 | + | "Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up", | |
| 704 | + | "Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down", | |
| 705 | + | "Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right", | |
| 706 | + | } { | |
| 707 | + | if d.Binds[combo] != action { | |
| 708 | + | t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action) | |
| 709 | + | } | |
| 710 | + | } | |
| 711 | + | // The up/down move-workspace-to-monitor actions have no default bind but | |
| 712 | + | // load from a config file. | |
| 713 | + | c, w := parseString(t, "bind.cmd+ctrl+alt+up = move-workspace-to-monitor-up\nbind.cmd+ctrl+alt+down = move-workspace-to-monitor-down\nbind.cmd+alt+u = focus-monitor-up\n") | |
| 714 | + | if len(w) != 0 || c.Binds["Alt+Cmd+Ctrl+Up"] != "move-workspace-to-monitor-up" || c.Binds["Alt+Cmd+Ctrl+Down"] != "move-workspace-to-monitor-down" { | |
| 715 | + | t.Fatalf("%v %v", w, c.Binds) | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | ||
| 700 | 719 | func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) { | |
| 701 | 720 | c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n") | |
| 702 | 721 | if got := c.Outputs[0].Anchor.Offset; got != 40 { | |
| @@ -23,7 +23,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) { | |||||
| 23 | func TestDefaultsAndLoad(t *testing.T) { | 23 | func TestDefaultsAndLoad(t *testing.T) { | ||
| 24 | t.Setenv("TERMINAL", "") | 24 | t.Setenv("TERMINAL", "") | ||
| 25 | d := Defaults() | 25 | d := Defaults() | ||
| 26 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 73 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | 26 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 77 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { |
| 27 | t.Fatalf("defaults: %+v", d) | 27 | t.Fatalf("defaults: %+v", d) | ||
| 28 | } | 28 | } | ||
| 29 | if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || 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 { | 29 | if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || 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 { | ||
| @@ -697,6 +697,25 @@ func TestOutputRelationWarnings(t *testing.T) { | |||||
| 697 | } | 697 | } | ||
| 698 | } | 698 | } | ||
| 699 | 699 | ||||
| 700 | + | func TestDefaultMonitorBinds(t *testing.T) { | |||
| 701 | + | d := Defaults() | |||
| 702 | + | for combo, action := range map[string]string{ | |||
| 703 | + | "Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up", | |||
| 704 | + | "Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down", | |||
| 705 | + | "Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right", | |||
| 706 | + | } { | |||
| 707 | + | if d.Binds[combo] != action { | |||
| 708 | + | t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action) | |||
| 709 | + | } | |||
| 710 | + | } | |||
| 711 | + | // The up/down move-workspace-to-monitor actions have no default bind but | |||
| 712 | + | // load from a config file. | |||
| 713 | + | c, w := parseString(t, "bind.cmd+ctrl+alt+up = move-workspace-to-monitor-up\nbind.cmd+ctrl+alt+down = move-workspace-to-monitor-down\nbind.cmd+alt+u = focus-monitor-up\n") | |||
| 714 | + | if len(w) != 0 || c.Binds["Alt+Cmd+Ctrl+Up"] != "move-workspace-to-monitor-up" || c.Binds["Alt+Cmd+Ctrl+Down"] != "move-workspace-to-monitor-down" { | |||
| 715 | + | t.Fatalf("%v %v", w, c.Binds) | |||
| 716 | + | } | |||
| 717 | + | } | |||
| 718 | + | ||||
| 700 | func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) { | 719 | func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) { | ||
| 701 | c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n") | 720 | c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n") | ||
| 702 | if got := c.Outputs[0].Anchor.Offset; got != 40 { | 721 | if got := c.Outputs[0].Anchor.Offset; got != 40 { | ||
M internal/core/action.go +23 -17
| @@ -32,11 +32,16 @@ const ( | |||
| 32 | 32 | ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down" | |
| 33 | 33 | ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up" | |
| 34 | 34 | ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down" | |
| 35 | - | // Monitors are ordered left to right (ADR 011). | |
| 36 | - | ActionFocusMonitorLeft Action = "focus-monitor-left" | |
| 37 | - | ActionFocusMonitorRight Action = "focus-monitor-right" | |
| 38 | - | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | |
| 39 | - | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | |
| 35 | + | // Monitors act on the neighbor in that direction of the global layout | |
| 36 | + | // (ADR 011). | |
| 37 | + | ActionFocusMonitorLeft Action = "focus-monitor-left" | |
| 38 | + | ActionFocusMonitorRight Action = "focus-monitor-right" | |
| 39 | + | ActionFocusMonitorUp Action = "focus-monitor-up" | |
| 40 | + | ActionFocusMonitorDown Action = "focus-monitor-down" | |
| 41 | + | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | |
| 42 | + | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | |
| 43 | + | ActionMoveWorkspaceToMonitorUp Action = "move-workspace-to-monitor-up" | |
| 44 | + | ActionMoveWorkspaceToMonitorDown Action = "move-workspace-to-monitor-down" | |
| 40 | 45 | // Scale steps through the clean scales of the output (see CleanScales). | |
| 41 | 46 | ActionScaleUp Action = "scale-up" | |
| 42 | 47 | ActionScaleDown Action = "scale-down" | |
| @@ -53,6 +58,15 @@ const ( | |||
| 53 | 58 | ActionToggleFloating Action = "toggle-floating" | |
| 54 | 59 | ) | |
| 55 | 60 | ||
| 61 | + | // monitorDirections maps the focus-monitor and move-workspace-to-monitor | |
| 62 | + | // actions to the direction of the neighbor screen they target. | |
| 63 | + | var monitorDirections = map[Action]direction{ | |
| 64 | + | ActionFocusMonitorLeft: dirLeft, ActionFocusMonitorRight: dirRight, | |
| 65 | + | ActionFocusMonitorUp: dirUp, ActionFocusMonitorDown: dirDown, | |
| 66 | + | ActionMoveWorkspaceLeft: dirLeft, ActionMoveWorkspaceRight: dirRight, | |
| 67 | + | ActionMoveWorkspaceToMonitorUp: dirUp, ActionMoveWorkspaceToMonitorDown: dirDown, | |
| 68 | + | } | |
| 69 | + | ||
| 56 | 70 | // Resize axes of ResizeArg. | |
| 57 | 71 | const ( | |
| 58 | 72 | ResizeWidth = 0 | |
| @@ -174,21 +188,13 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 174 | 188 | return Effect{} | |
| 175 | 189 | } | |
| 176 | 190 | switch a { | |
| 177 | - | case ActionFocusMonitorLeft, ActionFocusMonitorRight: | |
| 178 | - | d := dirLeft | |
| 179 | - | if a == ActionFocusMonitorRight { | |
| 180 | - | d = dirRight | |
| 181 | - | } | |
| 182 | - | if i := c.neighbor(d); i >= 0 { | |
| 191 | + | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown: | |
| 192 | + | if i := c.neighbor(monitorDirections[a]); i >= 0 { | |
| 183 | 193 | c.focusScreen = i | |
| 184 | 194 | } | |
| 185 | 195 | return Effect{} | |
| 186 | - | case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 187 | - | d := dirLeft | |
| 188 | - | if a == ActionMoveWorkspaceRight { | |
| 189 | - | d = dirRight | |
| 190 | - | } | |
| 191 | - | c.moveWorkspace(d) | |
| 196 | + | case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |
| 197 | + | c.moveWorkspace(monitorDirections[a]) | |
| 192 | 198 | return Effect{} | |
| 193 | 199 | case ActionFocusColumnLeft, ActionFocusColumnRight: | |
| 194 | 200 | w := c.cur().mon.Current() | |
| @@ -32,11 +32,16 @@ const ( | |||||
| 32 | ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down" | 32 | ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down" | ||
| 33 | ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up" | 33 | ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up" | ||
| 34 | ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down" | 34 | ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down" | ||
| 35 | - | // Monitors are ordered left to right (ADR 011). | 35 | + | // Monitors act on the neighbor in that direction of the global layout |
| 36 | - | ActionFocusMonitorLeft Action = "focus-monitor-left" | 36 | + | // (ADR 011). |
| 37 | - | ActionFocusMonitorRight Action = "focus-monitor-right" | 37 | + | ActionFocusMonitorLeft Action = "focus-monitor-left" |
| 38 | - | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | 38 | + | ActionFocusMonitorRight Action = "focus-monitor-right" |
| 39 | - | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | 39 | + | ActionFocusMonitorUp Action = "focus-monitor-up" |
| 40 | + | ActionFocusMonitorDown Action = "focus-monitor-down" | |||
| 41 | + | ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left" | |||
| 42 | + | ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right" | |||
| 43 | + | ActionMoveWorkspaceToMonitorUp Action = "move-workspace-to-monitor-up" | |||
| 44 | + | ActionMoveWorkspaceToMonitorDown Action = "move-workspace-to-monitor-down" | |||
| 40 | // Scale steps through the clean scales of the output (see CleanScales). | 45 | // Scale steps through the clean scales of the output (see CleanScales). | ||
| 41 | ActionScaleUp Action = "scale-up" | 46 | ActionScaleUp Action = "scale-up" | ||
| 42 | ActionScaleDown Action = "scale-down" | 47 | ActionScaleDown Action = "scale-down" | ||
| @@ -53,6 +58,15 @@ const ( | |||||
| 53 | ActionToggleFloating Action = "toggle-floating" | 58 | ActionToggleFloating Action = "toggle-floating" | ||
| 54 | ) | 59 | ) | ||
| 55 | 60 | ||||
| 61 | + | // monitorDirections maps the focus-monitor and move-workspace-to-monitor | |||
| 62 | + | // actions to the direction of the neighbor screen they target. | |||
| 63 | + | var monitorDirections = map[Action]direction{ | |||
| 64 | + | ActionFocusMonitorLeft: dirLeft, ActionFocusMonitorRight: dirRight, | |||
| 65 | + | ActionFocusMonitorUp: dirUp, ActionFocusMonitorDown: dirDown, | |||
| 66 | + | ActionMoveWorkspaceLeft: dirLeft, ActionMoveWorkspaceRight: dirRight, | |||
| 67 | + | ActionMoveWorkspaceToMonitorUp: dirUp, ActionMoveWorkspaceToMonitorDown: dirDown, | |||
| 68 | + | } | |||
| 69 | + | ||||
| 56 | // Resize axes of ResizeArg. | 70 | // Resize axes of ResizeArg. | ||
| 57 | const ( | 71 | const ( | ||
| 58 | ResizeWidth = 0 | 72 | ResizeWidth = 0 | ||
| @@ -174,21 +188,13 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 174 | return Effect{} | 188 | return Effect{} | ||
| 175 | } | 189 | } | ||
| 176 | switch a { | 190 | switch a { | ||
| 177 | - | case ActionFocusMonitorLeft, ActionFocusMonitorRight: | 191 | + | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown: |
| 178 | - | d := dirLeft | 192 | + | if i := c.neighbor(monitorDirections[a]); i >= 0 { |
| 179 | - | if a == ActionFocusMonitorRight { | |||
| 180 | - | d = dirRight | |||
| 181 | - | } | |||
| 182 | - | if i := c.neighbor(d); i >= 0 { | |||
| 183 | c.focusScreen = i | 193 | c.focusScreen = i | ||
| 184 | } | 194 | } | ||
| 185 | return Effect{} | 195 | return Effect{} | ||
| 186 | - | case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | 196 | + | case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: |
| 187 | - | d := dirLeft | 197 | + | c.moveWorkspace(monitorDirections[a]) |
| 188 | - | if a == ActionMoveWorkspaceRight { | |||
| 189 | - | d = dirRight | |||
| 190 | - | } | |||
| 191 | - | c.moveWorkspace(d) | |||
| 192 | return Effect{} | 198 | return Effect{} | ||
| 193 | case ActionFocusColumnLeft, ActionFocusColumnRight: | 199 | case ActionFocusColumnLeft, ActionFocusColumnRight: | ||
| 194 | w := c.cur().mon.Current() | 200 | w := c.cur().mon.Current() | ||
M internal/core/core.go +1 -1
| @@ -228,7 +228,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 228 | 228 | continue | |
| 229 | 229 | } | |
| 230 | 230 | switch Action(a) { | |
| 231 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | |
| 231 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | |
| 232 | 232 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating: | |
| 233 | 233 | default: | |
| 234 | 234 | return fmt.Errorf("invalid action %q", a) | |
| @@ -228,7 +228,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 228 | continue | 228 | continue | ||
| 229 | } | 229 | } | ||
| 230 | switch Action(a) { | 230 | switch Action(a) { | ||
| 231 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | 231 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, |
| 232 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating: | 232 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating: | ||
| 233 | default: | 233 | default: | ||
| 234 | return fmt.Errorf("invalid action %q", a) | 234 | return fmt.Errorf("invalid action %q", a) | ||
A internal/core/neighbor_test.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type tile struct { | |
| 10 | + | name string | |
| 11 | + | x, y, w, h int | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // layoutCore builds a core whose screens sit at the given logical rectangles, | |
| 15 | + | // in order, with the screen at index focus focused. | |
| 16 | + | func layoutCore(focus int, tiles ...tile) *Core { | |
| 17 | + | c := &Core{focusScreen: focus} | |
| 18 | + | for _, t := range tiles { | |
| 19 | + | s := &screen{info: ports.OutputInfo{Name: t.name}, mon: newMonitor(t.name, ""), x: t.x, y: t.y} | |
| 20 | + | s.mon.SetOutput(t.w, t.h) | |
| 21 | + | c.screens = append(c.screens, s) | |
| 22 | + | } | |
| 23 | + | return c | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestNeighbor(t *testing.T) { | |
| 27 | + | dirs := []direction{dirLeft, dirRight, dirUp, dirDown} | |
| 28 | + | // want holds the expected neighbor name for left, right, up, down; "" is | |
| 29 | + | // the edge. | |
| 30 | + | type step struct { | |
| 31 | + | from string | |
| 32 | + | want [4]string | |
| 33 | + | } | |
| 34 | + | cases := []struct { | |
| 35 | + | name string | |
| 36 | + | tiles []tile | |
| 37 | + | steps []step | |
| 38 | + | }{ | |
| 39 | + | {"row of three in connection order", []tile{{"B", 200, 0, 200, 100}, {"A", 0, 0, 200, 100}, {"C", 400, 0, 200, 100}}, []step{ | |
| 40 | + | {"A", [4]string{"", "B", "", ""}}, | |
| 41 | + | {"B", [4]string{"A", "C", "", ""}}, | |
| 42 | + | {"C", [4]string{"B", "", "", ""}}, | |
| 43 | + | }}, | |
| 44 | + | {"column of two", []tile{{"A", 0, 0, 200, 100}, {"B", 0, 100, 200, 100}}, []step{ | |
| 45 | + | {"A", [4]string{"", "", "", "B"}}, | |
| 46 | + | {"B", [4]string{"", "", "A", ""}}, | |
| 47 | + | }}, | |
| 48 | + | {"row with y offsets", []tile{{"A", 0, 0, 200, 100}, {"B", 200, 60, 200, 100}, {"C", 400, 120, 200, 100}}, []step{ | |
| 49 | + | // A and C share no edge range: only diagonal vertical neighbors. | |
| 50 | + | {"A", [4]string{"", "B", "", "C"}}, | |
| 51 | + | {"B", [4]string{"A", "C", "", ""}}, | |
| 52 | + | {"C", [4]string{"B", "", "A", ""}}, | |
| 53 | + | }}, | |
| 54 | + | {"diagonal only", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}}, []step{ | |
| 55 | + | {"A", [4]string{"", "B", "", "B"}}, | |
| 56 | + | {"B", [4]string{"A", "", "A", ""}}, | |
| 57 | + | }}, | |
| 58 | + | {"shared edge beats nearer diagonal", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}, {"C", 300, 50, 100, 100}}, []step{ | |
| 59 | + | {"A", [4]string{"", "C", "", "B"}}, | |
| 60 | + | }}, | |
| 61 | + | {"smaller gap wins", []tile{{"A", 0, 0, 100, 100}, {"far", 300, 0, 100, 100}, {"near", 150, 0, 100, 100}}, []step{ | |
| 62 | + | {"A", [4]string{"", "near", "", ""}}, | |
| 63 | + | }}, | |
| 64 | + | {"larger shared edge wins at equal gap", []tile{{"A", 0, 0, 100, 100}, {"little", 100, 80, 100, 100}, {"big", 100, 20, 100, 60}}, []step{ | |
| 65 | + | {"A", [4]string{"", "big", "", ""}}, | |
| 66 | + | }}, | |
| 67 | + | {"closer center wins at equal overlap", []tile{{"A", 0, 0, 100, 100}, {"low", 100, 50, 100, 100}, {"mid", 100, -20, 100, 70}}, []step{ | |
| 68 | + | // Both share 50 rows; mid's center is nearer to A's. | |
| 69 | + | {"A", [4]string{"", "mid", "", ""}}, | |
| 70 | + | }}, | |
| 71 | + | {"lowest index at a full tie", []tile{{"A", 0, 0, 100, 100}, {"first", 100, 0, 100, 100}, {"second", 100, 0, 100, 100}}, []step{ | |
| 72 | + | {"A", [4]string{"", "first", "", ""}}, | |
| 73 | + | }}, | |
| 74 | + | {"overlapping screens are not neighbors", []tile{{"A", 0, 0, 200, 100}, {"B", 100, 0, 200, 100}}, []step{ | |
| 75 | + | {"A", [4]string{"", "", "", ""}}, | |
| 76 | + | {"B", [4]string{"", "", "", ""}}, | |
| 77 | + | }}, | |
| 78 | + | {"placeholder ignored", []tile{{"A", 0, 0, 100, 100}, {"", 100, 0, 100, 100}, {"B", 300, 0, 100, 100}}, []step{ | |
| 79 | + | {"A", [4]string{"", "B", "", ""}}, | |
| 80 | + | {"B", [4]string{"A", "", "", ""}}, | |
| 81 | + | }}, | |
| 82 | + | {"two by three grid", []tile{ | |
| 83 | + | {"DP-1", 0, 0, 2560, 1440}, {"DP-2", -1920, 360, 1920, 1080}, {"DP-3", 2560, 360, 1920, 1080}, | |
| 84 | + | {"HDMI-1", 0, -1440, 2560, 1440}, {"DP-4", -1920, -1200, 1920, 1200}, {"DP-5", 2560, -1200, 1920, 1200}, | |
| 85 | + | }, []step{ | |
| 86 | + | {"DP-4", [4]string{"", "HDMI-1", "", "DP-2"}}, | |
| 87 | + | {"HDMI-1", [4]string{"DP-4", "DP-5", "", "DP-1"}}, | |
| 88 | + | {"DP-5", [4]string{"HDMI-1", "", "", "DP-3"}}, | |
| 89 | + | {"DP-2", [4]string{"", "DP-1", "DP-4", ""}}, | |
| 90 | + | {"DP-1", [4]string{"DP-2", "DP-3", "HDMI-1", ""}}, | |
| 91 | + | {"DP-3", [4]string{"DP-1", "", "DP-5", ""}}, | |
| 92 | + | }}, | |
| 93 | + | } | |
| 94 | + | for _, tc := range cases { | |
| 95 | + | for _, st := range tc.steps { | |
| 96 | + | c := layoutCore(0, tc.tiles...) | |
| 97 | + | for i, s := range c.screens { | |
| 98 | + | if s.name() == st.from { | |
| 99 | + | c.focusScreen = i | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | for i, d := range dirs { | |
| 103 | + | got := "" | |
| 104 | + | if n := c.neighbor(d); n >= 0 { | |
| 105 | + | got = c.screens[n].name() | |
| 106 | + | } | |
| 107 | + | if got != st.want[i] { | |
| 108 | + | t.Errorf("%s: from %s direction %d: got %q, want %q", tc.name, st.from, d, got, st.want[i]) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 { | |
| 114 | + | t.Fatalf("invalid direction: %d", n) | |
| 115 | + | } | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type tile struct { | |||
| 10 | + | name string | |||
| 11 | + | x, y, w, h int | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // layoutCore builds a core whose screens sit at the given logical rectangles, | |||
| 15 | + | // in order, with the screen at index focus focused. | |||
| 16 | + | func layoutCore(focus int, tiles ...tile) *Core { | |||
| 17 | + | c := &Core{focusScreen: focus} | |||
| 18 | + | for _, t := range tiles { | |||
| 19 | + | s := &screen{info: ports.OutputInfo{Name: t.name}, mon: newMonitor(t.name, ""), x: t.x, y: t.y} | |||
| 20 | + | s.mon.SetOutput(t.w, t.h) | |||
| 21 | + | c.screens = append(c.screens, s) | |||
| 22 | + | } | |||
| 23 | + | return c | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestNeighbor(t *testing.T) { | |||
| 27 | + | dirs := []direction{dirLeft, dirRight, dirUp, dirDown} | |||
| 28 | + | // want holds the expected neighbor name for left, right, up, down; "" is | |||
| 29 | + | // the edge. | |||
| 30 | + | type step struct { | |||
| 31 | + | from string | |||
| 32 | + | want [4]string | |||
| 33 | + | } | |||
| 34 | + | cases := []struct { | |||
| 35 | + | name string | |||
| 36 | + | tiles []tile | |||
| 37 | + | steps []step | |||
| 38 | + | }{ | |||
| 39 | + | {"row of three in connection order", []tile{{"B", 200, 0, 200, 100}, {"A", 0, 0, 200, 100}, {"C", 400, 0, 200, 100}}, []step{ | |||
| 40 | + | {"A", [4]string{"", "B", "", ""}}, | |||
| 41 | + | {"B", [4]string{"A", "C", "", ""}}, | |||
| 42 | + | {"C", [4]string{"B", "", "", ""}}, | |||
| 43 | + | }}, | |||
| 44 | + | {"column of two", []tile{{"A", 0, 0, 200, 100}, {"B", 0, 100, 200, 100}}, []step{ | |||
| 45 | + | {"A", [4]string{"", "", "", "B"}}, | |||
| 46 | + | {"B", [4]string{"", "", "A", ""}}, | |||
| 47 | + | }}, | |||
| 48 | + | {"row with y offsets", []tile{{"A", 0, 0, 200, 100}, {"B", 200, 60, 200, 100}, {"C", 400, 120, 200, 100}}, []step{ | |||
| 49 | + | // A and C share no edge range: only diagonal vertical neighbors. | |||
| 50 | + | {"A", [4]string{"", "B", "", "C"}}, | |||
| 51 | + | {"B", [4]string{"A", "C", "", ""}}, | |||
| 52 | + | {"C", [4]string{"B", "", "A", ""}}, | |||
| 53 | + | }}, | |||
| 54 | + | {"diagonal only", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}}, []step{ | |||
| 55 | + | {"A", [4]string{"", "B", "", "B"}}, | |||
| 56 | + | {"B", [4]string{"A", "", "A", ""}}, | |||
| 57 | + | }}, | |||
| 58 | + | {"shared edge beats nearer diagonal", []tile{{"A", 0, 0, 100, 100}, {"B", 100, 150, 100, 100}, {"C", 300, 50, 100, 100}}, []step{ | |||
| 59 | + | {"A", [4]string{"", "C", "", "B"}}, | |||
| 60 | + | }}, | |||
| 61 | + | {"smaller gap wins", []tile{{"A", 0, 0, 100, 100}, {"far", 300, 0, 100, 100}, {"near", 150, 0, 100, 100}}, []step{ | |||
| 62 | + | {"A", [4]string{"", "near", "", ""}}, | |||
| 63 | + | }}, | |||
| 64 | + | {"larger shared edge wins at equal gap", []tile{{"A", 0, 0, 100, 100}, {"little", 100, 80, 100, 100}, {"big", 100, 20, 100, 60}}, []step{ | |||
| 65 | + | {"A", [4]string{"", "big", "", ""}}, | |||
| 66 | + | }}, | |||
| 67 | + | {"closer center wins at equal overlap", []tile{{"A", 0, 0, 100, 100}, {"low", 100, 50, 100, 100}, {"mid", 100, -20, 100, 70}}, []step{ | |||
| 68 | + | // Both share 50 rows; mid's center is nearer to A's. | |||
| 69 | + | {"A", [4]string{"", "mid", "", ""}}, | |||
| 70 | + | }}, | |||
| 71 | + | {"lowest index at a full tie", []tile{{"A", 0, 0, 100, 100}, {"first", 100, 0, 100, 100}, {"second", 100, 0, 100, 100}}, []step{ | |||
| 72 | + | {"A", [4]string{"", "first", "", ""}}, | |||
| 73 | + | }}, | |||
| 74 | + | {"overlapping screens are not neighbors", []tile{{"A", 0, 0, 200, 100}, {"B", 100, 0, 200, 100}}, []step{ | |||
| 75 | + | {"A", [4]string{"", "", "", ""}}, | |||
| 76 | + | {"B", [4]string{"", "", "", ""}}, | |||
| 77 | + | }}, | |||
| 78 | + | {"placeholder ignored", []tile{{"A", 0, 0, 100, 100}, {"", 100, 0, 100, 100}, {"B", 300, 0, 100, 100}}, []step{ | |||
| 79 | + | {"A", [4]string{"", "B", "", ""}}, | |||
| 80 | + | {"B", [4]string{"A", "", "", ""}}, | |||
| 81 | + | }}, | |||
| 82 | + | {"two by three grid", []tile{ | |||
| 83 | + | {"DP-1", 0, 0, 2560, 1440}, {"DP-2", -1920, 360, 1920, 1080}, {"DP-3", 2560, 360, 1920, 1080}, | |||
| 84 | + | {"HDMI-1", 0, -1440, 2560, 1440}, {"DP-4", -1920, -1200, 1920, 1200}, {"DP-5", 2560, -1200, 1920, 1200}, | |||
| 85 | + | }, []step{ | |||
| 86 | + | {"DP-4", [4]string{"", "HDMI-1", "", "DP-2"}}, | |||
| 87 | + | {"HDMI-1", [4]string{"DP-4", "DP-5", "", "DP-1"}}, | |||
| 88 | + | {"DP-5", [4]string{"HDMI-1", "", "", "DP-3"}}, | |||
| 89 | + | {"DP-2", [4]string{"", "DP-1", "DP-4", ""}}, | |||
| 90 | + | {"DP-1", [4]string{"DP-2", "DP-3", "HDMI-1", ""}}, | |||
| 91 | + | {"DP-3", [4]string{"DP-1", "", "DP-5", ""}}, | |||
| 92 | + | }}, | |||
| 93 | + | } | |||
| 94 | + | for _, tc := range cases { | |||
| 95 | + | for _, st := range tc.steps { | |||
| 96 | + | c := layoutCore(0, tc.tiles...) | |||
| 97 | + | for i, s := range c.screens { | |||
| 98 | + | if s.name() == st.from { | |||
| 99 | + | c.focusScreen = i | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | for i, d := range dirs { | |||
| 103 | + | got := "" | |||
| 104 | + | if n := c.neighbor(d); n >= 0 { | |||
| 105 | + | got = c.screens[n].name() | |||
| 106 | + | } | |||
| 107 | + | if got != st.want[i] { | |||
| 108 | + | t.Errorf("%s: from %s direction %d: got %q, want %q", tc.name, st.from, d, got, st.want[i]) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | if n := layoutCore(0, tile{"A", 0, 0, 10, 10}).neighbor(direction(0)); n != -1 { | |||
| 114 | + | t.Fatalf("invalid direction: %d", n) | |||
| 115 | + | } | |||
| 116 | + | } | |||
M internal/core/overview.go +1 -0
| @@ -191,6 +191,7 @@ func overviewBlocks(a Action) bool { | |||
| 191 | 191 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 192 | 192 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | |
| 193 | 193 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 194 | + | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | |
| 194 | 195 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, | |
| 195 | 196 | ActionMoveWorkspaceDown, ActionToggleFloating: | |
| 196 | 197 | return true | |
| @@ -191,6 +191,7 @@ func overviewBlocks(a Action) bool { | |||||
| 191 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | 191 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | ||
| 192 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | 192 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | ||
| 193 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | 193 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | ||
| 194 | + | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | |||
| 194 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, | 195 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, | ||
| 195 | ActionMoveWorkspaceDown, ActionToggleFloating: | 196 | ActionMoveWorkspaceDown, ActionToggleFloating: | ||
| 196 | return true | 197 | return true | ||
M internal/core/overview_regression_test.go +1 -0
| @@ -127,6 +127,7 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) { | |||
| 127 | 127 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 128 | 128 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | |
| 129 | 129 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 130 | + | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | |
| 130 | 131 | } | |
| 131 | 132 | for _, a := range actions { | |
| 132 | 133 | t.Run(string(a), func(t *testing.T) { | |
| @@ -127,6 +127,7 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) { | |||||
| 127 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | 127 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | ||
| 128 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | 128 | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | ||
| 129 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | 129 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | ||
| 130 | + | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | |||
| 130 | } | 131 | } | ||
| 131 | for _, a := range actions { | 132 | for _, a := range actions { | ||
| 132 | t.Run(string(a), func(t *testing.T) { | 133 | t.Run(string(a), func(t *testing.T) { | ||
M internal/core/screens.go +37 -11
| @@ -412,21 +412,47 @@ const ( | |||
| 412 | 412 | ) | |
| 413 | 413 | ||
| 414 | 414 | // neighbor returns the index of the screen next to the focused one in | |
| 415 | - | // direction d, or -1 at the edge. | |
| 415 | + | // direction d, or -1 at the edge. Candidates lie fully beyond the focused | |
| 416 | + | // screen's edge; one sharing part of that edge wins over a diagonal one, then | |
| 417 | + | // the smallest gap, the largest shared edge, the closest centers and the | |
| 418 | + | // lowest index decide. There is no wrap. | |
| 416 | 419 | func (c *Core) neighbor(d direction) int { | |
| 417 | - | var i int | |
| 418 | - | switch d { | |
| 419 | - | case dirLeft: | |
| 420 | - | i = c.focusScreen - 1 | |
| 421 | - | case dirRight: | |
| 422 | - | i = c.focusScreen + 1 | |
| 423 | - | default: | |
| 420 | + | if d < dirLeft || d > dirDown { | |
| 424 | 421 | return -1 | |
| 425 | 422 | } | |
| 426 | - | if i < 0 || i >= len(c.screens) { | |
| 427 | - | return -1 | |
| 423 | + | // span is the extent of s along the move (lo, hi) and across it. | |
| 424 | + | span := func(s *screen) (lo, hi, clo, chi int) { | |
| 425 | + | o := s.mon.Output() | |
| 426 | + | if d == dirUp || d == dirDown { | |
| 427 | + | return s.y, s.y + o.H, s.x, s.x + o.W | |
| 428 | + | } | |
| 429 | + | return s.x, s.x + o.W, s.y, s.y + o.H | |
| 430 | + | } | |
| 431 | + | lo, hi, clo, chi := span(c.cur()) | |
| 432 | + | best, bestKey := -1, [4]int{} | |
| 433 | + | for i, s := range c.screens { | |
| 434 | + | if i == c.focusScreen || s.name() == "" { | |
| 435 | + | continue | |
| 436 | + | } | |
| 437 | + | l, h, cl, ch := span(s) | |
| 438 | + | gap := l - hi | |
| 439 | + | if d == dirLeft || d == dirUp { | |
| 440 | + | gap = lo - h | |
| 441 | + | } | |
| 442 | + | if gap < 0 { | |
| 443 | + | continue | |
| 444 | + | } | |
| 445 | + | overlap := min(chi, ch) - max(clo, cl) | |
| 446 | + | diagonal := 0 | |
| 447 | + | if overlap <= 0 { | |
| 448 | + | diagonal = 1 | |
| 449 | + | } | |
| 450 | + | key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)} | |
| 451 | + | if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 { | |
| 452 | + | best, bestKey = i, key | |
| 453 | + | } | |
| 428 | 454 | } | |
| 429 | - | return i | |
| 455 | + | return best | |
| 430 | 456 | } | |
| 431 | 457 | ||
| 432 | 458 | // moveWorkspace moves the workspace on screen to the neighbor screen, where | |
| @@ -412,21 +412,47 @@ const ( | |||||
| 412 | ) | 412 | ) | ||
| 413 | 413 | ||||
| 414 | // neighbor returns the index of the screen next to the focused one in | 414 | // neighbor returns the index of the screen next to the focused one in | ||
| 415 | - | // direction d, or -1 at the edge. | 415 | + | // direction d, or -1 at the edge. Candidates lie fully beyond the focused |
| 416 | + | // screen's edge; one sharing part of that edge wins over a diagonal one, then | |||
| 417 | + | // the smallest gap, the largest shared edge, the closest centers and the | |||
| 418 | + | // lowest index decide. There is no wrap. | |||
| 416 | func (c *Core) neighbor(d direction) int { | 419 | func (c *Core) neighbor(d direction) int { | ||
| 417 | - | var i int | 420 | + | if d < dirLeft || d > dirDown { |
| 418 | - | switch d { | |||
| 419 | - | case dirLeft: | |||
| 420 | - | i = c.focusScreen - 1 | |||
| 421 | - | case dirRight: | |||
| 422 | - | i = c.focusScreen + 1 | |||
| 423 | - | default: | |||
| 424 | return -1 | 421 | return -1 | ||
| 425 | } | 422 | } | ||
| 426 | - | if i < 0 || i >= len(c.screens) { | 423 | + | // span is the extent of s along the move (lo, hi) and across it. |
| 427 | - | return -1 | 424 | + | span := func(s *screen) (lo, hi, clo, chi int) { |
| 425 | + | o := s.mon.Output() | |||
| 426 | + | if d == dirUp || d == dirDown { | |||
| 427 | + | return s.y, s.y + o.H, s.x, s.x + o.W | |||
| 428 | + | } | |||
| 429 | + | return s.x, s.x + o.W, s.y, s.y + o.H | |||
| 430 | + | } | |||
| 431 | + | lo, hi, clo, chi := span(c.cur()) | |||
| 432 | + | best, bestKey := -1, [4]int{} | |||
| 433 | + | for i, s := range c.screens { | |||
| 434 | + | if i == c.focusScreen || s.name() == "" { | |||
| 435 | + | continue | |||
| 436 | + | } | |||
| 437 | + | l, h, cl, ch := span(s) | |||
| 438 | + | gap := l - hi | |||
| 439 | + | if d == dirLeft || d == dirUp { | |||
| 440 | + | gap = lo - h | |||
| 441 | + | } | |||
| 442 | + | if gap < 0 { | |||
| 443 | + | continue | |||
| 444 | + | } | |||
| 445 | + | overlap := min(chi, ch) - max(clo, cl) | |||
| 446 | + | diagonal := 0 | |||
| 447 | + | if overlap <= 0 { | |||
| 448 | + | diagonal = 1 | |||
| 449 | + | } | |||
| 450 | + | key := [4]int{diagonal, gap, -max(overlap, 0), abs(cl + ch - clo - chi)} | |||
| 451 | + | if best < 0 || slices.Compare(key[:], bestKey[:]) < 0 { | |||
| 452 | + | best, bestKey = i, key | |||
| 453 | + | } | |||
| 428 | } | 454 | } | ||
| 429 | - | return i | 455 | + | return best |
| 430 | } | 456 | } | ||
| 431 | 457 | ||||
| 432 | // moveWorkspace moves the workspace on screen to the neighbor screen, where | 458 | // moveWorkspace moves the workspace on screen to the neighbor screen, where | ||
M internal/core/screens_test.go +48 -0
| @@ -351,6 +351,54 @@ func TestMoveWorkspaceToMonitor(t *testing.T) { | |||
| 351 | 351 | } | |
| 352 | 352 | } | |
| 353 | 353 | ||
| 354 | + | func TestMonitorActionsFollowGeometry(t *testing.T) { | |
| 355 | + | r := startMulti(t, func(c *ports.Config) { | |
| 356 | + | c.Outputs = []ports.OutputConfig{ | |
| 357 | + | {Name: "DP-1"}, | |
| 358 | + | {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}}, | |
| 359 | + | } | |
| 360 | + | // Without a default bind; these replace move-workspace-up/down. | |
| 361 | + | c.Binds["Cmd+Ctrl+Shift+Up"] = string(core.ActionMoveWorkspaceToMonitorUp) | |
| 362 | + | c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown) | |
| 363 | + | }, left, right) | |
| 364 | + | // SetOutputs is sent only when the focus or layout changed. | |
| 365 | + | current := lastOutputs(t, r.commands).Focused | |
| 366 | + | focused := func() string { | |
| 367 | + | for len(r.commands) > 0 { | |
| 368 | + | if v, ok := (<-r.commands).(ports.SetOutputs); ok { | |
| 369 | + | current = v.Focused | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | return current | |
| 373 | + | } | |
| 374 | + | cmdCtrl := ports.ModAlt | ports.ModCtrl | |
| 375 | + | // DP-1 is focused and has no neighbor left, right or up. | |
| 376 | + | r.mapWindow(t, 1) | |
| 377 | + | for _, k := range []string{"Left", "Right", "Up"} { | |
| 378 | + | r.key(t, k, cmdCtrl) | |
| 379 | + | if got := focused(); got != "DP-1" { | |
| 380 | + | t.Fatalf("%s moved focus to %s", k, got) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | r.key(t, "Down", cmdCtrl) | |
| 384 | + | if got := focused(); got != "DP-2" { | |
| 385 | + | t.Fatalf("focus-monitor-down: %s", got) | |
| 386 | + | } | |
| 387 | + | r.key(t, "k", cmdCtrl) | |
| 388 | + | if got := focused(); got != "DP-1" { | |
| 389 | + | t.Fatalf("focus-monitor-up (vim twin): %s", got) | |
| 390 | + | } | |
| 391 | + | // The workspace goes to the monitor below and focus follows it. | |
| 392 | + | set := r.key(t, "Down", cmdCtrl|ports.ModShift) | |
| 393 | + | if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 { | |
| 394 | + | t.Fatal(got) | |
| 395 | + | } | |
| 396 | + | set = r.key(t, "Up", cmdCtrl|ports.ModShift) | |
| 397 | + | if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 { | |
| 398 | + | t.Fatalf("move-workspace-to-monitor-up: %v", got) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 354 | 402 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | |
| 355 | 403 | r := startMulti(t, nil, left, right) | |
| 356 | 404 | r.mapWindow(t, 1) | |
| @@ -351,6 +351,54 @@ func TestMoveWorkspaceToMonitor(t *testing.T) { | |||||
| 351 | } | 351 | } | ||
| 352 | } | 352 | } | ||
| 353 | 353 | ||||
| 354 | + | func TestMonitorActionsFollowGeometry(t *testing.T) { | |||
| 355 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 356 | + | c.Outputs = []ports.OutputConfig{ | |||
| 357 | + | {Name: "DP-1"}, | |||
| 358 | + | {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}}, | |||
| 359 | + | } | |||
| 360 | + | // Without a default bind; these replace move-workspace-up/down. | |||
| 361 | + | c.Binds["Cmd+Ctrl+Shift+Up"] = string(core.ActionMoveWorkspaceToMonitorUp) | |||
| 362 | + | c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown) | |||
| 363 | + | }, left, right) | |||
| 364 | + | // SetOutputs is sent only when the focus or layout changed. | |||
| 365 | + | current := lastOutputs(t, r.commands).Focused | |||
| 366 | + | focused := func() string { | |||
| 367 | + | for len(r.commands) > 0 { | |||
| 368 | + | if v, ok := (<-r.commands).(ports.SetOutputs); ok { | |||
| 369 | + | current = v.Focused | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | return current | |||
| 373 | + | } | |||
| 374 | + | cmdCtrl := ports.ModAlt | ports.ModCtrl | |||
| 375 | + | // DP-1 is focused and has no neighbor left, right or up. | |||
| 376 | + | r.mapWindow(t, 1) | |||
| 377 | + | for _, k := range []string{"Left", "Right", "Up"} { | |||
| 378 | + | r.key(t, k, cmdCtrl) | |||
| 379 | + | if got := focused(); got != "DP-1" { | |||
| 380 | + | t.Fatalf("%s moved focus to %s", k, got) | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | r.key(t, "Down", cmdCtrl) | |||
| 384 | + | if got := focused(); got != "DP-2" { | |||
| 385 | + | t.Fatalf("focus-monitor-down: %s", got) | |||
| 386 | + | } | |||
| 387 | + | r.key(t, "k", cmdCtrl) | |||
| 388 | + | if got := focused(); got != "DP-1" { | |||
| 389 | + | t.Fatalf("focus-monitor-up (vim twin): %s", got) | |||
| 390 | + | } | |||
| 391 | + | // The workspace goes to the monitor below and focus follows it. | |||
| 392 | + | set := r.key(t, "Down", cmdCtrl|ports.ModShift) | |||
| 393 | + | if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 { | |||
| 394 | + | t.Fatal(got) | |||
| 395 | + | } | |||
| 396 | + | set = r.key(t, "Up", cmdCtrl|ports.ModShift) | |||
| 397 | + | if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 { | |||
| 398 | + | t.Fatalf("move-workspace-to-monitor-up: %v", got) | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 354 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | 402 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | ||
| 355 | r := startMulti(t, nil, left, right) | 403 | r := startMulti(t, nil, left, right) | ||
| 356 | r.mapWindow(t, 1) | 404 | r.mapWindow(t, 1) | ||