fix(core): show named workspaces from any screen (#75)
* fix(core): show named workspaces from any screen A workspace <name> bind only searched the focused monitor, so a named workspace living on another screen could not be toggled. It now goes to its connected home monitor, or follows the focused screen when it has none (or its home is unplugged), with its fullscreen siblings. Named lookup no longer allocates. * refactor(core): simplify named workspace move and its tests moveNamed scans only numbered workspaces, where fullscreen siblings live, and no longer needs the core. The guest test now checks that a moved guest returns home on plug without a bind; tests share the third output and a core constructor.
- commit
- 1e27399669a49e13c4255d936d877162f9e71385
- author
- bnema · · 2026-10-01 03:29 UTC
- committer
- GitHub · · 2026-10-01 03:29 UTC
- parent
- 78083b5
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
7 files · +221 -10
M docs/config.md +1 -1
| @@ -39,7 +39,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 39 | 39 | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | |
| 40 | 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 41 | 41 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | | |
| 42 | - | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | |
| 42 | + | | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects | | |
| 43 | 43 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | |
| 44 | 44 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| 45 | 45 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| @@ -39,7 +39,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 39 | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | 39 | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | ||
| 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 41 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | | 41 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | | ||
| 42 | - | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | 42 | + | | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects | |
| 43 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | 43 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | ||
| 44 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | 44 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | ||
| 45 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | 45 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | ||
M internal/core/action.go +3 -0
| @@ -120,6 +120,9 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 120 | 120 | // A bind acts on the window on screen: under a covering fullscreen | |
| 121 | 121 | // window, that is the one Focused reports. | |
| 122 | 122 | c.cur().mon.Current().focusCover() | |
| 123 | + | if name, ok := NamedArg(a); ok && c.bringNamed(name) { | |
| 124 | + | return Effect{} | |
| 125 | + | } | |
| 123 | 126 | dir := 0 | |
| 124 | 127 | switch a { | |
| 125 | 128 | case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft: | |
| @@ -120,6 +120,9 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 120 | // A bind acts on the window on screen: under a covering fullscreen | 120 | // A bind acts on the window on screen: under a covering fullscreen | ||
| 121 | // window, that is the one Focused reports. | 121 | // window, that is the one Focused reports. | ||
| 122 | c.cur().mon.Current().focusCover() | 122 | c.cur().mon.Current().focusCover() | ||
| 123 | + | if name, ok := NamedArg(a); ok && c.bringNamed(name) { | |||
| 124 | + | return Effect{} | |||
| 125 | + | } | |||
| 123 | dir := 0 | 126 | dir := 0 | ||
| 124 | switch a { | 127 | switch a { | ||
| 125 | case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft: | 128 | case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft: | ||
M internal/core/monitor.go +6 -4
| @@ -45,7 +45,7 @@ type Monitor struct { | |||
| 45 | 45 | // Overflow follow the monitor defaults. | |
| 46 | 46 | type NamedWorkspace struct { | |
| 47 | 47 | Name string | |
| 48 | - | // Monitor is the home monitor (connector or key); "" for the first one. | |
| 48 | + | // Monitor is the home monitor (connector or key); "" follows the focus. | |
| 49 | 49 | Monitor string | |
| 50 | 50 | MaxColumns int | |
| 51 | 51 | Overflow Overflow | |
| @@ -229,9 +229,11 @@ func (m *Monitor) ToggleNamed(name string) { | |||
| 229 | 229 | ||
| 230 | 230 | // byName returns the configured workspace with that name, or nil. | |
| 231 | 231 | func (m *Monitor) byName(name string) *Workspace { | |
| 232 | - | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |
| 233 | - | if w.Name == name { | |
| 234 | - | return w | |
| 232 | + | for _, list := range [2][]*Workspace{m.Workspaces, m.hidden} { | |
| 233 | + | for _, w := range list { | |
| 234 | + | if w.Name == name { | |
| 235 | + | return w | |
| 236 | + | } | |
| 235 | 237 | } | |
| 236 | 238 | } | |
| 237 | 239 | return nil | |
| @@ -45,7 +45,7 @@ type Monitor struct { | |||||
| 45 | // Overflow follow the monitor defaults. | 45 | // Overflow follow the monitor defaults. | ||
| 46 | type NamedWorkspace struct { | 46 | type NamedWorkspace struct { | ||
| 47 | Name string | 47 | Name string | ||
| 48 | - | // Monitor is the home monitor (connector or key); "" for the first one. | 48 | + | // Monitor is the home monitor (connector or key); "" follows the focus. |
| 49 | Monitor string | 49 | Monitor string | ||
| 50 | MaxColumns int | 50 | MaxColumns int | ||
| 51 | Overflow Overflow | 51 | Overflow Overflow | ||
| @@ -229,9 +229,11 @@ func (m *Monitor) ToggleNamed(name string) { | |||||
| 229 | 229 | ||||
| 230 | // byName returns the configured workspace with that name, or nil. | 230 | // byName returns the configured workspace with that name, or nil. | ||
| 231 | func (m *Monitor) byName(name string) *Workspace { | 231 | func (m *Monitor) byName(name string) *Workspace { | ||
| 232 | - | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | 232 | + | for _, list := range [2][]*Workspace{m.Workspaces, m.hidden} { |
| 233 | - | if w.Name == name { | 233 | + | for _, w := range list { |
| 234 | - | return w | 234 | + | if w.Name == name { |
| 235 | + | return w | |||
| 236 | + | } | |||
| 235 | } | 237 | } | ||
| 236 | } | 238 | } | ||
| 237 | return nil | 239 | return nil | ||
M internal/core/screens.go +56 -0
| @@ -343,6 +343,62 @@ func (c *Core) named() { | |||
| 343 | 343 | } | |
| 344 | 344 | } | |
| 345 | 345 | ||
| 346 | + | // bringNamed prepares a "workspace <name>" toggle from the focused screen. | |
| 347 | + | // The workspace goes to its home monitor when connected, else to the focused | |
| 348 | + | // screen, with the fullscreen workspaces that came out of it; that screen | |
| 349 | + | // takes the focus. It reports true when the workspace is already on screen | |
| 350 | + | // there after a focus change: reaching it must not toggle it away. | |
| 351 | + | func (c *Core) bringNamed(name string) bool { | |
| 352 | + | src, w := c.byName(name) | |
| 353 | + | i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name }) | |
| 354 | + | if w == nil || i < 0 { | |
| 355 | + | return false | |
| 356 | + | } | |
| 357 | + | to, home := c.focusScreen, c.specs[i].Monitor | |
| 358 | + | if h := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(home) }); h >= 0 { | |
| 359 | + | to, home = h, "" | |
| 360 | + | } | |
| 361 | + | dst := c.screens[to] | |
| 362 | + | if to != c.focusScreen { | |
| 363 | + | c.focusScreen = to | |
| 364 | + | if src == dst && dst.mon.Current() == w { | |
| 365 | + | return true | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if src != dst { | |
| 369 | + | moveNamed(src, dst, w, home) | |
| 370 | + | } | |
| 371 | + | return false | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // moveNamed moves the named workspace w and its fullscreen siblings from src | |
| 375 | + | // to dst, without changing what dst shows. home is their new w.home. | |
| 376 | + | func moveNamed(src, dst *screen, w *Workspace, home string) { | |
| 377 | + | // Fullscreen siblings (always numbered) leave first: without their | |
| 378 | + | // origin on the source, normalize would unlink them. take edits the | |
| 379 | + | // list, so collect first. | |
| 380 | + | var buf [4]*Workspace | |
| 381 | + | siblings := buf[:0] | |
| 382 | + | for _, fs := range src.mon.Workspaces { | |
| 383 | + | if fs.origin == w { | |
| 384 | + | siblings = append(siblings, fs) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | for _, fs := range siblings { | |
| 388 | + | src.mon.take(fs) | |
| 389 | + | } | |
| 390 | + | hidden := src.mon.isHidden(w) | |
| 391 | + | src.mon.take(w) | |
| 392 | + | // A guest keeps waiting for its home; any other forgets the output it | |
| 393 | + | // was unplugged from. Siblings take the same home. | |
| 394 | + | w.home = home | |
| 395 | + | dst.mon.adopt(w, hidden, len(dst.mon.Workspaces)) | |
| 396 | + | for _, fs := range siblings { | |
| 397 | + | fs.home = home | |
| 398 | + | dst.mon.adopt(fs, false, len(dst.mon.Workspaces)) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 346 | 402 | // settings applies output-wide config to a monitor. | |
| 347 | 403 | func (c *Core) settings(m *Monitor) { | |
| 348 | 404 | rules := c.cfg.Layout.LayoutRules | |
| @@ -343,6 +343,62 @@ func (c *Core) named() { | |||||
| 343 | } | 343 | } | ||
| 344 | } | 344 | } | ||
| 345 | 345 | ||||
| 346 | + | // bringNamed prepares a "workspace <name>" toggle from the focused screen. | |||
| 347 | + | // The workspace goes to its home monitor when connected, else to the focused | |||
| 348 | + | // screen, with the fullscreen workspaces that came out of it; that screen | |||
| 349 | + | // takes the focus. It reports true when the workspace is already on screen | |||
| 350 | + | // there after a focus change: reaching it must not toggle it away. | |||
| 351 | + | func (c *Core) bringNamed(name string) bool { | |||
| 352 | + | src, w := c.byName(name) | |||
| 353 | + | i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name }) | |||
| 354 | + | if w == nil || i < 0 { | |||
| 355 | + | return false | |||
| 356 | + | } | |||
| 357 | + | to, home := c.focusScreen, c.specs[i].Monitor | |||
| 358 | + | if h := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(home) }); h >= 0 { | |||
| 359 | + | to, home = h, "" | |||
| 360 | + | } | |||
| 361 | + | dst := c.screens[to] | |||
| 362 | + | if to != c.focusScreen { | |||
| 363 | + | c.focusScreen = to | |||
| 364 | + | if src == dst && dst.mon.Current() == w { | |||
| 365 | + | return true | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | if src != dst { | |||
| 369 | + | moveNamed(src, dst, w, home) | |||
| 370 | + | } | |||
| 371 | + | return false | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // moveNamed moves the named workspace w and its fullscreen siblings from src | |||
| 375 | + | // to dst, without changing what dst shows. home is their new w.home. | |||
| 376 | + | func moveNamed(src, dst *screen, w *Workspace, home string) { | |||
| 377 | + | // Fullscreen siblings (always numbered) leave first: without their | |||
| 378 | + | // origin on the source, normalize would unlink them. take edits the | |||
| 379 | + | // list, so collect first. | |||
| 380 | + | var buf [4]*Workspace | |||
| 381 | + | siblings := buf[:0] | |||
| 382 | + | for _, fs := range src.mon.Workspaces { | |||
| 383 | + | if fs.origin == w { | |||
| 384 | + | siblings = append(siblings, fs) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | for _, fs := range siblings { | |||
| 388 | + | src.mon.take(fs) | |||
| 389 | + | } | |||
| 390 | + | hidden := src.mon.isHidden(w) | |||
| 391 | + | src.mon.take(w) | |||
| 392 | + | // A guest keeps waiting for its home; any other forgets the output it | |||
| 393 | + | // was unplugged from. Siblings take the same home. | |||
| 394 | + | w.home = home | |||
| 395 | + | dst.mon.adopt(w, hidden, len(dst.mon.Workspaces)) | |||
| 396 | + | for _, fs := range siblings { | |||
| 397 | + | fs.home = home | |||
| 398 | + | dst.mon.adopt(fs, false, len(dst.mon.Workspaces)) | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 346 | // settings applies output-wide config to a monitor. | 402 | // settings applies output-wide config to a monitor. | ||
| 347 | func (c *Core) settings(m *Monitor) { | 403 | func (c *Core) settings(m *Monitor) { | ||
| 348 | rules := c.cfg.Layout.LayoutRules | 404 | rules := c.cfg.Layout.LayoutRules | ||
M internal/core/screens_test.go +90 -0
| @@ -28,6 +28,7 @@ type multiRig struct { | |||
| 28 | 28 | var ( | |
| 29 | 29 | left = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100} | |
| 30 | 30 | right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200} | |
| 31 | + | third = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "C", Serial: "3", Width: 300, Height: 100} | |
| 31 | 32 | ) | |
| 32 | 33 | ||
| 33 | 34 | // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs | |
| @@ -1056,3 +1057,92 @@ func TestStashThinPeekClick(t *testing.T) { | |||
| 1056 | 1057 | } | |
| 1057 | 1058 | } | |
| 1058 | 1059 | } | |
| 1060 | + | ||
| 1061 | + | func TestNamedWorkspaceWithoutHomeFollowsFocus(t *testing.T) { | |
| 1062 | + | r := startMulti(t, func(c *ports.Config) { | |
| 1063 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}} | |
| 1064 | + | c.Binds["Alt+w"] = "workspace web" | |
| 1065 | + | }, left, right) | |
| 1066 | + | // Called from DP-2, web comes to DP-2. | |
| 1067 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1068 | + | r.key(t, "w", ports.ModAlt) | |
| 1069 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 { | |
| 1070 | + | t.Fatal(got) | |
| 1071 | + | } | |
| 1072 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-2"]) != 0 { | |
| 1073 | + | t.Fatal(got) | |
| 1074 | + | } | |
| 1075 | + | // Called from DP-1, it moves there with its window. | |
| 1076 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 1077 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 0 { | |
| 1078 | + | t.Fatal(got) | |
| 1079 | + | } | |
| 1080 | + | } | |
| 1081 | + | ||
| 1082 | + | func TestNamedWorkspaceWithHomeFocusesIt(t *testing.T) { | |
| 1083 | + | r := startMulti(t, func(c *ports.Config) { | |
| 1084 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}} | |
| 1085 | + | c.Binds["Alt+w"] = "workspace web" | |
| 1086 | + | }, left, right) | |
| 1087 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1088 | + | r.key(t, "w", ports.ModAlt) | |
| 1089 | + | // The focus went to DP-1: the window opens there. | |
| 1090 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 { | |
| 1091 | + | t.Fatal(got) | |
| 1092 | + | } | |
| 1093 | + | } | |
| 1094 | + | ||
| 1095 | + | func TestNamedGuestFollowsFocusAndReturnsHome(t *testing.T) { | |
| 1096 | + | r := startMulti(t, func(c *ports.Config) { | |
| 1097 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}} | |
| 1098 | + | c.Binds["Alt+w"] = "workspace web" | |
| 1099 | + | }, left, right) | |
| 1100 | + | // DP-3 is not plugged: web follows the focus to DP-2. | |
| 1101 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1102 | + | r.key(t, "w", ports.ModAlt) | |
| 1103 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 { | |
| 1104 | + | t.Fatal(got) | |
| 1105 | + | } | |
| 1106 | + | r.key(t, "w", ports.ModAlt) | |
| 1107 | + | // DP-3 arrives: web, not on screen, goes home with no bind. | |
| 1108 | + | r.plug(t, third) | |
| 1109 | + | st := receive(t, r.state) | |
| 1110 | + | for len(st.Outputs) < 3 { | |
| 1111 | + | st = receive(t, r.state) | |
| 1112 | + | } | |
| 1113 | + | if len(st.Windows) != 1 || st.Windows[0].Output != "DP-3" { | |
| 1114 | + | t.Fatalf("%+v", st.Windows) | |
| 1115 | + | } | |
| 1116 | + | } | |
| 1117 | + | ||
| 1118 | + | func TestNamedGuestOnScreenGoesHomeOnBind(t *testing.T) { | |
| 1119 | + | r := startMulti(t, func(c *ports.Config) { | |
| 1120 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}} | |
| 1121 | + | c.Binds["Alt+w"] = "workspace web" | |
| 1122 | + | }, left, right) | |
| 1123 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1124 | + | r.key(t, "w", ports.ModAlt) | |
| 1125 | + | r.mapWindow(t, 1) | |
| 1126 | + | // DP-3 arrives while web is on screen on DP-2: the bind takes it home. | |
| 1127 | + | r.plug(t, third) | |
| 1128 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-3"]) != 1 || len(got["DP-2"]) != 0 { | |
| 1129 | + | t.Fatal(got) | |
| 1130 | + | } | |
| 1131 | + | } | |
| 1132 | + | ||
| 1133 | + | func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) { | |
| 1134 | + | r := startMulti(t, func(c *ports.Config) { | |
| 1135 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}} | |
| 1136 | + | c.Binds["Alt+w"] = "workspace web" | |
| 1137 | + | }, left, right) | |
| 1138 | + | r.key(t, "w", ports.ModAlt) | |
| 1139 | + | r.mapWindow(t, 1) | |
| 1140 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1141 | + | // Reaching web from DP-2 focuses it; it does not hide it. | |
| 1142 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 { | |
| 1143 | + | t.Fatal(got) | |
| 1144 | + | } | |
| 1145 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 { | |
| 1146 | + | t.Fatal(got) | |
| 1147 | + | } | |
| 1148 | + | } | |
| @@ -28,6 +28,7 @@ type multiRig struct { | |||||
| 28 | var ( | 28 | var ( | ||
| 29 | left = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100} | 29 | left = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100} | ||
| 30 | right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200} | 30 | right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200} | ||
| 31 | + | third = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "C", Serial: "3", Width: 300, Height: 100} | |||
| 31 | ) | 32 | ) | ||
| 32 | 33 | ||||
| 33 | // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs | 34 | // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs | ||
| @@ -1056,3 +1057,92 @@ func TestStashThinPeekClick(t *testing.T) { | |||||
| 1056 | } | 1057 | } | ||
| 1057 | } | 1058 | } | ||
| 1058 | } | 1059 | } | ||
| 1060 | + | ||||
| 1061 | + | func TestNamedWorkspaceWithoutHomeFollowsFocus(t *testing.T) { | |||
| 1062 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 1063 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}} | |||
| 1064 | + | c.Binds["Alt+w"] = "workspace web" | |||
| 1065 | + | }, left, right) | |||
| 1066 | + | // Called from DP-2, web comes to DP-2. | |||
| 1067 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 1068 | + | r.key(t, "w", ports.ModAlt) | |||
| 1069 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 { | |||
| 1070 | + | t.Fatal(got) | |||
| 1071 | + | } | |||
| 1072 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-2"]) != 0 { | |||
| 1073 | + | t.Fatal(got) | |||
| 1074 | + | } | |||
| 1075 | + | // Called from DP-1, it moves there with its window. | |||
| 1076 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 1077 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 0 { | |||
| 1078 | + | t.Fatal(got) | |||
| 1079 | + | } | |||
| 1080 | + | } | |||
| 1081 | + | ||||
| 1082 | + | func TestNamedWorkspaceWithHomeFocusesIt(t *testing.T) { | |||
| 1083 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 1084 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}} | |||
| 1085 | + | c.Binds["Alt+w"] = "workspace web" | |||
| 1086 | + | }, left, right) | |||
| 1087 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 1088 | + | r.key(t, "w", ports.ModAlt) | |||
| 1089 | + | // The focus went to DP-1: the window opens there. | |||
| 1090 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 { | |||
| 1091 | + | t.Fatal(got) | |||
| 1092 | + | } | |||
| 1093 | + | } | |||
| 1094 | + | ||||
| 1095 | + | func TestNamedGuestFollowsFocusAndReturnsHome(t *testing.T) { | |||
| 1096 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 1097 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}} | |||
| 1098 | + | c.Binds["Alt+w"] = "workspace web" | |||
| 1099 | + | }, left, right) | |||
| 1100 | + | // DP-3 is not plugged: web follows the focus to DP-2. | |||
| 1101 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 1102 | + | r.key(t, "w", ports.ModAlt) | |||
| 1103 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 { | |||
| 1104 | + | t.Fatal(got) | |||
| 1105 | + | } | |||
| 1106 | + | r.key(t, "w", ports.ModAlt) | |||
| 1107 | + | // DP-3 arrives: web, not on screen, goes home with no bind. | |||
| 1108 | + | r.plug(t, third) | |||
| 1109 | + | st := receive(t, r.state) | |||
| 1110 | + | for len(st.Outputs) < 3 { | |||
| 1111 | + | st = receive(t, r.state) | |||
| 1112 | + | } | |||
| 1113 | + | if len(st.Windows) != 1 || st.Windows[0].Output != "DP-3" { | |||
| 1114 | + | t.Fatalf("%+v", st.Windows) | |||
| 1115 | + | } | |||
| 1116 | + | } | |||
| 1117 | + | ||||
| 1118 | + | func TestNamedGuestOnScreenGoesHomeOnBind(t *testing.T) { | |||
| 1119 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 1120 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}} | |||
| 1121 | + | c.Binds["Alt+w"] = "workspace web" | |||
| 1122 | + | }, left, right) | |||
| 1123 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 1124 | + | r.key(t, "w", ports.ModAlt) | |||
| 1125 | + | r.mapWindow(t, 1) | |||
| 1126 | + | // DP-3 arrives while web is on screen on DP-2: the bind takes it home. | |||
| 1127 | + | r.plug(t, third) | |||
| 1128 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-3"]) != 1 || len(got["DP-2"]) != 0 { | |||
| 1129 | + | t.Fatal(got) | |||
| 1130 | + | } | |||
| 1131 | + | } | |||
| 1132 | + | ||||
| 1133 | + | func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) { | |||
| 1134 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 1135 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}} | |||
| 1136 | + | c.Binds["Alt+w"] = "workspace web" | |||
| 1137 | + | }, left, right) | |||
| 1138 | + | r.key(t, "w", ports.ModAlt) | |||
| 1139 | + | r.mapWindow(t, 1) | |||
| 1140 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 1141 | + | // Reaching web from DP-2 focuses it; it does not hide it. | |||
| 1142 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 { | |||
| 1143 | + | t.Fatal(got) | |||
| 1144 | + | } | |||
| 1145 | + | if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 { | |||
| 1146 | + | t.Fatal(got) | |||
| 1147 | + | } | |||
| 1148 | + | } | |||
M internal/core/workspace_size_internal_test.go +64 -4
| @@ -143,10 +143,7 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) { | |||
| 143 | 143 | var cfg ports.Config | |
| 144 | 144 | cfg.Keyboard.CmdKey = "super" | |
| 145 | 145 | cfg.Layout.MaxColumns = 2 | |
| 146 | - | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)}) | |
| 147 | - | if err != nil { | |
| 148 | - | t.Fatal(err) | |
| 149 | - | } | |
| 146 | + | c := newSizedCore(t, cfg) | |
| 150 | 147 | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |
| 151 | 148 | s := c.screens[0] | |
| 152 | 149 | s.x = 1000 | |
| @@ -164,3 +161,66 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) { | |||
| 164 | 161 | t.Fatalf("scrolled off window has bounds %+v", r) | |
| 165 | 162 | } | |
| 166 | 163 | } | |
| 164 | + | ||
| 165 | + | // A named workspace following the focus to another screen takes its | |
| 166 | + | // fullscreen siblings along, still linked and sized by the declaration. | |
| 167 | + | func TestBringNamedMovesFullscreenSiblings(t *testing.T) { | |
| 168 | + | var cfg ports.Config | |
| 169 | + | cfg.Keyboard.CmdKey = "super" | |
| 170 | + | cfg.Layout.MaxColumns = 2 | |
| 171 | + | cfg.Layout.Overflow = string(OverflowFixed) | |
| 172 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "s", Size: [2]int{100, 100}}} | |
| 173 | + | c := newSizedCore(t, cfg) | |
| 174 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |
| 175 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 600, Height: 400}) | |
| 176 | + | a, b := c.screens[0], c.screens[1] | |
| 177 | + | a.mon.ToggleNamed("s") | |
| 178 | + | named := a.mon.Current() | |
| 179 | + | a.mon.AddWindow(1) | |
| 180 | + | a.mon.AddWindow(2) | |
| 181 | + | a.mon.ToggleFullscreen() | |
| 182 | + | fs := a.mon.Current() | |
| 183 | + | if fs.origin != named { | |
| 184 | + | t.Fatal("test did not create fullscreen sibling") | |
| 185 | + | } | |
| 186 | + | c.focusScreen = 1 | |
| 187 | + | c.bringNamed("s") | |
| 188 | + | if a.mon.has(named) || a.mon.has(fs) || !b.mon.has(named) || !b.mon.has(fs) { | |
| 189 | + | t.Fatal("named workspace or sibling left behind") | |
| 190 | + | } | |
| 191 | + | if fs.origin != named || fs.size != [2]int{100, 100} || named.Output.W != 100 { | |
| 192 | + | t.Fatalf("sibling %+v size %v, named %+v", fs.origin, fs.size, named.Output) | |
| 193 | + | } | |
| 194 | + | // Siblings drop the home of the screen they left, like the workspace. | |
| 195 | + | named.home, fs.home = "A", "A" | |
| 196 | + | c.focusScreen = 0 | |
| 197 | + | c.bringNamed("s") | |
| 198 | + | if named.home != "" || fs.home != "" || !a.mon.has(fs) || fs.origin != named { | |
| 199 | + | t.Fatalf("home %q sibling home %q", named.home, fs.home) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // Toggling a named workspace that is already on the focused screen, or on | |
| 204 | + | // its connected home, allocates nothing to find it. | |
| 205 | + | func TestBringNamedLookupAllocations(t *testing.T) { | |
| 206 | + | var cfg ports.Config | |
| 207 | + | cfg.Keyboard.CmdKey = "super" | |
| 208 | + | cfg.Layout.MaxColumns = 2 | |
| 209 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "s"}, {Name: "h", Monitor: "A"}} | |
| 210 | + | c := newSizedCore(t, cfg) | |
| 211 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |
| 212 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 600, Height: 400}) | |
| 213 | + | if n := testing.AllocsPerRun(100, func() { c.focusScreen = 0; c.bringNamed("s"); c.bringNamed("h") }); n != 0 { | |
| 214 | + | t.Fatalf("%v allocs", n) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // newSizedCore returns a core with buffered output channels and no outputs. | |
| 219 | + | func newSizedCore(t *testing.T, cfg ports.Config) *Core { | |
| 220 | + | t.Helper() | |
| 221 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)}) | |
| 222 | + | if err != nil { | |
| 223 | + | t.Fatal(err) | |
| 224 | + | } | |
| 225 | + | return c | |
| 226 | + | } | |
| @@ -143,10 +143,7 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) { | |||||
| 143 | var cfg ports.Config | 143 | var cfg ports.Config | ||
| 144 | cfg.Keyboard.CmdKey = "super" | 144 | cfg.Keyboard.CmdKey = "super" | ||
| 145 | cfg.Layout.MaxColumns = 2 | 145 | cfg.Layout.MaxColumns = 2 | ||
| 146 | - | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)}) | 146 | + | c := newSizedCore(t, cfg) |
| 147 | - | if err != nil { | |||
| 148 | - | t.Fatal(err) | |||
| 149 | - | } | |||
| 150 | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | 147 | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | ||
| 151 | s := c.screens[0] | 148 | s := c.screens[0] | ||
| 152 | s.x = 1000 | 149 | s.x = 1000 | ||
| @@ -164,3 +161,66 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) { | |||||
| 164 | t.Fatalf("scrolled off window has bounds %+v", r) | 161 | t.Fatalf("scrolled off window has bounds %+v", r) | ||
| 165 | } | 162 | } | ||
| 166 | } | 163 | } | ||
| 164 | + | ||||
| 165 | + | // A named workspace following the focus to another screen takes its | |||
| 166 | + | // fullscreen siblings along, still linked and sized by the declaration. | |||
| 167 | + | func TestBringNamedMovesFullscreenSiblings(t *testing.T) { | |||
| 168 | + | var cfg ports.Config | |||
| 169 | + | cfg.Keyboard.CmdKey = "super" | |||
| 170 | + | cfg.Layout.MaxColumns = 2 | |||
| 171 | + | cfg.Layout.Overflow = string(OverflowFixed) | |||
| 172 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "s", Size: [2]int{100, 100}}} | |||
| 173 | + | c := newSizedCore(t, cfg) | |||
| 174 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |||
| 175 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 600, Height: 400}) | |||
| 176 | + | a, b := c.screens[0], c.screens[1] | |||
| 177 | + | a.mon.ToggleNamed("s") | |||
| 178 | + | named := a.mon.Current() | |||
| 179 | + | a.mon.AddWindow(1) | |||
| 180 | + | a.mon.AddWindow(2) | |||
| 181 | + | a.mon.ToggleFullscreen() | |||
| 182 | + | fs := a.mon.Current() | |||
| 183 | + | if fs.origin != named { | |||
| 184 | + | t.Fatal("test did not create fullscreen sibling") | |||
| 185 | + | } | |||
| 186 | + | c.focusScreen = 1 | |||
| 187 | + | c.bringNamed("s") | |||
| 188 | + | if a.mon.has(named) || a.mon.has(fs) || !b.mon.has(named) || !b.mon.has(fs) { | |||
| 189 | + | t.Fatal("named workspace or sibling left behind") | |||
| 190 | + | } | |||
| 191 | + | if fs.origin != named || fs.size != [2]int{100, 100} || named.Output.W != 100 { | |||
| 192 | + | t.Fatalf("sibling %+v size %v, named %+v", fs.origin, fs.size, named.Output) | |||
| 193 | + | } | |||
| 194 | + | // Siblings drop the home of the screen they left, like the workspace. | |||
| 195 | + | named.home, fs.home = "A", "A" | |||
| 196 | + | c.focusScreen = 0 | |||
| 197 | + | c.bringNamed("s") | |||
| 198 | + | if named.home != "" || fs.home != "" || !a.mon.has(fs) || fs.origin != named { | |||
| 199 | + | t.Fatalf("home %q sibling home %q", named.home, fs.home) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // Toggling a named workspace that is already on the focused screen, or on | |||
| 204 | + | // its connected home, allocates nothing to find it. | |||
| 205 | + | func TestBringNamedLookupAllocations(t *testing.T) { | |||
| 206 | + | var cfg ports.Config | |||
| 207 | + | cfg.Keyboard.CmdKey = "super" | |||
| 208 | + | cfg.Layout.MaxColumns = 2 | |||
| 209 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "s"}, {Name: "h", Monitor: "A"}} | |||
| 210 | + | c := newSizedCore(t, cfg) | |||
| 211 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |||
| 212 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 600, Height: 400}) | |||
| 213 | + | if n := testing.AllocsPerRun(100, func() { c.focusScreen = 0; c.bringNamed("s"); c.bringNamed("h") }); n != 0 { | |||
| 214 | + | t.Fatalf("%v allocs", n) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // newSizedCore returns a core with buffered output channels and no outputs. | |||
| 219 | + | func newSizedCore(t *testing.T, cfg ports.Config) *Core { | |||
| 220 | + | t.Helper() | |||
| 221 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)}) | |||
| 222 | + | if err != nil { | |||
| 223 | + | t.Fatal(err) | |||
| 224 | + | } | |||
| 225 | + | return c | |||
| 226 | + | } | |||
M internal/ports/config.go +1 -1
| @@ -152,7 +152,7 @@ const ( | |||
| 152 | 152 | type WorkspaceConfig struct { | |
| 153 | 153 | Name string | |
| 154 | 154 | // Monitor is the home monitor: a connector name (DP-2) or a monitor key | |
| 155 | - | // ("make model serial"); empty means the first output. | |
| 155 | + | // ("make model serial"); empty follows the focused output when shown. | |
| 156 | 156 | Monitor string | |
| 157 | 157 | // Size overrides the workspace's logical width and height. Zero inherits | |
| 158 | 158 | // the monitor; its scale is always inherited. | |
| @@ -152,7 +152,7 @@ const ( | |||||
| 152 | type WorkspaceConfig struct { | 152 | type WorkspaceConfig struct { | ||
| 153 | Name string | 153 | Name string | ||
| 154 | // Monitor is the home monitor: a connector name (DP-2) or a monitor key | 154 | // Monitor is the home monitor: a connector name (DP-2) or a monitor key | ||
| 155 | - | // ("make model serial"); empty means the first output. | 155 | + | // ("make model serial"); empty follows the focused output when shown. |
| 156 | Monitor string | 156 | Monitor string | ||
| 157 | // Size overrides the workspace's logical width and height. Zero inherits | 157 | // Size overrides the workspace's logical width and height. Zero inherits | ||
| 158 | // the monitor; its scale is always inherited. | 158 | // the monitor; its scale is always inherited. | ||