detached 0843224

feat(config): simplify terminal and named workspaces (#14)

commit
084322407c85631766874840e615aebfbad394cb
author
bnema · · 2026-09-27 14:14 UTC
committer
GitHub · · 2026-09-27 14:14 UTC
parent
5731344
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
13 files · +206 -187
M README.md +6 -4
@@ -20,7 +20,7 @@ NeferWL has no animations, themes, built-in bar or wallpaper, by design. The com
2020
2121 - **Terminal first.** NeferWL opens your terminal at startup. Bars, launchers and notifications are external clients such as Waybar, fuzzel and mako.
2222 - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway. Past that, new columns scroll to the right as in Niri, or split in a spiral with `overflow = fixed`. Each workspace can set its own rules.
23-- **Custom workspaces.** Numbered workspaces appear and disappear as you use them. Named workspaces can be hidden behind a bind. **Slots** declare a workspace's columns (width and command): NeferWL starts the apps in the background and puts each window in its column.
23+- **Custom workspaces.** Numbered workspaces appear and disappear as you use them. Named workspaces are outside the numbered list and shown through a bind. **Slots** declare a workspace's columns (width and command): NeferWL starts the apps in the background and puts each window in its column.
2424 - **Multi-monitor.** Each monitor has its own workspaces. When you unplug a monitor, its workspaces move to another one; when you plug it back, they return, and your focus stays where it was.
2525 - **Games.** A fullscreen game is shown without composition, with tearing and VRR when the game asks for them. Details are in [the performance path](#the-performance-path).
2626 - **Low footprint.** About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.
@@ -30,7 +30,7 @@ An example config:
3030
3131 ```text
3232 keyboard.layout = fr
33-terminal = foot
33+terminal.auto-open = first
3434 startup = waybar
3535
3636 output.DP-1 = 3840x2160@144
@@ -38,13 +38,15 @@ output.DP-1.scale = 1.5
3838
3939 layout.max-columns = 3
4040
41-# A hidden dev workspace: editor and terminal, started and placed for you.
42-workspace.dev.hidden = on
41+# A bind-only dev workspace: editor and terminal, started and placed for you.
42+workspace.dev.monitor = DP-1
4343 workspace.dev.column.1 = 67%, code --new-window
4444 workspace.dev.column.2 = 33%, foot
4545 bind.cmd+d = workspace dev
4646 ```
4747
48+The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `terminal.auto-open = first` opens it once on the initial numbered workspace; `all` opens it on each visible empty workspace without slots, and `off` disables automatic opening. `--no-terminal` overrides automatic opening for one run; `spawn-terminal` still works. Launched programs inherit `$SHELL`, `$EDITOR`, and `$VISUAL`.
49+
4850 ### The performance path
4951
5052 These protocols and kernel features cut copies, waits and latency between the app and the screen:
@@ -20,7 +20,7 @@ NeferWL has no animations, themes, built-in bar or wallpaper, by design. The com
20 20
21 - **Terminal first.** NeferWL opens your terminal at startup. Bars, launchers and notifications are external clients such as Waybar, fuzzel and mako.21 - **Terminal first.** NeferWL opens your terminal at startup. Bars, launchers and notifications are external clients such as Waybar, fuzzel and mako.
22 - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway. Past that, new columns scroll to the right as in Niri, or split in a spiral with `overflow = fixed`. Each workspace can set its own rules.22 - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway. Past that, new columns scroll to the right as in Niri, or split in a spiral with `overflow = fixed`. Each workspace can set its own rules.
23-- **Custom workspaces.** Numbered workspaces appear and disappear as you use them. Named workspaces can be hidden behind a bind. **Slots** declare a workspace's columns (width and command): NeferWL starts the apps in the background and puts each window in its column.23+- **Custom workspaces.** Numbered workspaces appear and disappear as you use them. Named workspaces are outside the numbered list and shown through a bind. **Slots** declare a workspace's columns (width and command): NeferWL starts the apps in the background and puts each window in its column.
24 - **Multi-monitor.** Each monitor has its own workspaces. When you unplug a monitor, its workspaces move to another one; when you plug it back, they return, and your focus stays where it was.24 - **Multi-monitor.** Each monitor has its own workspaces. When you unplug a monitor, its workspaces move to another one; when you plug it back, they return, and your focus stays where it was.
25 - **Games.** A fullscreen game is shown without composition, with tearing and VRR when the game asks for them. Details are in [the performance path](#the-performance-path).25 - **Games.** A fullscreen game is shown without composition, with tearing and VRR when the game asks for them. Details are in [the performance path](#the-performance-path).
26 - **Low footprint.** About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.26 - **Low footprint.** About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.
@@ -30,7 +30,7 @@ An example config:
30 30
31 ```text31 ```text
32 keyboard.layout = fr32 keyboard.layout = fr
33-terminal = foot33+terminal.auto-open = first
34 startup = waybar34 startup = waybar
35 35
36 output.DP-1 = 3840x2160@14436 output.DP-1 = 3840x2160@144
@@ -38,13 +38,15 @@ output.DP-1.scale = 1.5
38 38
39 layout.max-columns = 339 layout.max-columns = 3
40 40
41-# A hidden dev workspace: editor and terminal, started and placed for you.41+# A bind-only dev workspace: editor and terminal, started and placed for you.
42-workspace.dev.hidden = on42+workspace.dev.monitor = DP-1
43 workspace.dev.column.1 = 67%, code --new-window43 workspace.dev.column.1 = 67%, code --new-window
44 workspace.dev.column.2 = 33%, foot44 workspace.dev.column.2 = 33%, foot
45 bind.cmd+d = workspace dev45 bind.cmd+d = workspace dev
46 ```46 ```
47 47
48+The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `terminal.auto-open = first` opens it once on the initial numbered workspace; `all` opens it on each visible empty workspace without slots, and `off` disables automatic opening. `--no-terminal` overrides automatic opening for one run; `spawn-terminal` still works. Launched programs inherit `$SHELL`, `$EDITOR`, and `$VISUAL`.
49+
48 ### The performance path50 ### The performance path
49 51
50 These protocols and kernel features cut copies, waits and latency between the app and the screen:52 These protocols and kernel features cut copies, waits and latency between the app and the screen:
M docs/config.md +4 -3
@@ -16,7 +16,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
1616 | `keyboard.layout`, `.variant`, `.options` | empty | xkb names, e.g. `fr`, `caps:escape` |
1717 | `keyboard.repeat-rate` / `repeat-delay` | `25` / `600` | Key repeat per second / delay in ms |
1818 | `keyboard.cmd` | `super` | Modifier that `cmd` means in binds: `super`, `alt` or `ctrl` |
19-| `terminal` | `foot` | Opened at startup and by `spawn-terminal` |
19+| `terminal` | `$TERMINAL`, or `foot` if unset/empty | Command for automatic requests and `spawn-terminal`; whitespace-separated |
20+| `terminal.auto-open` | `first` | `first`: one request for the initial numbered workspace per session; `all`: visible empty workspaces without slots, with retry protection; `off`: none. `--no-terminal` disables automatic requests for this run only |
2021 | `startup` | none | Command run once at session start; repeat the key for more |
2122 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
2223 | `background` | `#111111` | Solid background color |
@@ -28,8 +29,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
2829 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
2930 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` |
3031 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
31-| `workspace.<name>.hidden` | `off` | Reachable only through its bind, which toggles it |
32-| `workspace.<name>.monitor` | focused | Home monitor: connector (`DP-2`) or monitor key |
32+| `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |
33+| `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects |
3334 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
3435 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
3536 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
@@ -16,7 +16,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
16 | `keyboard.layout`, `.variant`, `.options` | empty | xkb names, e.g. `fr`, `caps:escape` |16 | `keyboard.layout`, `.variant`, `.options` | empty | xkb names, e.g. `fr`, `caps:escape` |
17 | `keyboard.repeat-rate` / `repeat-delay` | `25` / `600` | Key repeat per second / delay in ms |17 | `keyboard.repeat-rate` / `repeat-delay` | `25` / `600` | Key repeat per second / delay in ms |
18 | `keyboard.cmd` | `super` | Modifier that `cmd` means in binds: `super`, `alt` or `ctrl` |18 | `keyboard.cmd` | `super` | Modifier that `cmd` means in binds: `super`, `alt` or `ctrl` |
19-| `terminal` | `foot` | Opened at startup and by `spawn-terminal` |19+| `terminal` | `$TERMINAL`, or `foot` if unset/empty | Command for automatic requests and `spawn-terminal`; whitespace-separated |
20+| `terminal.auto-open` | `first` | `first`: one request for the initial numbered workspace per session; `all`: visible empty workspaces without slots, with retry protection; `off`: none. `--no-terminal` disables automatic requests for this run only |
20 | `startup` | none | Command run once at session start; repeat the key for more |21 | `startup` | none | Command run once at session start; repeat the key for more |
21 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |22 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
22 | `background` | `#111111` | Solid background color |23 | `background` | `#111111` | Solid background color |
@@ -28,8 +29,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
28 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |29 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
29 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` |30 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` |
30 | `focus.follow-move` | `off` | Follow a column moved to another workspace |31 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
31-| `workspace.<name>.hidden` | `off` | Reachable only through its bind, which toggles it |32+| `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |
32-| `workspace.<name>.monitor` | focused | Home monitor: connector (`DP-2`) or monitor key |33+| `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects |
33 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |34 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
34 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |35 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
35 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |36 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
M examples/config +8 -6
@@ -11,8 +11,11 @@ keyboard.repeat-delay = 600
1111 # Which modifier `cmd` means in binds: super, alt or ctrl.
1212 keyboard.cmd = super
1313
14-# Command for spawn-terminal and the terminal opened at startup.
15-terminal = foot
14+# Command for spawn-terminal: terminal, then $TERMINAL, then foot.
15+# Automatically open once on the initial numbered workspace (first), on every
16+# visible empty workspace (all), or never (off). --no-terminal overrides this.
17+# terminal = foot
18+terminal.auto-open = first
1619
1720 # Commands run once when the session starts, one line each. Like spawn, a
1821 # command runs without a shell: see "Running commands" in the README.
@@ -42,9 +45,8 @@ layout.presets = 1/3, 1/2, 2/3, 1
4245 # on: moving a column or window to another workspace shows that workspace.
4346 focus.follow-move = off
4447
45-# Named workspaces. Numbered at their place like any other and never removed
46-# when empty. hidden = on keeps one out of the numbers and of up/down: only its
47-# bind shows it, and pressing the bind again returns to the previous workspace.
48+# Named workspaces are outside the numbered list and require a bind. The bind
49+# shows one; pressing it again returns to the previous workspace.
4850 # max-columns and overflow override the layout.* values.
4951 # workspace.web.max-columns = 3
5052 # workspace.web.overflow = fixed
@@ -54,7 +56,7 @@ focus.follow-move = off
5456 # A closed slot stays empty until the workspace is shown again. Apps that
5557 # reuse a running instance need a new-instance flag. Widths need
5658 # overflow = scroll.
57-# workspace.dev.hidden = on
59+# workspace.dev.monitor = DP-2
5860 # workspace.dev.column.1 = 67%, code --new-window
5961 # workspace.dev.column.2 = 33%, foot
6062 # bind.cmd+d = workspace dev
@@ -11,8 +11,11 @@ keyboard.repeat-delay = 600
11 # Which modifier `cmd` means in binds: super, alt or ctrl.11 # Which modifier `cmd` means in binds: super, alt or ctrl.
12 keyboard.cmd = super12 keyboard.cmd = super
13 13
14-# Command for spawn-terminal and the terminal opened at startup.14+# Command for spawn-terminal: terminal, then $TERMINAL, then foot.
15-terminal = foot15+# Automatically open once on the initial numbered workspace (first), on every
16+# visible empty workspace (all), or never (off). --no-terminal overrides this.
17+# terminal = foot
18+terminal.auto-open = first
16 19
17 # Commands run once when the session starts, one line each. Like spawn, a20 # Commands run once when the session starts, one line each. Like spawn, a
18 # command runs without a shell: see "Running commands" in the README.21 # command runs without a shell: see "Running commands" in the README.
@@ -42,9 +45,8 @@ layout.presets = 1/3, 1/2, 2/3, 1
42 # on: moving a column or window to another workspace shows that workspace.45 # on: moving a column or window to another workspace shows that workspace.
43 focus.follow-move = off46 focus.follow-move = off
44 47
45-# Named workspaces. Numbered at their place like any other and never removed48+# Named workspaces are outside the numbered list and require a bind. The bind
46-# when empty. hidden = on keeps one out of the numbers and of up/down: only its49+# shows one; pressing it again returns to the previous workspace.
47-# bind shows it, and pressing the bind again returns to the previous workspace.
48 # max-columns and overflow override the layout.* values.50 # max-columns and overflow override the layout.* values.
49 # workspace.web.max-columns = 351 # workspace.web.max-columns = 3
50 # workspace.web.overflow = fixed52 # workspace.web.overflow = fixed
@@ -54,7 +56,7 @@ focus.follow-move = off
54 # A closed slot stays empty until the workspace is shown again. Apps that56 # A closed slot stays empty until the workspace is shown again. Apps that
55 # reuse a running instance need a new-instance flag. Widths need57 # reuse a running instance need a new-instance flag. Widths need
56 # overflow = scroll.58 # overflow = scroll.
57-# workspace.dev.hidden = on59+# workspace.dev.monitor = DP-2
58 # workspace.dev.column.1 = 67%, code --new-window60 # workspace.dev.column.1 = 67%, code --new-window
59 # workspace.dev.column.2 = 33%, foot61 # workspace.dev.column.2 = 33%, foot
60 # bind.cmd+d = workspace dev62 # bind.cmd+d = workspace dev
M internal/adapters/config/config.go +21 -10
@@ -79,7 +79,11 @@ func Defaults() ports.Config {
7979 c.Keyboard.RepeatRate = 25
8080 c.Keyboard.RepeatDelay = 600
8181 c.Keyboard.CmdKey = "super"
82- c.Terminal.Command = []string{"foot"}
82+ c.Terminal.Command = strings.Fields(os.Getenv("TERMINAL"))
83+ if len(c.Terminal.Command) == 0 {
84+ c.Terminal.Command = []string{"foot"}
85+ }
86+ c.Terminal.AutoOpen = "first"
8387 c.Xwayland = "xwayland-satellite"
8488 c.Background.Color = "#111111"
8589 c.Border.Width = 2
@@ -310,6 +314,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
310314 return c, raw, warnings, err
311315 }
312316 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
317+ sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
313318 // A user bind on a digit (cmd+1, even "none") replaces the default bound
314319 // to the same physical key (cmd+code:2), so older configs keep working.
315320 for combo := range c.Binds {
@@ -425,6 +430,11 @@ func set(c *ports.Config, key, v string) error {
425430 return fmt.Errorf("must not be empty")
426431 }
427432 c.Terminal.Command = argv
433+ case "terminal.auto-open":
434+ if v != "first" && v != "all" && v != "off" {
435+ return fmt.Errorf("must be first, all or off")
436+ }
437+ c.Terminal.AutoOpen = v
428438 case "xwayland":
429439 switch {
430440 case v == "off":
@@ -561,8 +571,7 @@ func setLayoutRule(r *ports.LayoutRules, field, v string) error {
561571 }
562572
563573 // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
564-// workspace (it does nothing) and a hidden workspace no bind reaches (its
565-// windows could only come back by editing the config).
574+// workspace (it does nothing) and a named workspace no bind reaches.
566575 func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
567576 declared := map[string]bool{}
568577 for _, w := range c.Workspaces {
@@ -594,8 +603,14 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
594603 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
595604 warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
596605 }
597- if w.Hidden && !bound[w.Name] {
598- 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)})
606+ if !bound[w.Name] {
607+ line := 0
608+ for key, n := range seen {
609+ if strings.HasPrefix(key, "workspace."+w.Name+".") && (line == 0 || n < line) {
610+ line = n
611+ }
612+ }
613+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
599614 }
600615 }
601616 sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
@@ -607,10 +622,6 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
607622 // setWorkspace applies one workspace.<name>.<field> key.
608623 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
609624 switch field {
610- case "hidden":
611- b, err := onOff(v)
612- w.Hidden = b
613- return err
614625 case "monitor":
615626 if v == "" {
616627 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
@@ -621,7 +632,7 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
621632 default:
622633 n, ok := strings.CutPrefix(field, "column.")
623634 if !ok {
624- return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)")
635+ return fmt.Errorf("unknown key (monitor, max-columns, overflow, column.N)")
625636 }
626637 index, err := strconv.Atoi(n)
627638 if err != nil || index < 1 || index > 16 {
@@ -79,7 +79,11 @@ func Defaults() ports.Config {
79 c.Keyboard.RepeatRate = 2579 c.Keyboard.RepeatRate = 25
80 c.Keyboard.RepeatDelay = 60080 c.Keyboard.RepeatDelay = 600
81 c.Keyboard.CmdKey = "super"81 c.Keyboard.CmdKey = "super"
82- c.Terminal.Command = []string{"foot"}82+ c.Terminal.Command = strings.Fields(os.Getenv("TERMINAL"))
83+ if len(c.Terminal.Command) == 0 {
84+ c.Terminal.Command = []string{"foot"}
85+ }
86+ c.Terminal.AutoOpen = "first"
83 c.Xwayland = "xwayland-satellite"87 c.Xwayland = "xwayland-satellite"
84 c.Background.Color = "#111111"88 c.Background.Color = "#111111"
85 c.Border.Width = 289 c.Border.Width = 2
@@ -310,6 +314,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
310 return c, raw, warnings, err314 return c, raw, warnings, err
311 }315 }
312 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)316 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
317+ sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
313 // A user bind on a digit (cmd+1, even "none") replaces the default bound318 // A user bind on a digit (cmd+1, even "none") replaces the default bound
314 // to the same physical key (cmd+code:2), so older configs keep working.319 // to the same physical key (cmd+code:2), so older configs keep working.
315 for combo := range c.Binds {320 for combo := range c.Binds {
@@ -425,6 +430,11 @@ func set(c *ports.Config, key, v string) error {
425 return fmt.Errorf("must not be empty")430 return fmt.Errorf("must not be empty")
426 }431 }
427 c.Terminal.Command = argv432 c.Terminal.Command = argv
433+ case "terminal.auto-open":
434+ if v != "first" && v != "all" && v != "off" {
435+ return fmt.Errorf("must be first, all or off")
436+ }
437+ c.Terminal.AutoOpen = v
428 case "xwayland":438 case "xwayland":
429 switch {439 switch {
430 case v == "off":440 case v == "off":
@@ -561,8 +571,7 @@ func setLayoutRule(r *ports.LayoutRules, field, v string) error {
561 }571 }
562 572
563 // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared573 // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
564-// workspace (it does nothing) and a hidden workspace no bind reaches (its574+// workspace (it does nothing) and a named workspace no bind reaches.
565-// windows could only come back by editing the config).
566 func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {575 func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
567 declared := map[string]bool{}576 declared := map[string]bool{}
568 for _, w := range c.Workspaces {577 for _, w := range c.Workspaces {
@@ -594,8 +603,14 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
594 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]603 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
595 warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})604 warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
596 }605 }
597- if w.Hidden && !bound[w.Name] {606+ if !bound[w.Name] {
598- 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)})607+ line := 0
608+ for key, n := range seen {
609+ if strings.HasPrefix(key, "workspace."+w.Name+".") && (line == 0 || n < line) {
610+ line = n
611+ }
612+ }
613+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
599 }614 }
600 }615 }
601 sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })616 sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
@@ -607,10 +622,6 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
607 // setWorkspace applies one workspace.<name>.<field> key.622 // setWorkspace applies one workspace.<name>.<field> key.
608 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {623 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
609 switch field {624 switch field {
610- case "hidden":
611- b, err := onOff(v)
612- w.Hidden = b
613- return err
614 case "monitor":625 case "monitor":
615 if v == "" {626 if v == "" {
616 return fmt.Errorf("needs a connector (DP-2) or a monitor key")627 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
@@ -621,7 +632,7 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
621 default:632 default:
622 n, ok := strings.CutPrefix(field, "column.")633 n, ok := strings.CutPrefix(field, "column.")
623 if !ok {634 if !ok {
624- return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)")635+ return fmt.Errorf("unknown key (monitor, max-columns, overflow, column.N)")
625 }636 }
626 index, err := strconv.Atoi(n)637 index, err := strconv.Atoi(n)
627 if err != nil || index < 1 || index > 16 {638 if err != nil || index < 1 || index > 16 {
M internal/adapters/config/config_test.go +28 -7
@@ -20,6 +20,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
2020 }
2121
2222 func TestDefaultsAndLoad(t *testing.T) {
23+ t.Setenv("TERMINAL", "")
2324 d := Defaults()
2425 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 52 || d.Layout.MaxColumns != 2 {
2526 t.Fatalf("defaults: %+v", d)
@@ -282,12 +283,12 @@ workspace.web.overflow = spiral
282283 bind.cmd+d = workspace dev
283284 bind.cmd+w = workspace
284285 bind.cmd+x = workspace typo
285-workspace.lost.hidden = on
286+workspace.lost.monitor = DP-1
286287 `)
287288 want := []ports.WorkspaceConfig{
288- {Name: "dev", Monitor: "DP-2", Hidden: true, MaxColumns: 3},
289+ {Name: "dev", Monitor: "DP-2", MaxColumns: 3},
289290 {Name: "web", Overflow: "scroll"},
290- {Name: "lost", Hidden: true},
291+ {Name: "lost", Monitor: "DP-1"},
291292 }
292293 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
293294 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
@@ -299,7 +300,7 @@ workspace.lost.hidden = on
299300 for _, x := range w {
300301 lines = append(lines, x.Line)
301302 }
302- if !reflect.DeepEqual(lines, []int{6, 7, 8, 10, 11, 12}) {
303+ if !reflect.DeepEqual(lines, []int{2, 4, 6, 7, 8, 10, 11, 12}) {
303304 t.Fatal(w)
304305 }
305306 }
@@ -327,13 +328,13 @@ workspace.dev.monitor = DP-2
327328 workspace.dev.column.1 = 50%, foot
328329 workspace.web.column.1 = 50%, foot
329330 `)
330- if len(w) != 1 || w[0].Line != 3 {
331+ if len(w) != 3 || w[1].Line != 3 {
331332 t.Fatal(w)
332333 }
333334 }
334335
335336 func TestWorkspaceSlots(t *testing.T) {
336- c, w := parseString(t, `workspace.dev.hidden = on
337+ c, w := parseString(t, `workspace.dev.monitor = DP-2
337338 workspace.dev.column.2 = 33%, foot --title x
338339 workspace.dev.column.1 = 67%, code --new-window
339340 workspace.dev.column.2 = 1/3, kitty
@@ -362,7 +363,27 @@ func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
362363 _, w := parseString(t, `workspace.dev.overflow = fixed
363364 workspace.dev.column.1 = 67%, foot
364365 `)
365- if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "ignored") {
366+ if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {
366367 t.Fatal(w)
367368 }
368369 }
370+
371+func TestTerminalResolutionAndAutoOpen(t *testing.T) {
372+ t.Setenv("TERMINAL", "ghostty --new-window")
373+ c, w := parseString(t, "terminal.auto-open = all\n")
374+ if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"ghostty", "--new-window"}) || c.Terminal.AutoOpen != "all" {
375+ t.Fatal(c.Terminal, w)
376+ }
377+ c, w = parseString(t, "terminal = foot --server\nterminal.auto-open = off\n")
378+ if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Terminal.AutoOpen != "off" {
379+ t.Fatal(c.Terminal, w)
380+ }
381+ c, w = parseString(t, "terminal.auto-open = invalid\n")
382+ if len(w) != 1 || c.Terminal.AutoOpen != "first" {
383+ t.Fatal(c.Terminal, w)
384+ }
385+ t.Setenv("TERMINAL", " ")
386+ if got := Defaults().Terminal.Command; !reflect.DeepEqual(got, []string{"foot"}) {
387+ t.Fatal(got)
388+ }
389+}
@@ -20,6 +20,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
20 }20 }
21 21
22 func TestDefaultsAndLoad(t *testing.T) {22 func TestDefaultsAndLoad(t *testing.T) {
23+ t.Setenv("TERMINAL", "")
23 d := Defaults()24 d := Defaults()
24 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 52 || d.Layout.MaxColumns != 2 {25 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 52 || d.Layout.MaxColumns != 2 {
25 t.Fatalf("defaults: %+v", d)26 t.Fatalf("defaults: %+v", d)
@@ -282,12 +283,12 @@ workspace.web.overflow = spiral
282 bind.cmd+d = workspace dev283 bind.cmd+d = workspace dev
283 bind.cmd+w = workspace284 bind.cmd+w = workspace
284 bind.cmd+x = workspace typo285 bind.cmd+x = workspace typo
285-workspace.lost.hidden = on286+workspace.lost.monitor = DP-1
286 `)287 `)
287 want := []ports.WorkspaceConfig{288 want := []ports.WorkspaceConfig{
288- {Name: "dev", Monitor: "DP-2", Hidden: true, MaxColumns: 3},289+ {Name: "dev", Monitor: "DP-2", MaxColumns: 3},
289 {Name: "web", Overflow: "scroll"},290 {Name: "web", Overflow: "scroll"},
290- {Name: "lost", Hidden: true},291+ {Name: "lost", Monitor: "DP-1"},
291 }292 }
292 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {293 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
293 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)294 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
@@ -299,7 +300,7 @@ workspace.lost.hidden = on
299 for _, x := range w {300 for _, x := range w {
300 lines = append(lines, x.Line)301 lines = append(lines, x.Line)
301 }302 }
302- if !reflect.DeepEqual(lines, []int{6, 7, 8, 10, 11, 12}) {303+ if !reflect.DeepEqual(lines, []int{2, 4, 6, 7, 8, 10, 11, 12}) {
303 t.Fatal(w)304 t.Fatal(w)
304 }305 }
305 }306 }
@@ -327,13 +328,13 @@ workspace.dev.monitor = DP-2
327 workspace.dev.column.1 = 50%, foot328 workspace.dev.column.1 = 50%, foot
328 workspace.web.column.1 = 50%, foot329 workspace.web.column.1 = 50%, foot
329 `)330 `)
330- if len(w) != 1 || w[0].Line != 3 {331+ if len(w) != 3 || w[1].Line != 3 {
331 t.Fatal(w)332 t.Fatal(w)
332 }333 }
333 }334 }
334 335
335 func TestWorkspaceSlots(t *testing.T) {336 func TestWorkspaceSlots(t *testing.T) {
336- c, w := parseString(t, `workspace.dev.hidden = on337+ c, w := parseString(t, `workspace.dev.monitor = DP-2
337 workspace.dev.column.2 = 33%, foot --title x338 workspace.dev.column.2 = 33%, foot --title x
338 workspace.dev.column.1 = 67%, code --new-window339 workspace.dev.column.1 = 67%, code --new-window
339 workspace.dev.column.2 = 1/3, kitty340 workspace.dev.column.2 = 1/3, kitty
@@ -362,7 +363,27 @@ func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
362 _, w := parseString(t, `workspace.dev.overflow = fixed363 _, w := parseString(t, `workspace.dev.overflow = fixed
363 workspace.dev.column.1 = 67%, foot364 workspace.dev.column.1 = 67%, foot
364 `)365 `)
365- if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "ignored") {366+ if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {
366 t.Fatal(w)367 t.Fatal(w)
367 }368 }
368 }369 }
370+
371+func TestTerminalResolutionAndAutoOpen(t *testing.T) {
372+ t.Setenv("TERMINAL", "ghostty --new-window")
373+ c, w := parseString(t, "terminal.auto-open = all\n")
374+ if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"ghostty", "--new-window"}) || c.Terminal.AutoOpen != "all" {
375+ t.Fatal(c.Terminal, w)
376+ }
377+ c, w = parseString(t, "terminal = foot --server\nterminal.auto-open = off\n")
378+ if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Terminal.AutoOpen != "off" {
379+ t.Fatal(c.Terminal, w)
380+ }
381+ c, w = parseString(t, "terminal.auto-open = invalid\n")
382+ if len(w) != 1 || c.Terminal.AutoOpen != "first" {
383+ t.Fatal(c.Terminal, w)
384+ }
385+ t.Setenv("TERMINAL", " ")
386+ if got := Defaults().Terminal.Command; !reflect.DeepEqual(got, []string{"foot"}) {
387+ t.Fatal(got)
388+ }
389+}
M internal/core/action.go +0 -5
@@ -168,11 +168,6 @@ func (c *Core) applyAction(a Action) Effect {
168168 }
169169 edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1)
170170 if i := c.neighbor(dir); edge && i >= 0 {
171- // The last column stays: a workspace on screen is never
172- // empty, and moving it would only spawn a new terminal.
173- if len(w.Columns) < 2 && c.keepsTerminal() {
174- return Effect{}
175- }
176171 col, ok := w.takeColumn()
177172 if !ok {
178173 return Effect{}
@@ -168,11 +168,6 @@ func (c *Core) applyAction(a Action) Effect {
168 }168 }
169 edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1)169 edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1)
170 if i := c.neighbor(dir); edge && i >= 0 {170 if i := c.neighbor(dir); edge && i >= 0 {
171- // The last column stays: a workspace on screen is never
172- // empty, and moving it would only spawn a new terminal.
173- if len(w.Columns) < 2 && c.keepsTerminal() {
174- return Effect{}
175- }
176 col, ok := w.takeColumn()171 col, ok := w.takeColumn()
177 if !ok {172 if !ok {
178 return Effect{}173 return Effect{}
M internal/core/core.go +5 -4
@@ -37,8 +37,7 @@ type Channels struct {
3737 // State, when set, receives a snapshot for scripts whenever it changes,
3838 // latest first (capacity 1, drained like Scenes).
3939 State chan ports.State
40- // Terminal, when set, keeps a window on every workspace on screen: an
41- // empty one gets the configured terminal (the terminal is the desktop).
40+ // Terminal enables automatic terminal opening according to terminal.auto-open.
4241 Terminal bool
4342 }
4443 type binding struct {
@@ -75,7 +74,8 @@ type Core struct {
7574 sentPending bool
7675 // terms are the terminals spawned for empty workspaces, by SlotEnv
7776 // token, until their window maps.
78- terms map[string]*termSpawn
77+ terms map[string]*termSpawn
78+ firstTerminalResolved bool
7979 // clients holds the app ID and PID of mapped windows, for State.
8080 clients map[WindowID]ports.WindowMapped
8181 sentState ports.State
@@ -222,7 +222,7 @@ func (c *Core) apply(cfg ports.Config) error {
222222 return fmt.Errorf("invalid workspace %q", ws.Name)
223223 }
224224 seen[ws.Name] = true
225- named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
225+ named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, MaxColumns: ws.MaxColumns, Overflow: o})
226226 }
227227 specs, err := parseSlots(cfg.Workspaces)
228228 if err != nil {
@@ -236,6 +236,7 @@ func (c *Core) apply(cfg ports.Config) error {
236236 for _, s := range c.screens {
237237 c.settings(s.mon)
238238 }
239+ c.settleGuests()
239240 c.applyConfigScales()
240241 return nil
241242 }
@@ -37,8 +37,7 @@ type Channels struct {
37 // State, when set, receives a snapshot for scripts whenever it changes,37 // State, when set, receives a snapshot for scripts whenever it changes,
38 // latest first (capacity 1, drained like Scenes).38 // latest first (capacity 1, drained like Scenes).
39 State chan ports.State39 State chan ports.State
40- // Terminal, when set, keeps a window on every workspace on screen: an40+ // Terminal enables automatic terminal opening according to terminal.auto-open.
41- // empty one gets the configured terminal (the terminal is the desktop).
42 Terminal bool41 Terminal bool
43 }42 }
44 type binding struct {43 type binding struct {
@@ -75,7 +74,8 @@ type Core struct {
75 sentPending bool74 sentPending bool
76 // terms are the terminals spawned for empty workspaces, by SlotEnv75 // terms are the terminals spawned for empty workspaces, by SlotEnv
77 // token, until their window maps.76 // token, until their window maps.
78- terms map[string]*termSpawn77+ terms map[string]*termSpawn
78+ firstTerminalResolved bool
79 // clients holds the app ID and PID of mapped windows, for State.79 // clients holds the app ID and PID of mapped windows, for State.
80 clients map[WindowID]ports.WindowMapped80 clients map[WindowID]ports.WindowMapped
81 sentState ports.State81 sentState ports.State
@@ -222,7 +222,7 @@ func (c *Core) apply(cfg ports.Config) error {
222 return fmt.Errorf("invalid workspace %q", ws.Name)222 return fmt.Errorf("invalid workspace %q", ws.Name)
223 }223 }
224 seen[ws.Name] = true224 seen[ws.Name] = true
225- named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})225+ named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, MaxColumns: ws.MaxColumns, Overflow: o})
226 }226 }
227 specs, err := parseSlots(cfg.Workspaces)227 specs, err := parseSlots(cfg.Workspaces)
228 if err != nil {228 if err != nil {
@@ -236,6 +236,7 @@ func (c *Core) apply(cfg ports.Config) error {
236 for _, s := range c.screens {236 for _, s := range c.screens {
237 c.settings(s.mon)237 c.settings(s.mon)
238 }238 }
239+ c.settleGuests()
239 c.applyConfigScales()240 c.applyConfigScales()
240 return nil241 return nil
241 }242 }
M internal/core/monitor.go +6 -20
@@ -8,7 +8,7 @@ import "slices"
88 // except the first. Output-wide settings apply to every workspace.
99 //
1010 // Named workspaces come from config and are never removed while configured.
11-// A hidden one is not numbered and not reachable by up/down: only its
11+// Each named workspace is not numbered and not reachable by up/down: only its
1212 // `workspace <name>` bind shows it.
1313 type Monitor struct {
1414 // Name is the connector; Key identifies the physical monitor (make,
@@ -32,7 +32,6 @@ type NamedWorkspace struct {
3232 Name string
3333 // Monitor is the home monitor (connector or key); "" for the first one.
3434 Monitor string
35- Hidden bool
3635 MaxColumns int
3736 Overflow Overflow
3837 }
@@ -612,11 +611,8 @@ func (m *Monitor) applyNamed() {
612611 })
613612 }
614613
615-// SetNamed applies the named workspaces from config. New numbered ones are
616-// inserted above the trailing empty workspace (never before the first one).
617-// A workspace dropped from config becomes a normal one; a dropped hidden one
618-// hands its windows to the active numbered workspace. The workspace on
619-// screen stays on screen.
614+// SetNamed applies bind-only named workspaces from config. Removed named
615+// workspaces hand their windows to the active numbered workspace.
620616 func (m *Monitor) SetNamed(specs []NamedWorkspace) {
621617 cur := m.Current()
622618 want := map[string]NamedWorkspace{}
@@ -660,20 +656,10 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
660656 w = m.newWorkspace()
661657 w.Name = s.Name
662658 }
663- i := indexOf(m.Workspaces, w)
664- switch {
665- case s.Hidden:
666- if i >= 0 {
667- m.removeNumbered(i)
668- }
669- nextHidden = append(nextHidden, w)
670- case i < 0:
671- at := max(len(m.Workspaces)-1, 1)
672- m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...)
673- if at <= m.Active {
674- m.Active++
675- }
659+ if i := indexOf(m.Workspaces, w); i >= 0 {
660+ m.removeNumbered(i)
676661 }
662+ nextHidden = append(nextHidden, w)
677663 }
678664 m.hidden = nextHidden
679665 if m.back != nil && !m.has(m.back) {
@@ -8,7 +8,7 @@ import "slices"
8 // except the first. Output-wide settings apply to every workspace.8 // except the first. Output-wide settings apply to every workspace.
9 //9 //
10 // Named workspaces come from config and are never removed while configured.10 // Named workspaces come from config and are never removed while configured.
11-// A hidden one is not numbered and not reachable by up/down: only its11+// Each named workspace is not numbered and not reachable by up/down: only its
12 // `workspace <name>` bind shows it.12 // `workspace <name>` bind shows it.
13 type Monitor struct {13 type Monitor struct {
14 // Name is the connector; Key identifies the physical monitor (make,14 // Name is the connector; Key identifies the physical monitor (make,
@@ -32,7 +32,6 @@ type NamedWorkspace struct {
32 Name string32 Name string
33 // Monitor is the home monitor (connector or key); "" for the first one.33 // Monitor is the home monitor (connector or key); "" for the first one.
34 Monitor string34 Monitor string
35- Hidden bool
36 MaxColumns int35 MaxColumns int
37 Overflow Overflow36 Overflow Overflow
38 }37 }
@@ -612,11 +611,8 @@ func (m *Monitor) applyNamed() {
612 })611 })
613 }612 }
614 613
615-// SetNamed applies the named workspaces from config. New numbered ones are614+// SetNamed applies bind-only named workspaces from config. Removed named
616-// inserted above the trailing empty workspace (never before the first one).615+// workspaces hand their windows to the active numbered workspace.
617-// A workspace dropped from config becomes a normal one; a dropped hidden one
618-// hands its windows to the active numbered workspace. The workspace on
619-// screen stays on screen.
620 func (m *Monitor) SetNamed(specs []NamedWorkspace) {616 func (m *Monitor) SetNamed(specs []NamedWorkspace) {
621 cur := m.Current()617 cur := m.Current()
622 want := map[string]NamedWorkspace{}618 want := map[string]NamedWorkspace{}
@@ -660,20 +656,10 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
660 w = m.newWorkspace()656 w = m.newWorkspace()
661 w.Name = s.Name657 w.Name = s.Name
662 }658 }
663- i := indexOf(m.Workspaces, w)659+ if i := indexOf(m.Workspaces, w); i >= 0 {
664- switch {660+ m.removeNumbered(i)
665- case s.Hidden:
666- if i >= 0 {
667- m.removeNumbered(i)
668- }
669- nextHidden = append(nextHidden, w)
670- case i < 0:
671- at := max(len(m.Workspaces)-1, 1)
672- m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...)
673- if at <= m.Active {
674- m.Active++
675- }
676 }661 }
662+ nextHidden = append(nextHidden, w)
677 }663 }
678 m.hidden = nextHidden664 m.hidden = nextHidden
679 if m.back != nil && !m.has(m.back) {665 if m.back != nil && !m.has(m.back) {
M internal/core/monitor_test.go +11 -97
@@ -320,7 +320,7 @@ func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
320320 }
321321
322322 func TestHiddenWorkspaceToggle(t *testing.T) {
323- m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true})
323+ m := named(monitor(), NamedWorkspace{Name: "dev"})
324324 m.AddWindow(1)
325325 if len(m.Workspaces) != 2 {
326326 t.Fatal("hidden workspace is numbered", windows(m))
@@ -361,40 +361,27 @@ func TestHiddenWorkspaceToggle(t *testing.T) {
361361 }
362362 }
363363
364-func TestNamedWorkspaceIsNumberedAndKept(t *testing.T) {
365- m := monitor()
366- m.AddWindow(1)
367- named(m, NamedWorkspace{Name: "web"})
368- // Inserted above the trailing empty workspace: [1:{1}] [2:web] [3:{}].
369- if len(m.Workspaces) != 3 || m.Workspaces[1].Name != "web" || m.Active != 0 {
370- t.Fatal(windows(m), m.Active)
371- }
372- // Empty named workspaces are not removed when left.
373- m.FocusNumber(2)
374- m.FocusNumber(1)
375- if m.Workspaces[1].Name != "web" {
364+func TestNamedWorkspaceBindOnly(t *testing.T) {
365+ m := named(monitor(), NamedWorkspace{Name: "web"})
366+ if len(m.Workspaces) != 1 {
376367 t.Fatal(windows(m))
377368 }
378369 m.Apply("workspace web")
379- if m.Active != 1 {
380- t.Fatal(m.Active)
370+ if m.Current().Name != "web" {
371+ t.Fatal(m.Current().Name)
381372 }
382373 m.Apply("workspace web")
383- if m.Active != 0 {
384- t.Fatal("toggle did not return", m.Active)
385- }
386- m.Apply("workspace nope") // unknown: nothing
387- if m.Active != 0 {
388- t.Fatal(m.Active)
374+ if m.Current().Name != "" {
375+ t.Fatal(m.Current().Name)
389376 }
390377 }
391378
392379 func TestNamedWorkspaceReload(t *testing.T) {
393- m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web"})
380+ m := named(monitor(), NamedWorkspace{Name: "dev"}, NamedWorkspace{Name: "web"})
394381 m.Apply("workspace dev")
395382 m.AddWindow(7)
396383 // Reload keeps windows and the workspace on screen; web becomes hidden.
397- named(m, NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web", Hidden: true})
384+ named(m, NamedWorkspace{Name: "dev"}, NamedWorkspace{Name: "web"})
398385 if m.Current().Name != "dev" || len(m.Workspaces) != 1 {
399386 t.Fatal(m.Current().Name, windows(m))
400387 }
@@ -406,7 +393,7 @@ func TestNamedWorkspaceReload(t *testing.T) {
406393 }
407394
408395 func TestNamedWorkspaceSettings(t *testing.T) {
409- m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true, MaxColumns: 3, Overflow: OverflowFixed})
396+ m := named(monitor(), NamedWorkspace{Name: "dev", MaxColumns: 3, Overflow: OverflowFixed})
410397 m.SetMaxColumns(1)
411398 if m.Current().MaxColumns != 1 || m.Current().Overflow != "" {
412399 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
@@ -424,79 +411,6 @@ func TestNamedWorkspaceSettings(t *testing.T) {
424411 }
425412 }
426413
427-// checkInvariants fails when the monitor is in a state no action should reach.
428-func checkInvariants(t *testing.T, m *Monitor, want []WindowID) {
429- t.Helper()
430- if m.Active < 0 || m.Active >= len(m.Workspaces) {
431- t.Fatalf("active %d of %d", m.Active, len(m.Workspaces))
432- }
433- if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" {
434- t.Fatal("no trailing empty unnamed workspace")
435- }
436- if m.shown != nil && indexOf(m.hidden, m.shown) < 0 {
437- t.Fatal("shown is not a hidden workspace")
438- }
439- if m.back != nil && !m.has(m.back) {
440- t.Fatal("back dangles")
441- }
442- seen := map[WindowID]int{}
443- for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
444- for _, c := range w.Columns {
445- for _, id := range c.Windows {
446- seen[id]++
447- }
448- }
449- }
450- for _, id := range want {
451- if seen[id] != 1 {
452- t.Fatalf("window %d present %d times", id, seen[id])
453- }
454- }
455- if len(seen) != len(want) {
456- t.Fatalf("windows %v, want %v", seen, want)
457- }
458-}
459-
460-func TestSetNamedTransitions(t *testing.T) {
461- dev := NamedWorkspace{Name: "dev"}
462- devHidden := NamedWorkspace{Name: "dev", Hidden: true}
463- web := NamedWorkspace{Name: "web"}
464- for _, tc := range []struct {
465- name string
466- before []NamedWorkspace
467- after []NamedWorkspace
468- onDev bool // show dev before the reload
469- want string // workspace name on screen after
470- }{
471- {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"},
472- {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"},
473- {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""},
474- {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""},
475- {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""},
476- {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""},
477- } {
478- t.Run(tc.name, func(t *testing.T) {
479- m := named(monitor(), tc.before...)
480- m.AddWindow(1)
481- if tc.onDev {
482- m.Apply("workspace dev")
483- }
484- m.AddWindow(2)
485- checkInvariants(t, m, []WindowID{1, 2})
486- m.SetNamed(tc.after)
487- checkInvariants(t, m, []WindowID{1, 2})
488- if got := m.Current().Name; got != tc.want {
489- t.Fatalf("on %q, want %q", got, tc.want)
490- }
491- if id, _ := m.Focused(); id != 2 {
492- t.Fatalf("focus moved to %d", id)
493- }
494- })
495- }
496-}
497-
498-// Under fixed overflow, fullscreen moves the window to its own workspace
499-// below; workspace up/down still work and leaving brings it back in place.
500414 func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
501415 m := monitor()
502416 m.SetOverflow(OverflowFixed)
@@ -320,7 +320,7 @@ func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
320 }320 }
321 321
322 func TestHiddenWorkspaceToggle(t *testing.T) {322 func TestHiddenWorkspaceToggle(t *testing.T) {
323- m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true})323+ m := named(monitor(), NamedWorkspace{Name: "dev"})
324 m.AddWindow(1)324 m.AddWindow(1)
325 if len(m.Workspaces) != 2 {325 if len(m.Workspaces) != 2 {
326 t.Fatal("hidden workspace is numbered", windows(m))326 t.Fatal("hidden workspace is numbered", windows(m))
@@ -361,40 +361,27 @@ func TestHiddenWorkspaceToggle(t *testing.T) {
361 }361 }
362 }362 }
363 363
364-func TestNamedWorkspaceIsNumberedAndKept(t *testing.T) {364+func TestNamedWorkspaceBindOnly(t *testing.T) {
365- m := monitor()365+ m := named(monitor(), NamedWorkspace{Name: "web"})
366- m.AddWindow(1)366+ if len(m.Workspaces) != 1 {
367- named(m, NamedWorkspace{Name: "web"})
368- // Inserted above the trailing empty workspace: [1:{1}] [2:web] [3:{}].
369- if len(m.Workspaces) != 3 || m.Workspaces[1].Name != "web" || m.Active != 0 {
370- t.Fatal(windows(m), m.Active)
371- }
372- // Empty named workspaces are not removed when left.
373- m.FocusNumber(2)
374- m.FocusNumber(1)
375- if m.Workspaces[1].Name != "web" {
376 t.Fatal(windows(m))367 t.Fatal(windows(m))
377 }368 }
378 m.Apply("workspace web")369 m.Apply("workspace web")
379- if m.Active != 1 {370+ if m.Current().Name != "web" {
380- t.Fatal(m.Active)371+ t.Fatal(m.Current().Name)
381 }372 }
382 m.Apply("workspace web")373 m.Apply("workspace web")
383- if m.Active != 0 {374+ if m.Current().Name != "" {
384- t.Fatal("toggle did not return", m.Active)375+ t.Fatal(m.Current().Name)
385- }
386- m.Apply("workspace nope") // unknown: nothing
387- if m.Active != 0 {
388- t.Fatal(m.Active)
389 }376 }
390 }377 }
391 378
392 func TestNamedWorkspaceReload(t *testing.T) {379 func TestNamedWorkspaceReload(t *testing.T) {
393- m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web"})380+ m := named(monitor(), NamedWorkspace{Name: "dev"}, NamedWorkspace{Name: "web"})
394 m.Apply("workspace dev")381 m.Apply("workspace dev")
395 m.AddWindow(7)382 m.AddWindow(7)
396 // Reload keeps windows and the workspace on screen; web becomes hidden.383 // Reload keeps windows and the workspace on screen; web becomes hidden.
397- named(m, NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web", Hidden: true})384+ named(m, NamedWorkspace{Name: "dev"}, NamedWorkspace{Name: "web"})
398 if m.Current().Name != "dev" || len(m.Workspaces) != 1 {385 if m.Current().Name != "dev" || len(m.Workspaces) != 1 {
399 t.Fatal(m.Current().Name, windows(m))386 t.Fatal(m.Current().Name, windows(m))
400 }387 }
@@ -406,7 +393,7 @@ func TestNamedWorkspaceReload(t *testing.T) {
406 }393 }
407 394
408 func TestNamedWorkspaceSettings(t *testing.T) {395 func TestNamedWorkspaceSettings(t *testing.T) {
409- m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true, MaxColumns: 3, Overflow: OverflowFixed})396+ m := named(monitor(), NamedWorkspace{Name: "dev", MaxColumns: 3, Overflow: OverflowFixed})
410 m.SetMaxColumns(1)397 m.SetMaxColumns(1)
411 if m.Current().MaxColumns != 1 || m.Current().Overflow != "" {398 if m.Current().MaxColumns != 1 || m.Current().Overflow != "" {
412 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)399 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
@@ -424,79 +411,6 @@ func TestNamedWorkspaceSettings(t *testing.T) {
424 }411 }
425 }412 }
426 413
427-// checkInvariants fails when the monitor is in a state no action should reach.
428-func checkInvariants(t *testing.T, m *Monitor, want []WindowID) {
429- t.Helper()
430- if m.Active < 0 || m.Active >= len(m.Workspaces) {
431- t.Fatalf("active %d of %d", m.Active, len(m.Workspaces))
432- }
433- if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" {
434- t.Fatal("no trailing empty unnamed workspace")
435- }
436- if m.shown != nil && indexOf(m.hidden, m.shown) < 0 {
437- t.Fatal("shown is not a hidden workspace")
438- }
439- if m.back != nil && !m.has(m.back) {
440- t.Fatal("back dangles")
441- }
442- seen := map[WindowID]int{}
443- for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
444- for _, c := range w.Columns {
445- for _, id := range c.Windows {
446- seen[id]++
447- }
448- }
449- }
450- for _, id := range want {
451- if seen[id] != 1 {
452- t.Fatalf("window %d present %d times", id, seen[id])
453- }
454- }
455- if len(seen) != len(want) {
456- t.Fatalf("windows %v, want %v", seen, want)
457- }
458-}
459-
460-func TestSetNamedTransitions(t *testing.T) {
461- dev := NamedWorkspace{Name: "dev"}
462- devHidden := NamedWorkspace{Name: "dev", Hidden: true}
463- web := NamedWorkspace{Name: "web"}
464- for _, tc := range []struct {
465- name string
466- before []NamedWorkspace
467- after []NamedWorkspace
468- onDev bool // show dev before the reload
469- want string // workspace name on screen after
470- }{
471- {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"},
472- {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"},
473- {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""},
474- {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""},
475- {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""},
476- {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""},
477- } {
478- t.Run(tc.name, func(t *testing.T) {
479- m := named(monitor(), tc.before...)
480- m.AddWindow(1)
481- if tc.onDev {
482- m.Apply("workspace dev")
483- }
484- m.AddWindow(2)
485- checkInvariants(t, m, []WindowID{1, 2})
486- m.SetNamed(tc.after)
487- checkInvariants(t, m, []WindowID{1, 2})
488- if got := m.Current().Name; got != tc.want {
489- t.Fatalf("on %q, want %q", got, tc.want)
490- }
491- if id, _ := m.Focused(); id != 2 {
492- t.Fatalf("focus moved to %d", id)
493- }
494- })
495- }
496-}
497-
498-// Under fixed overflow, fullscreen moves the window to its own workspace
499-// below; workspace up/down still work and leaving brings it back in place.
500 func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {414 func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
501 m := monitor()415 m := monitor()
502 m.SetOverflow(OverflowFixed)416 m.SetOverflow(OverflowFixed)
M internal/core/screens_test.go +95 -7
@@ -36,8 +36,7 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo
3636 return startRig(t, false, edit, outs...)
3737 }
3838
39-// startRig is startMulti; with terminal set, core keeps a terminal on every
40-// empty workspace on screen.
39+// startRig is startMulti; terminal enables the configured automatic-open policy.
4140 func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
4241 t.Helper()
4342 cfg := config.Defaults()
@@ -342,9 +341,26 @@ func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
342341 }
343342 }
344343
344+func TestNamedWorkspaceHomeChangeAppliesLive(t *testing.T) {
345+ r := startMulti(t, func(c *ports.Config) {
346+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
347+ c.Binds["Alt+w"] = "workspace web"
348+ }, left, right)
349+ r.key(t, "w", ports.ModAlt)
350+ r.mapWindow(t, 1)
351+ r.key(t, "w", ports.ModAlt)
352+ r.cfg.Workspaces[0].Monitor = "DP-2"
353+ r.reload <- ports.ConfigChanged{Config: r.cfg}
354+ receive(t, r.scenes)
355+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
356+ if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
357+ t.Fatal(got)
358+ }
359+}
360+
345361 func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
346362 r := startMulti(t, func(c *ports.Config) {
347- c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2", Hidden: true}}
363+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2"}}
348364 c.Binds["Alt+w"] = "workspace web"
349365 }, left)
350366 // DP-2 is not plugged: web lives on DP-1 as a guest.
@@ -474,8 +490,80 @@ func TestPointerFocusesOutput(t *testing.T) {
474490 }
475491 }
476492
493+func TestTerminalAutoOpenFirstOnly(t *testing.T) {
494+ r := startRig(t, true, nil, left)
495+ first := receive(t, r.spawn)
496+ if first.Argv[0] != "foot" {
497+ t.Fatal(first)
498+ }
499+ r.key(t, "Next", ports.ModAlt)
500+ if len(r.spawn) != 0 {
501+ t.Fatal("opened a terminal on the second workspace")
502+ }
503+}
504+
505+func TestTerminalAutoOpenOff(t *testing.T) {
506+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "off" }, left)
507+ if len(r.spawn) != 0 {
508+ t.Fatal("opened a terminal with auto-open off")
509+ }
510+ r.key(t, "Return", ports.ModAlt)
511+ if req := receive(t, r.spawn); req.Argv[0] != "foot" || len(req.Env) != 0 {
512+ t.Fatal(req)
513+ }
514+}
515+
516+func TestTerminalAutoOpenFirstSkipsPopulatedInitialWorkspace(t *testing.T) {
517+ r := startRig(t, false, nil, left)
518+ r.mapWindow(t, 1)
519+ r.cfg.Terminal.AutoOpen = "first"
520+ r.reload <- ports.ConfigChanged{Config: r.cfg}
521+ receive(t, r.scenes)
522+ r.key(t, "Next", ports.ModAlt)
523+ if len(r.spawn) != 0 {
524+ t.Fatal("opened a terminal after the initial workspace opportunity passed")
525+ }
526+}
527+
528+func TestTerminalAutoOpenFirstDoesNotTargetLaterWorkspace(t *testing.T) {
529+ r := startRig(t, false, nil, left)
530+ r.key(t, "Next", ports.ModAlt)
531+ r.cfg.Terminal.AutoOpen = "first"
532+ r.reload <- ports.ConfigChanged{Config: r.cfg}
533+ receive(t, r.scenes)
534+ if len(r.spawn) != 0 {
535+ t.Fatal("opened the first terminal outside workspace 1")
536+ }
537+}
538+
539+func TestTerminalAutoOpenFirstSurvivesReloadBeforeOutput(t *testing.T) {
540+ r := startRig(t, true, nil)
541+ r.reload <- ports.ConfigChanged{Config: r.cfg}
542+ receive(t, r.scenes)
543+ r.plug(t, left)
544+ if req := receive(t, r.spawn); req.Argv[0] != "foot" {
545+ t.Fatal(req)
546+ }
547+}
548+
549+func TestTerminalAutoOpenAllThenFirstDoesNotOpenAgain(t *testing.T) {
550+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
551+ req := receive(t, r.spawn)
552+ token := req.Env[0][len(ports.SlotEnv)+1:]
553+ r.client <- ports.WindowMapped{ID: 1, Slot: token}
554+ receive(t, r.scenes)
555+ r.cfg.Terminal.AutoOpen = "first"
556+ r.reload <- ports.ConfigChanged{Config: r.cfg}
557+ receive(t, r.scenes)
558+ r.client <- ports.WindowUnmapped{ID: 1}
559+ receive(t, r.scenes)
560+ if len(r.spawn) != 0 {
561+ t.Fatal("opened a second automatic terminal after all-to-first reload")
562+ }
563+}
564+
477565 func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
478- r := startRig(t, true, nil, left, right)
566+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left, right)
479567 // One terminal per output, each tagged for its workspace.
480568 var reqs []ports.SpawnRequest
481569 for len(reqs) < 2 {
@@ -504,11 +592,11 @@ func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
504592 }
505593 }
506594
507-func TestLastColumnStaysOnItsOutput(t *testing.T) {
595+func TestLastColumnMovesToNeighborOutput(t *testing.T) {
508596 r := startRig(t, true, nil, left, right)
509597 r.mapWindow(t, 1)
510598 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
511- if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
599+ if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
512600 t.Fatal(got)
513601 }
514602 }
@@ -568,7 +656,7 @@ func TestStateSnapshot(t *testing.T) {
568656
569657 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
570658 r := startMulti(t, func(c *ports.Config) {
571- c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}}
659+ c.Workspaces = []ports.WorkspaceConfig{{Name: "notes"}}
572660 c.Binds["Alt+n"] = "workspace notes"
573661 }, left)
574662 r.client <- ports.WindowMapped{ID: 1, PID: 100}
@@ -36,8 +36,7 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo
36 return startRig(t, false, edit, outs...)36 return startRig(t, false, edit, outs...)
37 }37 }
38 38
39-// startRig is startMulti; with terminal set, core keeps a terminal on every39+// startRig is startMulti; terminal enables the configured automatic-open policy.
40-// empty workspace on screen.
41 func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {40 func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
42 t.Helper()41 t.Helper()
43 cfg := config.Defaults()42 cfg := config.Defaults()
@@ -342,9 +341,26 @@ func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
342 }341 }
343 }342 }
344 343
344+func TestNamedWorkspaceHomeChangeAppliesLive(t *testing.T) {
345+ r := startMulti(t, func(c *ports.Config) {
346+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
347+ c.Binds["Alt+w"] = "workspace web"
348+ }, left, right)
349+ r.key(t, "w", ports.ModAlt)
350+ r.mapWindow(t, 1)
351+ r.key(t, "w", ports.ModAlt)
352+ r.cfg.Workspaces[0].Monitor = "DP-2"
353+ r.reload <- ports.ConfigChanged{Config: r.cfg}
354+ receive(t, r.scenes)
355+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
356+ if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
357+ t.Fatal(got)
358+ }
359+}
360+
345 func TestNamedWorkspaceOnItsMonitor(t *testing.T) {361 func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
346 r := startMulti(t, func(c *ports.Config) {362 r := startMulti(t, func(c *ports.Config) {
347- c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2", Hidden: true}}363+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2"}}
348 c.Binds["Alt+w"] = "workspace web"364 c.Binds["Alt+w"] = "workspace web"
349 }, left)365 }, left)
350 // DP-2 is not plugged: web lives on DP-1 as a guest.366 // DP-2 is not plugged: web lives on DP-1 as a guest.
@@ -474,8 +490,80 @@ func TestPointerFocusesOutput(t *testing.T) {
474 }490 }
475 }491 }
476 492
493+func TestTerminalAutoOpenFirstOnly(t *testing.T) {
494+ r := startRig(t, true, nil, left)
495+ first := receive(t, r.spawn)
496+ if first.Argv[0] != "foot" {
497+ t.Fatal(first)
498+ }
499+ r.key(t, "Next", ports.ModAlt)
500+ if len(r.spawn) != 0 {
501+ t.Fatal("opened a terminal on the second workspace")
502+ }
503+}
504+
505+func TestTerminalAutoOpenOff(t *testing.T) {
506+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "off" }, left)
507+ if len(r.spawn) != 0 {
508+ t.Fatal("opened a terminal with auto-open off")
509+ }
510+ r.key(t, "Return", ports.ModAlt)
511+ if req := receive(t, r.spawn); req.Argv[0] != "foot" || len(req.Env) != 0 {
512+ t.Fatal(req)
513+ }
514+}
515+
516+func TestTerminalAutoOpenFirstSkipsPopulatedInitialWorkspace(t *testing.T) {
517+ r := startRig(t, false, nil, left)
518+ r.mapWindow(t, 1)
519+ r.cfg.Terminal.AutoOpen = "first"
520+ r.reload <- ports.ConfigChanged{Config: r.cfg}
521+ receive(t, r.scenes)
522+ r.key(t, "Next", ports.ModAlt)
523+ if len(r.spawn) != 0 {
524+ t.Fatal("opened a terminal after the initial workspace opportunity passed")
525+ }
526+}
527+
528+func TestTerminalAutoOpenFirstDoesNotTargetLaterWorkspace(t *testing.T) {
529+ r := startRig(t, false, nil, left)
530+ r.key(t, "Next", ports.ModAlt)
531+ r.cfg.Terminal.AutoOpen = "first"
532+ r.reload <- ports.ConfigChanged{Config: r.cfg}
533+ receive(t, r.scenes)
534+ if len(r.spawn) != 0 {
535+ t.Fatal("opened the first terminal outside workspace 1")
536+ }
537+}
538+
539+func TestTerminalAutoOpenFirstSurvivesReloadBeforeOutput(t *testing.T) {
540+ r := startRig(t, true, nil)
541+ r.reload <- ports.ConfigChanged{Config: r.cfg}
542+ receive(t, r.scenes)
543+ r.plug(t, left)
544+ if req := receive(t, r.spawn); req.Argv[0] != "foot" {
545+ t.Fatal(req)
546+ }
547+}
548+
549+func TestTerminalAutoOpenAllThenFirstDoesNotOpenAgain(t *testing.T) {
550+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
551+ req := receive(t, r.spawn)
552+ token := req.Env[0][len(ports.SlotEnv)+1:]
553+ r.client <- ports.WindowMapped{ID: 1, Slot: token}
554+ receive(t, r.scenes)
555+ r.cfg.Terminal.AutoOpen = "first"
556+ r.reload <- ports.ConfigChanged{Config: r.cfg}
557+ receive(t, r.scenes)
558+ r.client <- ports.WindowUnmapped{ID: 1}
559+ receive(t, r.scenes)
560+ if len(r.spawn) != 0 {
561+ t.Fatal("opened a second automatic terminal after all-to-first reload")
562+ }
563+}
564+
477 func TestEmptyWorkspaceGetsTerminal(t *testing.T) {565 func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
478- r := startRig(t, true, nil, left, right)566+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left, right)
479 // One terminal per output, each tagged for its workspace.567 // One terminal per output, each tagged for its workspace.
480 var reqs []ports.SpawnRequest568 var reqs []ports.SpawnRequest
481 for len(reqs) < 2 {569 for len(reqs) < 2 {
@@ -504,11 +592,11 @@ func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
504 }592 }
505 }593 }
506 594
507-func TestLastColumnStaysOnItsOutput(t *testing.T) {595+func TestLastColumnMovesToNeighborOutput(t *testing.T) {
508 r := startRig(t, true, nil, left, right)596 r := startRig(t, true, nil, left, right)
509 r.mapWindow(t, 1)597 r.mapWindow(t, 1)
510 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)598 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
511- if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {599+ if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
512 t.Fatal(got)600 t.Fatal(got)
513 }601 }
514 }602 }
@@ -568,7 +656,7 @@ func TestStateSnapshot(t *testing.T) {
568 656
569 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {657 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
570 r := startMulti(t, func(c *ports.Config) {658 r := startMulti(t, func(c *ports.Config) {
571- c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}}659+ c.Workspaces = []ports.WorkspaceConfig{{Name: "notes"}}
572 c.Binds["Alt+n"] = "workspace notes"660 c.Binds["Alt+n"] = "workspace notes"
573 }, left)661 }, left)
574 r.client <- ports.WindowMapped{ID: 1, PID: 100}662 r.client <- ports.WindowMapped{ID: 1, PID: 100}
M internal/core/slots_test.go +1 -15
@@ -30,7 +30,7 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
3030 cfg := config.Defaults()
3131 cfg.Keyboard.CmdKey = "alt"
3232 cfg.Border.Width = 0
33- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: len(numbered) == 0, Slots: []ports.SlotConfig{
33+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
3434 {Index: 1, Width: "70%", Argv: []string{"code"}},
3535 {Index: 2, Width: "30%", Argv: []string{"foot"}},
3636 }}}
@@ -338,20 +338,6 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
338338
339339 // Moving a slot window to another workspace releases the slot; showing the
340340 // workspace again refills it.
341-func TestSlotReleasedWhenWindowMoved(t *testing.T) {
342- r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
343- r.fill(t)
344- r.press(t, "d") // on dev, focus on code (slot 1)
345- r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
346- scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1
347- noSpawn(t, r.spawn)
348- r.press(t, "d") // back to workspace 1
349- r.press(t, "d") // dev again: slot 1 is empty
350- if v := receive(t, r.spawn); v.Argv[0] != "code" {
351- t.Fatal(v)
352- }
353-}
354-
355341 // Startup commands are spawned once, before the slots.
356342 func TestStartupCommands(t *testing.T) {
357343 cfg := config.Defaults()
@@ -30,7 +30,7 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
30 cfg := config.Defaults()30 cfg := config.Defaults()
31 cfg.Keyboard.CmdKey = "alt"31 cfg.Keyboard.CmdKey = "alt"
32 cfg.Border.Width = 032 cfg.Border.Width = 0
33- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: len(numbered) == 0, Slots: []ports.SlotConfig{33+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
34 {Index: 1, Width: "70%", Argv: []string{"code"}},34 {Index: 1, Width: "70%", Argv: []string{"code"}},
35 {Index: 2, Width: "30%", Argv: []string{"foot"}},35 {Index: 2, Width: "30%", Argv: []string{"foot"}},
36 }}}36 }}}
@@ -338,20 +338,6 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
338 338
339 // Moving a slot window to another workspace releases the slot; showing the339 // Moving a slot window to another workspace releases the slot; showing the
340 // workspace again refills it.340 // workspace again refills it.
341-func TestSlotReleasedWhenWindowMoved(t *testing.T) {
342- r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
343- r.fill(t)
344- r.press(t, "d") // on dev, focus on code (slot 1)
345- r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
346- scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1
347- noSpawn(t, r.spawn)
348- r.press(t, "d") // back to workspace 1
349- r.press(t, "d") // dev again: slot 1 is empty
350- if v := receive(t, r.spawn); v.Argv[0] != "code" {
351- t.Fatal(v)
352- }
353-}
354-
355 // Startup commands are spawned once, before the slots.341 // Startup commands are spawned once, before the slots.
356 func TestStartupCommands(t *testing.T) {342 func TestStartupCommands(t *testing.T) {
357 cfg := config.Defaults()343 cfg := config.Defaults()
M internal/core/terminal.go +15 -4
@@ -8,9 +8,8 @@ import (
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
11-// The terminal is the desktop: a workspace on screen is never empty. When
12-// one is, core spawns the configured terminal with a SlotEnv token, and the
13-// window carrying it fills that workspace, wherever the focus is by then.
11+// Automatic terminal requests use a SlotEnv token to place their windows
12+// on the intended workspace even if focus changes before mapping.
1413
1514 // termRetry is the least time between two terminals for one workspace: a
1615 // terminal that fails, or maps and exits at once, is not respawned in a loop.
@@ -22,7 +21,9 @@ type termSpawn struct {
2221 }
2322
2423 // keepsTerminal reports whether empty workspaces get a terminal.
25-func (c *Core) keepsTerminal() bool { return c.ch.Terminal && len(c.cfg.Terminal.Command) > 0 }
24+func (c *Core) keepsTerminal() bool {
25+ return c.ch.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 0
26+}
2627
2728 // fillEmpty returns the terminals to spawn for empty workspaces on screen.
2829 // Workspaces with slots are left to them.
@@ -41,10 +42,20 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
4142 var reqs []ports.SpawnRequest
4243 for _, s := range c.screens {
4344 w := s.mon.Current()
45+ if c.cfg.Terminal.AutoOpen == "first" {
46+ if c.firstTerminalResolved || s != c.cur() || s.name() == "" {
47+ continue
48+ }
49+ c.firstTerminalResolved = true
50+ if w != s.mon.Workspaces[0] || !w.empty() || c.hasSlots(w) {
51+ continue
52+ }
53+ }
4454 if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry {
4555 continue
4656 }
4757 token := rand.Text()
58+ c.firstTerminalResolved = true
4859 w.termAt = now
4960 c.terms[token] = &termSpawn{w: w, at: now}
5061 reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})
@@ -8,9 +8,8 @@ import (
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 )9 )
10 10
11-// The terminal is the desktop: a workspace on screen is never empty. When11+// Automatic terminal requests use a SlotEnv token to place their windows
12-// one is, core spawns the configured terminal with a SlotEnv token, and the12+// on the intended workspace even if focus changes before mapping.
13-// window carrying it fills that workspace, wherever the focus is by then.
14 13
15 // termRetry is the least time between two terminals for one workspace: a14 // termRetry is the least time between two terminals for one workspace: a
16 // terminal that fails, or maps and exits at once, is not respawned in a loop.15 // terminal that fails, or maps and exits at once, is not respawned in a loop.
@@ -22,7 +21,9 @@ type termSpawn struct {
22 }21 }
23 22
24 // keepsTerminal reports whether empty workspaces get a terminal.23 // keepsTerminal reports whether empty workspaces get a terminal.
25-func (c *Core) keepsTerminal() bool { return c.ch.Terminal && len(c.cfg.Terminal.Command) > 0 }24+func (c *Core) keepsTerminal() bool {
25+ return c.ch.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 0
26+}
26 27
27 // fillEmpty returns the terminals to spawn for empty workspaces on screen.28 // fillEmpty returns the terminals to spawn for empty workspaces on screen.
28 // Workspaces with slots are left to them.29 // Workspaces with slots are left to them.
@@ -41,10 +42,20 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
41 var reqs []ports.SpawnRequest42 var reqs []ports.SpawnRequest
42 for _, s := range c.screens {43 for _, s := range c.screens {
43 w := s.mon.Current()44 w := s.mon.Current()
45+ if c.cfg.Terminal.AutoOpen == "first" {
46+ if c.firstTerminalResolved || s != c.cur() || s.name() == "" {
47+ continue
48+ }
49+ c.firstTerminalResolved = true
50+ if w != s.mon.Workspaces[0] || !w.empty() || c.hasSlots(w) {
51+ continue
52+ }
53+ }
44 if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry {54 if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry {
45 continue55 continue
46 }56 }
47 token := rand.Text()57 token := rand.Text()
58+ c.firstTerminalResolved = true
48 w.termAt = now59 w.termAt = now
49 c.terms[token] = &termSpawn{w: w, at: now}60 c.terms[token] = &termSpawn{w: w, at: now}
50 reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})61 reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})
M internal/ports/config.go +6 -5
@@ -64,7 +64,10 @@ type Config struct {
6464 RepeatDelay int
6565 CmdKey string
6666 }
67- Terminal struct{ Command []string }
67+ Terminal struct {
68+ Command []string
69+ AutoOpen string
70+ }
6871 // Xwayland is the xwayland-satellite binary serving X11 clients; empty
6972 // disables X11.
7073 Xwayland string
@@ -107,8 +110,7 @@ type Config struct {
107110 }
108111 }
109112
110-// WorkspaceConfig declares a named workspace. Hidden ones are not numbered and
111-// only reachable through a `workspace <name>` bind, which toggles them.
113+// WorkspaceConfig declares a bind-only named workspace, outside the numbered list.
112114 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
113115 type WorkspaceConfig struct {
114116 Name string
@@ -116,8 +118,7 @@ type WorkspaceConfig struct {
116118 // ("make model serial"); empty means the first output.
117119 Monitor string
118120 // Slots are the declared columns (workspace.<name>.column.N), by N.
119- Slots []SlotConfig
120- Hidden bool
121+ Slots []SlotConfig
121122 LayoutRules
122123 }
123124
@@ -64,7 +64,10 @@ type Config struct {
64 RepeatDelay int64 RepeatDelay int
65 CmdKey string65 CmdKey string
66 }66 }
67- Terminal struct{ Command []string }67+ Terminal struct {
68+ Command []string
69+ AutoOpen string
70+ }
68 // Xwayland is the xwayland-satellite binary serving X11 clients; empty71 // Xwayland is the xwayland-satellite binary serving X11 clients; empty
69 // disables X11.72 // disables X11.
70 Xwayland string73 Xwayland string
@@ -107,8 +110,7 @@ type Config struct {
107 }110 }
108 }111 }
109 112
110-// WorkspaceConfig declares a named workspace. Hidden ones are not numbered and113+// WorkspaceConfig declares a bind-only named workspace, outside the numbered list.
111-// only reachable through a `workspace <name>` bind, which toggles them.
112 // Zero MaxColumns and an empty Overflow use the layout.* defaults.114 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
113 type WorkspaceConfig struct {115 type WorkspaceConfig struct {
114 Name string116 Name string
@@ -116,8 +118,7 @@ type WorkspaceConfig struct {
116 // ("make model serial"); empty means the first output.118 // ("make model serial"); empty means the first output.
117 Monitor string119 Monitor string
118 // Slots are the declared columns (workspace.<name>.column.N), by N.120 // Slots are the declared columns (workspace.<name>.column.N), by N.
119- Slots []SlotConfig121+ Slots []SlotConfig
120- Hidden bool
121 LayoutRules122 LayoutRules
122 }123 }
123 124

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 25

Tags 10

↑↓ moveenter openesc close