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 Unverified
B5690EEEBB952194The 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 | |||
| 20 | 20 | ||
| 21 | 21 | - **Terminal first.** NeferWL opens your terminal at startup. Bars, launchers and notifications are external clients such as Waybar, fuzzel and mako. | |
| 22 | 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 | 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 | 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 | 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 | 31 | ```text | |
| 32 | 32 | keyboard.layout = fr | |
| 33 | - | terminal = foot | |
| 33 | + | terminal.auto-open = first | |
| 34 | 34 | startup = waybar | |
| 35 | 35 | ||
| 36 | 36 | output.DP-1 = 3840x2160@144 | |
| @@ -38,13 +38,15 @@ output.DP-1.scale = 1.5 | |||
| 38 | 38 | ||
| 39 | 39 | layout.max-columns = 3 | |
| 40 | 40 | ||
| 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 | |
| 43 | 43 | workspace.dev.column.1 = 67%, code --new-window | |
| 44 | 44 | workspace.dev.column.2 = 33%, foot | |
| 45 | 45 | 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 | 50 | ### The performance path | |
| 49 | 51 | ||
| 50 | 52 | 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 | ```text | 31 | ```text | ||
| 32 | keyboard.layout = fr | 32 | keyboard.layout = fr | ||
| 33 | - | terminal = foot | 33 | + | terminal.auto-open = first |
| 34 | startup = waybar | 34 | startup = waybar | ||
| 35 | 35 | ||||
| 36 | output.DP-1 = 3840x2160@144 | 36 | output.DP-1 = 3840x2160@144 | ||
| @@ -38,13 +38,15 @@ output.DP-1.scale = 1.5 | |||||
| 38 | 38 | ||||
| 39 | layout.max-columns = 3 | 39 | 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 = on | 42 | + | workspace.dev.monitor = DP-1 |
| 43 | workspace.dev.column.1 = 67%, code --new-window | 43 | workspace.dev.column.1 = 67%, code --new-window | ||
| 44 | workspace.dev.column.2 = 33%, foot | 44 | workspace.dev.column.2 = 33%, foot | ||
| 45 | bind.cmd+d = workspace dev | 45 | 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 path | 50 | ### 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`). | |||
| 16 | 16 | | `keyboard.layout`, `.variant`, `.options` | empty | xkb names, e.g. `fr`, `caps:escape` | | |
| 17 | 17 | | `keyboard.repeat-rate` / `repeat-delay` | `25` / `600` | Key repeat per second / delay in ms | | |
| 18 | 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 | 21 | | `startup` | none | Command run once at session start; repeat the key for more | | |
| 21 | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | |
| 22 | 23 | | `background` | `#111111` | Solid background color | | |
| @@ -28,8 +29,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 28 | 29 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | |
| 29 | 30 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` | | |
| 30 | 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>.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 | | |
| 33 | 34 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| 34 | 35 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| 35 | 36 | | `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 | |||
| 11 | 11 | # Which modifier `cmd` means in binds: super, alt or ctrl. | |
| 12 | 12 | keyboard.cmd = super | |
| 13 | 13 | ||
| 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 | |
| 16 | 19 | ||
| 17 | 20 | # Commands run once when the session starts, one line each. Like spawn, a | |
| 18 | 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 | 45 | # on: moving a column or window to another workspace shows that workspace. | |
| 43 | 46 | focus.follow-move = off | |
| 44 | 47 | ||
| 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. | |
| 48 | 50 | # max-columns and overflow override the layout.* values. | |
| 49 | 51 | # workspace.web.max-columns = 3 | |
| 50 | 52 | # workspace.web.overflow = fixed | |
| @@ -54,7 +56,7 @@ focus.follow-move = off | |||
| 54 | 56 | # A closed slot stays empty until the workspace is shown again. Apps that | |
| 55 | 57 | # reuse a running instance need a new-instance flag. Widths need | |
| 56 | 58 | # overflow = scroll. | |
| 57 | - | # workspace.dev.hidden = on | |
| 59 | + | # workspace.dev.monitor = DP-2 | |
| 58 | 60 | # workspace.dev.column.1 = 67%, code --new-window | |
| 59 | 61 | # workspace.dev.column.2 = 33%, foot | |
| 60 | 62 | # 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 = super | 12 | 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 = 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 | |||
| 16 | 19 | ||||
| 17 | # Commands run once when the session starts, one line each. Like spawn, a | 20 | # 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 = off | 46 | focus.follow-move = off | ||
| 44 | 47 | ||||
| 45 | - | # Named workspaces. Numbered at their place like any other and never removed | 48 | + | # 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 its | 49 | + | # 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 = 3 | 51 | # workspace.web.max-columns = 3 | ||
| 50 | # workspace.web.overflow = fixed | 52 | # 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 that | 56 | # A closed slot stays empty until the workspace is shown again. Apps that | ||
| 55 | # reuse a running instance need a new-instance flag. Widths need | 57 | # reuse a running instance need a new-instance flag. Widths need | ||
| 56 | # overflow = scroll. | 58 | # overflow = scroll. | ||
| 57 | - | # workspace.dev.hidden = on | 59 | + | # workspace.dev.monitor = DP-2 |
| 58 | # workspace.dev.column.1 = 67%, code --new-window | 60 | # workspace.dev.column.1 = 67%, code --new-window | ||
| 59 | # workspace.dev.column.2 = 33%, foot | 61 | # workspace.dev.column.2 = 33%, foot | ||
| 60 | # bind.cmd+d = workspace dev | 62 | # bind.cmd+d = workspace dev | ||
M internal/adapters/config/config.go +21 -10
| @@ -79,7 +79,11 @@ func Defaults() ports.Config { | |||
| 79 | 79 | c.Keyboard.RepeatRate = 25 | |
| 80 | 80 | c.Keyboard.RepeatDelay = 600 | |
| 81 | 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 | 87 | c.Xwayland = "xwayland-satellite" | |
| 84 | 88 | c.Background.Color = "#111111" | |
| 85 | 89 | c.Border.Width = 2 | |
| @@ -310,6 +314,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 310 | 314 | return c, raw, warnings, err | |
| 311 | 315 | } | |
| 312 | 316 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |
| 317 | + | sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | |
| 313 | 318 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | |
| 314 | 319 | // to the same physical key (cmd+code:2), so older configs keep working. | |
| 315 | 320 | for combo := range c.Binds { | |
| @@ -425,6 +430,11 @@ func set(c *ports.Config, key, v string) error { | |||
| 425 | 430 | return fmt.Errorf("must not be empty") | |
| 426 | 431 | } | |
| 427 | 432 | 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 | 438 | case "xwayland": | |
| 429 | 439 | switch { | |
| 430 | 440 | case v == "off": | |
| @@ -561,8 +571,7 @@ func setLayoutRule(r *ports.LayoutRules, field, v string) error { | |||
| 561 | 571 | } | |
| 562 | 572 | ||
| 563 | 573 | // 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. | |
| 566 | 575 | func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |
| 567 | 576 | declared := map[string]bool{} | |
| 568 | 577 | for _, w := range c.Workspaces { | |
| @@ -594,8 +603,14 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||
| 594 | 603 | line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)] | |
| 595 | 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] { | |
| 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)}) | |
| 599 | 614 | } | |
| 600 | 615 | } | |
| 601 | 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 | 622 | // setWorkspace applies one workspace.<name>.<field> key. | |
| 608 | 623 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |
| 609 | 624 | switch field { | |
| 610 | - | case "hidden": | |
| 611 | - | b, err := onOff(v) | |
| 612 | - | w.Hidden = b | |
| 613 | - | return err | |
| 614 | 625 | case "monitor": | |
| 615 | 626 | if v == "" { | |
| 616 | 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 | 632 | default: | |
| 622 | 633 | n, ok := strings.CutPrefix(field, "column.") | |
| 623 | 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 | 637 | index, err := strconv.Atoi(n) | |
| 627 | 638 | if err != nil || index < 1 || index > 16 { | |
| @@ -79,7 +79,11 @@ func Defaults() ports.Config { | |||||
| 79 | c.Keyboard.RepeatRate = 25 | 79 | c.Keyboard.RepeatRate = 25 | ||
| 80 | c.Keyboard.RepeatDelay = 600 | 80 | 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 = 2 | 89 | 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, err | 314 | 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 bound | 318 | // 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 = argv | 432 | 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 undeclared | 573 | // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared | ||
| 564 | - | // workspace (it does nothing) and a hidden workspace no bind reaches (its | 574 | + | // 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) { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | func TestDefaultsAndLoad(t *testing.T) { | |
| 23 | + | t.Setenv("TERMINAL", "") | |
| 23 | 24 | d := Defaults() | |
| 24 | 25 | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 52 || d.Layout.MaxColumns != 2 { | |
| 25 | 26 | t.Fatalf("defaults: %+v", d) | |
| @@ -282,12 +283,12 @@ workspace.web.overflow = spiral | |||
| 282 | 283 | bind.cmd+d = workspace dev | |
| 283 | 284 | bind.cmd+w = workspace | |
| 284 | 285 | bind.cmd+x = workspace typo | |
| 285 | - | workspace.lost.hidden = on | |
| 286 | + | workspace.lost.monitor = DP-1 | |
| 286 | 287 | `) | |
| 287 | 288 | want := []ports.WorkspaceConfig{ | |
| 288 | - | {Name: "dev", Monitor: "DP-2", Hidden: true, MaxColumns: 3}, | |
| 289 | + | {Name: "dev", Monitor: "DP-2", MaxColumns: 3}, | |
| 289 | 290 | {Name: "web", Overflow: "scroll"}, | |
| 290 | - | {Name: "lost", Hidden: true}, | |
| 291 | + | {Name: "lost", Monitor: "DP-1"}, | |
| 291 | 292 | } | |
| 292 | 293 | if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) { | |
| 293 | 294 | t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces) | |
| @@ -299,7 +300,7 @@ workspace.lost.hidden = on | |||
| 299 | 300 | for _, x := range w { | |
| 300 | 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 | 304 | t.Fatal(w) | |
| 304 | 305 | } | |
| 305 | 306 | } | |
| @@ -327,13 +328,13 @@ workspace.dev.monitor = DP-2 | |||
| 327 | 328 | workspace.dev.column.1 = 50%, foot | |
| 328 | 329 | 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 | 332 | t.Fatal(w) | |
| 332 | 333 | } | |
| 333 | 334 | } | |
| 334 | 335 | ||
| 335 | 336 | func TestWorkspaceSlots(t *testing.T) { | |
| 336 | - | c, w := parseString(t, `workspace.dev.hidden = on | |
| 337 | + | c, w := parseString(t, `workspace.dev.monitor = DP-2 | |
| 337 | 338 | workspace.dev.column.2 = 33%, foot --title x | |
| 338 | 339 | workspace.dev.column.1 = 67%, code --new-window | |
| 339 | 340 | workspace.dev.column.2 = 1/3, kitty | |
| @@ -362,7 +363,27 @@ func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) { | |||
| 362 | 363 | _, w := parseString(t, `workspace.dev.overflow = fixed | |
| 363 | 364 | 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 | 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 | + | } | |
| @@ -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 dev | 283 | bind.cmd+d = workspace dev | ||
| 283 | bind.cmd+w = workspace | 284 | bind.cmd+w = workspace | ||
| 284 | bind.cmd+x = workspace typo | 285 | bind.cmd+x = workspace typo | ||
| 285 | - | workspace.lost.hidden = on | 286 | + | 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%, foot | 328 | workspace.dev.column.1 = 50%, foot | ||
| 328 | workspace.web.column.1 = 50%, foot | 329 | 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 = on | 337 | + | c, w := parseString(t, `workspace.dev.monitor = DP-2 |
| 337 | workspace.dev.column.2 = 33%, foot --title x | 338 | workspace.dev.column.2 = 33%, foot --title x | ||
| 338 | workspace.dev.column.1 = 67%, code --new-window | 339 | workspace.dev.column.1 = 67%, code --new-window | ||
| 339 | workspace.dev.column.2 = 1/3, kitty | 340 | workspace.dev.column.2 = 1/3, kitty | ||
| @@ -362,7 +363,27 @@ func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) { | |||||
| 362 | _, w := parseString(t, `workspace.dev.overflow = fixed | 363 | _, w := parseString(t, `workspace.dev.overflow = fixed | ||
| 363 | workspace.dev.column.1 = 67%, foot | 364 | 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 { | |||
| 168 | 168 | } | |
| 169 | 169 | edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1) | |
| 170 | 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 | 171 | col, ok := w.takeColumn() | |
| 177 | 172 | if !ok { | |
| 178 | 173 | 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 { | |||
| 37 | 37 | // State, when set, receives a snapshot for scripts whenever it changes, | |
| 38 | 38 | // latest first (capacity 1, drained like Scenes). | |
| 39 | 39 | 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. | |
| 42 | 41 | Terminal bool | |
| 43 | 42 | } | |
| 44 | 43 | type binding struct { | |
| @@ -75,7 +74,8 @@ type Core struct { | |||
| 75 | 74 | sentPending bool | |
| 76 | 75 | // terms are the terminals spawned for empty workspaces, by SlotEnv | |
| 77 | 76 | // token, until their window maps. | |
| 78 | - | terms map[string]*termSpawn | |
| 77 | + | terms map[string]*termSpawn | |
| 78 | + | firstTerminalResolved bool | |
| 79 | 79 | // clients holds the app ID and PID of mapped windows, for State. | |
| 80 | 80 | clients map[WindowID]ports.WindowMapped | |
| 81 | 81 | sentState ports.State | |
| @@ -222,7 +222,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 222 | 222 | return fmt.Errorf("invalid workspace %q", ws.Name) | |
| 223 | 223 | } | |
| 224 | 224 | 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 | 227 | specs, err := parseSlots(cfg.Workspaces) | |
| 228 | 228 | if err != nil { | |
| @@ -236,6 +236,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 236 | 236 | for _, s := range c.screens { | |
| 237 | 237 | c.settings(s.mon) | |
| 238 | 238 | } | |
| 239 | + | c.settleGuests() | |
| 239 | 240 | c.applyConfigScales() | |
| 240 | 241 | return nil | |
| 241 | 242 | } | |
| @@ -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.State | 39 | State chan ports.State | ||
| 40 | - | // Terminal, when set, keeps a window on every workspace on screen: an | 40 | + | // Terminal enables automatic terminal opening according to terminal.auto-open. |
| 41 | - | // empty one gets the configured terminal (the terminal is the desktop). | |||
| 42 | Terminal bool | 41 | Terminal bool | ||
| 43 | } | 42 | } | ||
| 44 | type binding struct { | 43 | type binding struct { | ||
| @@ -75,7 +74,8 @@ type Core struct { | |||||
| 75 | sentPending bool | 74 | sentPending bool | ||
| 76 | // terms are the terminals spawned for empty workspaces, by SlotEnv | 75 | // 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]*termSpawn | 77 | + | 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.WindowMapped | 80 | clients map[WindowID]ports.WindowMapped | ||
| 81 | sentState ports.State | 81 | 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] = true | 224 | 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 nil | 241 | return nil | ||
| 241 | } | 242 | } | ||
M internal/core/monitor.go +6 -20
| @@ -8,7 +8,7 @@ import "slices" | |||
| 8 | 8 | // except the first. Output-wide settings apply to every workspace. | |
| 9 | 9 | // | |
| 10 | 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 its | |
| 11 | + | // Each named workspace is not numbered and not reachable by up/down: only its | |
| 12 | 12 | // `workspace <name>` bind shows it. | |
| 13 | 13 | type Monitor struct { | |
| 14 | 14 | // Name is the connector; Key identifies the physical monitor (make, | |
| @@ -32,7 +32,6 @@ type NamedWorkspace struct { | |||
| 32 | 32 | Name string | |
| 33 | 33 | // Monitor is the home monitor (connector or key); "" for the first one. | |
| 34 | 34 | Monitor string | |
| 35 | - | Hidden bool | |
| 36 | 35 | MaxColumns int | |
| 37 | 36 | 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 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. | |
| 620 | 616 | func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |
| 621 | 617 | cur := m.Current() | |
| 622 | 618 | want := map[string]NamedWorkspace{} | |
| @@ -660,20 +656,10 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 660 | 656 | w = m.newWorkspace() | |
| 661 | 657 | w.Name = s.Name | |
| 662 | 658 | } | |
| 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) | |
| 676 | 661 | } | |
| 662 | + | nextHidden = append(nextHidden, w) | |
| 677 | 663 | } | |
| 678 | 664 | m.hidden = nextHidden | |
| 679 | 665 | 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 its | 11 | + | // 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 string | 32 | 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 string | 34 | Monitor string | ||
| 35 | - | Hidden bool | |||
| 36 | MaxColumns int | 35 | MaxColumns int | ||
| 37 | Overflow Overflow | 36 | 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 are | 614 | + | // 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.Name | 657 | 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 = nextHidden | 664 | 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 { | |||
| 320 | 320 | } | |
| 321 | 321 | ||
| 322 | 322 | func TestHiddenWorkspaceToggle(t *testing.T) { | |
| 323 | - | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}) | |
| 323 | + | m := named(monitor(), NamedWorkspace{Name: "dev"}) | |
| 324 | 324 | m.AddWindow(1) | |
| 325 | 325 | if len(m.Workspaces) != 2 { | |
| 326 | 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) { | |
| 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 { | |
| 376 | 367 | t.Fatal(windows(m)) | |
| 377 | 368 | } | |
| 378 | 369 | 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) | |
| 381 | 372 | } | |
| 382 | 373 | 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) | |
| 389 | 376 | } | |
| 390 | 377 | } | |
| 391 | 378 | ||
| 392 | 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 | 381 | m.Apply("workspace dev") | |
| 395 | 382 | m.AddWindow(7) | |
| 396 | 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 | 385 | if m.Current().Name != "dev" || len(m.Workspaces) != 1 { | |
| 399 | 386 | t.Fatal(m.Current().Name, windows(m)) | |
| 400 | 387 | } | |
| @@ -406,7 +393,7 @@ func TestNamedWorkspaceReload(t *testing.T) { | |||
| 406 | 393 | } | |
| 407 | 394 | ||
| 408 | 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 | 397 | m.SetMaxColumns(1) | |
| 411 | 398 | if m.Current().MaxColumns != 1 || m.Current().Overflow != "" { | |
| 412 | 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 | 414 | func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | |
| 501 | 415 | m := monitor() | |
| 502 | 416 | 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 | |||
| 36 | 36 | return startRig(t, false, edit, outs...) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 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. | |
| 41 | 40 | func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | |
| 42 | 41 | t.Helper() | |
| 43 | 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 | 361 | func TestNamedWorkspaceOnItsMonitor(t *testing.T) { | |
| 346 | 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 | 364 | c.Binds["Alt+w"] = "workspace web" | |
| 349 | 365 | }, left) | |
| 350 | 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 | 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 | 567 | // One terminal per output, each tagged for its workspace. | |
| 480 | 568 | var reqs []ports.SpawnRequest | |
| 481 | 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 | 596 | r := startRig(t, true, nil, left, right) | |
| 509 | 597 | r.mapWindow(t, 1) | |
| 510 | 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 | 600 | t.Fatal(got) | |
| 513 | 601 | } | |
| 514 | 602 | } | |
| @@ -568,7 +656,7 @@ func TestStateSnapshot(t *testing.T) { | |||
| 568 | 656 | ||
| 569 | 657 | func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) { | |
| 570 | 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 | 660 | c.Binds["Alt+n"] = "workspace notes" | |
| 573 | 661 | }, left) | |
| 574 | 662 | 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 every | 39 | + | // 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.SpawnRequest | 568 | 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 { | |||
| 30 | 30 | cfg := config.Defaults() | |
| 31 | 31 | cfg.Keyboard.CmdKey = "alt" | |
| 32 | 32 | 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 | 34 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | |
| 35 | 35 | {Index: 2, Width: "30%", Argv: []string{"foot"}}, | |
| 36 | 36 | }}} | |
| @@ -338,20 +338,6 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | |||
| 338 | 338 | ||
| 339 | 339 | // Moving a slot window to another workspace releases the slot; showing the | |
| 340 | 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 | 341 | // Startup commands are spawned once, before the slots. | |
| 356 | 342 | func TestStartupCommands(t *testing.T) { | |
| 357 | 343 | 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 = 0 | 32 | 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 the | 339 | // 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 ( | |||
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 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. | |
| 14 | 13 | ||
| 15 | 14 | // termRetry is the least time between two terminals for one workspace: a | |
| 16 | 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 | 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 | 28 | // fillEmpty returns the terminals to spawn for empty workspaces on screen. | |
| 28 | 29 | // Workspaces with slots are left to them. | |
| @@ -41,10 +42,20 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||
| 41 | 42 | var reqs []ports.SpawnRequest | |
| 42 | 43 | for _, s := range c.screens { | |
| 43 | 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 | 54 | if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry { | |
| 45 | 55 | continue | |
| 46 | 56 | } | |
| 47 | 57 | token := rand.Text() | |
| 58 | + | c.firstTerminalResolved = true | |
| 48 | 59 | w.termAt = now | |
| 49 | 60 | c.terms[token] = &termSpawn{w: w, at: now} | |
| 50 | 61 | 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. When | 11 | + | // Automatic terminal requests use a SlotEnv token to place their windows |
| 12 | - | // one is, core spawns the configured terminal with a SlotEnv token, and the | 12 | + | // 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: a | 14 | // 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.SpawnRequest | 42 | 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 | continue | 55 | continue | ||
| 46 | } | 56 | } | ||
| 47 | token := rand.Text() | 57 | token := rand.Text() | ||
| 58 | + | c.firstTerminalResolved = true | |||
| 48 | w.termAt = now | 59 | 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 { | |||
| 64 | 64 | RepeatDelay int | |
| 65 | 65 | CmdKey string | |
| 66 | 66 | } | |
| 67 | - | Terminal struct{ Command []string } | |
| 67 | + | Terminal struct { | |
| 68 | + | Command []string | |
| 69 | + | AutoOpen string | |
| 70 | + | } | |
| 68 | 71 | // Xwayland is the xwayland-satellite binary serving X11 clients; empty | |
| 69 | 72 | // disables X11. | |
| 70 | 73 | 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 and | |
| 111 | - | // only reachable through a `workspace <name>` bind, which toggles them. | |
| 113 | + | // WorkspaceConfig declares a bind-only named workspace, outside the numbered list. | |
| 112 | 114 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | |
| 113 | 115 | type WorkspaceConfig struct { | |
| 114 | 116 | Name string | |
| @@ -116,8 +118,7 @@ type WorkspaceConfig struct { | |||
| 116 | 118 | // ("make model serial"); empty means the first output. | |
| 117 | 119 | Monitor string | |
| 118 | 120 | // Slots are the declared columns (workspace.<name>.column.N), by N. | |
| 119 | - | Slots []SlotConfig | |
| 120 | - | Hidden bool | |
| 121 | + | Slots []SlotConfig | |
| 121 | 122 | LayoutRules | |
| 122 | 123 | } | |
| 123 | 124 | ||
| @@ -64,7 +64,10 @@ type Config struct { | |||||
| 64 | RepeatDelay int | 64 | RepeatDelay int | ||
| 65 | CmdKey string | 65 | 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; empty | 71 | // Xwayland is the xwayland-satellite binary serving X11 clients; empty | ||
| 69 | // disables X11. | 72 | // disables X11. | ||
| 70 | Xwayland string | 73 | 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 and | 113 | + | // 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 string | 116 | 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 string | 119 | 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 []SlotConfig | 121 | + | Slots []SlotConfig |
| 120 | - | Hidden bool | |||
| 121 | LayoutRules | 122 | LayoutRules | ||
| 122 | } | 123 | } | ||
| 123 | 124 | ||||