feat(core): place new windows by app-id rules
- commit
- 0756882c491173a799f7749921049aeaa8d32064
- author
- brice · · 2026-10-08 10:03 UTC
- parent
- 720cbc3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +655 -6
M docs/config.md +34 -0
| @@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 55 | 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | |
| 56 | 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| 57 | 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| 58 | + | | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) | | |
| 58 | 59 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 59 | 60 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | |
| 60 | 61 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | |
| @@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 70 | 71 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 71 | 72 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | |
| 72 | 73 | ||
| 74 | + | ## Window rules | |
| 75 | + | ||
| 76 | + | A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`): | |
| 77 | + | ||
| 78 | + | | Key | Meaning | | |
| 79 | + | |---|---| | |
| 80 | + | | `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does | | |
| 81 | + | | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float | | |
| 82 | + | | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace | | |
| 83 | + | | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected | | |
| 84 | + | | `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only | | |
| 85 | + | ||
| 86 | + | ```ini | |
| 87 | + | rule.games.app-id = steam_app_.* | |
| 88 | + | rule.games.workspace = 3 | |
| 89 | + | rule.games.monitor = DP-2 | |
| 90 | + | rule.chat.app-id = discord|org\.telegram\..* | |
| 91 | + | rule.chat.workspace = chat | |
| 92 | + | rule.chat.width = 1/3 | |
| 93 | + | rule.calc.app-id = org\.gnome\.Calculator | |
| 94 | + | rule.calc.floating = on | |
| 95 | + | ``` | |
| 96 | + | ||
| 97 | + | - Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`. | |
| 98 | + | - Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards. | |
| 99 | + | - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does. | |
| 100 | + | - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual. | |
| 101 | + | - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them. | |
| 102 | + | - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width. | |
| 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. | |
| 104 | + | ||
| 105 | + | To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched. | |
| 106 | + | ||
| 73 | 107 | ## Output placement | |
| 74 | 108 | ||
| 75 | 109 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | |
| @@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | ||
| 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | ||
| 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | ||
| 58 | + | | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) | | |||
| 58 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | 59 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | ||
| 59 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | 60 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | ||
| 60 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | 61 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | ||
| @@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 70 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | 71 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | ||
| 71 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | 72 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | ||
| 72 | 73 | ||||
| 74 | + | ## Window rules | |||
| 75 | + | ||||
| 76 | + | A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`): | |||
| 77 | + | ||||
| 78 | + | | Key | Meaning | | |||
| 79 | + | |---|---| | |||
| 80 | + | | `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does | | |||
| 81 | + | | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float | | |||
| 82 | + | | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace | | |||
| 83 | + | | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected | | |||
| 84 | + | | `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only | | |||
| 85 | + | ||||
| 86 | + | ```ini | |||
| 87 | + | rule.games.app-id = steam_app_.* | |||
| 88 | + | rule.games.workspace = 3 | |||
| 89 | + | rule.games.monitor = DP-2 | |||
| 90 | + | rule.chat.app-id = discord|org\.telegram\..* | |||
| 91 | + | rule.chat.workspace = chat | |||
| 92 | + | rule.chat.width = 1/3 | |||
| 93 | + | rule.calc.app-id = org\.gnome\.Calculator | |||
| 94 | + | rule.calc.floating = on | |||
| 95 | + | ``` | |||
| 96 | + | ||||
| 97 | + | - Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`. | |||
| 98 | + | - Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards. | |||
| 99 | + | - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does. | |||
| 100 | + | - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual. | |||
| 101 | + | - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them. | |||
| 102 | + | - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width. | |||
| 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. | |||
| 104 | + | ||||
| 105 | + | To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched. | |||
| 106 | + | ||||
| 73 | ## Output placement | 107 | ## Output placement | ||
| 74 | 108 | ||||
| 75 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | 109 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | ||
M examples/config +11 -0
| @@ -106,6 +106,17 @@ focus.strength = 0.04 | |||
| 106 | 106 | # workspace.dev.column.2 = 33%, foot | |
| 107 | 107 | # bind.cmd+d = workspace dev | |
| 108 | 108 | ||
| 109 | + | # Window rules place an app's windows when they first open, without moving | |
| 110 | + | # the focus or the view. app-id is a regular expression matching the whole | |
| 111 | + | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |
| 112 | + | # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a | |
| 113 | + | # monitor key, width = like a column width. | |
| 114 | + | # rule.games.app-id = steam_app_.* | |
| 115 | + | # rule.games.workspace = 3 | |
| 116 | + | # rule.games.monitor = DP-2 | |
| 117 | + | # rule.calc.app-id = org\.gnome\.Calculator | |
| 118 | + | # rule.calc.floating = on | |
| 119 | + | ||
| 109 | 120 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | |
| 110 | 121 | # The first listed display that is connected and not off is used. | |
| 111 | 122 | # output.DP-2 = 5120x2160@165.058 | |
| @@ -106,6 +106,17 @@ focus.strength = 0.04 | |||||
| 106 | # workspace.dev.column.2 = 33%, foot | 106 | # workspace.dev.column.2 = 33%, foot | ||
| 107 | # bind.cmd+d = workspace dev | 107 | # bind.cmd+d = workspace dev | ||
| 108 | 108 | ||||
| 109 | + | # Window rules place an app's windows when they first open, without moving | |||
| 110 | + | # the focus or the view. app-id is a regular expression matching the whole | |||
| 111 | + | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |||
| 112 | + | # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a | |||
| 113 | + | # monitor key, width = like a column width. | |||
| 114 | + | # rule.games.app-id = steam_app_.* | |||
| 115 | + | # rule.games.workspace = 3 | |||
| 116 | + | # rule.games.monitor = DP-2 | |||
| 117 | + | # rule.calc.app-id = org\.gnome\.Calculator | |||
| 118 | + | # rule.calc.floating = on | |||
| 119 | + | ||||
| 109 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | 120 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | ||
| 110 | # The first listed display that is connected and not off is used. | 121 | # The first listed display that is connected and not off is used. | ||
| 111 | # output.DP-2 = 5120x2160@165.058 | 122 | # output.DP-2 = 5120x2160@165.058 | ||
M internal/core/core.go +6 -1
| @@ -117,6 +117,7 @@ type Core struct { | |||
| 117 | 117 | specs []NamedWorkspace | |
| 118 | 118 | presets []Width | |
| 119 | 119 | slots map[slotKey]*slotState | |
| 120 | + | rules []rule | |
| 120 | 121 | placement spawnPlacement | |
| 121 | 122 | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 122 | 123 | toSpawn []slotKey | |
| @@ -291,6 +292,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 291 | 292 | if err != nil { | |
| 292 | 293 | return err | |
| 293 | 294 | } | |
| 295 | + | rules, err := parseRules(cfg.Rules) | |
| 296 | + | if err != nil { | |
| 297 | + | return err | |
| 298 | + | } | |
| 294 | 299 | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | |
| 295 | 300 | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | |
| 296 | 301 | for _, s := range c.screens { | |
| @@ -299,7 +304,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 299 | 304 | c.cfg = cfg | |
| 300 | 305 | c.binds = binds | |
| 301 | 306 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | |
| 302 | - | c.specs, c.presets = named, presets | |
| 307 | + | c.specs, c.presets, c.rules = named, presets, rules | |
| 303 | 308 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 304 | 309 | c.named() | |
| 305 | 310 | for _, s := range c.screens { | |
| @@ -117,6 +117,7 @@ type Core struct { | |||||
| 117 | specs []NamedWorkspace | 117 | specs []NamedWorkspace | ||
| 118 | presets []Width | 118 | presets []Width | ||
| 119 | slots map[slotKey]*slotState | 119 | slots map[slotKey]*slotState | ||
| 120 | + | rules []rule | |||
| 120 | placement spawnPlacement | 121 | placement spawnPlacement | ||
| 121 | // toSpawn holds slots to start; Run sends them (apply has no context). | 122 | // toSpawn holds slots to start; Run sends them (apply has no context). | ||
| 122 | toSpawn []slotKey | 123 | toSpawn []slotKey | ||
| @@ -291,6 +292,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 291 | if err != nil { | 292 | if err != nil { | ||
| 292 | return err | 293 | return err | ||
| 293 | } | 294 | } | ||
| 295 | + | rules, err := parseRules(cfg.Rules) | |||
| 296 | + | if err != nil { | |||
| 297 | + | return err | |||
| 298 | + | } | |||
| 294 | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | 299 | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | ||
| 295 | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | 300 | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | ||
| 296 | for _, s := range c.screens { | 301 | for _, s := range c.screens { | ||
| @@ -299,7 +304,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 299 | c.cfg = cfg | 304 | c.cfg = cfg | ||
| 300 | c.binds = binds | 305 | c.binds = binds | ||
| 301 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | 306 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | ||
| 302 | - | c.specs, c.presets = named, presets | 307 | + | c.specs, c.presets, c.rules = named, presets, rules |
| 303 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | 308 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | ||
| 304 | c.named() | 309 | c.named() | ||
| 305 | for _, s := range c.screens { | 310 | for _, s := range c.screens { | ||
M internal/core/layout.go +6 -3
| @@ -490,7 +490,10 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 490 | 490 | } | |
| 491 | 491 | return c.Windows[c.Focus], true | |
| 492 | 492 | } | |
| 493 | - | func (w *Workspace) AddWindow(id WindowID) { | |
| 493 | + | func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) } | |
| 494 | + | ||
| 495 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |
| 496 | + | func (w *Workspace) addWindow(id WindowID, width Width) { | |
| 494 | 497 | if id == 0 || w.has(id) { | |
| 495 | 498 | return | |
| 496 | 499 | } | |
| @@ -502,10 +505,10 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 502 | 505 | // A covering float or stashed window may have no column under it. | |
| 503 | 506 | at = min(w.Focus+1, len(w.Columns)) | |
| 504 | 507 | } | |
| 505 | - | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}}) | |
| 508 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width}) | |
| 506 | 509 | return | |
| 507 | 510 | } | |
| 508 | - | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 511 | + | w.addColumn(Column{Windows: []WindowID{id}, Width: width}) | |
| 509 | 512 | } | |
| 510 | 513 | ||
| 511 | 514 | // addColumn places a new column like a new window and focuses it. | |
| @@ -490,7 +490,10 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||||
| 490 | } | 490 | } | ||
| 491 | return c.Windows[c.Focus], true | 491 | return c.Windows[c.Focus], true | ||
| 492 | } | 492 | } | ||
| 493 | - | func (w *Workspace) AddWindow(id WindowID) { | 493 | + | func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) } |
| 494 | + | ||||
| 495 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |||
| 496 | + | func (w *Workspace) addWindow(id WindowID, width Width) { | |||
| 494 | if id == 0 || w.has(id) { | 497 | if id == 0 || w.has(id) { | ||
| 495 | return | 498 | return | ||
| 496 | } | 499 | } | ||
| @@ -502,10 +505,10 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 502 | // A covering float or stashed window may have no column under it. | 505 | // A covering float or stashed window may have no column under it. | ||
| 503 | at = min(w.Focus+1, len(w.Columns)) | 506 | at = min(w.Focus+1, len(w.Columns)) | ||
| 504 | } | 507 | } | ||
| 505 | - | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}}) | 508 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width}) |
| 506 | return | 509 | return | ||
| 507 | } | 510 | } | ||
| 508 | - | w.addColumn(Column{Windows: []WindowID{id}}) | 511 | + | w.addColumn(Column{Windows: []WindowID{id}, Width: width}) |
| 509 | } | 512 | } | ||
| 510 | 513 | ||||
| 511 | // addColumn places a new column like a new window and focuses it. | 514 | // addColumn places a new column like a new window and focuses it. | ||
M internal/core/monitor.go +5 -2
| @@ -302,12 +302,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||
| 302 | 302 | ||
| 303 | 303 | // AddWindow places a new window on the workspace on screen; with | |
| 304 | 304 | // stash.capture, in its stash when shown. | |
| 305 | - | func (m *Monitor) AddWindow(id WindowID) { | |
| 305 | + | func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) } | |
| 306 | + | ||
| 307 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |
| 308 | + | func (m *Monitor) addWindow(id WindowID, width Width) { | |
| 306 | 309 | if w, _ := m.find(id); w != nil { | |
| 307 | 310 | return | |
| 308 | 311 | } | |
| 309 | 312 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | |
| 310 | - | w.AddWindow(id) | |
| 313 | + | w.addWindow(id, width) | |
| 311 | 314 | } | |
| 312 | 315 | m.normalize() | |
| 313 | 316 | } | |
| @@ -302,12 +302,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||||
| 302 | 302 | ||||
| 303 | // AddWindow places a new window on the workspace on screen; with | 303 | // AddWindow places a new window on the workspace on screen; with | ||
| 304 | // stash.capture, in its stash when shown. | 304 | // stash.capture, in its stash when shown. | ||
| 305 | - | func (m *Monitor) AddWindow(id WindowID) { | 305 | + | func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) } |
| 306 | + | ||||
| 307 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |||
| 308 | + | func (m *Monitor) addWindow(id WindowID, width Width) { | |||
| 306 | if w, _ := m.find(id); w != nil { | 309 | if w, _ := m.find(id); w != nil { | ||
| 307 | return | 310 | return | ||
| 308 | } | 311 | } | ||
| 309 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | 312 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | ||
| 310 | - | w.AddWindow(id) | 313 | + | w.addWindow(id, width) |
| 311 | } | 314 | } | ||
| 312 | m.normalize() | 315 | m.normalize() | ||
| 313 | } | 316 | } | ||
A internal/core/rules.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "regexp" | |
| 6 | + | "slices" | |
| 7 | + | "strconv" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Window rules (rule.<name>.* keys): the app ID of a window that maps for | |
| 13 | + | // the first time picks its place. Rules are evaluated once, at the first | |
| 14 | + | // map, in spawnPlacement.place; a later app ID change, a re-map or a config | |
| 15 | + | // reload moves nothing. | |
| 16 | + | // | |
| 17 | + | // A rule never moves the user (ADR 011): it does not take the focus and does | |
| 18 | + | // not change which workspace or monitor is shown or focused. A window sent to | |
| 19 | + | // another workspace joins it quietly, like a slot window. The one exception | |
| 20 | + | // is the workspace on screen of the focused monitor, where the window | |
| 21 | + | // behaves like any new window. | |
| 22 | + | ||
| 23 | + | // rule is one parsed window rule. | |
| 24 | + | type rule struct { | |
| 25 | + | appID *regexp.Regexp | |
| 26 | + | floating *bool | |
| 27 | + | // number is the 1-based numbered workspace; workspace the name of a | |
| 28 | + | // named one. At most one is set. | |
| 29 | + | number int | |
| 30 | + | workspace string | |
| 31 | + | monitor string | |
| 32 | + | width Width // zero: automatic | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ruleEffect is the merge of every rule matching a window: per field, the | |
| 36 | + | // last rule in config order that sets it wins. | |
| 37 | + | type ruleEffect struct { | |
| 38 | + | floating *bool | |
| 39 | + | number int | |
| 40 | + | workspace string | |
| 41 | + | monitor string | |
| 42 | + | width Width // zero: automatic | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func parseRules(rs []ports.WindowRule) ([]rule, error) { | |
| 46 | + | out := make([]rule, 0, len(rs)) | |
| 47 | + | for _, r := range rs { | |
| 48 | + | if r.AppID == nil { | |
| 49 | + | return nil, fmt.Errorf("rule %s: no app-id", r.Name) | |
| 50 | + | } | |
| 51 | + | p := rule{appID: r.AppID, floating: r.Floating, monitor: r.Monitor} | |
| 52 | + | if r.Workspace != "" { | |
| 53 | + | if n, err := strconv.Atoi(r.Workspace); err == nil { | |
| 54 | + | if n < 1 { | |
| 55 | + | return nil, fmt.Errorf("rule %s: invalid workspace %q", r.Name, r.Workspace) | |
| 56 | + | } | |
| 57 | + | p.number = n | |
| 58 | + | } else { | |
| 59 | + | p.workspace = r.Workspace | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if r.Width != "" { | |
| 63 | + | width, err := ParseWidth(r.Width) | |
| 64 | + | if err != nil { | |
| 65 | + | return nil, fmt.Errorf("rule %s: %w", r.Name, err) | |
| 66 | + | } | |
| 67 | + | p.width = width | |
| 68 | + | } | |
| 69 | + | out = append(out, p) | |
| 70 | + | } | |
| 71 | + | return out, nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // ruleFor merges the rules matching appID; ok is false when none does. | |
| 75 | + | func (c *Core) ruleFor(appID string) (eff ruleEffect, ok bool) { | |
| 76 | + | for i := range c.rules { | |
| 77 | + | r := &c.rules[i] | |
| 78 | + | if !r.appID.MatchString(appID) { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | ok = true | |
| 82 | + | if r.floating != nil { | |
| 83 | + | eff.floating = r.floating | |
| 84 | + | } | |
| 85 | + | if r.number > 0 || r.workspace != "" { | |
| 86 | + | eff.number, eff.workspace = r.number, r.workspace | |
| 87 | + | } | |
| 88 | + | if r.monitor != "" { | |
| 89 | + | eff.monitor = r.monitor | |
| 90 | + | } | |
| 91 | + | if r.width != (Width{}) { | |
| 92 | + | eff.width = r.width | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return eff, ok | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // ruleTarget is the screen and workspace a rule sends a window to. A named | |
| 99 | + | // workspace lives where it lives, whatever the rule's monitor; an unknown one | |
| 100 | + | // is ignored. A monitor that is not connected is ignored too. Without a | |
| 101 | + | // workspace the monitor's workspace on screen is the target, and a number is | |
| 102 | + | // clamped to the workspaces that exist (the last one is the spare empty one), | |
| 103 | + | // so no empty workspace is created. | |
| 104 | + | func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) { | |
| 105 | + | if eff.workspace != "" { | |
| 106 | + | if sc, w := c.byName(eff.workspace); w != nil { | |
| 107 | + | return sc, w | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | sc := c.cur() | |
| 111 | + | if eff.monitor != "" { | |
| 112 | + | if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 { | |
| 113 | + | sc = c.screens[i] | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | if eff.number > 0 { | |
| 117 | + | return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1] | |
| 118 | + | } | |
| 119 | + | return sc, sc.mon.Current() | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // placeByRule puts a window that maps for the first time where eff says. | |
| 123 | + | func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) { | |
| 124 | + | float := v.Floating | |
| 125 | + | if eff.floating != nil { | |
| 126 | + | float = *eff.floating | |
| 127 | + | } | |
| 128 | + | sc, w := c.ruleTarget(eff) | |
| 129 | + | // The workspace the user looks at takes a new window as usual; any | |
| 130 | + | // other one gets it without a change of focus or view. | |
| 131 | + | quiet := sc != c.cur() || w != sc.mon.Current() | |
| 132 | + | switch { | |
| 133 | + | case float: | |
| 134 | + | // A window that already floats keeps the size it maps with; a tile | |
| 135 | + | // floated by a rule becomes a free float, core sizing it. | |
| 136 | + | w.addFloat(w.ruleFloat(v), quiet) | |
| 137 | + | case quiet: | |
| 138 | + | w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width}) | |
| 139 | + | default: | |
| 140 | + | sc.mon.addWindow(v.ID, eff.width) | |
| 141 | + | return | |
| 142 | + | } | |
| 143 | + | sc.mon.normalize() | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // ruleFloat is the float of a window a rule floats. | |
| 147 | + | func (w *Workspace) ruleFloat(v ports.WindowMapped) Float { | |
| 148 | + | if v.Floating { | |
| 149 | + | return Float{ID: v.ID, W: v.Width, H: v.Height} | |
| 150 | + | } | |
| 151 | + | f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5} | |
| 152 | + | if w.Usable.W > 0 && w.Usable.H > 0 { | |
| 153 | + | // As makeFree: the size is core's, fixed now. Without an output | |
| 154 | + | // yet, floatRect's default (half the usable area) applies. | |
| 155 | + | r := w.floatRect(f) | |
| 156 | + | f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1) | |
| 157 | + | } | |
| 158 | + | return f | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // addFloat adds a new float; quiet puts it below the others without the | |
| 162 | + | // float focus, so the focused window stays the same. | |
| 163 | + | func (w *Workspace) addFloat(f Float, quiet bool) { | |
| 164 | + | if f.ID == 0 || w.has(f.ID) { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | if quiet { | |
| 168 | + | // A workspace with nothing else focuses it, so it has a focus when shown. | |
| 169 | + | w.floatFocus = w.floatFocus || w.empty() | |
| 170 | + | w.Floats = slices.Insert(w.Floats, 0, f) | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | w.Floats = append(w.Floats, f) | |
| 174 | + | w.floatFocus = true | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // addColumnQuiet adds a column where a new window goes, keeping the focus | |
| 178 | + | // where it is (ADR 011 golden rule). | |
| 179 | + | func (w *Workspace) addColumnQuiet(col Column) { | |
| 180 | + | at := len(w.Columns) | |
| 181 | + | if w.policy().equalCells { | |
| 182 | + | if at > 0 { | |
| 183 | + | w.unmaximize() | |
| 184 | + | } | |
| 185 | + | } else if at > 0 { | |
| 186 | + | at = w.Focus + 1 | |
| 187 | + | } | |
| 188 | + | w.Columns = slices.Insert(w.Columns, at, col) | |
| 189 | + | w.scroll() | |
| 190 | + | } | |
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "regexp" | |||
| 6 | + | "slices" | |||
| 7 | + | "strconv" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Window rules (rule.<name>.* keys): the app ID of a window that maps for | |||
| 13 | + | // the first time picks its place. Rules are evaluated once, at the first | |||
| 14 | + | // map, in spawnPlacement.place; a later app ID change, a re-map or a config | |||
| 15 | + | // reload moves nothing. | |||
| 16 | + | // | |||
| 17 | + | // A rule never moves the user (ADR 011): it does not take the focus and does | |||
| 18 | + | // not change which workspace or monitor is shown or focused. A window sent to | |||
| 19 | + | // another workspace joins it quietly, like a slot window. The one exception | |||
| 20 | + | // is the workspace on screen of the focused monitor, where the window | |||
| 21 | + | // behaves like any new window. | |||
| 22 | + | ||||
| 23 | + | // rule is one parsed window rule. | |||
| 24 | + | type rule struct { | |||
| 25 | + | appID *regexp.Regexp | |||
| 26 | + | floating *bool | |||
| 27 | + | // number is the 1-based numbered workspace; workspace the name of a | |||
| 28 | + | // named one. At most one is set. | |||
| 29 | + | number int | |||
| 30 | + | workspace string | |||
| 31 | + | monitor string | |||
| 32 | + | width Width // zero: automatic | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ruleEffect is the merge of every rule matching a window: per field, the | |||
| 36 | + | // last rule in config order that sets it wins. | |||
| 37 | + | type ruleEffect struct { | |||
| 38 | + | floating *bool | |||
| 39 | + | number int | |||
| 40 | + | workspace string | |||
| 41 | + | monitor string | |||
| 42 | + | width Width // zero: automatic | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func parseRules(rs []ports.WindowRule) ([]rule, error) { | |||
| 46 | + | out := make([]rule, 0, len(rs)) | |||
| 47 | + | for _, r := range rs { | |||
| 48 | + | if r.AppID == nil { | |||
| 49 | + | return nil, fmt.Errorf("rule %s: no app-id", r.Name) | |||
| 50 | + | } | |||
| 51 | + | p := rule{appID: r.AppID, floating: r.Floating, monitor: r.Monitor} | |||
| 52 | + | if r.Workspace != "" { | |||
| 53 | + | if n, err := strconv.Atoi(r.Workspace); err == nil { | |||
| 54 | + | if n < 1 { | |||
| 55 | + | return nil, fmt.Errorf("rule %s: invalid workspace %q", r.Name, r.Workspace) | |||
| 56 | + | } | |||
| 57 | + | p.number = n | |||
| 58 | + | } else { | |||
| 59 | + | p.workspace = r.Workspace | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if r.Width != "" { | |||
| 63 | + | width, err := ParseWidth(r.Width) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return nil, fmt.Errorf("rule %s: %w", r.Name, err) | |||
| 66 | + | } | |||
| 67 | + | p.width = width | |||
| 68 | + | } | |||
| 69 | + | out = append(out, p) | |||
| 70 | + | } | |||
| 71 | + | return out, nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // ruleFor merges the rules matching appID; ok is false when none does. | |||
| 75 | + | func (c *Core) ruleFor(appID string) (eff ruleEffect, ok bool) { | |||
| 76 | + | for i := range c.rules { | |||
| 77 | + | r := &c.rules[i] | |||
| 78 | + | if !r.appID.MatchString(appID) { | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | ok = true | |||
| 82 | + | if r.floating != nil { | |||
| 83 | + | eff.floating = r.floating | |||
| 84 | + | } | |||
| 85 | + | if r.number > 0 || r.workspace != "" { | |||
| 86 | + | eff.number, eff.workspace = r.number, r.workspace | |||
| 87 | + | } | |||
| 88 | + | if r.monitor != "" { | |||
| 89 | + | eff.monitor = r.monitor | |||
| 90 | + | } | |||
| 91 | + | if r.width != (Width{}) { | |||
| 92 | + | eff.width = r.width | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | return eff, ok | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // ruleTarget is the screen and workspace a rule sends a window to. A named | |||
| 99 | + | // workspace lives where it lives, whatever the rule's monitor; an unknown one | |||
| 100 | + | // is ignored. A monitor that is not connected is ignored too. Without a | |||
| 101 | + | // workspace the monitor's workspace on screen is the target, and a number is | |||
| 102 | + | // clamped to the workspaces that exist (the last one is the spare empty one), | |||
| 103 | + | // so no empty workspace is created. | |||
| 104 | + | func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) { | |||
| 105 | + | if eff.workspace != "" { | |||
| 106 | + | if sc, w := c.byName(eff.workspace); w != nil { | |||
| 107 | + | return sc, w | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | sc := c.cur() | |||
| 111 | + | if eff.monitor != "" { | |||
| 112 | + | if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 { | |||
| 113 | + | sc = c.screens[i] | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | if eff.number > 0 { | |||
| 117 | + | return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1] | |||
| 118 | + | } | |||
| 119 | + | return sc, sc.mon.Current() | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // placeByRule puts a window that maps for the first time where eff says. | |||
| 123 | + | func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) { | |||
| 124 | + | float := v.Floating | |||
| 125 | + | if eff.floating != nil { | |||
| 126 | + | float = *eff.floating | |||
| 127 | + | } | |||
| 128 | + | sc, w := c.ruleTarget(eff) | |||
| 129 | + | // The workspace the user looks at takes a new window as usual; any | |||
| 130 | + | // other one gets it without a change of focus or view. | |||
| 131 | + | quiet := sc != c.cur() || w != sc.mon.Current() | |||
| 132 | + | switch { | |||
| 133 | + | case float: | |||
| 134 | + | // A window that already floats keeps the size it maps with; a tile | |||
| 135 | + | // floated by a rule becomes a free float, core sizing it. | |||
| 136 | + | w.addFloat(w.ruleFloat(v), quiet) | |||
| 137 | + | case quiet: | |||
| 138 | + | w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width}) | |||
| 139 | + | default: | |||
| 140 | + | sc.mon.addWindow(v.ID, eff.width) | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | sc.mon.normalize() | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // ruleFloat is the float of a window a rule floats. | |||
| 147 | + | func (w *Workspace) ruleFloat(v ports.WindowMapped) Float { | |||
| 148 | + | if v.Floating { | |||
| 149 | + | return Float{ID: v.ID, W: v.Width, H: v.Height} | |||
| 150 | + | } | |||
| 151 | + | f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5} | |||
| 152 | + | if w.Usable.W > 0 && w.Usable.H > 0 { | |||
| 153 | + | // As makeFree: the size is core's, fixed now. Without an output | |||
| 154 | + | // yet, floatRect's default (half the usable area) applies. | |||
| 155 | + | r := w.floatRect(f) | |||
| 156 | + | f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1) | |||
| 157 | + | } | |||
| 158 | + | return f | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // addFloat adds a new float; quiet puts it below the others without the | |||
| 162 | + | // float focus, so the focused window stays the same. | |||
| 163 | + | func (w *Workspace) addFloat(f Float, quiet bool) { | |||
| 164 | + | if f.ID == 0 || w.has(f.ID) { | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | if quiet { | |||
| 168 | + | // A workspace with nothing else focuses it, so it has a focus when shown. | |||
| 169 | + | w.floatFocus = w.floatFocus || w.empty() | |||
| 170 | + | w.Floats = slices.Insert(w.Floats, 0, f) | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | w.Floats = append(w.Floats, f) | |||
| 174 | + | w.floatFocus = true | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // addColumnQuiet adds a column where a new window goes, keeping the focus | |||
| 178 | + | // where it is (ADR 011 golden rule). | |||
| 179 | + | func (w *Workspace) addColumnQuiet(col Column) { | |||
| 180 | + | at := len(w.Columns) | |||
| 181 | + | if w.policy().equalCells { | |||
| 182 | + | if at > 0 { | |||
| 183 | + | w.unmaximize() | |||
| 184 | + | } | |||
| 185 | + | } else if at > 0 { | |||
| 186 | + | at = w.Focus + 1 | |||
| 187 | + | } | |||
| 188 | + | w.Columns = slices.Insert(w.Columns, at, col) | |||
| 189 | + | w.scroll() | |||
| 190 | + | } | |||
A internal/core/rules_test.go +392 -0
| @@ -0,0 +1,392 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func winRule(name, appID string, edit func(*ports.WindowRule)) ports.WindowRule { | |
| 11 | + | r := ports.WindowRule{Name: name, AppID: regexp.MustCompile("^(?:" + appID + ")$")} | |
| 12 | + | if edit != nil { | |
| 13 | + | edit(&r) | |
| 14 | + | } | |
| 15 | + | return r | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func ruleOn() *bool { v := true; return &v } | |
| 19 | + | func ruleOff() *bool { v := false; return &v } | |
| 20 | + | ||
| 21 | + | func declareDev(c *ports.Config) { | |
| 22 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev"}} | |
| 23 | + | c.Binds["Alt+d"] = "workspace dev" | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // mapApp maps a window with an app ID and returns the state once it is in. | |
| 27 | + | func (r *multiRig) mapApp(t *testing.T, id ports.WindowID, appID string) ports.State { | |
| 28 | + | t.Helper() | |
| 29 | + | r.client <- ports.WindowMapped{ID: id, AppID: appID} | |
| 30 | + | return r.stateWith(t, id) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (r *multiRig) stateWith(t *testing.T, id ports.WindowID) ports.State { | |
| 34 | + | t.Helper() | |
| 35 | + | return stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, id) != nil }) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func stWindow(st ports.State, id ports.WindowID) *ports.WindowState { | |
| 39 | + | for i := range st.Windows { | |
| 40 | + | if st.Windows[i].ID == id { | |
| 41 | + | return &st.Windows[i] | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func stFocus(st ports.State) ports.WindowID { | |
| 48 | + | if st.Window == nil { | |
| 49 | + | return 0 | |
| 50 | + | } | |
| 51 | + | return st.Window.ID | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func stOutput(st ports.State, name string) ports.OutputState { | |
| 55 | + | for _, o := range st.Outputs { | |
| 56 | + | if o.Name == name { | |
| 57 | + | return o | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return ports.OutputState{} | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestRuleFloatingOnAndOff(t *testing.T) { | |
| 64 | + | r := startMulti(t, func(c *ports.Config) { | |
| 65 | + | c.Rules = []ports.WindowRule{ | |
| 66 | + | winRule("float", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |
| 67 | + | winRule("tile", "splash", func(r *ports.WindowRule) { r.Floating = ruleOff() }), | |
| 68 | + | } | |
| 69 | + | }, left) | |
| 70 | + | r.mapApp(t, 1, "term") | |
| 71 | + | st := r.mapApp(t, 2, "calc") | |
| 72 | + | if w := stWindow(st, 2); !w.Floating || stFocus(st) != 2 { | |
| 73 | + | t.Fatalf("calc not floating on the current workspace: %+v focus %d", w, stFocus(st)) | |
| 74 | + | } | |
| 75 | + | if w := stWindow(st, 1); w.Floating { | |
| 76 | + | t.Fatal("unrelated window floats") | |
| 77 | + | } | |
| 78 | + | // A fixed-size window (Floating from the client) tiles under floating = off. | |
| 79 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "splash", Floating: true, Width: 50, Height: 40} | |
| 80 | + | st = r.stateWith(t, 3) | |
| 81 | + | if w := stWindow(st, 3); w.Floating || w.Column == 0 { | |
| 82 | + | t.Fatalf("splash not tiled: %+v", w) | |
| 83 | + | } | |
| 84 | + | // Without a rule the same window floats as before. | |
| 85 | + | r.client <- ports.WindowMapped{ID: 4, AppID: "other", Floating: true, Width: 50, Height: 40} | |
| 86 | + | if w := stWindow(r.stateWith(t, 4), 4); !w.Floating { | |
| 87 | + | t.Fatalf("unmatched fixed-size window tiled: %+v", w) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestRuleNamedWorkspaceKeepsFocusAndView(t *testing.T) { | |
| 92 | + | r := startMulti(t, func(c *ports.Config) { | |
| 93 | + | declareDev(c) | |
| 94 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 95 | + | }, left) | |
| 96 | + | r.mapApp(t, 1, "term") | |
| 97 | + | st := r.mapApp(t, 2, "code") | |
| 98 | + | w := stWindow(st, 2) | |
| 99 | + | if w.WorkspaceName != "dev" || w.Workspace != 0 || w.Visible { | |
| 100 | + | t.Fatalf("window not quietly on dev: %+v", w) | |
| 101 | + | } | |
| 102 | + | if stFocus(st) != 1 || stOutput(st, "DP-1").Workspace != "" || stOutput(st, "DP-1").Active != 1 { | |
| 103 | + | t.Fatalf("rule moved focus or view: %+v", st) | |
| 104 | + | } | |
| 105 | + | // The workspace shows its window when the user goes there. | |
| 106 | + | r.key(t, "d", ports.ModAlt) | |
| 107 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |
| 108 | + | if stFocus(st) != 2 { | |
| 109 | + | t.Fatalf("dev window not focused on arrival: %d", stFocus(st)) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestRuleNamedWorkspaceFloat(t *testing.T) { | |
| 114 | + | r := startMulti(t, func(c *ports.Config) { | |
| 115 | + | declareDev(c) | |
| 116 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOn() })} | |
| 117 | + | }, left) | |
| 118 | + | r.mapApp(t, 1, "term") | |
| 119 | + | st := r.mapApp(t, 2, "code") | |
| 120 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating || stFocus(st) != 1 { | |
| 121 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 122 | + | } | |
| 123 | + | r.key(t, "d", ports.ModAlt) | |
| 124 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |
| 125 | + | if stFocus(st) != 2 { | |
| 126 | + | t.Fatalf("quiet float not focused on arrival: %d", stFocus(st)) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestRuleUndeclaredWorkspaceFallsBack(t *testing.T) { | |
| 131 | + | r := startMulti(t, func(c *ports.Config) { | |
| 132 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "nope" })} | |
| 133 | + | }, left) | |
| 134 | + | r.mapApp(t, 1, "term") | |
| 135 | + | st := r.mapApp(t, 2, "code") | |
| 136 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |
| 137 | + | t.Fatalf("%+v", w) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestRuleNumberedWorkspace(t *testing.T) { | |
| 142 | + | r := startMulti(t, func(c *ports.Config) { | |
| 143 | + | c.Rules = []ports.WindowRule{ | |
| 144 | + | winRule("two", "two", func(r *ports.WindowRule) { r.Workspace = "2" }), | |
| 145 | + | winRule("far", "far", func(r *ports.WindowRule) { r.Workspace = "9" }), | |
| 146 | + | } | |
| 147 | + | }, left) | |
| 148 | + | r.mapApp(t, 1, "term") | |
| 149 | + | st := r.mapApp(t, 2, "two") | |
| 150 | + | if w := stWindow(st, 2); w.Workspace != 2 || stFocus(st) != 1 || stOutput(st, "DP-1").Active != 1 { | |
| 151 | + | t.Fatalf("%+v active %d focus %d", w, stOutput(st, "DP-1").Active, stFocus(st)) | |
| 152 | + | } | |
| 153 | + | // 9 is clamped to the last workspace, the spare after workspace 2: no | |
| 154 | + | // pile of empty workspaces in between. | |
| 155 | + | st = r.mapApp(t, 3, "far") | |
| 156 | + | if w := stWindow(st, 3); w.Workspace != 3 { | |
| 157 | + | t.Fatalf("clamped to %d", w.Workspace) | |
| 158 | + | } | |
| 159 | + | if o := stOutput(st, "DP-1"); o.Count != 3 || o.Active != 1 || stFocus(st) != 1 { | |
| 160 | + | t.Fatalf("%+v focus %d", o, stFocus(st)) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestRuleNumberOnCurrentWorkspaceActsAsNormalMap(t *testing.T) { | |
| 165 | + | r := startMulti(t, func(c *ports.Config) { | |
| 166 | + | c.Rules = []ports.WindowRule{winRule("one", "term", func(r *ports.WindowRule) { r.Workspace = "1" })} | |
| 167 | + | }, left) | |
| 168 | + | r.mapApp(t, 1, "other") | |
| 169 | + | st := r.mapApp(t, 2, "term") | |
| 170 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |
| 171 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestRuleMonitor(t *testing.T) { | |
| 176 | + | for _, mon := range []string{"DP-2", "Acme B 2"} { | |
| 177 | + | t.Run(mon, func(t *testing.T) { | |
| 178 | + | r := startMulti(t, func(c *ports.Config) { | |
| 179 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = mon })} | |
| 180 | + | }, left, right) | |
| 181 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 182 | + | before := r.mapApp(t, 1, "term") | |
| 183 | + | if before.Output != "DP-1" { | |
| 184 | + | t.Fatalf("focused %s", before.Output) | |
| 185 | + | } | |
| 186 | + | st := r.mapApp(t, 2, "chat") | |
| 187 | + | w := stWindow(st, 2) | |
| 188 | + | if w.Output != "DP-2" || st.Output != "DP-1" || stFocus(st) != 1 { | |
| 189 | + | t.Fatalf("%+v focused output %s window %d", w, st.Output, stFocus(st)) | |
| 190 | + | } | |
| 191 | + | if !w.Visible { | |
| 192 | + | t.Fatal("the window is not shown on the current workspace of DP-2") | |
| 193 | + | } | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestRuleMonitorWithNumberedWorkspace(t *testing.T) { | |
| 199 | + | r := startMulti(t, func(c *ports.Config) { | |
| 200 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Workspace = "2" })} | |
| 201 | + | }, left, right) | |
| 202 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 203 | + | r.mapApp(t, 3, "term") // DP-2 gets a window, so a second workspace | |
| 204 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 205 | + | r.mapApp(t, 1, "term") | |
| 206 | + | st := r.mapApp(t, 2, "chat") | |
| 207 | + | if w := stWindow(st, 2); w.Output != "DP-2" || w.Workspace != 2 || st.Output != "DP-1" || stFocus(st) != 1 || stOutput(st, "DP-2").Active != 1 { | |
| 208 | + | t.Fatalf("%+v", w) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestRuleDisconnectedMonitorFallsBack(t *testing.T) { | |
| 213 | + | r := startMulti(t, func(c *ports.Config) { | |
| 214 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "HDMI-9" })} | |
| 215 | + | }, left, right) | |
| 216 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 217 | + | r.mapApp(t, 1, "term") | |
| 218 | + | st := r.mapApp(t, 2, "chat") | |
| 219 | + | if w := stWindow(st, 2); w.Output != "DP-1" || stFocus(st) != 2 { | |
| 220 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestRulesMerge(t *testing.T) { | |
| 225 | + | r := startMulti(t, func(c *ports.Config) { | |
| 226 | + | c.Layout.MaxColumns = 4 | |
| 227 | + | c.Rules = []ports.WindowRule{ | |
| 228 | + | winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }), | |
| 229 | + | winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }), | |
| 230 | + | } | |
| 231 | + | }, left, right) | |
| 232 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 233 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "chat"} | |
| 234 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term"} | |
| 235 | + | set := sceneMatch2(t, r, func(s ports.Scene) bool { return s.Output == "DP-2" && len(s.Windows) == 2 }) | |
| 236 | + | got := map[ports.WindowID]int{} | |
| 237 | + | for _, w := range set.Windows { | |
| 238 | + | got[w.ID] = w.Rect.W | |
| 239 | + | } | |
| 240 | + | // DP-2 is 400 wide: the later rule wins the width, the generic monitor still applies. | |
| 241 | + | if got[1] != 200 || got[2] != 100 { | |
| 242 | + | t.Fatalf("widths %v", got) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // sceneMatch2 waits for a scene of the multi rig that ok accepts. | |
| 247 | + | func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Scene { | |
| 248 | + | t.Helper() | |
| 249 | + | for range 50 { | |
| 250 | + | for _, s := range receive(t, r.scenes) { | |
| 251 | + | if ok(s) { | |
| 252 | + | return s | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | t.Fatal("no matching scene") | |
| 257 | + | return ports.Scene{} | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestRuleWidthOnCurrentWorkspace(t *testing.T) { | |
| 261 | + | r := startMulti(t, func(c *ports.Config) { | |
| 262 | + | c.Layout.MaxColumns = 4 | |
| 263 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })} | |
| 264 | + | }, left) | |
| 265 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "wide"} | |
| 266 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |
| 267 | + | if s.Windows[0].Rect.W != 100 { | |
| 268 | + | t.Fatalf("width %d", s.Windows[0].Rect.W) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestSlotWinsOverRule(t *testing.T) { | |
| 273 | + | r := startMulti(t, func(c *ports.Config) { | |
| 274 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}} | |
| 275 | + | c.Binds["Alt+d"] = "workspace dev" | |
| 276 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })} | |
| 277 | + | }, left) | |
| 278 | + | req := receive(t, r.spawn) | |
| 279 | + | var slot string | |
| 280 | + | for _, e := range req.Env { | |
| 281 | + | if len(e) > len(ports.SlotEnv)+1 && e[:len(ports.SlotEnv)+1] == ports.SlotEnv+"=" { | |
| 282 | + | slot = e[len(ports.SlotEnv)+1:] | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code", Slot: slot} | |
| 286 | + | st := r.stateWith(t, 1) | |
| 287 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |
| 288 | + | t.Fatalf("rule overrode the slot: %+v", w) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestDialogStaysOverParentDespiteRules(t *testing.T) { | |
| 293 | + | r := startMulti(t, func(c *ports.Config) { | |
| 294 | + | declareDev(c) | |
| 295 | + | c.Rules = []ports.WindowRule{winRule("x", ".*", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOff() })} | |
| 296 | + | }, left) | |
| 297 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |
| 298 | + | st := r.stateWith(t, 1) | |
| 299 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" { | |
| 300 | + | t.Fatalf("parent: %+v", w) | |
| 301 | + | } | |
| 302 | + | r.key(t, "d", ports.ModAlt) | |
| 303 | + | stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |
| 304 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term", Floating: true, Width: 30, Height: 30, Parent: 1} | |
| 305 | + | st = r.stateWith(t, 2) | |
| 306 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating { | |
| 307 | + | t.Fatalf("dialog moved or tiled: %+v", w) | |
| 308 | + | } | |
| 309 | + | // Same app ID without a parent: the rule applies (tiled on dev). | |
| 310 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "term", Floating: true, Width: 30, Height: 30} | |
| 311 | + | if w := stWindow(r.stateWith(t, 3), 3); w.Floating { | |
| 312 | + | t.Fatalf("parentless fixed-size window ignores floating = off: %+v", w) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func TestAppIDChangeAfterMapMovesNothing(t *testing.T) { | |
| 317 | + | r := startMulti(t, func(c *ports.Config) { | |
| 318 | + | declareDev(c) | |
| 319 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 320 | + | }, left) | |
| 321 | + | r.mapApp(t, 1, "") | |
| 322 | + | r.client <- ports.WindowAppID{ID: 1, AppID: "code"} | |
| 323 | + | st := r.mapApp(t, 2, "term") // after the change, to know it was handled | |
| 324 | + | if w := stWindow(st, 1); w.Workspace != 1 || w.AppID != "code" { | |
| 325 | + | t.Fatalf("window re-placed: %+v", w) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestRemapKeepsPlace(t *testing.T) { | |
| 330 | + | r := startMulti(t, func(c *ports.Config) { | |
| 331 | + | declareDev(c) | |
| 332 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 333 | + | }, left) | |
| 334 | + | r.mapApp(t, 1, "term") | |
| 335 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code"} | |
| 336 | + | if w := stWindow(r.mapApp(t, 2, "term"), 1); w.Workspace != 1 { | |
| 337 | + | t.Fatalf("re-map moved the window: %+v", w) | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) { | |
| 342 | + | r := startMulti(t, func(c *ports.Config) { | |
| 343 | + | declareDev(c) | |
| 344 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 345 | + | }, left) | |
| 346 | + | r.mapApp(t, 1, "code") | |
| 347 | + | cfg := r.cfg | |
| 348 | + | cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })} | |
| 349 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 350 | + | // Handled in order with the map below. | |
| 351 | + | st := r.mapApp(t, 2, "code") | |
| 352 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |
| 353 | + | t.Fatalf("reload moved a mapped window: %+v", w) | |
| 354 | + | } | |
| 355 | + | if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating { | |
| 356 | + | t.Fatalf("new rules not applied: %+v", w) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func TestRuleQuietPlacementWithAnimations(t *testing.T) { | |
| 361 | + | r := startMulti(t, func(c *ports.Config) { | |
| 362 | + | declareDev(c) | |
| 363 | + | c.Animations.On = true | |
| 364 | + | c.Rules = []ports.WindowRule{ | |
| 365 | + | winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }), | |
| 366 | + | winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |
| 367 | + | } | |
| 368 | + | }, left) | |
| 369 | + | r.mapApp(t, 1, "term") | |
| 370 | + | st := r.mapApp(t, 2, "code") | |
| 371 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 { | |
| 372 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 373 | + | } | |
| 374 | + | st = r.mapApp(t, 3, "calc") | |
| 375 | + | if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 { | |
| 376 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) { | |
| 381 | + | r := startMulti(t, func(c *ports.Config) { | |
| 382 | + | c.Layout.MaxColumns = 2 | |
| 383 | + | c.Layout.Overflow = "fixed" | |
| 384 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })} | |
| 385 | + | }, left) | |
| 386 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |
| 387 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "wide"} | |
| 388 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 389 | + | if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 { | |
| 390 | + | t.Fatalf("%+v", s.Windows) | |
| 391 | + | } | |
| 392 | + | } | |
| @@ -0,0 +1,392 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func winRule(name, appID string, edit func(*ports.WindowRule)) ports.WindowRule { | |||
| 11 | + | r := ports.WindowRule{Name: name, AppID: regexp.MustCompile("^(?:" + appID + ")$")} | |||
| 12 | + | if edit != nil { | |||
| 13 | + | edit(&r) | |||
| 14 | + | } | |||
| 15 | + | return r | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func ruleOn() *bool { v := true; return &v } | |||
| 19 | + | func ruleOff() *bool { v := false; return &v } | |||
| 20 | + | ||||
| 21 | + | func declareDev(c *ports.Config) { | |||
| 22 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev"}} | |||
| 23 | + | c.Binds["Alt+d"] = "workspace dev" | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // mapApp maps a window with an app ID and returns the state once it is in. | |||
| 27 | + | func (r *multiRig) mapApp(t *testing.T, id ports.WindowID, appID string) ports.State { | |||
| 28 | + | t.Helper() | |||
| 29 | + | r.client <- ports.WindowMapped{ID: id, AppID: appID} | |||
| 30 | + | return r.stateWith(t, id) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (r *multiRig) stateWith(t *testing.T, id ports.WindowID) ports.State { | |||
| 34 | + | t.Helper() | |||
| 35 | + | return stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, id) != nil }) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func stWindow(st ports.State, id ports.WindowID) *ports.WindowState { | |||
| 39 | + | for i := range st.Windows { | |||
| 40 | + | if st.Windows[i].ID == id { | |||
| 41 | + | return &st.Windows[i] | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func stFocus(st ports.State) ports.WindowID { | |||
| 48 | + | if st.Window == nil { | |||
| 49 | + | return 0 | |||
| 50 | + | } | |||
| 51 | + | return st.Window.ID | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func stOutput(st ports.State, name string) ports.OutputState { | |||
| 55 | + | for _, o := range st.Outputs { | |||
| 56 | + | if o.Name == name { | |||
| 57 | + | return o | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return ports.OutputState{} | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestRuleFloatingOnAndOff(t *testing.T) { | |||
| 64 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 65 | + | c.Rules = []ports.WindowRule{ | |||
| 66 | + | winRule("float", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |||
| 67 | + | winRule("tile", "splash", func(r *ports.WindowRule) { r.Floating = ruleOff() }), | |||
| 68 | + | } | |||
| 69 | + | }, left) | |||
| 70 | + | r.mapApp(t, 1, "term") | |||
| 71 | + | st := r.mapApp(t, 2, "calc") | |||
| 72 | + | if w := stWindow(st, 2); !w.Floating || stFocus(st) != 2 { | |||
| 73 | + | t.Fatalf("calc not floating on the current workspace: %+v focus %d", w, stFocus(st)) | |||
| 74 | + | } | |||
| 75 | + | if w := stWindow(st, 1); w.Floating { | |||
| 76 | + | t.Fatal("unrelated window floats") | |||
| 77 | + | } | |||
| 78 | + | // A fixed-size window (Floating from the client) tiles under floating = off. | |||
| 79 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "splash", Floating: true, Width: 50, Height: 40} | |||
| 80 | + | st = r.stateWith(t, 3) | |||
| 81 | + | if w := stWindow(st, 3); w.Floating || w.Column == 0 { | |||
| 82 | + | t.Fatalf("splash not tiled: %+v", w) | |||
| 83 | + | } | |||
| 84 | + | // Without a rule the same window floats as before. | |||
| 85 | + | r.client <- ports.WindowMapped{ID: 4, AppID: "other", Floating: true, Width: 50, Height: 40} | |||
| 86 | + | if w := stWindow(r.stateWith(t, 4), 4); !w.Floating { | |||
| 87 | + | t.Fatalf("unmatched fixed-size window tiled: %+v", w) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestRuleNamedWorkspaceKeepsFocusAndView(t *testing.T) { | |||
| 92 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 93 | + | declareDev(c) | |||
| 94 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 95 | + | }, left) | |||
| 96 | + | r.mapApp(t, 1, "term") | |||
| 97 | + | st := r.mapApp(t, 2, "code") | |||
| 98 | + | w := stWindow(st, 2) | |||
| 99 | + | if w.WorkspaceName != "dev" || w.Workspace != 0 || w.Visible { | |||
| 100 | + | t.Fatalf("window not quietly on dev: %+v", w) | |||
| 101 | + | } | |||
| 102 | + | if stFocus(st) != 1 || stOutput(st, "DP-1").Workspace != "" || stOutput(st, "DP-1").Active != 1 { | |||
| 103 | + | t.Fatalf("rule moved focus or view: %+v", st) | |||
| 104 | + | } | |||
| 105 | + | // The workspace shows its window when the user goes there. | |||
| 106 | + | r.key(t, "d", ports.ModAlt) | |||
| 107 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |||
| 108 | + | if stFocus(st) != 2 { | |||
| 109 | + | t.Fatalf("dev window not focused on arrival: %d", stFocus(st)) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestRuleNamedWorkspaceFloat(t *testing.T) { | |||
| 114 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 115 | + | declareDev(c) | |||
| 116 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOn() })} | |||
| 117 | + | }, left) | |||
| 118 | + | r.mapApp(t, 1, "term") | |||
| 119 | + | st := r.mapApp(t, 2, "code") | |||
| 120 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating || stFocus(st) != 1 { | |||
| 121 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 122 | + | } | |||
| 123 | + | r.key(t, "d", ports.ModAlt) | |||
| 124 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |||
| 125 | + | if stFocus(st) != 2 { | |||
| 126 | + | t.Fatalf("quiet float not focused on arrival: %d", stFocus(st)) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestRuleUndeclaredWorkspaceFallsBack(t *testing.T) { | |||
| 131 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 132 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "nope" })} | |||
| 133 | + | }, left) | |||
| 134 | + | r.mapApp(t, 1, "term") | |||
| 135 | + | st := r.mapApp(t, 2, "code") | |||
| 136 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |||
| 137 | + | t.Fatalf("%+v", w) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestRuleNumberedWorkspace(t *testing.T) { | |||
| 142 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 143 | + | c.Rules = []ports.WindowRule{ | |||
| 144 | + | winRule("two", "two", func(r *ports.WindowRule) { r.Workspace = "2" }), | |||
| 145 | + | winRule("far", "far", func(r *ports.WindowRule) { r.Workspace = "9" }), | |||
| 146 | + | } | |||
| 147 | + | }, left) | |||
| 148 | + | r.mapApp(t, 1, "term") | |||
| 149 | + | st := r.mapApp(t, 2, "two") | |||
| 150 | + | if w := stWindow(st, 2); w.Workspace != 2 || stFocus(st) != 1 || stOutput(st, "DP-1").Active != 1 { | |||
| 151 | + | t.Fatalf("%+v active %d focus %d", w, stOutput(st, "DP-1").Active, stFocus(st)) | |||
| 152 | + | } | |||
| 153 | + | // 9 is clamped to the last workspace, the spare after workspace 2: no | |||
| 154 | + | // pile of empty workspaces in between. | |||
| 155 | + | st = r.mapApp(t, 3, "far") | |||
| 156 | + | if w := stWindow(st, 3); w.Workspace != 3 { | |||
| 157 | + | t.Fatalf("clamped to %d", w.Workspace) | |||
| 158 | + | } | |||
| 159 | + | if o := stOutput(st, "DP-1"); o.Count != 3 || o.Active != 1 || stFocus(st) != 1 { | |||
| 160 | + | t.Fatalf("%+v focus %d", o, stFocus(st)) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestRuleNumberOnCurrentWorkspaceActsAsNormalMap(t *testing.T) { | |||
| 165 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 166 | + | c.Rules = []ports.WindowRule{winRule("one", "term", func(r *ports.WindowRule) { r.Workspace = "1" })} | |||
| 167 | + | }, left) | |||
| 168 | + | r.mapApp(t, 1, "other") | |||
| 169 | + | st := r.mapApp(t, 2, "term") | |||
| 170 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |||
| 171 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestRuleMonitor(t *testing.T) { | |||
| 176 | + | for _, mon := range []string{"DP-2", "Acme B 2"} { | |||
| 177 | + | t.Run(mon, func(t *testing.T) { | |||
| 178 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 179 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = mon })} | |||
| 180 | + | }, left, right) | |||
| 181 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 182 | + | before := r.mapApp(t, 1, "term") | |||
| 183 | + | if before.Output != "DP-1" { | |||
| 184 | + | t.Fatalf("focused %s", before.Output) | |||
| 185 | + | } | |||
| 186 | + | st := r.mapApp(t, 2, "chat") | |||
| 187 | + | w := stWindow(st, 2) | |||
| 188 | + | if w.Output != "DP-2" || st.Output != "DP-1" || stFocus(st) != 1 { | |||
| 189 | + | t.Fatalf("%+v focused output %s window %d", w, st.Output, stFocus(st)) | |||
| 190 | + | } | |||
| 191 | + | if !w.Visible { | |||
| 192 | + | t.Fatal("the window is not shown on the current workspace of DP-2") | |||
| 193 | + | } | |||
| 194 | + | }) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestRuleMonitorWithNumberedWorkspace(t *testing.T) { | |||
| 199 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 200 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Workspace = "2" })} | |||
| 201 | + | }, left, right) | |||
| 202 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 203 | + | r.mapApp(t, 3, "term") // DP-2 gets a window, so a second workspace | |||
| 204 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 205 | + | r.mapApp(t, 1, "term") | |||
| 206 | + | st := r.mapApp(t, 2, "chat") | |||
| 207 | + | if w := stWindow(st, 2); w.Output != "DP-2" || w.Workspace != 2 || st.Output != "DP-1" || stFocus(st) != 1 || stOutput(st, "DP-2").Active != 1 { | |||
| 208 | + | t.Fatalf("%+v", w) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestRuleDisconnectedMonitorFallsBack(t *testing.T) { | |||
| 213 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 214 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "HDMI-9" })} | |||
| 215 | + | }, left, right) | |||
| 216 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 217 | + | r.mapApp(t, 1, "term") | |||
| 218 | + | st := r.mapApp(t, 2, "chat") | |||
| 219 | + | if w := stWindow(st, 2); w.Output != "DP-1" || stFocus(st) != 2 { | |||
| 220 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestRulesMerge(t *testing.T) { | |||
| 225 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 226 | + | c.Layout.MaxColumns = 4 | |||
| 227 | + | c.Rules = []ports.WindowRule{ | |||
| 228 | + | winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }), | |||
| 229 | + | winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }), | |||
| 230 | + | } | |||
| 231 | + | }, left, right) | |||
| 232 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 233 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "chat"} | |||
| 234 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term"} | |||
| 235 | + | set := sceneMatch2(t, r, func(s ports.Scene) bool { return s.Output == "DP-2" && len(s.Windows) == 2 }) | |||
| 236 | + | got := map[ports.WindowID]int{} | |||
| 237 | + | for _, w := range set.Windows { | |||
| 238 | + | got[w.ID] = w.Rect.W | |||
| 239 | + | } | |||
| 240 | + | // DP-2 is 400 wide: the later rule wins the width, the generic monitor still applies. | |||
| 241 | + | if got[1] != 200 || got[2] != 100 { | |||
| 242 | + | t.Fatalf("widths %v", got) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // sceneMatch2 waits for a scene of the multi rig that ok accepts. | |||
| 247 | + | func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Scene { | |||
| 248 | + | t.Helper() | |||
| 249 | + | for range 50 { | |||
| 250 | + | for _, s := range receive(t, r.scenes) { | |||
| 251 | + | if ok(s) { | |||
| 252 | + | return s | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | t.Fatal("no matching scene") | |||
| 257 | + | return ports.Scene{} | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestRuleWidthOnCurrentWorkspace(t *testing.T) { | |||
| 261 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 262 | + | c.Layout.MaxColumns = 4 | |||
| 263 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })} | |||
| 264 | + | }, left) | |||
| 265 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "wide"} | |||
| 266 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |||
| 267 | + | if s.Windows[0].Rect.W != 100 { | |||
| 268 | + | t.Fatalf("width %d", s.Windows[0].Rect.W) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestSlotWinsOverRule(t *testing.T) { | |||
| 273 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 274 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}} | |||
| 275 | + | c.Binds["Alt+d"] = "workspace dev" | |||
| 276 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })} | |||
| 277 | + | }, left) | |||
| 278 | + | req := receive(t, r.spawn) | |||
| 279 | + | var slot string | |||
| 280 | + | for _, e := range req.Env { | |||
| 281 | + | if len(e) > len(ports.SlotEnv)+1 && e[:len(ports.SlotEnv)+1] == ports.SlotEnv+"=" { | |||
| 282 | + | slot = e[len(ports.SlotEnv)+1:] | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code", Slot: slot} | |||
| 286 | + | st := r.stateWith(t, 1) | |||
| 287 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |||
| 288 | + | t.Fatalf("rule overrode the slot: %+v", w) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func TestDialogStaysOverParentDespiteRules(t *testing.T) { | |||
| 293 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 294 | + | declareDev(c) | |||
| 295 | + | c.Rules = []ports.WindowRule{winRule("x", ".*", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOff() })} | |||
| 296 | + | }, left) | |||
| 297 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |||
| 298 | + | st := r.stateWith(t, 1) | |||
| 299 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" { | |||
| 300 | + | t.Fatalf("parent: %+v", w) | |||
| 301 | + | } | |||
| 302 | + | r.key(t, "d", ports.ModAlt) | |||
| 303 | + | stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |||
| 304 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term", Floating: true, Width: 30, Height: 30, Parent: 1} | |||
| 305 | + | st = r.stateWith(t, 2) | |||
| 306 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating { | |||
| 307 | + | t.Fatalf("dialog moved or tiled: %+v", w) | |||
| 308 | + | } | |||
| 309 | + | // Same app ID without a parent: the rule applies (tiled on dev). | |||
| 310 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "term", Floating: true, Width: 30, Height: 30} | |||
| 311 | + | if w := stWindow(r.stateWith(t, 3), 3); w.Floating { | |||
| 312 | + | t.Fatalf("parentless fixed-size window ignores floating = off: %+v", w) | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | func TestAppIDChangeAfterMapMovesNothing(t *testing.T) { | |||
| 317 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 318 | + | declareDev(c) | |||
| 319 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 320 | + | }, left) | |||
| 321 | + | r.mapApp(t, 1, "") | |||
| 322 | + | r.client <- ports.WindowAppID{ID: 1, AppID: "code"} | |||
| 323 | + | st := r.mapApp(t, 2, "term") // after the change, to know it was handled | |||
| 324 | + | if w := stWindow(st, 1); w.Workspace != 1 || w.AppID != "code" { | |||
| 325 | + | t.Fatalf("window re-placed: %+v", w) | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func TestRemapKeepsPlace(t *testing.T) { | |||
| 330 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 331 | + | declareDev(c) | |||
| 332 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 333 | + | }, left) | |||
| 334 | + | r.mapApp(t, 1, "term") | |||
| 335 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code"} | |||
| 336 | + | if w := stWindow(r.mapApp(t, 2, "term"), 1); w.Workspace != 1 { | |||
| 337 | + | t.Fatalf("re-map moved the window: %+v", w) | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) { | |||
| 342 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 343 | + | declareDev(c) | |||
| 344 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 345 | + | }, left) | |||
| 346 | + | r.mapApp(t, 1, "code") | |||
| 347 | + | cfg := r.cfg | |||
| 348 | + | cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })} | |||
| 349 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 350 | + | // Handled in order with the map below. | |||
| 351 | + | st := r.mapApp(t, 2, "code") | |||
| 352 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |||
| 353 | + | t.Fatalf("reload moved a mapped window: %+v", w) | |||
| 354 | + | } | |||
| 355 | + | if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating { | |||
| 356 | + | t.Fatalf("new rules not applied: %+v", w) | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func TestRuleQuietPlacementWithAnimations(t *testing.T) { | |||
| 361 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 362 | + | declareDev(c) | |||
| 363 | + | c.Animations.On = true | |||
| 364 | + | c.Rules = []ports.WindowRule{ | |||
| 365 | + | winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }), | |||
| 366 | + | winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |||
| 367 | + | } | |||
| 368 | + | }, left) | |||
| 369 | + | r.mapApp(t, 1, "term") | |||
| 370 | + | st := r.mapApp(t, 2, "code") | |||
| 371 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 { | |||
| 372 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 373 | + | } | |||
| 374 | + | st = r.mapApp(t, 3, "calc") | |||
| 375 | + | if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 { | |||
| 376 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) { | |||
| 381 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 382 | + | c.Layout.MaxColumns = 2 | |||
| 383 | + | c.Layout.Overflow = "fixed" | |||
| 384 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })} | |||
| 385 | + | }, left) | |||
| 386 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |||
| 387 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "wide"} | |||
| 388 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |||
| 389 | + | if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 { | |||
| 390 | + | t.Fatalf("%+v", s.Windows) | |||
| 391 | + | } | |||
| 392 | + | } | |||
M internal/core/spawn_placement.go +11 -0
| @@ -70,6 +70,8 @@ func (p *spawnPlacement) expireTerminals(now time.Time) { | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | // place claims a token once and puts its window in the target workspace. | |
| 73 | + | // Without a slot, a window rule (rules.go) may choose its place; otherwise it | |
| 74 | + | // opens on the focused screen's workspace. | |
| 73 | 75 | // Untagged windows return before any map lookup or allocation. | |
| 74 | 76 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |
| 75 | 77 | if v.Floating { | |
| @@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 82 | 84 | ps.mon.normalize() | |
| 83 | 85 | return | |
| 84 | 86 | } | |
| 87 | + | // Rules come after the dialog: it stays over its parent. | |
| 88 | + | if eff, ok := c.ruleFor(v.AppID); ok { | |
| 89 | + | c.placeByRule(v, eff) | |
| 90 | + | return | |
| 91 | + | } | |
| 85 | 92 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | |
| 86 | 93 | return | |
| 87 | 94 | } | |
| @@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 108 | 115 | } | |
| 109 | 116 | } | |
| 110 | 117 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 118 | + | if eff, ok := c.ruleFor(v.AppID); ok { | |
| 119 | + | c.placeByRule(v, eff) | |
| 120 | + | return | |
| 121 | + | } | |
| 111 | 122 | c.cur().mon.AddWindow(v.ID) | |
| 112 | 123 | } | |
| 113 | 124 | } | |
| @@ -70,6 +70,8 @@ func (p *spawnPlacement) expireTerminals(now time.Time) { | |||||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | // place claims a token once and puts its window in the target workspace. | 72 | // place claims a token once and puts its window in the target workspace. | ||
| 73 | + | // Without a slot, a window rule (rules.go) may choose its place; otherwise it | |||
| 74 | + | // opens on the focused screen's workspace. | |||
| 73 | // Untagged windows return before any map lookup or allocation. | 75 | // Untagged windows return before any map lookup or allocation. | ||
| 74 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | 76 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | ||
| 75 | if v.Floating { | 77 | if v.Floating { | ||
| @@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 82 | ps.mon.normalize() | 84 | ps.mon.normalize() | ||
| 83 | return | 85 | return | ||
| 84 | } | 86 | } | ||
| 87 | + | // Rules come after the dialog: it stays over its parent. | |||
| 88 | + | if eff, ok := c.ruleFor(v.AppID); ok { | |||
| 89 | + | c.placeByRule(v, eff) | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 85 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | 92 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | ||
| 86 | return | 93 | return | ||
| 87 | } | 94 | } | ||
| @@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 108 | } | 115 | } | ||
| 109 | } | 116 | } | ||
| 110 | if s, _ := c.screenOf(v.ID); s == nil { | 117 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 118 | + | if eff, ok := c.ruleFor(v.AppID); ok { | |||
| 119 | + | c.placeByRule(v, eff) | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 111 | c.cur().mon.AddWindow(v.ID) | 122 | c.cur().mon.AddWindow(v.ID) | ||
| 112 | } | 123 | } | ||
| 113 | } | 124 | } | ||