detached 0e21ae4

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 }{
5252 {"cmd+shift+pagedown", "move-column-to-workspace-down"},
5353 {"cmd+ctrl+left", "focus-monitor-left"},
5454 {"cmd+ctrl+right", "focus-monitor-right"},
55+ {"cmd+ctrl+up", "focus-monitor-up"},
56+ {"cmd+ctrl+down", "focus-monitor-down"},
5557 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
5658 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
5759 {"cmd+shift+up", "move-window-up"},
@@ -69,6 +71,10 @@ var actions = map[string]bool{
6971 "focus-monitor-right": true,
7072 "move-workspace-to-monitor-left": true,
7173 "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,
7278 "scale-up": true,
7379 "scale-down": true,
7480 "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) {
2323 func TestDefaultsAndLoad(t *testing.T) {
2424 t.Setenv("TERMINAL", "")
2525 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 {
2727 t.Fatalf("defaults: %+v", d)
2828 }
2929 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) {
697697 }
698698 }
699699
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+
700719 func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) {
701720 c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n")
702721 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 (
3232 ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down"
3333 ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up"
3434 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"
4045 // Scale steps through the clean scales of the output (see CleanScales).
4146 ActionScaleUp Action = "scale-up"
4247 ActionScaleDown Action = "scale-down"
@@ -53,6 +58,15 @@ const (
5358 ActionToggleFloating Action = "toggle-floating"
5459 )
5560
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+
5670 // Resize axes of ResizeArg.
5771 const (
5872 ResizeWidth = 0
@@ -174,21 +188,13 @@ func (c *Core) applyAction(a Action) Effect {
174188 return Effect{}
175189 }
176190 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 {
183193 c.focusScreen = i
184194 }
185195 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])
192198 return Effect{}
193199 case ActionFocusColumnLeft, ActionFocusColumnRight:
194200 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 = 072 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 := dirLeft192+ 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 = i193 c.focusScreen = i
184 }194 }
185 return Effect{}195 return Effect{}
186- case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:196+ case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
187- d := dirLeft197+ 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 {
228228 continue
229229 }
230230 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,
232232 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating:
233233 default:
234234 return fmt.Errorf("invalid action %q", a)
@@ -228,7 +228,7 @@ func (c *Core) apply(cfg ports.Config) error {
228 continue228 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 {
191191 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
192192 ActionToggleStashVisible, ActionConsumeOrExpelLeft,
193193 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
194+ ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
194195 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp,
195196 ActionMoveWorkspaceDown, ActionToggleFloating:
196197 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 true197 return true
M internal/core/overview_regression_test.go +1 -0
@@ -127,6 +127,7 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) {
127127 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
128128 ActionToggleStashVisible, ActionConsumeOrExpelLeft,
129129 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
130+ ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
130131 }
131132 for _, a := range actions {
132133 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 (
412412 )
413413
414414 // 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.
416419 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 {
424421 return -1
425422 }
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+ }
428454 }
429- return i
455+ return best
430456 }
431457
432458 // 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 in414 // 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 int420+ 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 -1421 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 -1424+ 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 i455+ return best
430 }456 }
431 457
432 // moveWorkspace moves the workspace on screen to the neighbor screen, where458 // 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) {
351351 }
352352 }
353353
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+
354402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
355403 r := startMulti(t, nil, left, right)
356404 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close