detached 1e27399

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 UnverifiedB5690EEEBB952194 The 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`).
3939 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |
4040 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
4141 | `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 |
4343 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
4444 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
4545 | `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 {
120120 // A bind acts on the window on screen: under a covering fullscreen
121121 // window, that is the one Focused reports.
122122 c.cur().mon.Current().focusCover()
123+ if name, ok := NamedArg(a); ok && c.bringNamed(name) {
124+ return Effect{}
125+ }
123126 dir := 0
124127 switch a {
125128 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 fullscreen120 // 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 := 0126 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 {
4545 // Overflow follow the monitor defaults.
4646 type NamedWorkspace struct {
4747 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.
4949 Monitor string
5050 MaxColumns int
5151 Overflow Overflow
@@ -229,9 +229,11 @@ func (m *Monitor) ToggleNamed(name string) {
229229
230230 // byName returns the configured workspace with that name, or nil.
231231 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+ }
235237 }
236238 }
237239 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 string47 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 string49 Monitor string
50 MaxColumns int50 MaxColumns int
51 Overflow Overflow51 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 w234+ if w.Name == name {
235+ return w
236+ }
235 }237 }
236 }238 }
237 return nil239 return nil
M internal/core/screens.go +56 -0
@@ -343,6 +343,62 @@ func (c *Core) named() {
343343 }
344344 }
345345
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+
346402 // settings applies output-wide config to a monitor.
347403 func (c *Core) settings(m *Monitor) {
348404 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.LayoutRules404 rules := c.cfg.Layout.LayoutRules
M internal/core/screens_test.go +90 -0
@@ -28,6 +28,7 @@ type multiRig struct {
2828 var (
2929 left = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100}
3030 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}
3132 )
3233
3334 // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
@@ -1056,3 +1057,92 @@ func TestStashThinPeekClick(t *testing.T) {
10561057 }
10571058 }
10581059 }
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 plugs34 // 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) {
143143 var cfg ports.Config
144144 cfg.Keyboard.CmdKey = "super"
145145 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)
150147 c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
151148 s := c.screens[0]
152149 s.x = 1000
@@ -164,3 +161,66 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) {
164161 t.Fatalf("scrolled off window has bounds %+v", r)
165162 }
166163 }
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.Config143 var cfg ports.Config
144 cfg.Keyboard.CmdKey = "super"144 cfg.Keyboard.CmdKey = "super"
145 cfg.Layout.MaxColumns = 2145 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 = 1000149 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 (
152152 type WorkspaceConfig struct {
153153 Name string
154154 // 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.
156156 Monitor string
157157 // Size overrides the workspace's logical width and height. Zero inherits
158158 // the monitor; its scale is always inherited.
@@ -152,7 +152,7 @@ const (
152 type WorkspaceConfig struct {152 type WorkspaceConfig struct {
153 Name string153 Name string
154 // Monitor is the home monitor: a connector name (DP-2) or a monitor key154 // 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 string156 Monitor string
157 // Size overrides the workspace's logical width and height. Zero inherits157 // 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.

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