detached ac31712

Merge pull request 'feat: window rules by app-id (rule.<name>.*)' (#24) from feat/window-rules-v2 into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/24
commit
ac3171230b256445cbec4f2fa2d71999e63f30cc
author
bnema · · 2026-10-08 12:52 UTC
merge of
21b6ce6 + 837869b · diff against the first parent
signature
unsigned
15 files · +983 -9
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/adapters/config/config.go +119 -0
@@ -3,6 +3,7 @@ package config
33
44 import (
55 "bufio"
6+ "errors"
67 "fmt"
78 "io"
89 "math"
@@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
326327 relKeys := map[string]string{} // output -> key of its relation
327328 offsetLines := map[string]int{} // output -> line of its offset
328329 workspaces := map[string]int{}
330+ rules := map[string]*ruleEntry{}
331+ var ruleOrder []*ruleEntry
329332 scanner := bufio.NewScanner(r)
330333 for n := 1; scanner.Scan(); n++ {
331334 line := strings.TrimSpace(scanner.Text())
@@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
510513 }
511514 continue
512515 }
516+ if rest, ok := strings.CutPrefix(key, "rule."); ok {
517+ name, field, _ := strings.Cut(rest, ".")
518+ if !workspaceName.MatchString(name) {
519+ warn("%s: rule name must be letters, digits, - or _", key)
520+ continue
521+ }
522+ e := rules[name]
523+ if e == nil {
524+ e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n}
525+ rules[name] = e
526+ ruleOrder = append(ruleOrder, e)
527+ }
528+ if err := setRule(&e.rule, field, value); err != nil {
529+ warn("%s: %v", key, err)
530+ if !errors.Is(err, errUnknownRuleField) {
531+ e.dropped = true
532+ }
533+ continue
534+ }
535+ override()
536+ continue
537+ }
513538 if err := set(&c, key, value); err != nil {
514539 warn("%s: %v", key, err)
515540 continue
@@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
526551 }
527552 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
528553 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
554+ var ruleWarnings []Warning
555+ c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen)
556+ warnings = append(warnings, ruleWarnings...)
557+ if len(c.Rules) > 0 {
558+ // Rules merge in file order: moving one is a change.
559+ names := make([]string, len(c.Rules))
560+ for i, r := range c.Rules {
561+ names[i] = r.Name
562+ }
563+ raw["rules"] = strings.Join(names, ",")
564+ }
529565 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
530566 // A user bind on a digit (cmd+1, even "none") replaces the default bound
531567 // to the same physical key (cmd+code:2), so older configs keep working.
@@ -972,6 +1008,89 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
9721008
9731009 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
9741010
1011+// ruleEntry is a rule.<name>.* group while the file is read.
1012+type ruleEntry struct {
1013+ rule ports.WindowRule
1014+ line int // of its first key
1015+ // dropped is set by an invalid value: the whole rule is ignored.
1016+ dropped bool
1017+}
1018+
1019+var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)")
1020+
1021+// setRule applies one rule.<name>.<field> key.
1022+func setRule(r *ports.WindowRule, field, v string) error {
1023+ switch field {
1024+ case "app-id":
1025+ if v == "" {
1026+ return fmt.Errorf("needs a regular expression")
1027+ }
1028+ // Compiled alone first, so a value cannot close the anchoring
1029+ // group ("a)|(b").
1030+ if _, err := regexp.Compile(v); err != nil {
1031+ return fmt.Errorf("invalid regular expression: %v", err)
1032+ }
1033+ // The whole app ID must match: "steam" does not match "steam_app_1".
1034+ r.AppID = regexp.MustCompile("^(?:" + v + ")$")
1035+ case "floating":
1036+ b, err := onOff(v)
1037+ if err != nil {
1038+ return err
1039+ }
1040+ r.Floating = &b
1041+ case "workspace":
1042+ if n, err := strconv.Atoi(v); err == nil {
1043+ if n < 1 || n > maxRuleWorkspace {
1044+ return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace)
1045+ }
1046+ } else if !workspaceName.MatchString(v) {
1047+ return fmt.Errorf("must be a workspace number or the name of a declared workspace")
1048+ }
1049+ r.Workspace = v
1050+ case "monitor":
1051+ if v == "" {
1052+ return fmt.Errorf("needs a connector (DP-2) or a monitor key")
1053+ }
1054+ r.Monitor = v
1055+ case "width":
1056+ if !width(v) {
1057+ return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")
1058+ }
1059+ r.Width = v
1060+ default:
1061+ return errUnknownRuleField
1062+ }
1063+ return nil
1064+}
1065+
1066+// maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the
1067+// workspaces that exist.
1068+const maxRuleWorkspace = 1000
1069+
1070+// finishRules keeps the valid rules in file order, warning about those
1071+// without an app-id (dropped) and about named workspaces nobody declared
1072+// (kept: core falls back to the default place).
1073+func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) {
1074+ var rules []ports.WindowRule
1075+ var warnings []Warning
1076+ for _, e := range entries {
1077+ if e.dropped {
1078+ continue
1079+ }
1080+ if e.rule.AppID == nil {
1081+ warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)})
1082+ continue
1083+ }
1084+ if w := e.rule.Workspace; w != "" {
1085+ if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) {
1086+ warnings = append(warnings, Warning{Line: seen["rule."+e.rule.Name+".workspace"], Msg: fmt.Sprintf("rule.%s.workspace: no workspace.%s.* declared", e.rule.Name, w)})
1087+ }
1088+ }
1089+ rules = append(rules, e.rule)
1090+ }
1091+ return rules, warnings
1092+}
1093+
9751094 // setWorkspace applies one workspace.<name>.<field> key.
9761095 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
9771096 switch field {
@@ -3,6 +3,7 @@ package config
3 3
4 import (4 import (
5 "bufio"5 "bufio"
6+ "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "math"9 "math"
@@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
326 relKeys := map[string]string{} // output -> key of its relation327 relKeys := map[string]string{} // output -> key of its relation
327 offsetLines := map[string]int{} // output -> line of its offset328 offsetLines := map[string]int{} // output -> line of its offset
328 workspaces := map[string]int{}329 workspaces := map[string]int{}
330+ rules := map[string]*ruleEntry{}
331+ var ruleOrder []*ruleEntry
329 scanner := bufio.NewScanner(r)332 scanner := bufio.NewScanner(r)
330 for n := 1; scanner.Scan(); n++ {333 for n := 1; scanner.Scan(); n++ {
331 line := strings.TrimSpace(scanner.Text())334 line := strings.TrimSpace(scanner.Text())
@@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
510 }513 }
511 continue514 continue
512 }515 }
516+ if rest, ok := strings.CutPrefix(key, "rule."); ok {
517+ name, field, _ := strings.Cut(rest, ".")
518+ if !workspaceName.MatchString(name) {
519+ warn("%s: rule name must be letters, digits, - or _", key)
520+ continue
521+ }
522+ e := rules[name]
523+ if e == nil {
524+ e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n}
525+ rules[name] = e
526+ ruleOrder = append(ruleOrder, e)
527+ }
528+ if err := setRule(&e.rule, field, value); err != nil {
529+ warn("%s: %v", key, err)
530+ if !errors.Is(err, errUnknownRuleField) {
531+ e.dropped = true
532+ }
533+ continue
534+ }
535+ override()
536+ continue
537+ }
513 if err := set(&c, key, value); err != nil {538 if err := set(&c, key, value); err != nil {
514 warn("%s: %v", key, err)539 warn("%s: %v", key, err)
515 continue540 continue
@@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
526 }551 }
527 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)552 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
528 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)553 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
554+ var ruleWarnings []Warning
555+ c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen)
556+ warnings = append(warnings, ruleWarnings...)
557+ if len(c.Rules) > 0 {
558+ // Rules merge in file order: moving one is a change.
559+ names := make([]string, len(c.Rules))
560+ for i, r := range c.Rules {
561+ names[i] = r.Name
562+ }
563+ raw["rules"] = strings.Join(names, ",")
564+ }
529 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })565 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
530 // A user bind on a digit (cmd+1, even "none") replaces the default bound566 // A user bind on a digit (cmd+1, even "none") replaces the default bound
531 // to the same physical key (cmd+code:2), so older configs keep working.567 // to the same physical key (cmd+code:2), so older configs keep working.
@@ -972,6 +1008,89 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
972 1008
973 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)1009 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
974 1010
1011+// ruleEntry is a rule.<name>.* group while the file is read.
1012+type ruleEntry struct {
1013+ rule ports.WindowRule
1014+ line int // of its first key
1015+ // dropped is set by an invalid value: the whole rule is ignored.
1016+ dropped bool
1017+}
1018+
1019+var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)")
1020+
1021+// setRule applies one rule.<name>.<field> key.
1022+func setRule(r *ports.WindowRule, field, v string) error {
1023+ switch field {
1024+ case "app-id":
1025+ if v == "" {
1026+ return fmt.Errorf("needs a regular expression")
1027+ }
1028+ // Compiled alone first, so a value cannot close the anchoring
1029+ // group ("a)|(b").
1030+ if _, err := regexp.Compile(v); err != nil {
1031+ return fmt.Errorf("invalid regular expression: %v", err)
1032+ }
1033+ // The whole app ID must match: "steam" does not match "steam_app_1".
1034+ r.AppID = regexp.MustCompile("^(?:" + v + ")$")
1035+ case "floating":
1036+ b, err := onOff(v)
1037+ if err != nil {
1038+ return err
1039+ }
1040+ r.Floating = &b
1041+ case "workspace":
1042+ if n, err := strconv.Atoi(v); err == nil {
1043+ if n < 1 || n > maxRuleWorkspace {
1044+ return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace)
1045+ }
1046+ } else if !workspaceName.MatchString(v) {
1047+ return fmt.Errorf("must be a workspace number or the name of a declared workspace")
1048+ }
1049+ r.Workspace = v
1050+ case "monitor":
1051+ if v == "" {
1052+ return fmt.Errorf("needs a connector (DP-2) or a monitor key")
1053+ }
1054+ r.Monitor = v
1055+ case "width":
1056+ if !width(v) {
1057+ return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")
1058+ }
1059+ r.Width = v
1060+ default:
1061+ return errUnknownRuleField
1062+ }
1063+ return nil
1064+}
1065+
1066+// maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the
1067+// workspaces that exist.
1068+const maxRuleWorkspace = 1000
1069+
1070+// finishRules keeps the valid rules in file order, warning about those
1071+// without an app-id (dropped) and about named workspaces nobody declared
1072+// (kept: core falls back to the default place).
1073+func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) {
1074+ var rules []ports.WindowRule
1075+ var warnings []Warning
1076+ for _, e := range entries {
1077+ if e.dropped {
1078+ continue
1079+ }
1080+ if e.rule.AppID == nil {
1081+ warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)})
1082+ continue
1083+ }
1084+ if w := e.rule.Workspace; w != "" {
1085+ if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) {
1086+ warnings = append(warnings, Warning{Line: seen["rule."+e.rule.Name+".workspace"], Msg: fmt.Sprintf("rule.%s.workspace: no workspace.%s.* declared", e.rule.Name, w)})
1087+ }
1088+ }
1089+ rules = append(rules, e.rule)
1090+ }
1091+ return rules, warnings
1092+}
1093+
975 // setWorkspace applies one workspace.<name>.<field> key.1094 // setWorkspace applies one workspace.<name>.<field> key.
976 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {1095 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
977 switch field {1096 switch field {
A internal/adapters/config/rules_test.go +117 -0
@@ -0,0 +1,117 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRulesInFileOrder(t *testing.T) {
9+ c, w := parseString(t, `
10+workspace.chat.max-columns = 2
11+bind.cmd+c = workspace chat
12+rule.games.app-id = steam_app_.*
13+rule.games.floating = on
14+rule.games.workspace = 3
15+rule.chat.app-id = org\.telegram\..*|discord
16+rule.chat.workspace = chat
17+rule.chat.monitor = DP-2
18+rule.chat.width = 1/3
19+rule.games.width = 50%
20+`)
21+ if len(w) != 0 {
22+ t.Fatal(w)
23+ }
24+ if len(c.Rules) != 2 || c.Rules[0].Name != "games" || c.Rules[1].Name != "chat" {
25+ t.Fatalf("%+v", c.Rules)
26+ }
27+ g, ch := c.Rules[0], c.Rules[1]
28+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {
29+ t.Fatalf("%+v", g)
30+ }
31+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {
32+ t.Fatalf("%+v", ch)
33+ }
34+ if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
35+ t.Fatal("app-id did not match")
36+ }
37+}
38+
39+func TestRuleAppIDIsAnchored(t *testing.T) {
40+ c, _ := parseString(t, "rule.a.app-id = steam\nrule.b.app-id = steam|foot\n")
41+ for _, tc := range []struct {
42+ rule int
43+ id string
44+ want bool
45+ }{
46+ {0, "steam", true}, {0, "steam_app_1", false}, {0, "xsteam", false}, {0, "", false},
47+ // The alternation is anchored as a whole, not per branch.
48+ {1, "foot", true}, {1, "footclient", false}, {1, "steamfoot", false},
49+ } {
50+ if got := c.Rules[tc.rule].AppID.MatchString(tc.id); got != tc.want {
51+ t.Errorf("rule %d on %q = %v, want %v", tc.rule, tc.id, got, tc.want)
52+ }
53+ }
54+}
55+
56+func TestRuleWarnings(t *testing.T) {
57+ for _, tc := range []struct {
58+ name, text, warn string
59+ line, rules int
60+ }{
61+ {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0},
62+ // Closing the anchoring group would match any app ID.
63+ {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0},
64+ {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},
65+ {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},
66+ {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0},
67+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
68+ {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
69+ {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
70+ {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
71+ {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0},
72+ {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1},
73+ } {
74+ t.Run(tc.name, func(t *testing.T) {
75+ c, w := parseString(t, tc.text)
76+ if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) {
77+ t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line)
78+ }
79+ if len(c.Rules) != tc.rules {
80+ t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules)
81+ }
82+ })
83+ }
84+}
85+
86+func TestRuleWidthReusesColumnWidth(t *testing.T) {
87+ for _, v := range []string{"1", "33%", "1/3", "640px"} {
88+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")
89+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {
90+ t.Fatalf("%s: %v %+v", v, w, c.Rules)
91+ }
92+ }
93+}
94+
95+func TestRuleReloadDiff(t *testing.T) {
96+ _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n")
97+ _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n")
98+ _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n")
99+ if d := diff(a, b); len(d) == 0 {
100+ t.Fatal("reordering rules is not a change")
101+ }
102+ if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" {
103+ t.Fatal(d)
104+ }
105+ if d := diff(a, a); len(d) != 0 {
106+ t.Fatal(d)
107+ }
108+}
109+
110+func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
111+ t.Helper()
112+ c, raw, w, err := parse(strings.NewReader(s))
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ return c, raw, w
117+}
@@ -0,0 +1,117 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRulesInFileOrder(t *testing.T) {
9+ c, w := parseString(t, `
10+workspace.chat.max-columns = 2
11+bind.cmd+c = workspace chat
12+rule.games.app-id = steam_app_.*
13+rule.games.floating = on
14+rule.games.workspace = 3
15+rule.chat.app-id = org\.telegram\..*|discord
16+rule.chat.workspace = chat
17+rule.chat.monitor = DP-2
18+rule.chat.width = 1/3
19+rule.games.width = 50%
20+`)
21+ if len(w) != 0 {
22+ t.Fatal(w)
23+ }
24+ if len(c.Rules) != 2 || c.Rules[0].Name != "games" || c.Rules[1].Name != "chat" {
25+ t.Fatalf("%+v", c.Rules)
26+ }
27+ g, ch := c.Rules[0], c.Rules[1]
28+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {
29+ t.Fatalf("%+v", g)
30+ }
31+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {
32+ t.Fatalf("%+v", ch)
33+ }
34+ if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
35+ t.Fatal("app-id did not match")
36+ }
37+}
38+
39+func TestRuleAppIDIsAnchored(t *testing.T) {
40+ c, _ := parseString(t, "rule.a.app-id = steam\nrule.b.app-id = steam|foot\n")
41+ for _, tc := range []struct {
42+ rule int
43+ id string
44+ want bool
45+ }{
46+ {0, "steam", true}, {0, "steam_app_1", false}, {0, "xsteam", false}, {0, "", false},
47+ // The alternation is anchored as a whole, not per branch.
48+ {1, "foot", true}, {1, "footclient", false}, {1, "steamfoot", false},
49+ } {
50+ if got := c.Rules[tc.rule].AppID.MatchString(tc.id); got != tc.want {
51+ t.Errorf("rule %d on %q = %v, want %v", tc.rule, tc.id, got, tc.want)
52+ }
53+ }
54+}
55+
56+func TestRuleWarnings(t *testing.T) {
57+ for _, tc := range []struct {
58+ name, text, warn string
59+ line, rules int
60+ }{
61+ {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0},
62+ // Closing the anchoring group would match any app ID.
63+ {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0},
64+ {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},
65+ {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},
66+ {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0},
67+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
68+ {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
69+ {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
70+ {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
71+ {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0},
72+ {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1},
73+ } {
74+ t.Run(tc.name, func(t *testing.T) {
75+ c, w := parseString(t, tc.text)
76+ if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) {
77+ t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line)
78+ }
79+ if len(c.Rules) != tc.rules {
80+ t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules)
81+ }
82+ })
83+ }
84+}
85+
86+func TestRuleWidthReusesColumnWidth(t *testing.T) {
87+ for _, v := range []string{"1", "33%", "1/3", "640px"} {
88+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")
89+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {
90+ t.Fatalf("%s: %v %+v", v, w, c.Rules)
91+ }
92+ }
93+}
94+
95+func TestRuleReloadDiff(t *testing.T) {
96+ _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n")
97+ _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n")
98+ _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n")
99+ if d := diff(a, b); len(d) == 0 {
100+ t.Fatal("reordering rules is not a change")
101+ }
102+ if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" {
103+ t.Fatal(d)
104+ }
105+ if d := diff(a, a); len(d) != 0 {
106+ t.Fatal(d)
107+ }
108+}
109+
110+func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
111+ t.Helper()
112+ c, raw, w, err := parse(strings.NewReader(s))
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ return c, raw, w
117+}
M internal/adapters/wayland/foreigntoplevel_test.go +6 -2
@@ -157,8 +157,12 @@ func TestForeignToplevel(t *testing.T) {
157157 registerWireProxy(c, xp)
158158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
159159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)
160- if again := mapped(t, events, 2*time.Second); again.ID != w.ID {
161- t.Fatalf("remapped as %d, want %d", again.ID, w.ID)
160+ // Core learns it is a remap: window rules only place a first map.
161+ if w.Remap {
162+ t.Fatal("first map flagged as a remap")
163+ }
164+ if again := mapped(t, events, 2*time.Second); again.ID != w.ID || !again.Remap {
165+ t.Fatalf("remapped as %+v, want ID %d and Remap", again, w.ID)
162166 }
163167 if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" {
164168 t.Fatalf("remap = %q", got)
@@ -157,8 +157,12 @@ func TestForeignToplevel(t *testing.T) {
157 registerWireProxy(c, xp)157 registerWireProxy(c, xp)
158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)
160- if again := mapped(t, events, 2*time.Second); again.ID != w.ID {160+ // Core learns it is a remap: window rules only place a first map.
161- t.Fatalf("remapped as %d, want %d", again.ID, w.ID)161+ if w.Remap {
162+ t.Fatal("first map flagged as a remap")
163+ }
164+ if again := mapped(t, events, 2*time.Second); again.ID != w.ID || !again.Remap {
165+ t.Fatalf("remapped as %+v, want ID %d and Remap", again, w.ID)
162 }166 }
163 if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" {167 if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" {
164 t.Fatalf("remap = %q", got)168 t.Fatalf("remap = %q", got)
M internal/adapters/wayland/xdg.go +1 -1
@@ -243,7 +243,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
243243 if w.parent != nil && w.parent.mapped {
244244 parent = w.parent.id
245245 }
246- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent})
246+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1})
247247 x.server.syncInhibitors()
248248 x.server.toplevelChanged(w)
249249 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
@@ -243,7 +243,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
243 if w.parent != nil && w.parent.mapped {243 if w.parent != nil && w.parent.mapped {
244 parent = w.parent.id244 parent = w.parent.id
245 }245 }
246- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent})246+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1})
247 x.server.syncInhibitors()247 x.server.syncInhibitors()
248 x.server.toplevelChanged(w)248 x.server.toplevelChanged(w)
249 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")249 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
M internal/core/core.go +6 -1
@@ -118,6 +118,7 @@ type Core struct {
118118 specs []NamedWorkspace
119119 presets []Width
120120 slots map[slotKey]*slotState
121+ rules []rule
121122 placement spawnPlacement
122123 // toSpawn holds slots to start; Run sends them (apply has no context).
123124 toSpawn []slotKey
@@ -303,6 +304,10 @@ func (c *Core) apply(cfg ports.Config) error {
303304 if err != nil {
304305 return err
305306 }
307+ rules, err := parseRules(cfg.Rules)
308+ if err != nil {
309+ return err
310+ }
306311 // Reload is cold-path work; unrelated changes keep a scroll or gesture.
307312 axes := make(map[*Monitor]layoutPolicy, len(c.screens))
308313 for _, s := range c.screens {
@@ -311,7 +316,7 @@ func (c *Core) apply(cfg ports.Config) error {
311316 c.cfg = cfg
312317 c.binds = binds
313318 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
314- c.specs, c.presets = named, presets
319+ c.specs, c.presets, c.rules = named, presets, rules
315320 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
316321 c.named()
317322 for _, s := range c.screens {
@@ -118,6 +118,7 @@ type Core struct {
118 specs []NamedWorkspace118 specs []NamedWorkspace
119 presets []Width119 presets []Width
120 slots map[slotKey]*slotState120 slots map[slotKey]*slotState
121+ rules []rule
121 placement spawnPlacement122 placement spawnPlacement
122 // toSpawn holds slots to start; Run sends them (apply has no context).123 // toSpawn holds slots to start; Run sends them (apply has no context).
123 toSpawn []slotKey124 toSpawn []slotKey
@@ -303,6 +304,10 @@ func (c *Core) apply(cfg ports.Config) error {
303 if err != nil {304 if err != nil {
304 return err305 return err
305 }306 }
307+ rules, err := parseRules(cfg.Rules)
308+ if err != nil {
309+ return err
310+ }
306 // Reload is cold-path work; unrelated changes keep a scroll or gesture.311 // Reload is cold-path work; unrelated changes keep a scroll or gesture.
307 axes := make(map[*Monitor]layoutPolicy, len(c.screens))312 axes := make(map[*Monitor]layoutPolicy, len(c.screens))
308 for _, s := range c.screens {313 for _, s := range c.screens {
@@ -311,7 +316,7 @@ func (c *Core) apply(cfg ports.Config) error {
311 c.cfg = cfg316 c.cfg = cfg
312 c.binds = binds317 c.binds = binds
313 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]318 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
314- c.specs, c.presets = named, presets319+ c.specs, c.presets, c.rules = named, presets, rules
315 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)320 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
316 c.named()321 c.named()
317 for _, s := range c.screens {322 for _, s := range c.screens {
M internal/core/layout.go +6 -3
@@ -492,7 +492,10 @@ func (w *Workspace) Focused() (WindowID, bool) {
492492 }
493493 return c.Windows[c.Focus], true
494494 }
495-func (w *Workspace) AddWindow(id WindowID) {
495+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }
496+
497+// addWindow is AddWindow with the width of the new column (zero: automatic).
498+func (w *Workspace) addWindow(id WindowID, width Width) {
496499 if id == 0 || w.has(id) {
497500 return
498501 }
@@ -504,10 +507,10 @@ func (w *Workspace) AddWindow(id WindowID) {
504507 // A covering float or stashed window may have no column under it.
505508 at = min(w.Focus+1, len(w.Columns))
506509 }
507- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}})
510+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})
508511 return
509512 }
510- w.addColumn(Column{Windows: []WindowID{id}})
513+ w.addColumn(Column{Windows: []WindowID{id}, Width: width})
511514 }
512515
513516 // addColumn places a new column like a new window and focuses it.
@@ -492,7 +492,10 @@ func (w *Workspace) Focused() (WindowID, bool) {
492 }492 }
493 return c.Windows[c.Focus], true493 return c.Windows[c.Focus], true
494 }494 }
495-func (w *Workspace) AddWindow(id WindowID) {495+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }
496+
497+// addWindow is AddWindow with the width of the new column (zero: automatic).
498+func (w *Workspace) addWindow(id WindowID, width Width) {
496 if id == 0 || w.has(id) {499 if id == 0 || w.has(id) {
497 return500 return
498 }501 }
@@ -504,10 +507,10 @@ func (w *Workspace) AddWindow(id WindowID) {
504 // A covering float or stashed window may have no column under it.507 // A covering float or stashed window may have no column under it.
505 at = min(w.Focus+1, len(w.Columns))508 at = min(w.Focus+1, len(w.Columns))
506 }509 }
507- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}})510+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})
508 return511 return
509 }512 }
510- w.addColumn(Column{Windows: []WindowID{id}})513+ w.addColumn(Column{Windows: []WindowID{id}, Width: width})
511 }514 }
512 515
513 // addColumn places a new column like a new window and focuses it.516 // 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 +204 -0
@@ -0,0 +1,204 @@
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 v's app ID; ok is false when none does.
75+// A toplevel mapping again after an unmap (a tray app restored) opens as
76+// any other window: rules only place its first map.
77+func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
78+ if v.Remap {
79+ return eff, false
80+ }
81+ for i := range c.rules {
82+ r := &c.rules[i]
83+ if !r.appID.MatchString(v.AppID) {
84+ continue
85+ }
86+ ok = true
87+ if r.floating != nil {
88+ eff.floating = r.floating
89+ }
90+ if r.number > 0 || r.workspace != "" {
91+ eff.number, eff.workspace = r.number, r.workspace
92+ }
93+ if r.monitor != "" {
94+ eff.monitor = r.monitor
95+ }
96+ if r.width != (Width{}) {
97+ eff.width = r.width
98+ }
99+ }
100+ return eff, ok
101+}
102+
103+// ruleTarget is the screen and workspace a rule sends a window to. A named
104+// workspace lives where it lives, whatever the rule's monitor; an unknown one
105+// is ignored. A monitor that is not connected is ignored too. Without a
106+// workspace the monitor's workspace on screen is the target, and a number is
107+// clamped to the workspaces that exist (the last one is the spare empty one),
108+// so no empty workspace is created.
109+func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) {
110+ if eff.workspace != "" {
111+ if sc, w := c.byName(eff.workspace); w != nil {
112+ return sc, w
113+ }
114+ }
115+ sc := c.cur()
116+ if eff.monitor != "" {
117+ if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 {
118+ sc = c.screens[i]
119+ }
120+ }
121+ if eff.number > 0 {
122+ return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1]
123+ }
124+ return sc, sc.mon.Current()
125+}
126+
127+// placeByRule puts a window that maps for the first time where eff says.
128+func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
129+ float := v.Floating
130+ if eff.floating != nil {
131+ float = *eff.floating
132+ }
133+ sc, w := c.ruleTarget(eff)
134+ // The workspace the user looks at takes a new window as usual; any
135+ // other one gets it without a change of focus or view.
136+ quiet := sc != c.cur() || w != sc.mon.Current()
137+ switch {
138+ case float:
139+ // A window that already floats keeps the size it maps with; a tile
140+ // floated by a rule becomes a free float, core sizing it.
141+ w.addFloat(w.ruleFloat(v), quiet)
142+ case quiet:
143+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})
144+ default:
145+ sc.mon.addWindow(v.ID, eff.width)
146+ return
147+ }
148+ sc.mon.normalize()
149+}
150+
151+// ruleFloat is the float of a window a rule floats.
152+func (w *Workspace) ruleFloat(v ports.WindowMapped) Float {
153+ if v.Floating {
154+ return Float{ID: v.ID, W: v.Width, H: v.Height}
155+ }
156+ f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5}
157+ if w.Usable.W > 0 && w.Usable.H > 0 {
158+ // As makeFree: the size is core's, fixed now. Without an output
159+ // yet, floatRect's default (half the usable area) applies.
160+ r := w.floatRect(f)
161+ f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
162+ }
163+ return f
164+}
165+
166+// addFloat adds a new float; quiet puts it below the others without the
167+// float focus, so the focused window stays the same.
168+func (w *Workspace) addFloat(f Float, quiet bool) {
169+ if f.ID == 0 || w.has(f.ID) {
170+ return
171+ }
172+ if quiet {
173+ // A workspace with nothing else focuses it, so it has a focus when shown.
174+ w.floatFocus = w.floatFocus || w.empty()
175+ w.Floats = slices.Insert(w.Floats, 0, f)
176+ return
177+ }
178+ w.Floats = append(w.Floats, f)
179+ w.floatFocus = true
180+}
181+
182+// addColumnQuiet adds a column where a new window goes, keeping the focus
183+// where it is (ADR 011 golden rule).
184+func (w *Workspace) addColumnQuiet(col Column) {
185+ at := len(w.Columns)
186+ if w.cover() != 0 {
187+ // As AddWindow: under a covering fullscreen window the column
188+ // waits hidden, and a maximized layout stays as it is.
189+ if !w.policy().equalCells {
190+ at = min(w.Focus+1, at)
191+ }
192+ w.Columns = slices.Insert(w.Columns, at, col)
193+ return
194+ }
195+ if w.policy().equalCells {
196+ if at > 0 {
197+ w.unmaximize()
198+ }
199+ } else if at > 0 {
200+ at = w.Focus + 1
201+ }
202+ w.Columns = slices.Insert(w.Columns, at, col)
203+ w.scroll()
204+}
@@ -0,0 +1,204 @@
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 v's app ID; ok is false when none does.
75+// A toplevel mapping again after an unmap (a tray app restored) opens as
76+// any other window: rules only place its first map.
77+func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
78+ if v.Remap {
79+ return eff, false
80+ }
81+ for i := range c.rules {
82+ r := &c.rules[i]
83+ if !r.appID.MatchString(v.AppID) {
84+ continue
85+ }
86+ ok = true
87+ if r.floating != nil {
88+ eff.floating = r.floating
89+ }
90+ if r.number > 0 || r.workspace != "" {
91+ eff.number, eff.workspace = r.number, r.workspace
92+ }
93+ if r.monitor != "" {
94+ eff.monitor = r.monitor
95+ }
96+ if r.width != (Width{}) {
97+ eff.width = r.width
98+ }
99+ }
100+ return eff, ok
101+}
102+
103+// ruleTarget is the screen and workspace a rule sends a window to. A named
104+// workspace lives where it lives, whatever the rule's monitor; an unknown one
105+// is ignored. A monitor that is not connected is ignored too. Without a
106+// workspace the monitor's workspace on screen is the target, and a number is
107+// clamped to the workspaces that exist (the last one is the spare empty one),
108+// so no empty workspace is created.
109+func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) {
110+ if eff.workspace != "" {
111+ if sc, w := c.byName(eff.workspace); w != nil {
112+ return sc, w
113+ }
114+ }
115+ sc := c.cur()
116+ if eff.monitor != "" {
117+ if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 {
118+ sc = c.screens[i]
119+ }
120+ }
121+ if eff.number > 0 {
122+ return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1]
123+ }
124+ return sc, sc.mon.Current()
125+}
126+
127+// placeByRule puts a window that maps for the first time where eff says.
128+func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
129+ float := v.Floating
130+ if eff.floating != nil {
131+ float = *eff.floating
132+ }
133+ sc, w := c.ruleTarget(eff)
134+ // The workspace the user looks at takes a new window as usual; any
135+ // other one gets it without a change of focus or view.
136+ quiet := sc != c.cur() || w != sc.mon.Current()
137+ switch {
138+ case float:
139+ // A window that already floats keeps the size it maps with; a tile
140+ // floated by a rule becomes a free float, core sizing it.
141+ w.addFloat(w.ruleFloat(v), quiet)
142+ case quiet:
143+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})
144+ default:
145+ sc.mon.addWindow(v.ID, eff.width)
146+ return
147+ }
148+ sc.mon.normalize()
149+}
150+
151+// ruleFloat is the float of a window a rule floats.
152+func (w *Workspace) ruleFloat(v ports.WindowMapped) Float {
153+ if v.Floating {
154+ return Float{ID: v.ID, W: v.Width, H: v.Height}
155+ }
156+ f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5}
157+ if w.Usable.W > 0 && w.Usable.H > 0 {
158+ // As makeFree: the size is core's, fixed now. Without an output
159+ // yet, floatRect's default (half the usable area) applies.
160+ r := w.floatRect(f)
161+ f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
162+ }
163+ return f
164+}
165+
166+// addFloat adds a new float; quiet puts it below the others without the
167+// float focus, so the focused window stays the same.
168+func (w *Workspace) addFloat(f Float, quiet bool) {
169+ if f.ID == 0 || w.has(f.ID) {
170+ return
171+ }
172+ if quiet {
173+ // A workspace with nothing else focuses it, so it has a focus when shown.
174+ w.floatFocus = w.floatFocus || w.empty()
175+ w.Floats = slices.Insert(w.Floats, 0, f)
176+ return
177+ }
178+ w.Floats = append(w.Floats, f)
179+ w.floatFocus = true
180+}
181+
182+// addColumnQuiet adds a column where a new window goes, keeping the focus
183+// where it is (ADR 011 golden rule).
184+func (w *Workspace) addColumnQuiet(col Column) {
185+ at := len(w.Columns)
186+ if w.cover() != 0 {
187+ // As AddWindow: under a covering fullscreen window the column
188+ // waits hidden, and a maximized layout stays as it is.
189+ if !w.policy().equalCells {
190+ at = min(w.Focus+1, at)
191+ }
192+ w.Columns = slices.Insert(w.Columns, at, col)
193+ return
194+ }
195+ if w.policy().equalCells {
196+ if at > 0 {
197+ w.unmaximize()
198+ }
199+ } else if at > 0 {
200+ at = w.Focus + 1
201+ }
202+ w.Columns = slices.Insert(w.Columns, at, col)
203+ w.scroll()
204+}
A internal/core/rules_internal_test.go +29 -0
@@ -0,0 +1,29 @@
1+package core
2+
3+import "testing"
4+
5+// Under a covering fullscreen window, a rule's quiet column waits hidden:
6+// focus, fullscreen and a fixed-overflow maximized column stay as they are,
7+// as for any new window (AddWindow's cover branch).
8+func TestAddColumnQuietUnderCover(t *testing.T) {
9+ w := workspace()
10+ w.Overflow = OverflowFixed
11+ w.AddWindow(1)
12+ w.AddWindow(2)
13+ w.FocusID(1)
14+ w.maximize(w.Focus)
15+ w.ToggleFullscreen()
16+ if w.cover() != 1 {
17+ t.Fatalf("setup: cover %d", w.cover())
18+ }
19+ w.addColumnQuiet(Column{Windows: []WindowID{3}})
20+ if !w.has(3) {
21+ t.Fatal("column not added")
22+ }
23+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
24+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
25+ }
26+ if i := w.columnOf(1); i < 0 || !w.Columns[i].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 1 {
27+ t.Fatalf("maximized layout lost: %+v %v", w.Columns, w.maximized)
28+ }
29+}
@@ -0,0 +1,29 @@
1+package core
2+
3+import "testing"
4+
5+// Under a covering fullscreen window, a rule's quiet column waits hidden:
6+// focus, fullscreen and a fixed-overflow maximized column stay as they are,
7+// as for any new window (AddWindow's cover branch).
8+func TestAddColumnQuietUnderCover(t *testing.T) {
9+ w := workspace()
10+ w.Overflow = OverflowFixed
11+ w.AddWindow(1)
12+ w.AddWindow(2)
13+ w.FocusID(1)
14+ w.maximize(w.Focus)
15+ w.ToggleFullscreen()
16+ if w.cover() != 1 {
17+ t.Fatalf("setup: cover %d", w.cover())
18+ }
19+ w.addColumnQuiet(Column{Windows: []WindowID{3}})
20+ if !w.has(3) {
21+ t.Fatal("column not added")
22+ }
23+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
24+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
25+ }
26+ if i := w.columnOf(1); i < 0 || !w.Columns[i].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 1 {
27+ t.Fatalf("maximized layout lost: %+v %v", w.Columns, w.maximized)
28+ }
29+}
A internal/core/rules_test.go +411 -0
@@ -0,0 +1,411 @@
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+// A toplevel that maps again after an unmap (a tray app restored) opens as
342+// any other window: on the workspace the user looks at, not its rule's.
343+func TestRuleSkipsRemapAfterUnmap(t *testing.T) {
344+ r := startMulti(t, func(c *ports.Config) {
345+ declareDev(c)
346+ c.Rules = []ports.WindowRule{winRule("dev", "discord", func(r *ports.WindowRule) { r.Workspace = "dev" })}
347+ }, left)
348+ if w := stWindow(r.mapApp(t, 1, "discord"), 1); w.WorkspaceName != "dev" {
349+ t.Fatalf("first map not placed by the rule: %+v", w)
350+ }
351+ r.client <- ports.WindowUnmapped{ID: 1}
352+ stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, 1) == nil })
353+ r.client <- ports.WindowMapped{ID: 1, AppID: "discord", Remap: true}
354+ st := r.stateWith(t, 1)
355+ if w := stWindow(st, 1); w.WorkspaceName != "" || w.Workspace != 1 || stFocus(st) != 1 {
356+ t.Fatalf("remap placed by the rule: %+v focus %d", w, stFocus(st))
357+ }
358+}
359+
360+func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) {
361+ r := startMulti(t, func(c *ports.Config) {
362+ declareDev(c)
363+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
364+ }, left)
365+ r.mapApp(t, 1, "code")
366+ cfg := r.cfg
367+ cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })}
368+ r.reload <- ports.ConfigChanged{Config: cfg}
369+ // Handled in order with the map below.
370+ st := r.mapApp(t, 2, "code")
371+ if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
372+ t.Fatalf("reload moved a mapped window: %+v", w)
373+ }
374+ if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating {
375+ t.Fatalf("new rules not applied: %+v", w)
376+ }
377+}
378+
379+func TestRuleQuietPlacementWithAnimations(t *testing.T) {
380+ r := startMulti(t, func(c *ports.Config) {
381+ declareDev(c)
382+ c.Animations.On = true
383+ c.Rules = []ports.WindowRule{
384+ winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }),
385+ winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
386+ }
387+ }, left)
388+ r.mapApp(t, 1, "term")
389+ st := r.mapApp(t, 2, "code")
390+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 {
391+ t.Fatalf("%+v focus %d", w, stFocus(st))
392+ }
393+ st = r.mapApp(t, 3, "calc")
394+ if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 {
395+ t.Fatalf("%+v focus %d", w, stFocus(st))
396+ }
397+}
398+
399+func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {
400+ r := startMulti(t, func(c *ports.Config) {
401+ c.Layout.MaxColumns = 2
402+ c.Layout.Overflow = "fixed"
403+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}
404+ }, left)
405+ r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406+ r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407+ s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408+ if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {
409+ t.Fatalf("%+v", s.Windows)
410+ }
411+}
@@ -0,0 +1,411 @@
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+// A toplevel that maps again after an unmap (a tray app restored) opens as
342+// any other window: on the workspace the user looks at, not its rule's.
343+func TestRuleSkipsRemapAfterUnmap(t *testing.T) {
344+ r := startMulti(t, func(c *ports.Config) {
345+ declareDev(c)
346+ c.Rules = []ports.WindowRule{winRule("dev", "discord", func(r *ports.WindowRule) { r.Workspace = "dev" })}
347+ }, left)
348+ if w := stWindow(r.mapApp(t, 1, "discord"), 1); w.WorkspaceName != "dev" {
349+ t.Fatalf("first map not placed by the rule: %+v", w)
350+ }
351+ r.client <- ports.WindowUnmapped{ID: 1}
352+ stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, 1) == nil })
353+ r.client <- ports.WindowMapped{ID: 1, AppID: "discord", Remap: true}
354+ st := r.stateWith(t, 1)
355+ if w := stWindow(st, 1); w.WorkspaceName != "" || w.Workspace != 1 || stFocus(st) != 1 {
356+ t.Fatalf("remap placed by the rule: %+v focus %d", w, stFocus(st))
357+ }
358+}
359+
360+func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) {
361+ r := startMulti(t, func(c *ports.Config) {
362+ declareDev(c)
363+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
364+ }, left)
365+ r.mapApp(t, 1, "code")
366+ cfg := r.cfg
367+ cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })}
368+ r.reload <- ports.ConfigChanged{Config: cfg}
369+ // Handled in order with the map below.
370+ st := r.mapApp(t, 2, "code")
371+ if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
372+ t.Fatalf("reload moved a mapped window: %+v", w)
373+ }
374+ if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating {
375+ t.Fatalf("new rules not applied: %+v", w)
376+ }
377+}
378+
379+func TestRuleQuietPlacementWithAnimations(t *testing.T) {
380+ r := startMulti(t, func(c *ports.Config) {
381+ declareDev(c)
382+ c.Animations.On = true
383+ c.Rules = []ports.WindowRule{
384+ winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }),
385+ winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
386+ }
387+ }, left)
388+ r.mapApp(t, 1, "term")
389+ st := r.mapApp(t, 2, "code")
390+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 {
391+ t.Fatalf("%+v focus %d", w, stFocus(st))
392+ }
393+ st = r.mapApp(t, 3, "calc")
394+ if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 {
395+ t.Fatalf("%+v focus %d", w, stFocus(st))
396+ }
397+}
398+
399+func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {
400+ r := startMulti(t, func(c *ports.Config) {
401+ c.Layout.MaxColumns = 2
402+ c.Layout.Overflow = "fixed"
403+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}
404+ }, left)
405+ r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406+ r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407+ s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408+ if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {
409+ t.Fatalf("%+v", s.Windows)
410+ }
411+}
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); 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); 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); 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); 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 }
M internal/ports/client_events.go +3 -0
@@ -20,6 +20,9 @@ type WindowMapped struct {
2020 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
2121 // it.
2222 Parent WindowID
23+ // Remap is set when the toplevel mapped before and was unmapped (a tray
24+ // app restored): window rules only place its first map.
25+ Remap bool
2326 }
2427
2528 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -20,6 +20,9 @@ type WindowMapped struct {
20 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over20 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
21 // it.21 // it.
22 Parent WindowID22 Parent WindowID
23+ // Remap is set when the toplevel mapped before and was unmapped (a tray
24+ // app restored): window rules only place its first map.
25+ Remap bool
23 }26 }
24 27
25 // SlotEnv is the environment variable neferwl sets on processes it spawns28 // SlotEnv is the environment variable neferwl sets on processes it spawns
M internal/ports/config.go +20 -0
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "image"
5+ "regexp"
56 "strings"
67 "time"
78 )
@@ -164,6 +165,8 @@ type Config struct {
164165 }
165166 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
166167 Workspaces []WorkspaceConfig
168+ // Rules are the window rules (rule.<name>.* keys), in first-seen order.
169+ Rules []WindowRule
167170 // Outputs selects and configures physical displays (drm backend).
168171 Outputs []OutputConfig
169172 Binds map[string]string
@@ -257,3 +260,20 @@ type SlotConfig struct {
257260 Width string
258261 Argv []string
259262 }
263+
264+// WindowRule places the windows whose app ID it matches when they first map.
265+// Matching rules merge in config order, a later rule winning per field.
266+type WindowRule struct {
267+ Name string
268+ // AppID matches the whole app ID (the config anchors it).
269+ AppID *regexp.Regexp
270+ // Floating forces a window to float (true) or to tile (false); nil leaves it.
271+ Floating *bool
272+ // Workspace is a 1-based number of a numbered workspace or the name of a
273+ // declared one; empty leaves it.
274+ Workspace string
275+ // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276+ Monitor string
277+ // Width is the layout width of the new column of a tiled window.
278+ Width string
279+}
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "image"4 "image"
5+ "regexp"
5 "strings"6 "strings"
6 "time"7 "time"
7 )8 )
@@ -164,6 +165,8 @@ type Config struct {
164 }165 }
165 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.166 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
166 Workspaces []WorkspaceConfig167 Workspaces []WorkspaceConfig
168+ // Rules are the window rules (rule.<name>.* keys), in first-seen order.
169+ Rules []WindowRule
167 // Outputs selects and configures physical displays (drm backend).170 // Outputs selects and configures physical displays (drm backend).
168 Outputs []OutputConfig171 Outputs []OutputConfig
169 Binds map[string]string172 Binds map[string]string
@@ -257,3 +260,20 @@ type SlotConfig struct {
257 Width string260 Width string
258 Argv []string261 Argv []string
259 }262 }
263+
264+// WindowRule places the windows whose app ID it matches when they first map.
265+// Matching rules merge in config order, a later rule winning per field.
266+type WindowRule struct {
267+ Name string
268+ // AppID matches the whole app ID (the config anchors it).
269+ AppID *regexp.Regexp
270+ // Floating forces a window to float (true) or to tile (false); nil leaves it.
271+ Floating *bool
272+ // Workspace is a 1-based number of a numbered workspace or the name of a
273+ // declared one; empty leaves it.
274+ Workspace string
275+ // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276+ Monitor string
277+ // Width is the layout width of the new column of a tiled window.
278+ Width string
279+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close