detached 0756882

feat(core): place new windows by app-id rules

commit
0756882c491173a799f7749921049aeaa8d32064
author
brice · · 2026-10-08 10:03 UTC
parent
720cbc3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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`).
5555 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
5656 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
5757 | `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) |
5859 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
5960 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
6061 | `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`).
7071 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
7172 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
7273
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+
73107 ## Output placement
74108
75109 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 placement107 ## 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
106106 # workspace.dev.column.2 = 33%, foot
107107 # bind.cmd+d = workspace dev
108108
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+
109120 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
110121 # The first listed display that is connected and not off is used.
111122 # output.DP-2 = 5120x2160@165.058
@@ -106,6 +106,17 @@ focus.strength = 0.04
106 # workspace.dev.column.2 = 33%, foot106 # workspace.dev.column.2 = 33%, foot
107 # bind.cmd+d = workspace dev107 # 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.058122 # output.DP-2 = 5120x2160@165.058
M internal/core/core.go +6 -1
@@ -117,6 +117,7 @@ type Core struct {
117117 specs []NamedWorkspace
118118 presets []Width
119119 slots map[slotKey]*slotState
120+ rules []rule
120121 placement spawnPlacement
121122 // toSpawn holds slots to start; Run sends them (apply has no context).
122123 toSpawn []slotKey
@@ -291,6 +292,10 @@ func (c *Core) apply(cfg ports.Config) error {
291292 if err != nil {
292293 return err
293294 }
295+ rules, err := parseRules(cfg.Rules)
296+ if err != nil {
297+ return err
298+ }
294299 // Reload is cold-path work; unrelated changes keep a scroll or gesture.
295300 axes := make(map[*Monitor]layoutPolicy, len(c.screens))
296301 for _, s := range c.screens {
@@ -299,7 +304,7 @@ func (c *Core) apply(cfg ports.Config) error {
299304 c.cfg = cfg
300305 c.binds = binds
301306 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
303308 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
304309 c.named()
305310 for _, s := range c.screens {
@@ -117,6 +117,7 @@ type Core struct {
117 specs []NamedWorkspace117 specs []NamedWorkspace
118 presets []Width118 presets []Width
119 slots map[slotKey]*slotState119 slots map[slotKey]*slotState
120+ rules []rule
120 placement spawnPlacement121 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 []slotKey123 toSpawn []slotKey
@@ -291,6 +292,10 @@ func (c *Core) apply(cfg ports.Config) error {
291 if err != nil {292 if err != nil {
292 return err293 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 = cfg304 c.cfg = cfg
300 c.binds = binds305 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, presets307+ 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) {
490490 }
491491 return c.Windows[c.Focus], true
492492 }
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) {
494497 if id == 0 || w.has(id) {
495498 return
496499 }
@@ -502,10 +505,10 @@ func (w *Workspace) AddWindow(id WindowID) {
502505 // A covering float or stashed window may have no column under it.
503506 at = min(w.Focus+1, len(w.Columns))
504507 }
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})
506509 return
507510 }
508- w.addColumn(Column{Windows: []WindowID{id}})
511+ w.addColumn(Column{Windows: []WindowID{id}, Width: width})
509512 }
510513
511514 // 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], true491 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 return498 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 return509 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) {
302302
303303 // AddWindow places a new window on the workspace on screen; with
304304 // 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) {
306309 if w, _ := m.find(id); w != nil {
307310 return
308311 }
309312 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
310- w.AddWindow(id)
313+ w.addWindow(id, width)
311314 }
312315 m.normalize()
313316 }
@@ -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; with303 // 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 return310 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) {
7070 }
7171
7272 // 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.
7375 // Untagged windows return before any map lookup or allocation.
7476 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
7577 if v.Floating {
@@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
8284 ps.mon.normalize()
8385 return
8486 }
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+ }
8592 c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
8693 return
8794 }
@@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
108115 }
109116 }
110117 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+ }
111122 c.cur().mon.AddWindow(v.ID)
112123 }
113124 }
@@ -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 return85 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 return93 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close