detached f2678e8

merge: custom-workspaces (named/hidden workspaces, toggle bind, fixed overflow spiral)

commit
f2678e8d63851ece20012fdfc123a0227c9c1356
author
brice · · 2026-09-24 16:54 UTC
merge of
7929f62 + 96f4e50 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +789 -55
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 +93 -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
@@ -225,6 +251,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
225251 if err := scanner.Err(); err != nil {
226252 return c, raw, warnings, err
227253 }
254+ warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
228255 // A user bind on a digit (cmd+1, even "none") replaces the default bound
229256 // to the same physical key (cmd+code:2), so older configs keep working.
230257 for combo := range c.Binds {
@@ -365,6 +392,11 @@ func set(c *ports.Config, key, v string) error {
365392 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
366393 case "layout.max-columns":
367394 return positive(&c.Layout.MaxColumns, v, 16)
395+ case "layout.overflow":
396+ if v != "scroll" && v != "fixed" {
397+ return fmt.Errorf("must be scroll or fixed")
398+ }
399+ c.Layout.Overflow = v
368400 case "layout.presets":
369401 list := splitList(v)
370402 if len(list) == 0 {
@@ -401,6 +433,60 @@ func set(c *ports.Config, key, v string) error {
401433 return nil
402434 }
403435
436+// checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
437+// workspace (it does nothing) and a hidden workspace no bind reaches (its
438+// windows could only come back by editing the config).
439+func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
440+ declared := map[string]bool{}
441+ for _, w := range c.Workspaces {
442+ declared[w.Name] = true
443+ }
444+ bound := map[string]bool{}
445+ var warnings []Warning
446+ for combo, a := range c.Binds {
447+ name, ok := strings.CutPrefix(a, "workspace ")
448+ if !ok {
449+ continue
450+ }
451+ name = strings.TrimSpace(name)
452+ bound[name] = true
453+ if !declared[name] {
454+ warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)})
455+ }
456+ }
457+ for _, w := range c.Workspaces {
458+ if w.Hidden && !bound[w.Name] {
459+ warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
460+ }
461+ }
462+ sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
463+ return warnings
464+}
465+
466+var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
467+
468+// setWorkspace applies one workspace.<name>.<field> key.
469+func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
470+ switch field {
471+ case "hidden":
472+ b, err := onOff(v)
473+ w.Hidden = b
474+ return err
475+ case "monitor":
476+ return fmt.Errorf("not supported yet: one monitor only")
477+ case "max-columns":
478+ return positive(&w.MaxColumns, v, 16)
479+ case "overflow":
480+ if v != "scroll" && v != "fixed" {
481+ return fmt.Errorf("must be scroll or fixed")
482+ }
483+ w.Overflow = v
484+ default:
485+ return fmt.Errorf("unknown key (hidden, max-columns, overflow)")
486+ }
487+ return nil
488+}
489+
404490 func positive(dst *int, v string, max int) error {
405491 n, err := strconv.Atoi(v)
406492 if err != nil || n <= 0 || n > max {
@@ -448,6 +534,12 @@ func checkAction(v string) error {
448534 }
449535 return nil
450536 }
537+ if rest, ok := strings.CutPrefix(v, "workspace "); ok {
538+ if !workspaceName.MatchString(strings.TrimSpace(rest)) {
539+ return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
540+ }
541+ return nil
542+ }
451543 for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
452544 if rest, ok := strings.CutPrefix(v, prefix); ok {
453545 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
@@ -457,7 +549,7 @@ func checkAction(v string) error {
457549 }
458550 }
459551 if !actions[v] {
460- return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
552+ return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
461553 }
462554 return nil
463555 }
@@ -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
@@ -225,6 +251,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
225 if err := scanner.Err(); err != nil {251 if err := scanner.Err(); err != nil {
226 return c, raw, warnings, err252 return c, raw, warnings, err
227 }253 }
254+ warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
228 // A user bind on a digit (cmd+1, even "none") replaces the default bound255 // A user bind on a digit (cmd+1, even "none") replaces the default bound
229 // to the same physical key (cmd+code:2), so older configs keep working.256 // to the same physical key (cmd+code:2), so older configs keep working.
230 for combo := range c.Binds {257 for combo := range c.Binds {
@@ -365,6 +392,11 @@ func set(c *ports.Config, key, v string) error {
365 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")392 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
366 case "layout.max-columns":393 case "layout.max-columns":
367 return positive(&c.Layout.MaxColumns, v, 16)394 return positive(&c.Layout.MaxColumns, v, 16)
395+ case "layout.overflow":
396+ if v != "scroll" && v != "fixed" {
397+ return fmt.Errorf("must be scroll or fixed")
398+ }
399+ c.Layout.Overflow = v
368 case "layout.presets":400 case "layout.presets":
369 list := splitList(v)401 list := splitList(v)
370 if len(list) == 0 {402 if len(list) == 0 {
@@ -401,6 +433,60 @@ func set(c *ports.Config, key, v string) error {
401 return nil433 return nil
402 }434 }
403 435
436+// checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
437+// workspace (it does nothing) and a hidden workspace no bind reaches (its
438+// windows could only come back by editing the config).
439+func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
440+ declared := map[string]bool{}
441+ for _, w := range c.Workspaces {
442+ declared[w.Name] = true
443+ }
444+ bound := map[string]bool{}
445+ var warnings []Warning
446+ for combo, a := range c.Binds {
447+ name, ok := strings.CutPrefix(a, "workspace ")
448+ if !ok {
449+ continue
450+ }
451+ name = strings.TrimSpace(name)
452+ bound[name] = true
453+ if !declared[name] {
454+ warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)})
455+ }
456+ }
457+ for _, w := range c.Workspaces {
458+ if w.Hidden && !bound[w.Name] {
459+ warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
460+ }
461+ }
462+ sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
463+ return warnings
464+}
465+
466+var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
467+
468+// setWorkspace applies one workspace.<name>.<field> key.
469+func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
470+ switch field {
471+ case "hidden":
472+ b, err := onOff(v)
473+ w.Hidden = b
474+ return err
475+ case "monitor":
476+ return fmt.Errorf("not supported yet: one monitor only")
477+ case "max-columns":
478+ return positive(&w.MaxColumns, v, 16)
479+ case "overflow":
480+ if v != "scroll" && v != "fixed" {
481+ return fmt.Errorf("must be scroll or fixed")
482+ }
483+ w.Overflow = v
484+ default:
485+ return fmt.Errorf("unknown key (hidden, max-columns, overflow)")
486+ }
487+ return nil
488+}
489+
404 func positive(dst *int, v string, max int) error {490 func positive(dst *int, v string, max int) error {
405 n, err := strconv.Atoi(v)491 n, err := strconv.Atoi(v)
406 if err != nil || n <= 0 || n > max {492 if err != nil || n <= 0 || n > max {
@@ -448,6 +534,12 @@ func checkAction(v string) error {
448 }534 }
449 return nil535 return nil
450 }536 }
537+ if rest, ok := strings.CutPrefix(v, "workspace "); ok {
538+ if !workspaceName.MatchString(strings.TrimSpace(rest)) {
539+ return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
540+ }
541+ return nil
542+ }
451 for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {543 for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
452 if rest, ok := strings.CutPrefix(v, prefix); ok {544 if rest, ok := strings.CutPrefix(v, prefix); ok {
453 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {545 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
@@ -457,7 +549,7 @@ func checkAction(v string) error {
457 }549 }
458 }550 }
459 if !actions[v] {551 if !actions[v] {
460- return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)552+ return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
461 }553 }
462 return nil554 return nil
463 }555 }
M internal/adapters/config/config_test.go +34 -0
@@ -233,3 +233,37 @@ 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+bind.cmd+x = workspace typo
249+workspace.lost.hidden = on
250+`)
251+ want := []ports.WorkspaceConfig{
252+ {Name: "dev", Hidden: true, MaxColumns: 3},
253+ {Name: "web", Overflow: "scroll"},
254+ {Name: "lost", Hidden: true},
255+ }
256+ if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
257+ t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
258+ }
259+ if c.Binds["Cmd+d"] != "workspace dev" {
260+ t.Fatal(c.Binds["Cmd+d"])
261+ }
262+ lines := []int{}
263+ for _, x := range w {
264+ lines = append(lines, x.Line)
265+ }
266+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 10, 11, 12}) {
267+ t.Fatal(w)
268+ }
269+}
@@ -233,3 +233,37 @@ 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+bind.cmd+x = workspace typo
249+workspace.lost.hidden = on
250+`)
251+ want := []ports.WorkspaceConfig{
252+ {Name: "dev", Hidden: true, MaxColumns: 3},
253+ {Name: "web", Overflow: "scroll"},
254+ {Name: "lost", Hidden: true},
255+ }
256+ if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
257+ t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
258+ }
259+ if c.Binds["Cmd+d"] != "workspace dev" {
260+ t.Fatal(c.Binds["Cmd+d"])
261+ }
262+ lines := []int{}
263+ for _, x := range w {
264+ lines = append(lines, x.Line)
265+ }
266+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 10, 11, 12}) {
267+ t.Fatal(w)
268+ }
269+}
M internal/core/action.go +26 -8
@@ -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,23 +98,27 @@ 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:
98- if m.Active > 0 {
114+ if m.shown == nil && m.Active > 0 {
99115 m.MoveToWorkspace(m.Active - 1)
100116 }
101117 return Effect{}
102118 case ActionMoveToWorkspaceDown:
103- m.MoveToWorkspace(m.Active + 1)
119+ if m.shown == nil {
120+ m.MoveToWorkspace(m.Active + 1)
121+ }
104122 return Effect{}
105123 }
106124 return m.Current().Apply(a)
@@ -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,23 +98,27 @@ 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.shown == nil && m.Active > 0 {
99 m.MoveToWorkspace(m.Active - 1)115 m.MoveToWorkspace(m.Active - 1)
100 }116 }
101 return Effect{}117 return Effect{}
102 case ActionMoveToWorkspaceDown:118 case ActionMoveToWorkspaceDown:
103- m.MoveToWorkspace(m.Active + 1)119+ if m.shown == nil {
120+ m.MoveToWorkspace(m.Active + 1)
121+ }
104 return Effect{}122 return Effect{}
105 }123 }
106 return m.Current().Apply(a)124 return m.Current().Apply(a)
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 +78 -14
@@ -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
@@ -126,7 +142,10 @@ func (w *Workspace) AddWindow(id WindowID) {
126142 return
127143 }
128144 at := 0
129- if len(w.Columns) > 0 {
145+ if w.Overflow == OverflowFixed {
146+ // The spiral depends only on window order: new windows go last.
147+ at = len(w.Columns)
148+ } else if len(w.Columns) > 0 {
130149 at = w.Focus + 1
131150 }
132151 w.Columns = append(w.Columns, Column{})
@@ -311,7 +330,8 @@ func (w *Workspace) columnWidth(i int) int {
311330 return w.Output.W
312331 }
313332 g := w.gap()
314- if w.Columns[i].Width == (Width{}) {
333+ // Fixed overflow never scrolls, so presets would push columns off screen.
334+ if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
315335 if len(w.Columns) == 1 {
316336 return max(w.Usable.W-2*g, 0)
317337 }
@@ -329,6 +349,10 @@ func (w *Workspace) columnX(i int) int {
329349 return x
330350 }
331351 func (w *Workspace) scroll() {
352+ if w.Overflow == OverflowFixed {
353+ w.ViewX = 0
354+ return
355+ }
332356 if len(w.Columns) == 0 {
333357 return
334358 }
@@ -346,39 +370,79 @@ func (w *Workspace) scroll() {
346370 w.ViewX += left + width - maxX
347371 }
348372 }
373+
374+// columnRects returns each column's area on screen, before stacking windows.
375+func (w *Workspace) columnRects() []Rect {
376+ g := w.gap()
377+ y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
378+ rects := make([]Rect, len(w.Columns))
379+ for i := range w.Columns {
380+ rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h}
381+ }
382+ k := max(w.MaxColumns, 1)
383+ if w.Overflow != OverflowFixed || len(w.Columns) <= k {
384+ return rects
385+ }
386+ // The last slot holds the spiral; the columns before it keep their place.
387+ area := rects[k-1]
388+ for i := k - 1; i < len(w.Columns)-1; i++ {
389+ rects[i], area = split(area, g, (i-k+1)%2 == 0)
390+ }
391+ rects[len(rects)-1] = area
392+ return rects
393+}
394+
395+// split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
396+func split(r Rect, gap int, vertical bool) (Rect, Rect) {
397+ if vertical {
398+ h := max((r.H-gap)/2, 0)
399+ 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)}
400+ }
401+ w := max((r.W-gap)/2, 0)
402+ 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}
403+}
404+
349405 func (w *Workspace) Layout() []Placement {
350406 var result []Placement
351407 gap := w.gap()
408+ cols := w.columnRects()
352409 for i, c := range w.Columns {
353- x := w.columnX(i) - w.ViewX
354- width := w.columnWidth(i)
410+ col := cols[i]
355411 n := len(c.Windows)
356412 if n == 0 {
357413 continue
358414 }
359415 fullColumn := w.fullscreenColumn(i)
360- available := max(w.Usable.H-(n+1)*gap, 0)
416+ if fullColumn {
417+ col.W = w.Output.W
418+ }
419+ available := max(col.H-(n-1)*gap, 0)
361420 height := available / n
362- y := w.Usable.Y + gap
421+ y := col.Y
422+ bottom := col.Y + col.H
363423 for j, id := range c.Windows {
364424 h := height
365425 if j == n-1 {
366426 h = available - height*(n-1)
367427 }
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}
428+ y = min(y, bottom)
429+ h = min(h, bottom-y)
430+ r := Rect{X: col.X, Y: y, W: col.W, H: h}
373431 full := w.fullscreen == id && id != 0
374- hidden := fullColumn && !full
432+ // Fixed overflow keeps every column on screen: hide them all
433+ // behind a fullscreen window.
434+ hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full
375435 if full {
376- r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H}
436+ // Scroll mode aligns the view on the column; fixed never scrolls.
437+ r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
438+ if w.Overflow == OverflowFixed {
439+ r.X = 0
440+ }
377441 }
378442 if hidden {
379443 r = Rect{}
380444 }
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})
445+ 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 && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})
382446 y += h + gap
383447 }
384448 }
@@ -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
@@ -126,7 +142,10 @@ func (w *Workspace) AddWindow(id WindowID) {
126 return142 return
127 }143 }
128 at := 0144 at := 0
129- if len(w.Columns) > 0 {145+ if w.Overflow == OverflowFixed {
146+ // The spiral depends only on window order: new windows go last.
147+ at = len(w.Columns)
148+ } else if len(w.Columns) > 0 {
130 at = w.Focus + 1149 at = w.Focus + 1
131 }150 }
132 w.Columns = append(w.Columns, Column{})151 w.Columns = append(w.Columns, Column{})
@@ -311,7 +330,8 @@ func (w *Workspace) columnWidth(i int) int {
311 return w.Output.W330 return w.Output.W
312 }331 }
313 g := w.gap()332 g := w.gap()
314- if w.Columns[i].Width == (Width{}) {333+ // Fixed overflow never scrolls, so presets would push columns off screen.
334+ if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
315 if len(w.Columns) == 1 {335 if len(w.Columns) == 1 {
316 return max(w.Usable.W-2*g, 0)336 return max(w.Usable.W-2*g, 0)
317 }337 }
@@ -329,6 +349,10 @@ func (w *Workspace) columnX(i int) int {
329 return x349 return x
330 }350 }
331 func (w *Workspace) scroll() {351 func (w *Workspace) scroll() {
352+ if w.Overflow == OverflowFixed {
353+ w.ViewX = 0
354+ return
355+ }
332 if len(w.Columns) == 0 {356 if len(w.Columns) == 0 {
333 return357 return
334 }358 }
@@ -346,39 +370,79 @@ func (w *Workspace) scroll() {
346 w.ViewX += left + width - maxX370 w.ViewX += left + width - maxX
347 }371 }
348 }372 }
373+
374+// columnRects returns each column's area on screen, before stacking windows.
375+func (w *Workspace) columnRects() []Rect {
376+ g := w.gap()
377+ y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
378+ rects := make([]Rect, len(w.Columns))
379+ for i := range w.Columns {
380+ rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h}
381+ }
382+ k := max(w.MaxColumns, 1)
383+ if w.Overflow != OverflowFixed || len(w.Columns) <= k {
384+ return rects
385+ }
386+ // The last slot holds the spiral; the columns before it keep their place.
387+ area := rects[k-1]
388+ for i := k - 1; i < len(w.Columns)-1; i++ {
389+ rects[i], area = split(area, g, (i-k+1)%2 == 0)
390+ }
391+ rects[len(rects)-1] = area
392+ return rects
393+}
394+
395+// split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
396+func split(r Rect, gap int, vertical bool) (Rect, Rect) {
397+ if vertical {
398+ h := max((r.H-gap)/2, 0)
399+ 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)}
400+ }
401+ w := max((r.W-gap)/2, 0)
402+ 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}
403+}
404+
349 func (w *Workspace) Layout() []Placement {405 func (w *Workspace) Layout() []Placement {
350 var result []Placement406 var result []Placement
351 gap := w.gap()407 gap := w.gap()
408+ cols := w.columnRects()
352 for i, c := range w.Columns {409 for i, c := range w.Columns {
353- x := w.columnX(i) - w.ViewX410+ col := cols[i]
354- width := w.columnWidth(i)
355 n := len(c.Windows)411 n := len(c.Windows)
356 if n == 0 {412 if n == 0 {
357 continue413 continue
358 }414 }
359 fullColumn := w.fullscreenColumn(i)415 fullColumn := w.fullscreenColumn(i)
360- available := max(w.Usable.H-(n+1)*gap, 0)416+ if fullColumn {
417+ col.W = w.Output.W
418+ }
419+ available := max(col.H-(n-1)*gap, 0)
361 height := available / n420 height := available / n
362- y := w.Usable.Y + gap421+ y := col.Y
422+ bottom := col.Y + col.H
363 for j, id := range c.Windows {423 for j, id := range c.Windows {
364 h := height424 h := height
365 if j == n-1 {425 if j == n-1 {
366 h = available - height*(n-1)426 h = available - height*(n-1)
367 }427 }
368- if y > w.Usable.Y+w.Usable.H {428+ y = min(y, bottom)
369- y = w.Usable.Y + w.Usable.H429+ h = min(h, bottom-y)
370- }430+ 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 != 0431 full := w.fullscreen == id && id != 0
374- hidden := fullColumn && !full432+ // Fixed overflow keeps every column on screen: hide them all
433+ // behind a fullscreen window.
434+ hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full
375 if full {435 if full {
376- r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H}436+ // Scroll mode aligns the view on the column; fixed never scrolls.
437+ r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
438+ if w.Overflow == OverflowFixed {
439+ r.X = 0
440+ }
377 }441 }
378 if hidden {442 if hidden {
379 r = Rect{}443 r = Rect{}
380 }444 }
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})445+ 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 && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})
382 y += h + gap446 y += h + gap
383 }447 }
384 }448 }
M internal/core/layout_test.go +99 -0
@@ -350,3 +350,102 @@ 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+ }
362+ got := map[WindowID]Rect{}
363+ for _, p := range w.Layout() {
364+ got[p.ID] = p.Rect
365+ }
366+ want := map[WindowID]Rect{
367+ 1: {X: 0, Y: 0, W: 50, H: 80},
368+ 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2
369+ 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half
370+ 4: {X: 75, Y: 40, W: 25, H: 20},
371+ 5: {X: 75, Y: 60, W: 25, H: 20},
372+ }
373+ if !reflect.DeepEqual(got, want) {
374+ t.Fatalf("got %v\nwant %v", got, want)
375+ }
376+ if w.ViewX != 0 {
377+ t.Fatal("fixed overflow scrolled", w.ViewX)
378+ }
379+ // Up to max-columns it is the plain equal split.
380+ w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
381+ w2.SetOutput(100, 80)
382+ w2.AddWindow(1)
383+ w2.AddWindow(2)
384+ if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) {
385+ t.Fatal(p)
386+ }
387+}
388+
389+func fixedWorkspace(maxCols, gaps, n int) *Workspace {
390+ w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
391+ w.SetOutput(100, 80)
392+ for id := 1; id <= n; id++ {
393+ w.AddWindow(WindowID(id))
394+ }
395+ return w
396+}
397+
398+func placements(w *Workspace) map[WindowID]Placement {
399+ got := map[WindowID]Placement{}
400+ for _, p := range w.Layout() {
401+ got[p.ID] = p
402+ }
403+ return got
404+}
405+
406+func TestFixedOverflowEdgeCases(t *testing.T) {
407+ t.Run("new windows go last whatever the focus", func(t *testing.T) {
408+ w := fixedWorkspace(2, 0, 3)
409+ w.FocusID(1)
410+ w.AddWindow(4)
411+ if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 {
412+ t.Fatal(got)
413+ }
414+ })
415+ for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot
416+ t.Run("fullscreen covers the output", func(t *testing.T) {
417+ w := fixedWorkspace(2, 4, 4)
418+ w.FocusID(col)
419+ w.ToggleFullscreen()
420+ for id, p := range placements(w) {
421+ if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) {
422+ t.Fatal(p)
423+ }
424+ if id != col && !p.Hidden {
425+ t.Fatalf("window %d visible over fullscreen: %+v", id, p)
426+ }
427+ }
428+ })
429+ }
430+ t.Run("presets ignored", func(t *testing.T) {
431+ w := fixedWorkspace(2, 0, 2)
432+ w.SetPresets([]Width{{Num: 2, Den: 3}})
433+ w.CycleWidth()
434+ w.FocusColumn(-1)
435+ w.CycleWidth()
436+ for id, p := range placements(w) {
437+ if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
438+ t.Fatal(id, p)
439+ }
440+ }
441+ })
442+ t.Run("gaps between spiral halves", func(t *testing.T) {
443+ got := placements(fixedWorkspace(1, 4, 2))
444+ if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
445+ t.Fatal(got[1].Rect, got[2].Rect)
446+ }
447+ if got[1].Borderless || got[2].Borderless {
448+ t.Fatal("spiral windows must keep borders to show focus")
449+ }
450+ })
451+}
@@ -350,3 +350,102 @@ 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+ }
362+ got := map[WindowID]Rect{}
363+ for _, p := range w.Layout() {
364+ got[p.ID] = p.Rect
365+ }
366+ want := map[WindowID]Rect{
367+ 1: {X: 0, Y: 0, W: 50, H: 80},
368+ 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2
369+ 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half
370+ 4: {X: 75, Y: 40, W: 25, H: 20},
371+ 5: {X: 75, Y: 60, W: 25, H: 20},
372+ }
373+ if !reflect.DeepEqual(got, want) {
374+ t.Fatalf("got %v\nwant %v", got, want)
375+ }
376+ if w.ViewX != 0 {
377+ t.Fatal("fixed overflow scrolled", w.ViewX)
378+ }
379+ // Up to max-columns it is the plain equal split.
380+ w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
381+ w2.SetOutput(100, 80)
382+ w2.AddWindow(1)
383+ w2.AddWindow(2)
384+ if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) {
385+ t.Fatal(p)
386+ }
387+}
388+
389+func fixedWorkspace(maxCols, gaps, n int) *Workspace {
390+ w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
391+ w.SetOutput(100, 80)
392+ for id := 1; id <= n; id++ {
393+ w.AddWindow(WindowID(id))
394+ }
395+ return w
396+}
397+
398+func placements(w *Workspace) map[WindowID]Placement {
399+ got := map[WindowID]Placement{}
400+ for _, p := range w.Layout() {
401+ got[p.ID] = p
402+ }
403+ return got
404+}
405+
406+func TestFixedOverflowEdgeCases(t *testing.T) {
407+ t.Run("new windows go last whatever the focus", func(t *testing.T) {
408+ w := fixedWorkspace(2, 0, 3)
409+ w.FocusID(1)
410+ w.AddWindow(4)
411+ if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 {
412+ t.Fatal(got)
413+ }
414+ })
415+ for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot
416+ t.Run("fullscreen covers the output", func(t *testing.T) {
417+ w := fixedWorkspace(2, 4, 4)
418+ w.FocusID(col)
419+ w.ToggleFullscreen()
420+ for id, p := range placements(w) {
421+ if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) {
422+ t.Fatal(p)
423+ }
424+ if id != col && !p.Hidden {
425+ t.Fatalf("window %d visible over fullscreen: %+v", id, p)
426+ }
427+ }
428+ })
429+ }
430+ t.Run("presets ignored", func(t *testing.T) {
431+ w := fixedWorkspace(2, 0, 2)
432+ w.SetPresets([]Width{{Num: 2, Den: 3}})
433+ w.CycleWidth()
434+ w.FocusColumn(-1)
435+ w.CycleWidth()
436+ for id, p := range placements(w) {
437+ if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
438+ t.Fatal(id, p)
439+ }
440+ }
441+ })
442+ t.Run("gaps between spiral halves", func(t *testing.T) {
443+ got := placements(fixedWorkspace(1, 4, 2))
444+ if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
445+ t.Fatal(got[1].Rect, got[2].Rect)
446+ }
447+ if got[1].Borderless || got[2].Borderless {
448+ t.Fatal("spiral windows must keep borders to show focus")
449+ }
450+ })
451+}
M internal/core/monitor.go +219 -29
@@ -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
@@ -81,18 +160,6 @@ func (m *Monitor) RemoveWindow(id WindowID) {
81160 }
82161 }
83162
84-// FocusID shows the window's workspace and focuses it.
85-func (m *Monitor) FocusID(id WindowID) bool {
86- w, i := m.find(id)
87- if w == nil {
88- return false
89- }
90- m.Active = i
91- w.FocusID(id)
92- m.normalize()
93- return true
94-}
95-
96163 // SetFullscreen applies a client request on the window's own workspace. It does
97164 // not switch workspaces: client requests never move the user.
98165 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
@@ -101,12 +168,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
101168 }
102169 }
103170
104-// MoveToWorkspace moves the focused window to workspace index i; focus stays here.
171+// MoveToWorkspace moves the focused window to numbered workspace index i;
172+// focus stays here.
105173 func (m *Monitor) MoveToWorkspace(i int) {
106174 i = min(max(i, 0), len(m.Workspaces)-1)
107175 cur := m.Current()
108176 id, ok := cur.Focused()
109- if !ok || i == m.Active {
177+ if !ok || m.Workspaces[i] == cur {
110178 return
111179 }
112180 cur.RemoveWindow(id)
@@ -116,11 +184,12 @@ func (m *Monitor) MoveToWorkspace(i int) {
116184
117185 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
118186
119-// Layout places every window: the active workspace on screen, others hidden.
187+// Layout places every window: the workspace on screen laid out, others hidden.
120188 func (m *Monitor) Layout() []Placement {
121189 var result []Placement
122- for i, w := range m.Workspaces {
123- if i == m.Active {
190+ cur := m.Current()
191+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
192+ if w == cur {
124193 result = append(result, w.Layout()...)
125194 continue
126195 }
@@ -140,6 +209,9 @@ func (m *Monitor) each(f func(*Workspace)) {
140209 for _, w := range m.Workspaces {
141210 f(w)
142211 }
212+ for _, w := range m.hidden {
213+ f(w)
214+ }
143215 }
144216 func (m *Monitor) SetOutput(width, height int) {
145217 m.each(func(w *Workspace) { w.SetOutput(width, height) })
@@ -147,4 +219,122 @@ func (m *Monitor) SetOutput(width, height int) {
147219 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
148220 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
149221 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) }) }
222+
223+// SetMaxColumns sets the default; named workspaces may override it.
224+func (m *Monitor) SetMaxColumns(n int) {
225+ m.template.MaxColumns = n
226+ m.applyNamed()
227+}
228+
229+// SetOverflow sets the default; named workspaces may override it.
230+func (m *Monitor) SetOverflow(o Overflow) {
231+ m.template.Overflow = o
232+ m.applyNamed()
233+}
234+
235+// applyNamed gives every workspace the defaults or its named overrides.
236+func (m *Monitor) applyNamed() {
237+ specs := map[string]NamedWorkspace{}
238+ for _, s := range m.named {
239+ specs[s.Name] = s
240+ }
241+ m.each(func(w *Workspace) {
242+ s := specs[w.Name]
243+ w.Overflow = m.template.Overflow
244+ if s.Overflow != "" {
245+ w.Overflow = s.Overflow
246+ }
247+ w.SetMaxColumns(m.template.MaxColumns)
248+ if s.MaxColumns > 0 {
249+ w.SetMaxColumns(s.MaxColumns)
250+ }
251+ })
252+}
253+
254+// SetNamed applies the named workspaces from config. New numbered ones are
255+// inserted above the trailing empty workspace (never before the first one).
256+// A workspace dropped from config becomes a normal one; a dropped hidden one
257+// hands its windows to the active numbered workspace. The workspace on
258+// screen stays on screen.
259+func (m *Monitor) SetNamed(specs []NamedWorkspace) {
260+ cur := m.Current()
261+ want := map[string]NamedWorkspace{}
262+ for _, s := range specs {
263+ want[s.Name] = s
264+ }
265+ byName := map[string]*Workspace{}
266+ for _, w := range m.Workspaces {
267+ if _, ok := want[w.Name]; w.Name != "" && !ok {
268+ w.Name = ""
269+ }
270+ if w.Name != "" {
271+ byName[w.Name] = w
272+ }
273+ }
274+ var hidden []*Workspace
275+ for _, w := range m.hidden {
276+ if _, ok := want[w.Name]; ok {
277+ byName[w.Name] = w
278+ hidden = append(hidden, w)
279+ continue
280+ }
281+ if w == cur {
282+ cur = nil
283+ }
284+ for _, c := range w.Columns {
285+ for _, id := range c.Windows {
286+ m.Workspaces[m.Active].AddWindow(id)
287+ }
288+ }
289+ }
290+ m.hidden = hidden
291+ m.named = append([]NamedWorkspace(nil), specs...)
292+ var nextHidden []*Workspace
293+ for _, s := range specs {
294+ w := byName[s.Name]
295+ if w == nil {
296+ w = m.newWorkspace()
297+ w.Name = s.Name
298+ }
299+ i := indexOf(m.Workspaces, w)
300+ switch {
301+ case s.Hidden:
302+ if i >= 0 {
303+ m.removeNumbered(i)
304+ }
305+ nextHidden = append(nextHidden, w)
306+ case i < 0:
307+ at := max(len(m.Workspaces)-1, 1)
308+ m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...)
309+ if at <= m.Active {
310+ m.Active++
311+ }
312+ }
313+ }
314+ m.hidden = nextHidden
315+ if m.back != nil && !m.has(m.back) {
316+ m.back = nil
317+ }
318+ m.shown = nil
319+ switch {
320+ case cur == nil:
321+ case indexOf(m.hidden, cur) >= 0:
322+ m.shown = cur
323+ case indexOf(m.Workspaces, cur) >= 0:
324+ m.Active = indexOf(m.Workspaces, cur)
325+ }
326+ m.applyNamed()
327+ m.normalize()
328+}
329+
330+// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
331+// when possible.
332+func (m *Monitor) removeNumbered(i int) {
333+ m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
334+ if i < m.Active || m.Active >= len(m.Workspaces) {
335+ m.Active = max(m.Active-1, 0)
336+ }
337+ if len(m.Workspaces) == 0 {
338+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
339+ }
340+}
@@ -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
@@ -81,18 +160,6 @@ func (m *Monitor) RemoveWindow(id WindowID) {
81 }160 }
82 }161 }
83 162
84-// FocusID shows the window's workspace and focuses it.
85-func (m *Monitor) FocusID(id WindowID) bool {
86- w, i := m.find(id)
87- if w == nil {
88- return false
89- }
90- m.Active = i
91- w.FocusID(id)
92- m.normalize()
93- return true
94-}
95-
96 // SetFullscreen applies a client request on the window's own workspace. It does163 // SetFullscreen applies a client request on the window's own workspace. It does
97 // not switch workspaces: client requests never move the user.164 // not switch workspaces: client requests never move the user.
98 func (m *Monitor) SetFullscreen(id WindowID, on bool) {165 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
@@ -101,12 +168,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
101 }168 }
102 }169 }
103 170
104-// MoveToWorkspace moves the focused window to workspace index i; focus stays here.171+// MoveToWorkspace moves the focused window to numbered workspace index i;
172+// focus stays here.
105 func (m *Monitor) MoveToWorkspace(i int) {173 func (m *Monitor) MoveToWorkspace(i int) {
106 i = min(max(i, 0), len(m.Workspaces)-1)174 i = min(max(i, 0), len(m.Workspaces)-1)
107 cur := m.Current()175 cur := m.Current()
108 id, ok := cur.Focused()176 id, ok := cur.Focused()
109- if !ok || i == m.Active {177+ if !ok || m.Workspaces[i] == cur {
110 return178 return
111 }179 }
112 cur.RemoveWindow(id)180 cur.RemoveWindow(id)
@@ -116,11 +184,12 @@ func (m *Monitor) MoveToWorkspace(i int) {
116 184
117 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }185 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
118 186
119-// Layout places every window: the active workspace on screen, others hidden.187+// Layout places every window: the workspace on screen laid out, others hidden.
120 func (m *Monitor) Layout() []Placement {188 func (m *Monitor) Layout() []Placement {
121 var result []Placement189 var result []Placement
122- for i, w := range m.Workspaces {190+ cur := m.Current()
123- if i == m.Active {191+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
192+ if w == cur {
124 result = append(result, w.Layout()...)193 result = append(result, w.Layout()...)
125 continue194 continue
126 }195 }
@@ -140,6 +209,9 @@ func (m *Monitor) each(f func(*Workspace)) {
140 for _, w := range m.Workspaces {209 for _, w := range m.Workspaces {
141 f(w)210 f(w)
142 }211 }
212+ for _, w := range m.hidden {
213+ f(w)
214+ }
143 }215 }
144 func (m *Monitor) SetOutput(width, height int) {216 func (m *Monitor) SetOutput(width, height int) {
145 m.each(func(w *Workspace) { w.SetOutput(width, height) })217 m.each(func(w *Workspace) { w.SetOutput(width, height) })
@@ -147,4 +219,122 @@ func (m *Monitor) SetOutput(width, height int) {
147 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }219 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) }) }220 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) }) }221 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) }) }222+
223+// SetMaxColumns sets the default; named workspaces may override it.
224+func (m *Monitor) SetMaxColumns(n int) {
225+ m.template.MaxColumns = n
226+ m.applyNamed()
227+}
228+
229+// SetOverflow sets the default; named workspaces may override it.
230+func (m *Monitor) SetOverflow(o Overflow) {
231+ m.template.Overflow = o
232+ m.applyNamed()
233+}
234+
235+// applyNamed gives every workspace the defaults or its named overrides.
236+func (m *Monitor) applyNamed() {
237+ specs := map[string]NamedWorkspace{}
238+ for _, s := range m.named {
239+ specs[s.Name] = s
240+ }
241+ m.each(func(w *Workspace) {
242+ s := specs[w.Name]
243+ w.Overflow = m.template.Overflow
244+ if s.Overflow != "" {
245+ w.Overflow = s.Overflow
246+ }
247+ w.SetMaxColumns(m.template.MaxColumns)
248+ if s.MaxColumns > 0 {
249+ w.SetMaxColumns(s.MaxColumns)
250+ }
251+ })
252+}
253+
254+// SetNamed applies the named workspaces from config. New numbered ones are
255+// inserted above the trailing empty workspace (never before the first one).
256+// A workspace dropped from config becomes a normal one; a dropped hidden one
257+// hands its windows to the active numbered workspace. The workspace on
258+// screen stays on screen.
259+func (m *Monitor) SetNamed(specs []NamedWorkspace) {
260+ cur := m.Current()
261+ want := map[string]NamedWorkspace{}
262+ for _, s := range specs {
263+ want[s.Name] = s
264+ }
265+ byName := map[string]*Workspace{}
266+ for _, w := range m.Workspaces {
267+ if _, ok := want[w.Name]; w.Name != "" && !ok {
268+ w.Name = ""
269+ }
270+ if w.Name != "" {
271+ byName[w.Name] = w
272+ }
273+ }
274+ var hidden []*Workspace
275+ for _, w := range m.hidden {
276+ if _, ok := want[w.Name]; ok {
277+ byName[w.Name] = w
278+ hidden = append(hidden, w)
279+ continue
280+ }
281+ if w == cur {
282+ cur = nil
283+ }
284+ for _, c := range w.Columns {
285+ for _, id := range c.Windows {
286+ m.Workspaces[m.Active].AddWindow(id)
287+ }
288+ }
289+ }
290+ m.hidden = hidden
291+ m.named = append([]NamedWorkspace(nil), specs...)
292+ var nextHidden []*Workspace
293+ for _, s := range specs {
294+ w := byName[s.Name]
295+ if w == nil {
296+ w = m.newWorkspace()
297+ w.Name = s.Name
298+ }
299+ i := indexOf(m.Workspaces, w)
300+ switch {
301+ case s.Hidden:
302+ if i >= 0 {
303+ m.removeNumbered(i)
304+ }
305+ nextHidden = append(nextHidden, w)
306+ case i < 0:
307+ at := max(len(m.Workspaces)-1, 1)
308+ m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...)
309+ if at <= m.Active {
310+ m.Active++
311+ }
312+ }
313+ }
314+ m.hidden = nextHidden
315+ if m.back != nil && !m.has(m.back) {
316+ m.back = nil
317+ }
318+ m.shown = nil
319+ switch {
320+ case cur == nil:
321+ case indexOf(m.hidden, cur) >= 0:
322+ m.shown = cur
323+ case indexOf(m.Workspaces, cur) >= 0:
324+ m.Active = indexOf(m.Workspaces, cur)
325+ }
326+ m.applyNamed()
327+ m.normalize()
328+}
329+
330+// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
331+// when possible.
332+func (m *Monitor) removeNumbered(i int) {
333+ m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
334+ if i < m.Active || m.Active >= len(m.Workspaces) {
335+ m.Active = max(m.Active-1, 0)
336+ }
337+ if len(m.Workspaces) == 0 {
338+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
339+ }
340+}
M internal/core/monitor_test.go +181 -3
@@ -118,9 +118,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
118118 t.Fatal(m.Layout())
119119 }
120120 }
121- if !m.FocusID(1) || m.Active != 0 {
122- t.Fatal(m.Active)
123- }
124121 }
125122
126123 func TestMonitorFullscreenRequestStaysPut(t *testing.T) {
@@ -239,3 +236,184 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
239236 t.Fatal(p)
240237 }
241238 }
239+
240+func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
241+ m.SetNamed(specs)
242+ return m
243+}
244+
245+func TestHiddenWorkspaceToggle(t *testing.T) {
246+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true})
247+ m.AddWindow(1)
248+ if len(m.Workspaces) != 2 {
249+ t.Fatal("hidden workspace is numbered", windows(m))
250+ }
251+ m.Apply("workspace dev")
252+ if m.Current().Name != "dev" {
253+ t.Fatal(m.Current().Name)
254+ }
255+ m.AddWindow(2)
256+ // Up/down do not leave a hidden workspace.
257+ m.Apply(ActionFocusWorkspaceDown)
258+ m.Apply(ActionFocusWindowDown)
259+ if m.Current().Name != "dev" {
260+ t.Fatal("left the hidden workspace")
261+ }
262+ for _, p := range m.Layout() {
263+ if hidden := p.ID == 1; p.Hidden != hidden {
264+ t.Fatal(m.Layout())
265+ }
266+ }
267+ // The same bind returns to the previous workspace.
268+ m.Apply("workspace dev")
269+ if id, _ := m.Focused(); id != 1 || m.Current().Name != "" {
270+ t.Fatal(id, m.Current().Name)
271+ }
272+ // Cmd+N from the hidden workspace goes to that number.
273+ m.Apply("workspace dev")
274+ m.FocusNumber(1)
275+ if id, _ := m.Focused(); id != 1 {
276+ t.Fatal(id)
277+ }
278+ // Moving a window up/down from a hidden workspace does nothing.
279+ m.Apply("workspace dev")
280+ m.Apply(ActionMoveToWorkspaceDown)
281+ m.Apply(ActionMoveToWorkspaceUp)
282+ if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
283+ t.Fatal(id, windows(m))
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+}
349+
350+// checkInvariants fails when the monitor is in a state no action should reach.
351+func checkInvariants(t *testing.T, m *Monitor, want []WindowID) {
352+ t.Helper()
353+ if m.Active < 0 || m.Active >= len(m.Workspaces) {
354+ t.Fatalf("active %d of %d", m.Active, len(m.Workspaces))
355+ }
356+ if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" {
357+ t.Fatal("no trailing empty unnamed workspace")
358+ }
359+ if m.shown != nil && indexOf(m.hidden, m.shown) < 0 {
360+ t.Fatal("shown is not a hidden workspace")
361+ }
362+ if m.back != nil && !m.has(m.back) {
363+ t.Fatal("back dangles")
364+ }
365+ seen := map[WindowID]int{}
366+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
367+ for _, c := range w.Columns {
368+ for _, id := range c.Windows {
369+ seen[id]++
370+ }
371+ }
372+ }
373+ for _, id := range want {
374+ if seen[id] != 1 {
375+ t.Fatalf("window %d present %d times", id, seen[id])
376+ }
377+ }
378+ if len(seen) != len(want) {
379+ t.Fatalf("windows %v, want %v", seen, want)
380+ }
381+}
382+
383+func TestSetNamedTransitions(t *testing.T) {
384+ dev := NamedWorkspace{Name: "dev"}
385+ devHidden := NamedWorkspace{Name: "dev", Hidden: true}
386+ web := NamedWorkspace{Name: "web"}
387+ for _, tc := range []struct {
388+ name string
389+ before []NamedWorkspace
390+ after []NamedWorkspace
391+ onDev bool // show dev before the reload
392+ want string // workspace name on screen after
393+ }{
394+ {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"},
395+ {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"},
396+ {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""},
397+ {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""},
398+ {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""},
399+ {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""},
400+ } {
401+ t.Run(tc.name, func(t *testing.T) {
402+ m := named(monitor(), tc.before...)
403+ m.AddWindow(1)
404+ if tc.onDev {
405+ m.Apply("workspace dev")
406+ }
407+ m.AddWindow(2)
408+ checkInvariants(t, m, []WindowID{1, 2})
409+ m.SetNamed(tc.after)
410+ checkInvariants(t, m, []WindowID{1, 2})
411+ if got := m.Current().Name; got != tc.want {
412+ t.Fatalf("on %q, want %q", got, tc.want)
413+ }
414+ if id, _ := m.Focused(); id != 2 {
415+ t.Fatalf("focus moved to %d", id)
416+ }
417+ })
418+ }
419+}
@@ -118,9 +118,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
118 t.Fatal(m.Layout())118 t.Fatal(m.Layout())
119 }119 }
120 }120 }
121- if !m.FocusID(1) || m.Active != 0 {
122- t.Fatal(m.Active)
123- }
124 }121 }
125 122
126 func TestMonitorFullscreenRequestStaysPut(t *testing.T) {123 func TestMonitorFullscreenRequestStaysPut(t *testing.T) {
@@ -239,3 +236,184 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
239 t.Fatal(p)236 t.Fatal(p)
240 }237 }
241 }238 }
239+
240+func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
241+ m.SetNamed(specs)
242+ return m
243+}
244+
245+func TestHiddenWorkspaceToggle(t *testing.T) {
246+ m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true})
247+ m.AddWindow(1)
248+ if len(m.Workspaces) != 2 {
249+ t.Fatal("hidden workspace is numbered", windows(m))
250+ }
251+ m.Apply("workspace dev")
252+ if m.Current().Name != "dev" {
253+ t.Fatal(m.Current().Name)
254+ }
255+ m.AddWindow(2)
256+ // Up/down do not leave a hidden workspace.
257+ m.Apply(ActionFocusWorkspaceDown)
258+ m.Apply(ActionFocusWindowDown)
259+ if m.Current().Name != "dev" {
260+ t.Fatal("left the hidden workspace")
261+ }
262+ for _, p := range m.Layout() {
263+ if hidden := p.ID == 1; p.Hidden != hidden {
264+ t.Fatal(m.Layout())
265+ }
266+ }
267+ // The same bind returns to the previous workspace.
268+ m.Apply("workspace dev")
269+ if id, _ := m.Focused(); id != 1 || m.Current().Name != "" {
270+ t.Fatal(id, m.Current().Name)
271+ }
272+ // Cmd+N from the hidden workspace goes to that number.
273+ m.Apply("workspace dev")
274+ m.FocusNumber(1)
275+ if id, _ := m.Focused(); id != 1 {
276+ t.Fatal(id)
277+ }
278+ // Moving a window up/down from a hidden workspace does nothing.
279+ m.Apply("workspace dev")
280+ m.Apply(ActionMoveToWorkspaceDown)
281+ m.Apply(ActionMoveToWorkspaceUp)
282+ if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
283+ t.Fatal(id, windows(m))
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+}
349+
350+// checkInvariants fails when the monitor is in a state no action should reach.
351+func checkInvariants(t *testing.T, m *Monitor, want []WindowID) {
352+ t.Helper()
353+ if m.Active < 0 || m.Active >= len(m.Workspaces) {
354+ t.Fatalf("active %d of %d", m.Active, len(m.Workspaces))
355+ }
356+ if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" {
357+ t.Fatal("no trailing empty unnamed workspace")
358+ }
359+ if m.shown != nil && indexOf(m.hidden, m.shown) < 0 {
360+ t.Fatal("shown is not a hidden workspace")
361+ }
362+ if m.back != nil && !m.has(m.back) {
363+ t.Fatal("back dangles")
364+ }
365+ seen := map[WindowID]int{}
366+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
367+ for _, c := range w.Columns {
368+ for _, id := range c.Windows {
369+ seen[id]++
370+ }
371+ }
372+ }
373+ for _, id := range want {
374+ if seen[id] != 1 {
375+ t.Fatalf("window %d present %d times", id, seen[id])
376+ }
377+ }
378+ if len(seen) != len(want) {
379+ t.Fatalf("windows %v, want %v", seen, want)
380+ }
381+}
382+
383+func TestSetNamedTransitions(t *testing.T) {
384+ dev := NamedWorkspace{Name: "dev"}
385+ devHidden := NamedWorkspace{Name: "dev", Hidden: true}
386+ web := NamedWorkspace{Name: "web"}
387+ for _, tc := range []struct {
388+ name string
389+ before []NamedWorkspace
390+ after []NamedWorkspace
391+ onDev bool // show dev before the reload
392+ want string // workspace name on screen after
393+ }{
394+ {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"},
395+ {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"},
396+ {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""},
397+ {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""},
398+ {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""},
399+ {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""},
400+ } {
401+ t.Run(tc.name, func(t *testing.T) {
402+ m := named(monitor(), tc.before...)
403+ m.AddWindow(1)
404+ if tc.onDev {
405+ m.Apply("workspace dev")
406+ }
407+ m.AddWindow(2)
408+ checkInvariants(t, m, []WindowID{1, 2})
409+ m.SetNamed(tc.after)
410+ checkInvariants(t, m, []WindowID{1, 2})
411+ if got := m.Current().Name; got != tc.want {
412+ t.Fatalf("on %q, want %q", got, tc.want)
413+ }
414+ if id, _ := m.Focused(); id != 2 {
415+ t.Fatalf("focus moved to %d", id)
416+ }
417+ })
418+ }
419+}
M internal/ports/config.go +14 -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,13 @@ 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+type WorkspaceConfig struct {
62+ Name string
63+ Hidden bool
64+ MaxColumns int
65+ Overflow string
66+}
@@ -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,13 @@ 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+type WorkspaceConfig struct {
62+ Name string
63+ Hidden bool
64+ MaxColumns int
65+ Overflow string
66+}

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