detached c703b25

feat(workspaces): named and hidden workspaces with toggle bind, fixed overflow spiral

workspace.<name>.{hidden,monitor,max-columns,overflow} and layout.overflow
config keys; bind action `workspace <name>` toggles back to the previous
workspace. overflow = fixed keeps every window on screen by splitting the
newest slot, alternating top/bottom and left/right.
commit
c703b25452431c80e1d4cbaecd897c35b7e5f49f
author
brice · · 2026-09-24 16:45 UTC
parent
7929f62
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +612 -38
M examples/config +12 -0
@@ -25,9 +25,21 @@ layout.gaps = 0
2525 # Columns sharing the screen equally before new ones scroll off to the right.
2626 # A lone column fills the screen without a border.
2727 layout.max-columns = 2
28+# Past max-columns: scroll opens columns to the right; fixed keeps everything
29+# on screen, each new window splitting the newest one (spiral).
30+layout.overflow = scroll
2831 # cycle-column-width steps through these, then back to the equal share.
2932 layout.presets = 1/3, 1/2, 2/3, 1
3033
34+# Named workspaces. Numbered at their place like any other and never removed
35+# when empty. hidden = on keeps one out of the numbers and of up/down: only its
36+# bind shows it, and pressing the bind again returns to the previous workspace.
37+# max-columns and overflow override the layout.* values.
38+# workspace.dev.hidden = on
39+# workspace.dev.max-columns = 3
40+# workspace.dev.overflow = fixed
41+# bind.cmd+d = workspace dev
42+
3143 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
3244 # The first listed display that is connected and not off is used.
3345 # output.DP-2 = 5120x2160@165.058
@@ -25,9 +25,21 @@ layout.gaps = 0
25 # Columns sharing the screen equally before new ones scroll off to the right.25 # Columns sharing the screen equally before new ones scroll off to the right.
26 # A lone column fills the screen without a border.26 # A lone column fills the screen without a border.
27 layout.max-columns = 227 layout.max-columns = 2
28+# Past max-columns: scroll opens columns to the right; fixed keeps everything
29+# on screen, each new window splitting the newest one (spiral).
30+layout.overflow = scroll
28 # cycle-column-width steps through these, then back to the equal share.31 # cycle-column-width steps through these, then back to the equal share.
29 layout.presets = 1/3, 1/2, 2/3, 132 layout.presets = 1/3, 1/2, 2/3, 1
30 33
34+# Named workspaces. Numbered at their place like any other and never removed
35+# when empty. hidden = on keeps one out of the numbers and of up/down: only its
36+# bind shows it, and pressing the bind again returns to the previous workspace.
37+# max-columns and overflow override the layout.* values.
38+# workspace.dev.hidden = on
39+# workspace.dev.max-columns = 3
40+# workspace.dev.overflow = fixed
41+# bind.cmd+d = workspace dev
42+
31 # Displays (drm backend): WxH, WxH@Hz, preferred or off.43 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
32 # The first listed display that is connected and not off is used.44 # The first listed display that is connected and not off is used.
33 # output.DP-2 = 5120x2160@165.05845 # output.DP-2 = 5120x2160@165.058
M internal/adapters/config/config.go +65 -1
@@ -78,6 +78,7 @@ func Defaults() ports.Config {
7878 c.Border.Active = "#b4befe"
7979 c.Layout.MaxColumns = 2
8080 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
81+ c.Layout.Overflow = "scroll"
8182 c.Binds = map[string]string{}
8283 for _, b := range defaultBinds {
8384 combo, err := parseCombo(b.combo)
@@ -136,6 +137,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
136137 raw := map[string]string{}
137138 seen := map[string]int{}
138139 outputs := map[string]int{}
140+ workspaces := map[string]int{}
139141 scanner := bufio.NewScanner(r)
140142 for n := 1; scanner.Scan(); n++ {
141143 line := strings.TrimSpace(scanner.Text())
@@ -216,6 +218,30 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
216218 *e = o
217219 continue
218220 }
221+ if rest, ok := strings.CutPrefix(key, "workspace."); ok {
222+ name, field, _ := strings.Cut(rest, ".")
223+ if !workspaceName.MatchString(name) {
224+ warn("%s: workspace name must be letters, digits, - or _", key)
225+ continue
226+ }
227+ var ws ports.WorkspaceConfig
228+ if i, ok := workspaces[name]; ok {
229+ ws = c.Workspaces[i]
230+ }
231+ if err := setWorkspace(&ws, field, value); err != nil {
232+ warn("%s: %v", key, err)
233+ continue
234+ }
235+ override()
236+ if i, ok := workspaces[name]; ok {
237+ c.Workspaces[i] = ws
238+ } else {
239+ ws.Name = name
240+ workspaces[name] = len(c.Workspaces)
241+ c.Workspaces = append(c.Workspaces, ws)
242+ }
243+ continue
244+ }
219245 if err := set(&c, key, value); err != nil {
220246 warn("%s: %v", key, err)
221247 continue
@@ -365,6 +391,11 @@ func set(c *ports.Config, key, v string) error {
365391 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
366392 case "layout.max-columns":
367393 return positive(&c.Layout.MaxColumns, v, 16)
394+ case "layout.overflow":
395+ if v != "scroll" && v != "fixed" {
396+ return fmt.Errorf("must be scroll or fixed")
397+ }
398+ c.Layout.Overflow = v
368399 case "layout.presets":
369400 list := splitList(v)
370401 if len(list) == 0 {
@@ -401,6 +432,33 @@ func set(c *ports.Config, key, v string) error {
401432 return nil
402433 }
403434
435+var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
436+
437+// setWorkspace applies one workspace.<name>.<field> key.
438+func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
439+ switch field {
440+ case "hidden":
441+ b, err := onOff(v)
442+ w.Hidden = b
443+ return err
444+ case "monitor":
445+ if v == "" {
446+ return fmt.Errorf("must not be empty")
447+ }
448+ w.Monitor = v
449+ case "max-columns":
450+ return positive(&w.MaxColumns, v, 16)
451+ case "overflow":
452+ if v != "scroll" && v != "fixed" {
453+ return fmt.Errorf("must be scroll or fixed")
454+ }
455+ w.Overflow = v
456+ default:
457+ return fmt.Errorf("unknown key (hidden, monitor, max-columns, overflow)")
458+ }
459+ return nil
460+}
461+
404462 func positive(dst *int, v string, max int) error {
405463 n, err := strconv.Atoi(v)
406464 if err != nil || n <= 0 || n > max {
@@ -448,6 +506,12 @@ func checkAction(v string) error {
448506 }
449507 return nil
450508 }
509+ if rest, ok := strings.CutPrefix(v, "workspace "); ok {
510+ if !workspaceName.MatchString(strings.TrimSpace(rest)) {
511+ return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
512+ }
513+ return nil
514+ }
451515 for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
452516 if rest, ok := strings.CutPrefix(v, prefix); ok {
453517 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
@@ -457,7 +521,7 @@ func checkAction(v string) error {
457521 }
458522 }
459523 if !actions[v] {
460- return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
524+ return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
461525 }
462526 return nil
463527 }
@@ -78,6 +78,7 @@ func Defaults() ports.Config {
78 c.Border.Active = "#b4befe"78 c.Border.Active = "#b4befe"
79 c.Layout.MaxColumns = 279 c.Layout.MaxColumns = 2
80 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}80 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
81+ c.Layout.Overflow = "scroll"
81 c.Binds = map[string]string{}82 c.Binds = map[string]string{}
82 for _, b := range defaultBinds {83 for _, b := range defaultBinds {
83 combo, err := parseCombo(b.combo)84 combo, err := parseCombo(b.combo)
@@ -136,6 +137,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
136 raw := map[string]string{}137 raw := map[string]string{}
137 seen := map[string]int{}138 seen := map[string]int{}
138 outputs := map[string]int{}139 outputs := map[string]int{}
140+ workspaces := map[string]int{}
139 scanner := bufio.NewScanner(r)141 scanner := bufio.NewScanner(r)
140 for n := 1; scanner.Scan(); n++ {142 for n := 1; scanner.Scan(); n++ {
141 line := strings.TrimSpace(scanner.Text())143 line := strings.TrimSpace(scanner.Text())
@@ -216,6 +218,30 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
216 *e = o218 *e = o
217 continue219 continue
218 }220 }
221+ if rest, ok := strings.CutPrefix(key, "workspace."); ok {
222+ name, field, _ := strings.Cut(rest, ".")
223+ if !workspaceName.MatchString(name) {
224+ warn("%s: workspace name must be letters, digits, - or _", key)
225+ continue
226+ }
227+ var ws ports.WorkspaceConfig
228+ if i, ok := workspaces[name]; ok {
229+ ws = c.Workspaces[i]
230+ }
231+ if err := setWorkspace(&ws, field, value); err != nil {
232+ warn("%s: %v", key, err)
233+ continue
234+ }
235+ override()
236+ if i, ok := workspaces[name]; ok {
237+ c.Workspaces[i] = ws
238+ } else {
239+ ws.Name = name
240+ workspaces[name] = len(c.Workspaces)
241+ c.Workspaces = append(c.Workspaces, ws)
242+ }
243+ continue
244+ }
219 if err := set(&c, key, value); err != nil {245 if err := set(&c, key, value); err != nil {
220 warn("%s: %v", key, err)246 warn("%s: %v", key, err)
221 continue247 continue
@@ -365,6 +391,11 @@ func set(c *ports.Config, key, v string) error {
365 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")391 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
366 case "layout.max-columns":392 case "layout.max-columns":
367 return positive(&c.Layout.MaxColumns, v, 16)393 return positive(&c.Layout.MaxColumns, v, 16)
394+ case "layout.overflow":
395+ if v != "scroll" && v != "fixed" {
396+ return fmt.Errorf("must be scroll or fixed")
397+ }
398+ c.Layout.Overflow = v
368 case "layout.presets":399 case "layout.presets":
369 list := splitList(v)400 list := splitList(v)
370 if len(list) == 0 {401 if len(list) == 0 {
@@ -401,6 +432,33 @@ func set(c *ports.Config, key, v string) error {
401 return nil432 return nil
402 }433 }
403 434
435+var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
436+
437+// setWorkspace applies one workspace.<name>.<field> key.
438+func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
439+ switch field {
440+ case "hidden":
441+ b, err := onOff(v)
442+ w.Hidden = b
443+ return err
444+ case "monitor":
445+ if v == "" {
446+ return fmt.Errorf("must not be empty")
447+ }
448+ w.Monitor = v
449+ case "max-columns":
450+ return positive(&w.MaxColumns, v, 16)
451+ case "overflow":
452+ if v != "scroll" && v != "fixed" {
453+ return fmt.Errorf("must be scroll or fixed")
454+ }
455+ w.Overflow = v
456+ default:
457+ return fmt.Errorf("unknown key (hidden, monitor, max-columns, overflow)")
458+ }
459+ return nil
460+}
461+
404 func positive(dst *int, v string, max int) error {462 func positive(dst *int, v string, max int) error {
405 n, err := strconv.Atoi(v)463 n, err := strconv.Atoi(v)
406 if err != nil || n <= 0 || n > max {464 if err != nil || n <= 0 || n > max {
@@ -448,6 +506,12 @@ func checkAction(v string) error {
448 }506 }
449 return nil507 return nil
450 }508 }
509+ if rest, ok := strings.CutPrefix(v, "workspace "); ok {
510+ if !workspaceName.MatchString(strings.TrimSpace(rest)) {
511+ return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
512+ }
513+ return nil
514+ }
451 for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {515 for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
452 if rest, ok := strings.CutPrefix(v, prefix); ok {516 if rest, ok := strings.CutPrefix(v, prefix); ok {
453 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {517 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
@@ -457,7 +521,7 @@ func checkAction(v string) error {
457 }521 }
458 }522 }
459 if !actions[v] {523 if !actions[v] {
460- return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)524+ return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
461 }525 }
462 return nil526 return nil
463 }527 }
M internal/adapters/config/config_test.go +31 -0
@@ -233,3 +233,34 @@ func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
233233 t.Fatal("other defaults must stay")
234234 }
235235 }
236+
237+func TestWorkspaces(t *testing.T) {
238+ c, w := parseString(t, `layout.overflow = fixed
239+workspace.dev.hidden = on
240+workspace.dev.max-columns = 3
241+workspace.web.overflow = scroll
242+workspace.dev.monitor = DP-2
243+workspace.bad name.hidden = on
244+workspace.dev.color = red
245+workspace.web.overflow = spiral
246+bind.cmd+d = workspace dev
247+bind.cmd+w = workspace
248+`)
249+ want := []ports.WorkspaceConfig{
250+ {Name: "dev", Hidden: true, MaxColumns: 3, Monitor: "DP-2"},
251+ {Name: "web", Overflow: "scroll"},
252+ }
253+ if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
254+ t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
255+ }
256+ if c.Binds["Cmd+d"] != "workspace dev" {
257+ t.Fatal(c.Binds["Cmd+d"])
258+ }
259+ lines := []int{}
260+ for _, x := range w {
261+ lines = append(lines, x.Line)
262+ }
263+ if !reflect.DeepEqual(lines, []int{6, 7, 8, 10}) {
264+ t.Fatal(w)
265+ }
266+}
@@ -233,3 +233,34 @@ func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
233 t.Fatal("other defaults must stay")233 t.Fatal("other defaults must stay")
234 }234 }
235 }235 }
236+
237+func TestWorkspaces(t *testing.T) {
238+ c, w := parseString(t, `layout.overflow = fixed
239+workspace.dev.hidden = on
240+workspace.dev.max-columns = 3
241+workspace.web.overflow = scroll
242+workspace.dev.monitor = DP-2
243+workspace.bad name.hidden = on
244+workspace.dev.color = red
245+workspace.web.overflow = spiral
246+bind.cmd+d = workspace dev
247+bind.cmd+w = workspace
248+`)
249+ want := []ports.WorkspaceConfig{
250+ {Name: "dev", Hidden: true, MaxColumns: 3, Monitor: "DP-2"},
251+ {Name: "web", Overflow: "scroll"},
252+ }
253+ if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
254+ t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
255+ }
256+ if c.Binds["Cmd+d"] != "workspace dev" {
257+ t.Fatal(c.Binds["Cmd+d"])
258+ }
259+ lines := []int{}
260+ for _, x := range w {
261+ lines = append(lines, x.Line)
262+ }
263+ if !reflect.DeepEqual(lines, []int{6, 7, 8, 10}) {
264+ t.Fatal(w)
265+ }
266+}
M internal/core/action.go +22 -6
@@ -49,6 +49,16 @@ func WorkspaceArg(a Action) (n int, move bool, ok bool) {
4949 return n, move, true
5050 }
5151
52+// namedPrefix toggles a named workspace: "workspace dev".
53+const namedPrefix = "workspace "
54+
55+// NamedArg returns the name of a "workspace <name>" action.
56+func NamedArg(a Action) (string, bool) {
57+ rest, ok := strings.CutPrefix(string(a), namedPrefix)
58+ name := strings.TrimSpace(rest)
59+ return name, ok && name != "" && !strings.ContainsAny(name, " \t")
60+}
61+
5262 // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".
5363 // Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").
5464 const spawnPrefix = "spawn "
@@ -77,6 +87,10 @@ func (m *Monitor) Apply(a Action) Effect {
7787 }
7888 return Effect{}
7989 }
90+ if name, ok := NamedArg(a); ok {
91+ m.ToggleNamed(name)
92+ return Effect{}
93+ }
8094 switch a {
8195 case ActionFocusWindowUp, ActionFocusWindowDown:
8296 // Past the top or bottom window of the column, move to the next workspace.
@@ -84,15 +98,17 @@ func (m *Monitor) Apply(a Action) Effect {
8498 if a == ActionFocusWindowUp {
8599 dir = -1
86100 }
87- if !m.Current().FocusWindow(dir) {
101+ // A hidden workspace is outside the vertical list.
102+ if !m.Current().FocusWindow(dir) && m.shown == nil {
88103 m.Focus(m.Active + dir)
89104 }
90105 return Effect{}
91- case ActionFocusWorkspaceUp:
92- m.Focus(m.Active - 1)
93- return Effect{}
94- case ActionFocusWorkspaceDown:
95- m.Focus(m.Active + 1)
106+ case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown:
107+ if m.shown == nil && a == ActionFocusWorkspaceUp {
108+ m.Focus(m.Active - 1)
109+ } else if m.shown == nil {
110+ m.Focus(m.Active + 1)
111+ }
96112 return Effect{}
97113 case ActionMoveToWorkspaceUp:
98114 if m.Active > 0 {
@@ -49,6 +49,16 @@ func WorkspaceArg(a Action) (n int, move bool, ok bool) {
49 return n, move, true49 return n, move, true
50 }50 }
51 51
52+// namedPrefix toggles a named workspace: "workspace dev".
53+const namedPrefix = "workspace "
54+
55+// NamedArg returns the name of a "workspace <name>" action.
56+func NamedArg(a Action) (string, bool) {
57+ rest, ok := strings.CutPrefix(string(a), namedPrefix)
58+ name := strings.TrimSpace(rest)
59+ return name, ok && name != "" && !strings.ContainsAny(name, " \t")
60+}
61+
52 // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".62 // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".
53 // Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").63 // Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").
54 const spawnPrefix = "spawn "64 const spawnPrefix = "spawn "
@@ -77,6 +87,10 @@ func (m *Monitor) Apply(a Action) Effect {
77 }87 }
78 return Effect{}88 return Effect{}
79 }89 }
90+ if name, ok := NamedArg(a); ok {
91+ m.ToggleNamed(name)
92+ return Effect{}
93+ }
80 switch a {94 switch a {
81 case ActionFocusWindowUp, ActionFocusWindowDown:95 case ActionFocusWindowUp, ActionFocusWindowDown:
82 // Past the top or bottom window of the column, move to the next workspace.96 // Past the top or bottom window of the column, move to the next workspace.
@@ -84,15 +98,17 @@ func (m *Monitor) Apply(a Action) Effect {
84 if a == ActionFocusWindowUp {98 if a == ActionFocusWindowUp {
85 dir = -199 dir = -1
86 }100 }
87- if !m.Current().FocusWindow(dir) {101+ // A hidden workspace is outside the vertical list.
102+ if !m.Current().FocusWindow(dir) && m.shown == nil {
88 m.Focus(m.Active + dir)103 m.Focus(m.Active + dir)
89 }104 }
90 return Effect{}105 return Effect{}
91- case ActionFocusWorkspaceUp:106+ case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown:
92- m.Focus(m.Active - 1)107+ if m.shown == nil && a == ActionFocusWorkspaceUp {
93- return Effect{}108+ m.Focus(m.Active - 1)
94- case ActionFocusWorkspaceDown:109+ } else if m.shown == nil {
95- m.Focus(m.Active + 1)110+ m.Focus(m.Active + 1)
111+ }
96 return Effect{}112 return Effect{}
97 case ActionMoveToWorkspaceUp:113 case ActionMoveToWorkspaceUp:
98 if m.Active > 0 {114 if m.Active > 0 {
M internal/core/core.go +33 -0
@@ -131,6 +131,10 @@ func (c *Core) apply(cfg ports.Config) error {
131131 binds[b] = Action(a)
132132 continue
133133 }
134+ if _, ok := NamedArg(Action(a)); ok {
135+ binds[b] = Action(a)
136+ continue
137+ }
134138 switch Action(a) {
135139 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:
136140 default:
@@ -138,9 +142,38 @@ func (c *Core) apply(cfg ports.Config) error {
138142 }
139143 binds[b] = Action(a)
140144 }
145+ overflow := func(v string) (Overflow, error) {
146+ switch o := Overflow(v); o {
147+ case "", OverflowScroll, OverflowFixed:
148+ return o, nil
149+ }
150+ return "", fmt.Errorf("invalid overflow %q", v)
151+ }
152+ defOverflow, err := overflow(cfg.Layout.Overflow)
153+ if err != nil {
154+ return err
155+ }
156+ if defOverflow == "" {
157+ defOverflow = OverflowScroll
158+ }
159+ named := make([]NamedWorkspace, 0, len(cfg.Workspaces))
160+ seen := map[string]bool{}
161+ for _, ws := range cfg.Workspaces {
162+ o, err := overflow(ws.Overflow)
163+ if err != nil {
164+ return err
165+ }
166+ if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 {
167+ return fmt.Errorf("invalid workspace %q", ws.Name)
168+ }
169+ seen[ws.Name] = true
170+ named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
171+ }
141172 c.cfg = cfg
142173 c.binds = binds
143174 c.applyConfigScale()
175+ c.ws.SetNamed(named)
176+ c.ws.SetOverflow(defOverflow)
144177 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
145178 c.ws.SetPresets(presets)
146179 c.ws.SetGaps(cfg.Layout.Gaps)
@@ -131,6 +131,10 @@ func (c *Core) apply(cfg ports.Config) error {
131 binds[b] = Action(a)131 binds[b] = Action(a)
132 continue132 continue
133 }133 }
134+ if _, ok := NamedArg(Action(a)); ok {
135+ binds[b] = Action(a)
136+ continue
137+ }
134 switch Action(a) {138 switch Action(a) {
135 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:139 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:
136 default:140 default:
@@ -138,9 +142,38 @@ func (c *Core) apply(cfg ports.Config) error {
138 }142 }
139 binds[b] = Action(a)143 binds[b] = Action(a)
140 }144 }
145+ overflow := func(v string) (Overflow, error) {
146+ switch o := Overflow(v); o {
147+ case "", OverflowScroll, OverflowFixed:
148+ return o, nil
149+ }
150+ return "", fmt.Errorf("invalid overflow %q", v)
151+ }
152+ defOverflow, err := overflow(cfg.Layout.Overflow)
153+ if err != nil {
154+ return err
155+ }
156+ if defOverflow == "" {
157+ defOverflow = OverflowScroll
158+ }
159+ named := make([]NamedWorkspace, 0, len(cfg.Workspaces))
160+ seen := map[string]bool{}
161+ for _, ws := range cfg.Workspaces {
162+ o, err := overflow(ws.Overflow)
163+ if err != nil {
164+ return err
165+ }
166+ if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 {
167+ return fmt.Errorf("invalid workspace %q", ws.Name)
168+ }
169+ seen[ws.Name] = true
170+ named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
171+ }
141 c.cfg = cfg172 c.cfg = cfg
142 c.binds = binds173 c.binds = binds
143 c.applyConfigScale()174 c.applyConfigScale()
175+ c.ws.SetNamed(named)
176+ c.ws.SetOverflow(defOverflow)
144 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)177 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
145 c.ws.SetPresets(presets)178 c.ws.SetPresets(presets)
146 c.ws.SetGaps(cfg.Layout.Gaps)179 c.ws.SetGaps(cfg.Layout.Gaps)
M internal/core/layout.go +65 -11
@@ -85,7 +85,23 @@ type Placement struct {
8585 // column on screen, so no border marks focus.
8686 Borderless bool
8787 }
88+
89+// Overflow says what happens past MaxColumns columns.
90+type Overflow string
91+
92+const (
93+ // OverflowScroll opens further columns to the right; the view scrolls.
94+ OverflowScroll Overflow = "scroll"
95+ // OverflowFixed keeps every column on screen: past the max, each new
96+ // column splits the newest one, alternating top/bottom and left/right
97+ // (dwindle spiral).
98+ OverflowFixed Overflow = "fixed"
99+)
100+
88101 type Workspace struct {
102+ // Name is set for workspaces declared in config; empty for dynamic ones.
103+ Name string
104+ Overflow Overflow
89105 Columns []Column
90106 Focus int
91107 ViewX int
@@ -329,6 +345,10 @@ func (w *Workspace) columnX(i int) int {
329345 return x
330346 }
331347 func (w *Workspace) scroll() {
348+ if w.Overflow == OverflowFixed {
349+ w.ViewX = 0
350+ return
351+ }
332352 if len(w.Columns) == 0 {
333353 return
334354 }
@@ -346,39 +366,73 @@ func (w *Workspace) scroll() {
346366 w.ViewX += left + width - maxX
347367 }
348368 }
369+
370+// columnRects returns each column's area on screen, before stacking windows.
371+func (w *Workspace) columnRects() []Rect {
372+ g := w.gap()
373+ y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
374+ rects := make([]Rect, len(w.Columns))
375+ for i := range w.Columns {
376+ rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h}
377+ }
378+ k := max(w.MaxColumns, 1)
379+ if w.Overflow != OverflowFixed || len(w.Columns) <= k {
380+ return rects
381+ }
382+ // The last slot holds the spiral; the columns before it keep their place.
383+ area := rects[k-1]
384+ for i := k - 1; i < len(w.Columns)-1; i++ {
385+ rects[i], area = split(area, g, (i-k+1)%2 == 0)
386+ }
387+ rects[len(rects)-1] = area
388+ return rects
389+}
390+
391+// split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
392+func split(r Rect, gap int, vertical bool) (Rect, Rect) {
393+ if vertical {
394+ h := max((r.H-gap)/2, 0)
395+ return Rect{X: r.X, Y: r.Y, W: r.W, H: h}, Rect{X: r.X, Y: r.Y + h + gap, W: r.W, H: max(r.H-gap-h, 0)}
396+ }
397+ w := max((r.W-gap)/2, 0)
398+ return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}
399+}
400+
349401 func (w *Workspace) Layout() []Placement {
350402 var result []Placement
351403 gap := w.gap()
404+ cols := w.columnRects()
352405 for i, c := range w.Columns {
353- x := w.columnX(i) - w.ViewX
354- width := w.columnWidth(i)
406+ col := cols[i]
355407 n := len(c.Windows)
356408 if n == 0 {
357409 continue
358410 }
359411 fullColumn := w.fullscreenColumn(i)
360- available := max(w.Usable.H-(n+1)*gap, 0)
412+ if fullColumn {
413+ col.W = w.Output.W
414+ }
415+ available := max(col.H-(n-1)*gap, 0)
361416 height := available / n
362- y := w.Usable.Y + gap
417+ y := col.Y
418+ bottom := col.Y + col.H
363419 for j, id := range c.Windows {
364420 h := height
365421 if j == n-1 {
366422 h = available - height*(n-1)
367423 }
368- if y > w.Usable.Y+w.Usable.H {
369- y = w.Usable.Y + w.Usable.H
370- }
371- h = min(h, w.Usable.Y+w.Usable.H-y)
372- r := Rect{X: x, Y: y, W: width, H: h}
424+ y = min(y, bottom)
425+ h = min(h, bottom-y)
426+ r := Rect{X: col.X, Y: y, W: col.W, H: h}
373427 full := w.fullscreen == id && id != 0
374428 hidden := fullColumn && !full
375429 if full {
376- r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H}
430+ r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
377431 }
378432 if hidden {
379433 r = Rect{}
380434 }
381- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap})
435+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap})
382436 y += h + gap
383437 }
384438 }
@@ -85,7 +85,23 @@ type Placement struct {
85 // column on screen, so no border marks focus.85 // column on screen, so no border marks focus.
86 Borderless bool86 Borderless bool
87 }87 }
88+
89+// Overflow says what happens past MaxColumns columns.
90+type Overflow string
91+
92+const (
93+ // OverflowScroll opens further columns to the right; the view scrolls.
94+ OverflowScroll Overflow = "scroll"
95+ // OverflowFixed keeps every column on screen: past the max, each new
96+ // column splits the newest one, alternating top/bottom and left/right
97+ // (dwindle spiral).
98+ OverflowFixed Overflow = "fixed"
99+)
100+
88 type Workspace struct {101 type Workspace struct {
102+ // Name is set for workspaces declared in config; empty for dynamic ones.
103+ Name string
104+ Overflow Overflow
89 Columns []Column105 Columns []Column
90 Focus int106 Focus int
91 ViewX int107 ViewX int
@@ -329,6 +345,10 @@ func (w *Workspace) columnX(i int) int {
329 return x345 return x
330 }346 }
331 func (w *Workspace) scroll() {347 func (w *Workspace) scroll() {
348+ if w.Overflow == OverflowFixed {
349+ w.ViewX = 0
350+ return
351+ }
332 if len(w.Columns) == 0 {352 if len(w.Columns) == 0 {
333 return353 return
334 }354 }
@@ -346,39 +366,73 @@ func (w *Workspace) scroll() {
346 w.ViewX += left + width - maxX366 w.ViewX += left + width - maxX
347 }367 }
348 }368 }
369+
370+// columnRects returns each column's area on screen, before stacking windows.
371+func (w *Workspace) columnRects() []Rect {
372+ g := w.gap()
373+ y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
374+ rects := make([]Rect, len(w.Columns))
375+ for i := range w.Columns {
376+ rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h}
377+ }
378+ k := max(w.MaxColumns, 1)
379+ if w.Overflow != OverflowFixed || len(w.Columns) <= k {
380+ return rects
381+ }
382+ // The last slot holds the spiral; the columns before it keep their place.
383+ area := rects[k-1]
384+ for i := k - 1; i < len(w.Columns)-1; i++ {
385+ rects[i], area = split(area, g, (i-k+1)%2 == 0)
386+ }
387+ rects[len(rects)-1] = area
388+ return rects
389+}
390+
391+// split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
392+func split(r Rect, gap int, vertical bool) (Rect, Rect) {
393+ if vertical {
394+ h := max((r.H-gap)/2, 0)
395+ return Rect{X: r.X, Y: r.Y, W: r.W, H: h}, Rect{X: r.X, Y: r.Y + h + gap, W: r.W, H: max(r.H-gap-h, 0)}
396+ }
397+ w := max((r.W-gap)/2, 0)
398+ return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}
399+}
400+
349 func (w *Workspace) Layout() []Placement {401 func (w *Workspace) Layout() []Placement {
350 var result []Placement402 var result []Placement
351 gap := w.gap()403 gap := w.gap()
404+ cols := w.columnRects()
352 for i, c := range w.Columns {405 for i, c := range w.Columns {
353- x := w.columnX(i) - w.ViewX406+ col := cols[i]
354- width := w.columnWidth(i)
355 n := len(c.Windows)407 n := len(c.Windows)
356 if n == 0 {408 if n == 0 {
357 continue409 continue
358 }410 }
359 fullColumn := w.fullscreenColumn(i)411 fullColumn := w.fullscreenColumn(i)
360- available := max(w.Usable.H-(n+1)*gap, 0)412+ if fullColumn {
413+ col.W = w.Output.W
414+ }
415+ available := max(col.H-(n-1)*gap, 0)
361 height := available / n416 height := available / n
362- y := w.Usable.Y + gap417+ y := col.Y
418+ bottom := col.Y + col.H
363 for j, id := range c.Windows {419 for j, id := range c.Windows {
364 h := height420 h := height
365 if j == n-1 {421 if j == n-1 {
366 h = available - height*(n-1)422 h = available - height*(n-1)
367 }423 }
368- if y > w.Usable.Y+w.Usable.H {424+ y = min(y, bottom)
369- y = w.Usable.Y + w.Usable.H425+ h = min(h, bottom-y)
370- }426+ r := Rect{X: col.X, Y: y, W: col.W, H: h}
371- h = min(h, w.Usable.Y+w.Usable.H-y)
372- r := Rect{X: x, Y: y, W: width, H: h}
373 full := w.fullscreen == id && id != 0427 full := w.fullscreen == id && id != 0
374 hidden := fullColumn && !full428 hidden := fullColumn && !full
375 if full {429 if full {
376- r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H}430+ r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
377 }431 }
378 if hidden {432 if hidden {
379 r = Rect{}433 r = Rect{}
380 }434 }
381- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap})435+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap})
382 y += h + gap436 y += h + gap
383 }437 }
384 }438 }
M internal/core/layout_test.go +37 -0
@@ -350,3 +350,40 @@ func TestSetFullscreenStackDeactivation(t *testing.T) {
350350 t.Fatal(w.Layout())
351351 }
352352 }
353+
354+// Fixed overflow keeps every window on screen: past max-columns, new columns
355+// split the newest one, alternating top/bottom and left/right.
356+func TestFixedOverflowSpiral(t *testing.T) {
357+ w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
358+ w.SetOutput(100, 80)
359+ for id := WindowID(1); id <= 5; id++ {
360+ w.AddWindow(id)
361+ // New columns open after the focused one; keep order by focusing the last.
362+ w.Focus = len(w.Columns) - 1
363+ }
364+ got := map[WindowID]Rect{}
365+ for _, p := range w.Layout() {
366+ got[p.ID] = p.Rect
367+ }
368+ want := map[WindowID]Rect{
369+ 1: {X: 0, Y: 0, W: 50, H: 80},
370+ 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2
371+ 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half
372+ 4: {X: 75, Y: 40, W: 25, H: 20},
373+ 5: {X: 75, Y: 60, W: 25, H: 20},
374+ }
375+ if !reflect.DeepEqual(got, want) {
376+ t.Fatalf("got %v\nwant %v", got, want)
377+ }
378+ if w.ViewX != 0 {
379+ t.Fatal("fixed overflow scrolled", w.ViewX)
380+ }
381+ // Up to max-columns it is the plain equal split.
382+ w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
383+ w2.SetOutput(100, 80)
384+ w2.AddWindow(1)
385+ w2.AddWindow(2)
386+ if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) {
387+ t.Fatal(p)
388+ }
389+}
@@ -350,3 +350,40 @@ func TestSetFullscreenStackDeactivation(t *testing.T) {
350 t.Fatal(w.Layout())350 t.Fatal(w.Layout())
351 }351 }
352 }352 }
353+
354+// Fixed overflow keeps every window on screen: past max-columns, new columns
355+// split the newest one, alternating top/bottom and left/right.
356+func TestFixedOverflowSpiral(t *testing.T) {
357+ w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
358+ w.SetOutput(100, 80)
359+ for id := WindowID(1); id <= 5; id++ {
360+ w.AddWindow(id)
361+ // New columns open after the focused one; keep order by focusing the last.
362+ w.Focus = len(w.Columns) - 1
363+ }
364+ got := map[WindowID]Rect{}
365+ for _, p := range w.Layout() {
366+ got[p.ID] = p.Rect
367+ }
368+ want := map[WindowID]Rect{
369+ 1: {X: 0, Y: 0, W: 50, H: 80},
370+ 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2
371+ 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half
372+ 4: {X: 75, Y: 40, W: 25, H: 20},
373+ 5: {X: 75, Y: 60, W: 25, H: 20},
374+ }
375+ if !reflect.DeepEqual(got, want) {
376+ t.Fatalf("got %v\nwant %v", got, want)
377+ }
378+ if w.ViewX != 0 {
379+ t.Fatal("fixed overflow scrolled", w.ViewX)
380+ }
381+ // Up to max-columns it is the plain equal split.
382+ w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
383+ w2.SetOutput(100, 80)
384+ w2.AddWindow(1)
385+ w2.AddWindow(2)
386+ if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) {
387+ t.Fatal(p)
388+ }
389+}
M internal/core/monitor.go +224 -20
@@ -2,12 +2,29 @@ package core
22
33 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
44 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
5-// below the last one, and an empty workspace is removed once left, except the
6-// first. Output-wide settings apply to every workspace.
5+// below the last one, and an empty unnamed workspace is removed once left,
6+// except the first. Output-wide settings apply to every workspace.
7+//
8+// Named workspaces come from config and are never removed while configured.
9+// A hidden one is not numbered and not reachable by up/down: only its
10+// `workspace <name>` bind shows it.
711 type Monitor struct {
812 Workspaces []*Workspace
913 Active int
14+ hidden []*Workspace
15+ shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
16+ back *Workspace // where a named workspace toggle returns to
1017 template Workspace
18+ named []NamedWorkspace
19+}
20+
21+// NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
22+// Overflow follow the monitor defaults.
23+type NamedWorkspace struct {
24+ Name string
25+ Hidden bool
26+ MaxColumns int
27+ Overflow Overflow
1128 }
1229
1330 func NewMonitor() *Monitor {
@@ -17,7 +34,12 @@ func NewMonitor() *Monitor {
1734 }
1835
1936 // Current is the workspace on screen.
20-func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] }
37+func (m *Monitor) Current() *Workspace {
38+ if m.shown != nil {
39+ return m.shown
40+ }
41+ return m.Workspaces[m.Active]
42+}
2143
2244 func (m *Monitor) newWorkspace() *Workspace {
2345 w := m.template
@@ -25,28 +47,31 @@ func (m *Monitor) newWorkspace() *Workspace {
2547 return &w
2648 }
2749
28-// normalize keeps one trailing empty workspace and drops other empty ones that
29-// are neither the first nor active. The active workspace never changes.
50+// normalize keeps one trailing empty unnamed workspace and drops other empty
51+// unnamed ones that are neither the first nor active. The active workspace
52+// never changes.
3053 func (m *Monitor) normalize() {
54+ spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
3155 for i := len(m.Workspaces) - 2; i >= 1; i-- {
32- if m.Workspaces[i].empty() && i != m.Active {
56+ if spare(i) && i != m.Active {
3357 m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
3458 if i < m.Active {
3559 m.Active--
3660 }
3761 }
3862 }
39- for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- {
63+ for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- {
4064 m.Workspaces = m.Workspaces[:n-1]
4165 }
42- if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() {
66+ if n := len(m.Workspaces); n == 0 || !spare(n-1) {
4367 m.Workspaces = append(m.Workspaces, m.newWorkspace())
4468 }
4569 }
4670
47-// Focus switches to the workspace at index i, clamped to the list.
71+// Focus switches to the numbered workspace at index i, clamped to the list.
4872 func (m *Monitor) Focus(i int) {
4973 i = min(max(i, 0), len(m.Workspaces)-1)
74+ m.shown = nil
5075 m.Active = i
5176 m.normalize()
5277 }
@@ -54,17 +79,71 @@ func (m *Monitor) Focus(i int) {
5479 // FocusNumber switches to workspace n (1-based); past the end means the last.
5580 func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
5681
57-// find returns the workspace holding the window.
82+// show puts w on screen, numbered or hidden.
83+func (m *Monitor) show(w *Workspace) {
84+ if i := indexOf(m.Workspaces, w); i >= 0 {
85+ m.Focus(i)
86+ return
87+ }
88+ m.shown = w
89+ m.normalize()
90+}
91+
92+// ToggleNamed shows the named workspace, or returns to the previous one when
93+// it is already on screen. Unknown names do nothing.
94+func (m *Monitor) ToggleNamed(name string) {
95+ var target *Workspace
96+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
97+ if w.Name == name {
98+ target = w
99+ }
100+ }
101+ switch cur := m.Current(); {
102+ case target == nil:
103+ case target != cur:
104+ m.back = cur
105+ m.show(target)
106+ case m.back != nil && m.has(m.back):
107+ back := m.back
108+ m.back = nil
109+ m.show(back)
110+ case m.shown != nil:
111+ m.back = nil
112+ m.shown = nil
113+ m.normalize()
114+ }
115+}
116+
117+func indexOf(list []*Workspace, w *Workspace) int {
118+ for i, v := range list {
119+ if v == w {
120+ return i
121+ }
122+ }
123+ return -1
124+}
125+
126+// has reports whether w still belongs to the monitor.
127+func (m *Monitor) has(w *Workspace) bool {
128+ return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0
129+}
130+
131+// find returns the workspace holding the window; i is -1 for hidden ones.
58132 func (m *Monitor) find(id WindowID) (*Workspace, int) {
59133 for i, w := range m.Workspaces {
60134 if w.has(id) {
61135 return w, i
62136 }
63137 }
138+ for _, w := range m.hidden {
139+ if w.has(id) {
140+ return w, -1
141+ }
142+ }
64143 return nil, -1
65144 }
66145
67-// AddWindow places a new window on the active workspace.
146+// AddWindow places a new window on the workspace on screen.
68147 func (m *Monitor) AddWindow(id WindowID) {
69148 if w, _ := m.find(id); w != nil {
70149 return
@@ -83,13 +162,12 @@ func (m *Monitor) RemoveWindow(id WindowID) {
83162
84163 // FocusID shows the window's workspace and focuses it.
85164 func (m *Monitor) FocusID(id WindowID) bool {
86- w, i := m.find(id)
165+ w, _ := m.find(id)
87166 if w == nil {
88167 return false
89168 }
90- m.Active = i
169+ m.show(w)
91170 w.FocusID(id)
92- m.normalize()
93171 return true
94172 }
95173
@@ -101,12 +179,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
101179 }
102180 }
103181
104-// MoveToWorkspace moves the focused window to workspace index i; focus stays here.
182+// MoveToWorkspace moves the focused window to numbered workspace index i;
183+// focus stays here.
105184 func (m *Monitor) MoveToWorkspace(i int) {
106185 i = min(max(i, 0), len(m.Workspaces)-1)
107186 cur := m.Current()
108187 id, ok := cur.Focused()
109- if !ok || i == m.Active {
188+ if !ok || m.Workspaces[i] == cur {
110189 return
111190 }
112191 cur.RemoveWindow(id)
@@ -116,11 +195,12 @@ func (m *Monitor) MoveToWorkspace(i int) {
116195
117196 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
118197
119-// Layout places every window: the active workspace on screen, others hidden.
198+// Layout places every window: the workspace on screen laid out, others hidden.
120199 func (m *Monitor) Layout() []Placement {
121200 var result []Placement
122- for i, w := range m.Workspaces {
123- if i == m.Active {
201+ cur := m.Current()
202+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
203+ if w == cur {
124204 result = append(result, w.Layout()...)
125205 continue
126206 }
@@ -140,6 +220,9 @@ func (m *Monitor) each(f func(*Workspace)) {
140220 for _, w := range m.Workspaces {
141221 f(w)
142222 }
223+ for _, w := range m.hidden {
224+ f(w)
225+ }
143226 }
144227 func (m *Monitor) SetOutput(width, height int) {
145228 m.each(func(w *Workspace) { w.SetOutput(width, height) })
@@ -147,4 +230,125 @@ func (m *Monitor) SetOutput(width, height int) {
147230 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
148231 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
149232 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
150-func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) }
233+
234+// SetMaxColumns sets the default; named workspaces may override it.
235+func (m *Monitor) SetMaxColumns(n int) {
236+ m.template.MaxColumns = n
237+ m.applyNamed()
238+}
239+
240+// SetOverflow sets the default; named workspaces may override it.
241+func (m *Monitor) SetOverflow(o Overflow) {
242+ m.template.Overflow = o
243+ m.applyNamed()
244+}
245+
246+// applyNamed gives every workspace the defaults or its named overrides.
247+func (m *Monitor) applyNamed() {
248+ specs := map[string]NamedWorkspace{}
249+ for _, s := range m.named {
250+ specs[s.Name] = s
251+ }
252+ m.each(func(w *Workspace) {
253+ s := specs[w.Name]
254+ w.Overflow = m.template.Overflow
255+ if s.Overflow != "" {
256+ w.Overflow = s.Overflow
257+ }
258+ w.SetMaxColumns(m.template.MaxColumns)
259+ if s.MaxColumns > 0 {
260+ w.SetMaxColumns(s.MaxColumns)
261+ }
262+ })
263+}
264+
265+// SetNamed applies the named workspaces from config. New numbered ones are
266+// inserted above the trailing empty workspace (never before the first one).
267+// A workspace dropped from config becomes a normal one; a dropped hidden one
268+// hands its windows to the active numbered workspace. The workspace on
269+// screen stays on screen.
270+func (m *Monitor) SetNamed(specs []NamedWorkspace) {
271+ cur := m.Current()
272+ want := map[string]NamedWorkspace{}
273+ for _, s := range specs {
274+ want[s.Name] = s
275+ }
276+ byName := map[string]*Workspace{}
277+ var keep []*Workspace
278+ for _, w := range m.Workspaces {
279+ if _, ok := want[w.Name]; w.Name != "" && !ok {
280+ w.Name = ""
281+ }
282+ if w.Name != "" {
283+ byName[w.Name] = w
284+ }
285+ keep = append(keep, w)
286+ }
287+ m.Workspaces = keep
288+ var hidden []*Workspace
289+ for _, w := range m.hidden {
290+ if _, ok := want[w.Name]; ok {
291+ byName[w.Name] = w
292+ hidden = append(hidden, w)
293+ continue
294+ }
295+ if w == cur {
296+ cur = nil
297+ }
298+ for _, c := range w.Columns {
299+ for _, id := range c.Windows {
300+ m.Workspaces[m.Active].AddWindow(id)
301+ }
302+ }
303+ }
304+ m.hidden = hidden
305+ m.named = append([]NamedWorkspace(nil), specs...)
306+ var nextHidden []*Workspace
307+ for _, s := range specs {
308+ w := byName[s.Name]
309+ if w == nil {
310+ w = m.newWorkspace()
311+ w.Name = s.Name
312+ }
313+ i := indexOf(m.Workspaces, w)
314+ switch {
315+ case s.Hidden:
316+ if i >= 0 {
317+ m.removeNumbered(i)
318+ }
319+ nextHidden = append(nextHidden, w)
320+ case i < 0:
321+ at := max(len(m.Workspaces)-1, 1)
322+ m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...)
323+ if at <= m.Active {
324+ m.Active++
325+ }
326+ }
327+ }
328+ m.hidden = nextHidden
329+ if m.back != nil && !m.has(m.back) {
330+ m.back = nil
331+ }
332+ m.shown = nil
333+ switch {
334+ case cur == nil:
335+ case indexOf(m.hidden, cur) >= 0:
336+ m.shown = cur
337+ case indexOf(m.Workspaces, cur) >= 0:
338+ m.Active = indexOf(m.Workspaces, cur)
339+ }
340+ m.applyNamed()
341+ m.normalize()
342+}
343+
344+// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
345+// when possible.
346+func (m *Monitor) removeNumbered(i int) {
347+ m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
348+ if i < m.Active || m.Active >= len(m.Workspaces) {
349+ m.Active = max(m.Active-1, 0)
350+ }
351+ if len(m.Workspaces) == 0 {
352+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
353+ }
354+}
@@ -2,12 +2,29 @@ package core
2 2
3 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers3 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
4 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits4 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
5-// below the last one, and an empty workspace is removed once left, except the5+// below the last one, and an empty unnamed workspace is removed once left,
6-// first. Output-wide settings apply to every workspace.6+// except the first. Output-wide settings apply to every workspace.
7+//
8+// Named workspaces come from config and are never removed while configured.
9+// A hidden one is not numbered and not reachable by up/down: only its
10+// `workspace <name>` bind shows it.
7 type Monitor struct {11 type Monitor struct {
8 Workspaces []*Workspace12 Workspaces []*Workspace
9 Active int13 Active int
14+ hidden []*Workspace
15+ shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
16+ back *Workspace // where a named workspace toggle returns to
10 template Workspace17 template Workspace
18+ named []NamedWorkspace
19+}
20+
21+// NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
22+// Overflow follow the monitor defaults.
23+type NamedWorkspace struct {
24+ Name string
25+ Hidden bool
26+ MaxColumns int
27+ Overflow Overflow
11 }28 }
12 29
13 func NewMonitor() *Monitor {30 func NewMonitor() *Monitor {
@@ -17,7 +34,12 @@ func NewMonitor() *Monitor {
17 }34 }
18 35
19 // Current is the workspace on screen.36 // Current is the workspace on screen.
20-func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] }37+func (m *Monitor) Current() *Workspace {
38+ if m.shown != nil {
39+ return m.shown
40+ }
41+ return m.Workspaces[m.Active]
42+}
21 43
22 func (m *Monitor) newWorkspace() *Workspace {44 func (m *Monitor) newWorkspace() *Workspace {
23 w := m.template45 w := m.template
@@ -25,28 +47,31 @@ func (m *Monitor) newWorkspace() *Workspace {
25 return &w47 return &w
26 }48 }
27 49
28-// normalize keeps one trailing empty workspace and drops other empty ones that50+// normalize keeps one trailing empty unnamed workspace and drops other empty
29-// are neither the first nor active. The active workspace never changes.51+// unnamed ones that are neither the first nor active. The active workspace
52+// never changes.
30 func (m *Monitor) normalize() {53 func (m *Monitor) normalize() {
54+ spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
31 for i := len(m.Workspaces) - 2; i >= 1; i-- {55 for i := len(m.Workspaces) - 2; i >= 1; i-- {
32- if m.Workspaces[i].empty() && i != m.Active {56+ if spare(i) && i != m.Active {
33 m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)57 m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
34 if i < m.Active {58 if i < m.Active {
35 m.Active--59 m.Active--
36 }60 }
37 }61 }
38 }62 }
39- for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- {63+ for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- {
40 m.Workspaces = m.Workspaces[:n-1]64 m.Workspaces = m.Workspaces[:n-1]
41 }65 }
42- if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() {66+ if n := len(m.Workspaces); n == 0 || !spare(n-1) {
43 m.Workspaces = append(m.Workspaces, m.newWorkspace())67 m.Workspaces = append(m.Workspaces, m.newWorkspace())
44 }68 }
45 }69 }
46 70
47-// Focus switches to the workspace at index i, clamped to the list.71+// Focus switches to the numbered workspace at index i, clamped to the list.
48 func (m *Monitor) Focus(i int) {72 func (m *Monitor) Focus(i int) {
49 i = min(max(i, 0), len(m.Workspaces)-1)73 i = min(max(i, 0), len(m.Workspaces)-1)
74+ m.shown = nil
50 m.Active = i75 m.Active = i
51 m.normalize()76 m.normalize()
52 }77 }
@@ -54,17 +79,71 @@ func (m *Monitor) Focus(i int) {
54 // FocusNumber switches to workspace n (1-based); past the end means the last.79 // FocusNumber switches to workspace n (1-based); past the end means the last.
55 func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }80 func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
56 81
57-// find returns the workspace holding the window.82+// show puts w on screen, numbered or hidden.
83+func (m *Monitor) show(w *Workspace) {
84+ if i := indexOf(m.Workspaces, w); i >= 0 {
85+ m.Focus(i)
86+ return
87+ }
88+ m.shown = w
89+ m.normalize()
90+}
91+
92+// ToggleNamed shows the named workspace, or returns to the previous one when
93+// it is already on screen. Unknown names do nothing.
94+func (m *Monitor) ToggleNamed(name string) {
95+ var target *Workspace
96+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
97+ if w.Name == name {
98+ target = w
99+ }
100+ }
101+ switch cur := m.Current(); {
102+ case target == nil:
103+ case target != cur:
104+ m.back = cur
105+ m.show(target)
106+ case m.back != nil && m.has(m.back):
107+ back := m.back
108+ m.back = nil
109+ m.show(back)
110+ case m.shown != nil:
111+ m.back = nil
112+ m.shown = nil
113+ m.normalize()
114+ }
115+}
116+
117+func indexOf(list []*Workspace, w *Workspace) int {
118+ for i, v := range list {
119+ if v == w {
120+ return i
121+ }
122+ }
123+ return -1
124+}
125+
126+// has reports whether w still belongs to the monitor.
127+func (m *Monitor) has(w *Workspace) bool {
128+ return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0
129+}
130+
131+// find returns the workspace holding the window; i is -1 for hidden ones.
58 func (m *Monitor) find(id WindowID) (*Workspace, int) {132 func (m *Monitor) find(id WindowID) (*Workspace, int) {
59 for i, w := range m.Workspaces {133 for i, w := range m.Workspaces {
60 if w.has(id) {134 if w.has(id) {
61 return w, i135 return w, i
62 }136 }
63 }137 }
138+ for _, w := range m.hidden {
139+ if w.has(id) {
140+ return w, -1
141+ }
142+ }
64 return nil, -1143 return nil, -1
65 }144 }
66 145
67-// AddWindow places a new window on the active workspace.146+// AddWindow places a new window on the workspace on screen.
68 func (m *Monitor) AddWindow(id WindowID) {147 func (m *Monitor) AddWindow(id WindowID) {
69 if w, _ := m.find(id); w != nil {148 if w, _ := m.find(id); w != nil {
70 return149 return
@@ -83,13 +162,12 @@ func (m *Monitor) RemoveWindow(id WindowID) {
83 162
84 // FocusID shows the window's workspace and focuses it.163 // FocusID shows the window's workspace and focuses it.
85 func (m *Monitor) FocusID(id WindowID) bool {164 func (m *Monitor) FocusID(id WindowID) bool {
86- w, i := m.find(id)165+ w, _ := m.find(id)
87 if w == nil {166 if w == nil {
88 return false167 return false
89 }168 }
90- m.Active = i169+ m.show(w)
91 w.FocusID(id)170 w.FocusID(id)
92- m.normalize()
93 return true171 return true
94 }172 }
95 173
@@ -101,12 +179,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
101 }179 }
102 }180 }
103 181
104-// MoveToWorkspace moves the focused window to workspace index i; focus stays here.182+// MoveToWorkspace moves the focused window to numbered workspace index i;
183+// focus stays here.
105 func (m *Monitor) MoveToWorkspace(i int) {184 func (m *Monitor) MoveToWorkspace(i int) {
106 i = min(max(i, 0), len(m.Workspaces)-1)185 i = min(max(i, 0), len(m.Workspaces)-1)
107 cur := m.Current()186 cur := m.Current()
108 id, ok := cur.Focused()187 id, ok := cur.Focused()
109- if !ok || i == m.Active {188+ if !ok || m.Workspaces[i] == cur {
110 return189 return
111 }190 }
112 cur.RemoveWindow(id)191 cur.RemoveWindow(id)
@@ -116,11 +195,12 @@ func (m *Monitor) MoveToWorkspace(i int) {
116 195
117 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }196 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
118 197
119-// Layout places every window: the active workspace on screen, others hidden.198+// Layout places every window: the workspace on screen laid out, others hidden.
120 func (m *Monitor) Layout() []Placement {199 func (m *Monitor) Layout() []Placement {
121 var result []Placement200 var result []Placement
122- for i, w := range m.Workspaces {201+ cur := m.Current()
123- if i == m.Active {202+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
203+ if w == cur {
124 result = append(result, w.Layout()...)204 result = append(result, w.Layout()...)
125 continue205 continue
126 }206 }
@@ -140,6 +220,9 @@ func (m *Monitor) each(f func(*Workspace)) {
140 for _, w := range m.Workspaces {220 for _, w := range m.Workspaces {
141 f(w)221 f(w)
142 }222 }
223+ for _, w := range m.hidden {
224+ f(w)
225+ }
143 }226 }
144 func (m *Monitor) SetOutput(width, height int) {227 func (m *Monitor) SetOutput(width, height int) {
145 m.each(func(w *Workspace) { w.SetOutput(width, height) })228 m.each(func(w *Workspace) { w.SetOutput(width, height) })
@@ -147,4 +230,125 @@ func (m *Monitor) SetOutput(width, height int) {
147 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }230 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
148 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }231 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
149 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }232 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
150-func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) }233+
234+// SetMaxColumns sets the default; named workspaces may override it.
235+func (m *Monitor) SetMaxColumns(n int) {
236+ m.template.MaxColumns = n
237+ m.applyNamed()
238+}
239+
240+// SetOverflow sets the default; named workspaces may override it.
241+func (m *Monitor) SetOverflow(o Overflow) {
242+ m.template.Overflow = o
243+ m.applyNamed()
244+}
245+
246+// applyNamed gives every workspace the defaults or its named overrides.
247+func (m *Monitor) applyNamed() {
248+ specs := map[string]NamedWorkspace{}
249+ for _, s := range m.named {
250+ specs[s.Name] = s
251+ }
252+ m.each(func(w *Workspace) {
253+ s := specs[w.Name]
254+ w.Overflow = m.template.Overflow
255+ if s.Overflow != "" {
256+ w.Overflow = s.Overflow
257+ }
258+ w.SetMaxColumns(m.template.MaxColumns)
259+ if s.MaxColumns > 0 {
260+ w.SetMaxColumns(s.MaxColumns)
261+ }
262+ })
263+}
264+
265+// SetNamed applies the named workspaces from config. New numbered ones are
266+// inserted above the trailing empty workspace (never before the first one).
267+// A workspace dropped from config becomes a normal one; a dropped hidden one
268+// hands its windows to the active numbered workspace. The workspace on
269+// screen stays on screen.
270+func (m *Monitor) SetNamed(specs []NamedWorkspace) {
271+ cur := m.Current()
272+ want := map[string]NamedWorkspace{}
273+ for _, s := range specs {
274+ want[s.Name] = s
275+ }
276+ byName := map[string]*Workspace{}
277+ var keep []*Workspace
278+ for _, w := range m.Workspaces {
279+ if _, ok := want[w.Name]; w.Name != "" && !ok {
280+ w.Name = ""
281+ }
282+ if w.Name != "" {
283+ byName[w.Name] = w
284+ }
285+ keep = append(keep, w)
286+ }
287+ m.Workspaces = keep
288+ var hidden []*Workspace
289+ for _, w := range m.hidden {
290+ if _, ok := want[w.Name]; ok {
291+ byName[w.Name] = w
292+ hidden = append(hidden, w)
293+ continue
294+ }
295+ if w == cur {
296+ cur = nil
297+ }
298+ for _, c := range w.Columns {
299+ for _, id := range c.Windows {
300+ m.Workspaces[m.Active].AddWindow(id)
301+ }
302+ }
303+ }
304+ m.hidden = hidden
305+ m.named = append([]NamedWorkspace(nil), specs...)
306+ var nextHidden []*Workspace
307+ for _, s := range specs {
308+ w := byName[s.Name]
309+ if w == nil {
310+ w = m.newWorkspace()
311+ w.Name = s.Name
312+ }
313+ i := indexOf(m.Workspaces, w)
314+ switch {
315+ case s.Hidden:
316+ if i >= 0 {
317+ m.removeNumbered(i)
318+ }
319+ nextHidden = append(nextHidden, w)
320+ case i < 0:
321+ at := max(len(m.Workspaces)-1, 1)
322+ m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...)
323+ if at <= m.Active {
324+ m.Active++
325+ }
326+ }
327+ }
328+ m.hidden = nextHidden
329+ if m.back != nil && !m.has(m.back) {
330+ m.back = nil
331+ }
332+ m.shown = nil
333+ switch {
334+ case cur == nil:
335+ case indexOf(m.hidden, cur) >= 0:
336+ m.shown = cur
337+ case indexOf(m.Workspaces, cur) >= 0:
338+ m.Active = indexOf(m.Workspaces, cur)
339+ }
340+ m.applyNamed()
341+ m.normalize()
342+}
343+
344+// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
345+// when possible.
346+func (m *Monitor) removeNumbered(i int) {
347+ m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
348+ if i < m.Active || m.Active >= len(m.Workspaces) {
349+ m.Active = max(m.Active-1, 0)
350+ }
351+ if len(m.Workspaces) == 0 {
352+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
353+ }
354+}
M internal/core/monitor_test.go +107 -0
@@ -239,3 +239,110 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
239239 t.Fatal(p)
240240 }
241241 }
242+
243+func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
244+ m.SetNamed(specs)
245+ return m
246+}
247+
248+func TestHiddenWorkspaceToggle(t *testing.T) {
249+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true})
250+ m.AddWindow(1)
251+ if len(m.Workspaces) != 2 {
252+ t.Fatal("hidden workspace is numbered", windows(m))
253+ }
254+ m.Apply("workspace dev")
255+ if m.Current().Name != "dev" {
256+ t.Fatal(m.Current().Name)
257+ }
258+ m.AddWindow(2)
259+ // Up/down do not leave a hidden workspace.
260+ m.Apply(ActionFocusWorkspaceDown)
261+ m.Apply(ActionFocusWindowDown)
262+ if m.Current().Name != "dev" {
263+ t.Fatal("left the hidden workspace")
264+ }
265+ for _, p := range m.Layout() {
266+ if hidden := p.ID == 1; p.Hidden != hidden {
267+ t.Fatal(m.Layout())
268+ }
269+ }
270+ // The same bind returns to the previous workspace.
271+ m.Apply("workspace dev")
272+ if id, _ := m.Focused(); id != 1 || m.Current().Name != "" {
273+ t.Fatal(id, m.Current().Name)
274+ }
275+ // Cmd+N from the hidden workspace goes to that number.
276+ m.Apply("workspace dev")
277+ m.FocusNumber(1)
278+ if id, _ := m.Focused(); id != 1 {
279+ t.Fatal(id)
280+ }
281+ // Clicking a window of the hidden workspace shows it (FocusID).
282+ if !m.FocusID(2) || m.Current().Name != "dev" {
283+ t.Fatal("FocusID did not show the hidden workspace")
284+ }
285+}
286+
287+func TestNamedWorkspaceIsNumberedAndKept(t *testing.T) {
288+ m := monitor()
289+ m.AddWindow(1)
290+ named(m, NamedWorkspace{Name: "web"})
291+ // Inserted above the trailing empty workspace: [1:{1}] [2:web] [3:{}].
292+ if len(m.Workspaces) != 3 || m.Workspaces[1].Name != "web" || m.Active != 0 {
293+ t.Fatal(windows(m), m.Active)
294+ }
295+ // Empty named workspaces are not removed when left.
296+ m.FocusNumber(2)
297+ m.FocusNumber(1)
298+ if m.Workspaces[1].Name != "web" {
299+ t.Fatal(windows(m))
300+ }
301+ m.Apply("workspace web")
302+ if m.Active != 1 {
303+ t.Fatal(m.Active)
304+ }
305+ m.Apply("workspace web")
306+ if m.Active != 0 {
307+ t.Fatal("toggle did not return", m.Active)
308+ }
309+ m.Apply("workspace nope") // unknown: nothing
310+ if m.Active != 0 {
311+ t.Fatal(m.Active)
312+ }
313+}
314+
315+func TestNamedWorkspaceReload(t *testing.T) {
316+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web"})
317+ m.Apply("workspace dev")
318+ m.AddWindow(7)
319+ // Reload keeps windows and the workspace on screen; web becomes hidden.
320+ named(m, NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web", Hidden: true})
321+ if m.Current().Name != "dev" || len(m.Workspaces) != 1 {
322+ t.Fatal(m.Current().Name, windows(m))
323+ }
324+ // Dropping dev from config hands its windows to the numbered workspace.
325+ named(m)
326+ if m.Current().Name != "" || !reflect.DeepEqual(windows(m), [][]WindowID{{7}, {}}) {
327+ t.Fatal(m.Current().Name, windows(m))
328+ }
329+}
330+
331+func TestNamedWorkspaceSettings(t *testing.T) {
332+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true, MaxColumns: 3, Overflow: OverflowFixed})
333+ m.SetMaxColumns(1)
334+ if m.Current().MaxColumns != 1 || m.Current().Overflow != "" {
335+ t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
336+ }
337+ m.Apply("workspace dev")
338+ if m.Current().MaxColumns != 3 || m.Current().Overflow != OverflowFixed {
339+ t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
340+ }
341+ // New dynamic workspaces follow the defaults.
342+ m.SetOverflow(OverflowScroll)
343+ m.Apply("workspace dev")
344+ m.FocusNumber(9)
345+ if m.Current().MaxColumns != 1 || m.Current().Overflow != OverflowScroll {
346+ t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
347+ }
348+}
@@ -239,3 +239,110 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
239 t.Fatal(p)239 t.Fatal(p)
240 }240 }
241 }241 }
242+
243+func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
244+ m.SetNamed(specs)
245+ return m
246+}
247+
248+func TestHiddenWorkspaceToggle(t *testing.T) {
249+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true})
250+ m.AddWindow(1)
251+ if len(m.Workspaces) != 2 {
252+ t.Fatal("hidden workspace is numbered", windows(m))
253+ }
254+ m.Apply("workspace dev")
255+ if m.Current().Name != "dev" {
256+ t.Fatal(m.Current().Name)
257+ }
258+ m.AddWindow(2)
259+ // Up/down do not leave a hidden workspace.
260+ m.Apply(ActionFocusWorkspaceDown)
261+ m.Apply(ActionFocusWindowDown)
262+ if m.Current().Name != "dev" {
263+ t.Fatal("left the hidden workspace")
264+ }
265+ for _, p := range m.Layout() {
266+ if hidden := p.ID == 1; p.Hidden != hidden {
267+ t.Fatal(m.Layout())
268+ }
269+ }
270+ // The same bind returns to the previous workspace.
271+ m.Apply("workspace dev")
272+ if id, _ := m.Focused(); id != 1 || m.Current().Name != "" {
273+ t.Fatal(id, m.Current().Name)
274+ }
275+ // Cmd+N from the hidden workspace goes to that number.
276+ m.Apply("workspace dev")
277+ m.FocusNumber(1)
278+ if id, _ := m.Focused(); id != 1 {
279+ t.Fatal(id)
280+ }
281+ // Clicking a window of the hidden workspace shows it (FocusID).
282+ if !m.FocusID(2) || m.Current().Name != "dev" {
283+ t.Fatal("FocusID did not show the hidden workspace")
284+ }
285+}
286+
287+func TestNamedWorkspaceIsNumberedAndKept(t *testing.T) {
288+ m := monitor()
289+ m.AddWindow(1)
290+ named(m, NamedWorkspace{Name: "web"})
291+ // Inserted above the trailing empty workspace: [1:{1}] [2:web] [3:{}].
292+ if len(m.Workspaces) != 3 || m.Workspaces[1].Name != "web" || m.Active != 0 {
293+ t.Fatal(windows(m), m.Active)
294+ }
295+ // Empty named workspaces are not removed when left.
296+ m.FocusNumber(2)
297+ m.FocusNumber(1)
298+ if m.Workspaces[1].Name != "web" {
299+ t.Fatal(windows(m))
300+ }
301+ m.Apply("workspace web")
302+ if m.Active != 1 {
303+ t.Fatal(m.Active)
304+ }
305+ m.Apply("workspace web")
306+ if m.Active != 0 {
307+ t.Fatal("toggle did not return", m.Active)
308+ }
309+ m.Apply("workspace nope") // unknown: nothing
310+ if m.Active != 0 {
311+ t.Fatal(m.Active)
312+ }
313+}
314+
315+func TestNamedWorkspaceReload(t *testing.T) {
316+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web"})
317+ m.Apply("workspace dev")
318+ m.AddWindow(7)
319+ // Reload keeps windows and the workspace on screen; web becomes hidden.
320+ named(m, NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web", Hidden: true})
321+ if m.Current().Name != "dev" || len(m.Workspaces) != 1 {
322+ t.Fatal(m.Current().Name, windows(m))
323+ }
324+ // Dropping dev from config hands its windows to the numbered workspace.
325+ named(m)
326+ if m.Current().Name != "" || !reflect.DeepEqual(windows(m), [][]WindowID{{7}, {}}) {
327+ t.Fatal(m.Current().Name, windows(m))
328+ }
329+}
330+
331+func TestNamedWorkspaceSettings(t *testing.T) {
332+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true, MaxColumns: 3, Overflow: OverflowFixed})
333+ m.SetMaxColumns(1)
334+ if m.Current().MaxColumns != 1 || m.Current().Overflow != "" {
335+ t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
336+ }
337+ m.Apply("workspace dev")
338+ if m.Current().MaxColumns != 3 || m.Current().Overflow != OverflowFixed {
339+ t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
340+ }
341+ // New dynamic workspaces follow the defaults.
342+ m.SetOverflow(OverflowScroll)
343+ m.Apply("workspace dev")
344+ m.FocusNumber(9)
345+ if m.Current().MaxColumns != 1 || m.Current().Overflow != OverflowScroll {
346+ t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
347+ }
348+}
M internal/ports/config.go +16 -0
@@ -38,7 +38,11 @@ type Config struct {
3838 // MaxColumns is how many columns share the screen before scrolling.
3939 MaxColumns int
4040 Presets []string
41+ // Overflow is "scroll" or "fixed" (see WorkspaceConfig).
42+ Overflow string
4143 }
44+ // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
45+ Workspaces []WorkspaceConfig
4246 // Outputs selects and configures physical displays (drm backend).
4347 Outputs []OutputConfig
4448 Binds map[string]string
@@ -50,3 +54,15 @@ type Config struct {
5054 Debug []string
5155 }
5256 }
57+
58+// WorkspaceConfig declares a named workspace. Hidden ones are not numbered and
59+// only reachable through a `workspace <name>` bind, which toggles them.
60+// Zero MaxColumns and an empty Overflow use the layout.* defaults.
61+// Monitor names the connector it belongs to (one monitor is supported so far).
62+type WorkspaceConfig struct {
63+ Name string
64+ Monitor string
65+ Hidden bool
66+ MaxColumns int
67+ Overflow string
68+}
@@ -38,7 +38,11 @@ type Config struct {
38 // MaxColumns is how many columns share the screen before scrolling.38 // MaxColumns is how many columns share the screen before scrolling.
39 MaxColumns int39 MaxColumns int
40 Presets []string40 Presets []string
41+ // Overflow is "scroll" or "fixed" (see WorkspaceConfig).
42+ Overflow string
41 }43 }
44+ // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
45+ Workspaces []WorkspaceConfig
42 // Outputs selects and configures physical displays (drm backend).46 // Outputs selects and configures physical displays (drm backend).
43 Outputs []OutputConfig47 Outputs []OutputConfig
44 Binds map[string]string48 Binds map[string]string
@@ -50,3 +54,15 @@ type Config struct {
50 Debug []string54 Debug []string
51 }55 }
52 }56 }
57+
58+// WorkspaceConfig declares a named workspace. Hidden ones are not numbered and
59+// only reachable through a `workspace <name>` bind, which toggles them.
60+// Zero MaxColumns and an empty Overflow use the layout.* defaults.
61+// Monitor names the connector it belongs to (one monitor is supported so far).
62+type WorkspaceConfig struct {
63+ Name string
64+ Monitor string
65+ Hidden bool
66+ MaxColumns int
67+ Overflow string
68+}

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