Merge branch 'main' into feat/pointer-gestures
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`). | |||
| 55 | 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | |
| 56 | 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| 57 | 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| 58 | + | | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) | | |
| 58 | 59 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 59 | 60 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | |
| 60 | 61 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | |
| @@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 70 | 71 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 71 | 72 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | |
| 72 | 73 | ||
| 74 | + | ## Window rules | |
| 75 | + | ||
| 76 | + | A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`): | |
| 77 | + | ||
| 78 | + | | Key | Meaning | | |
| 79 | + | |---|---| | |
| 80 | + | | `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does | | |
| 81 | + | | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float | | |
| 82 | + | | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace | | |
| 83 | + | | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected | | |
| 84 | + | | `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only | | |
| 85 | + | ||
| 86 | + | ```ini | |
| 87 | + | rule.games.app-id = steam_app_.* | |
| 88 | + | rule.games.workspace = 3 | |
| 89 | + | rule.games.monitor = DP-2 | |
| 90 | + | rule.chat.app-id = discord|org\.telegram\..* | |
| 91 | + | rule.chat.workspace = chat | |
| 92 | + | rule.chat.width = 1/3 | |
| 93 | + | rule.calc.app-id = org\.gnome\.Calculator | |
| 94 | + | rule.calc.floating = on | |
| 95 | + | ``` | |
| 96 | + | ||
| 97 | + | - Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`. | |
| 98 | + | - Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards. | |
| 99 | + | - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does. | |
| 100 | + | - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual. | |
| 101 | + | - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them. | |
| 102 | + | - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width. | |
| 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. | |
| 104 | + | ||
| 105 | + | To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched. | |
| 106 | + | ||
| 73 | 107 | ## Output placement | |
| 74 | 108 | ||
| 75 | 109 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | |
| @@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | ||
| 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | ||
| 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | ||
| 58 | + | | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) | | |||
| 58 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | 59 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | ||
| 59 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | 60 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | ||
| 60 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | 61 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | ||
| @@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 70 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | 71 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | ||
| 71 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | 72 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | ||
| 72 | 73 | ||||
| 74 | + | ## Window rules | |||
| 75 | + | ||||
| 76 | + | A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`): | |||
| 77 | + | ||||
| 78 | + | | Key | Meaning | | |||
| 79 | + | |---|---| | |||
| 80 | + | | `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does | | |||
| 81 | + | | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float | | |||
| 82 | + | | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace | | |||
| 83 | + | | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected | | |||
| 84 | + | | `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only | | |||
| 85 | + | ||||
| 86 | + | ```ini | |||
| 87 | + | rule.games.app-id = steam_app_.* | |||
| 88 | + | rule.games.workspace = 3 | |||
| 89 | + | rule.games.monitor = DP-2 | |||
| 90 | + | rule.chat.app-id = discord|org\.telegram\..* | |||
| 91 | + | rule.chat.workspace = chat | |||
| 92 | + | rule.chat.width = 1/3 | |||
| 93 | + | rule.calc.app-id = org\.gnome\.Calculator | |||
| 94 | + | rule.calc.floating = on | |||
| 95 | + | ``` | |||
| 96 | + | ||||
| 97 | + | - Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`. | |||
| 98 | + | - Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards. | |||
| 99 | + | - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does. | |||
| 100 | + | - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual. | |||
| 101 | + | - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them. | |||
| 102 | + | - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width. | |||
| 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. | |||
| 104 | + | ||||
| 105 | + | To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched. | |||
| 106 | + | ||||
| 73 | ## Output placement | 107 | ## Output placement | ||
| 74 | 108 | ||||
| 75 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | 109 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | ||
M examples/config +11 -0
| @@ -106,6 +106,17 @@ focus.strength = 0.04 | |||
| 106 | 106 | # workspace.dev.column.2 = 33%, foot | |
| 107 | 107 | # bind.cmd+d = workspace dev | |
| 108 | 108 | ||
| 109 | + | # Window rules place an app's windows when they first open, without moving | |
| 110 | + | # the focus or the view. app-id is a regular expression matching the whole | |
| 111 | + | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |
| 112 | + | # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a | |
| 113 | + | # monitor key, width = like a column width. | |
| 114 | + | # rule.games.app-id = steam_app_.* | |
| 115 | + | # rule.games.workspace = 3 | |
| 116 | + | # rule.games.monitor = DP-2 | |
| 117 | + | # rule.calc.app-id = org\.gnome\.Calculator | |
| 118 | + | # rule.calc.floating = on | |
| 119 | + | ||
| 109 | 120 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | |
| 110 | 121 | # The first listed display that is connected and not off is used. | |
| 111 | 122 | # output.DP-2 = 5120x2160@165.058 | |
| @@ -106,6 +106,17 @@ focus.strength = 0.04 | |||||
| 106 | # workspace.dev.column.2 = 33%, foot | 106 | # workspace.dev.column.2 = 33%, foot | ||
| 107 | # bind.cmd+d = workspace dev | 107 | # bind.cmd+d = workspace dev | ||
| 108 | 108 | ||||
| 109 | + | # Window rules place an app's windows when they first open, without moving | |||
| 110 | + | # the focus or the view. app-id is a regular expression matching the whole | |||
| 111 | + | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |||
| 112 | + | # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a | |||
| 113 | + | # monitor key, width = like a column width. | |||
| 114 | + | # rule.games.app-id = steam_app_.* | |||
| 115 | + | # rule.games.workspace = 3 | |||
| 116 | + | # rule.games.monitor = DP-2 | |||
| 117 | + | # rule.calc.app-id = org\.gnome\.Calculator | |||
| 118 | + | # rule.calc.floating = on | |||
| 119 | + | ||||
| 109 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | 120 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | ||
| 110 | # The first listed display that is connected and not off is used. | 121 | # The first listed display that is connected and not off is used. | ||
| 111 | # output.DP-2 = 5120x2160@165.058 | 122 | # output.DP-2 = 5120x2160@165.058 | ||
M internal/adapters/config/config.go +119 -0
| @@ -3,6 +3,7 @@ package config | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bufio" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "math" | |
| @@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 326 | 327 | relKeys := map[string]string{} // output -> key of its relation | |
| 327 | 328 | offsetLines := map[string]int{} // output -> line of its offset | |
| 328 | 329 | workspaces := map[string]int{} | |
| 330 | + | rules := map[string]*ruleEntry{} | |
| 331 | + | var ruleOrder []*ruleEntry | |
| 329 | 332 | scanner := bufio.NewScanner(r) | |
| 330 | 333 | for n := 1; scanner.Scan(); n++ { | |
| 331 | 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 | 514 | 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 | 538 | if err := set(&c, key, value); err != nil { | |
| 514 | 539 | warn("%s: %v", key, err) | |
| 515 | 540 | continue | |
| @@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 526 | 551 | } | |
| 527 | 552 | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | |
| 528 | 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 | 565 | sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | |
| 530 | 566 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | |
| 531 | 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 | 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 | 1094 | // setWorkspace applies one workspace.<name>.<field> key. | |
| 976 | 1095 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |
| 977 | 1096 | 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 relation | 327 | relKeys := map[string]string{} // output -> key of its relation | ||
| 327 | offsetLines := map[string]int{} // output -> line of its offset | 328 | 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 | continue | 514 | 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 | continue | 540 | 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 bound | 566 | // 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) { | |||
| 157 | 157 | registerWireProxy(c, xp) | |
| 158 | 158 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 159 | 159 | 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) | |
| 162 | 166 | } | |
| 163 | 167 | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | |
| 164 | 168 | 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) { | |||
| 243 | 243 | if w.parent != nil && w.parent.mapped { | |
| 244 | 244 | 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 | 247 | x.server.syncInhibitors() | |
| 248 | 248 | x.server.toplevelChanged(w) | |
| 249 | 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") | |
| @@ -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.id | 244 | 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 { | |||
| 118 | 118 | specs []NamedWorkspace | |
| 119 | 119 | presets []Width | |
| 120 | 120 | slots map[slotKey]*slotState | |
| 121 | + | rules []rule | |
| 121 | 122 | placement spawnPlacement | |
| 122 | 123 | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 123 | 124 | toSpawn []slotKey | |
| @@ -306,6 +307,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 306 | 307 | if err != nil { | |
| 307 | 308 | return err | |
| 308 | 309 | } | |
| 310 | + | rules, err := parseRules(cfg.Rules) | |
| 311 | + | if err != nil { | |
| 312 | + | return err | |
| 313 | + | } | |
| 309 | 314 | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | |
| 310 | 315 | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | |
| 311 | 316 | for _, s := range c.screens { | |
| @@ -314,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 314 | 319 | c.cfg = cfg | |
| 315 | 320 | c.binds = binds | |
| 316 | 321 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | |
| 317 | - | c.specs, c.presets = named, presets | |
| 322 | + | c.specs, c.presets, c.rules = named, presets, rules | |
| 318 | 323 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 319 | 324 | c.named() | |
| 320 | 325 | for _, s := range c.screens { | |
| @@ -118,6 +118,7 @@ type Core struct { | |||||
| 118 | specs []NamedWorkspace | 118 | specs []NamedWorkspace | ||
| 119 | presets []Width | 119 | presets []Width | ||
| 120 | slots map[slotKey]*slotState | 120 | slots map[slotKey]*slotState | ||
| 121 | + | rules []rule | |||
| 121 | placement spawnPlacement | 122 | 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 []slotKey | 124 | toSpawn []slotKey | ||
| @@ -306,6 +307,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 306 | if err != nil { | 307 | if err != nil { | ||
| 307 | return err | 308 | return err | ||
| 308 | } | 309 | } | ||
| 310 | + | rules, err := parseRules(cfg.Rules) | |||
| 311 | + | if err != nil { | |||
| 312 | + | return err | |||
| 313 | + | } | |||
| 309 | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | 314 | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | ||
| 310 | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | 315 | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | ||
| 311 | for _, s := range c.screens { | 316 | for _, s := range c.screens { | ||
| @@ -314,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 314 | c.cfg = cfg | 319 | c.cfg = cfg | ||
| 315 | c.binds = binds | 320 | c.binds = binds | ||
| 316 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | 321 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | ||
| 317 | - | c.specs, c.presets = named, presets | 322 | + | c.specs, c.presets, c.rules = named, presets, rules |
| 318 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | 323 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | ||
| 319 | c.named() | 324 | c.named() | ||
| 320 | for _, s := range c.screens { | 325 | for _, s := range c.screens { | ||
M internal/core/layout.go +6 -3
| @@ -492,7 +492,10 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 492 | 492 | } | |
| 493 | 493 | 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 | 499 | if id == 0 || w.has(id) { | |
| 497 | 500 | return | |
| 498 | 501 | } | |
| @@ -504,10 +507,10 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 504 | 507 | // A covering float or stashed window may have no column under it. | |
| 505 | 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 | 511 | return | |
| 509 | 512 | } | |
| 510 | - | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 513 | + | w.addColumn(Column{Windows: []WindowID{id}, Width: width}) | |
| 511 | 514 | } | |
| 512 | 515 | ||
| 513 | 516 | // 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], true | 493 | 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 | return | 500 | 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 | return | 511 | 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) { | |||
| 302 | 302 | ||
| 303 | 303 | // AddWindow places a new window on the workspace on screen; with | |
| 304 | 304 | // stash.capture, in its stash when shown. | |
| 305 | - | func (m *Monitor) AddWindow(id WindowID) { | |
| 305 | + | func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) } | |
| 306 | + | ||
| 307 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |
| 308 | + | func (m *Monitor) addWindow(id WindowID, width Width) { | |
| 306 | 309 | if w, _ := m.find(id); w != nil { | |
| 307 | 310 | return | |
| 308 | 311 | } | |
| 309 | 312 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | |
| 310 | - | w.AddWindow(id) | |
| 313 | + | w.addWindow(id, width) | |
| 311 | 314 | } | |
| 312 | 315 | m.normalize() | |
| 313 | 316 | } | |
| @@ -302,12 +302,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||||
| 302 | 302 | ||||
| 303 | // AddWindow places a new window on the workspace on screen; with | 303 | // AddWindow places a new window on the workspace on screen; with | ||
| 304 | // stash.capture, in its stash when shown. | 304 | // stash.capture, in its stash when shown. | ||
| 305 | - | func (m *Monitor) AddWindow(id WindowID) { | 305 | + | func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) } |
| 306 | + | ||||
| 307 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |||
| 308 | + | func (m *Monitor) addWindow(id WindowID, width Width) { | |||
| 306 | if w, _ := m.find(id); w != nil { | 309 | if w, _ := m.find(id); w != nil { | ||
| 307 | return | 310 | return | ||
| 308 | } | 311 | } | ||
| 309 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | 312 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | ||
| 310 | - | w.AddWindow(id) | 313 | + | w.addWindow(id, width) |
| 311 | } | 314 | } | ||
| 312 | m.normalize() | 315 | m.normalize() | ||
| 313 | } | 316 | } | ||
A internal/core/rules.go +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) { | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | // place claims a token once and puts its window in the target workspace. | |
| 73 | + | // Without a slot, a window rule (rules.go) may choose its place; otherwise it | |
| 74 | + | // opens on the focused screen's workspace. | |
| 73 | 75 | // Untagged windows return before any map lookup or allocation. | |
| 74 | 76 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |
| 75 | 77 | if v.Floating { | |
| @@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 82 | 84 | ps.mon.normalize() | |
| 83 | 85 | return | |
| 84 | 86 | } | |
| 87 | + | // Rules come after the dialog: it stays over its parent. | |
| 88 | + | if eff, ok := c.ruleFor(v); ok { | |
| 89 | + | c.placeByRule(v, eff) | |
| 90 | + | return | |
| 91 | + | } | |
| 85 | 92 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | |
| 86 | 93 | return | |
| 87 | 94 | } | |
| @@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 108 | 115 | } | |
| 109 | 116 | } | |
| 110 | 117 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 118 | + | if eff, ok := c.ruleFor(v); ok { | |
| 119 | + | c.placeByRule(v, eff) | |
| 120 | + | return | |
| 121 | + | } | |
| 111 | 122 | c.cur().mon.AddWindow(v.ID) | |
| 112 | 123 | } | |
| 113 | 124 | } | |
| @@ -70,6 +70,8 @@ func (p *spawnPlacement) expireTerminals(now time.Time) { | |||||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | // place claims a token once and puts its window in the target workspace. | 72 | // place claims a token once and puts its window in the target workspace. | ||
| 73 | + | // Without a slot, a window rule (rules.go) may choose its place; otherwise it | |||
| 74 | + | // opens on the focused screen's workspace. | |||
| 73 | // Untagged windows return before any map lookup or allocation. | 75 | // Untagged windows return before any map lookup or allocation. | ||
| 74 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | 76 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | ||
| 75 | if v.Floating { | 77 | if v.Floating { | ||
| @@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 82 | ps.mon.normalize() | 84 | ps.mon.normalize() | ||
| 83 | return | 85 | return | ||
| 84 | } | 86 | } | ||
| 87 | + | // Rules come after the dialog: it stays over its parent. | |||
| 88 | + | if eff, ok := c.ruleFor(v); ok { | |||
| 89 | + | c.placeByRule(v, eff) | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 85 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | 92 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | ||
| 86 | return | 93 | return | ||
| 87 | } | 94 | } | ||
| @@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 108 | } | 115 | } | ||
| 109 | } | 116 | } | ||
| 110 | if s, _ := c.screenOf(v.ID); s == nil { | 117 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 118 | + | if eff, ok := c.ruleFor(v); 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 { | |||
| 20 | 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | |
| 21 | 21 | // it. | |
| 22 | 22 | 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 | 28 | // 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 over | 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | ||
| 21 | // it. | 21 | // it. | ||
| 22 | Parent WindowID | 22 | 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 spawns | 28 | // SlotEnv is the environment variable neferwl sets on processes it spawns | ||
M internal/ports/config.go +20 -0
| @@ -2,6 +2,7 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | + | "regexp" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ) | |
| @@ -164,6 +165,8 @@ type Config struct { | |||
| 164 | 165 | } | |
| 165 | 166 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 166 | 167 | Workspaces []WorkspaceConfig | |
| 168 | + | // Rules are the window rules (rule.<name>.* keys), in first-seen order. | |
| 169 | + | Rules []WindowRule | |
| 167 | 170 | // Outputs selects and configures physical displays (drm backend). | |
| 168 | 171 | Outputs []OutputConfig | |
| 169 | 172 | Binds map[string]string | |
| @@ -257,3 +260,20 @@ type SlotConfig struct { | |||
| 257 | 260 | Width string | |
| 258 | 261 | 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 | + | } | |
| @@ -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 []WorkspaceConfig | 167 | 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 []OutputConfig | 171 | Outputs []OutputConfig | ||
| 169 | Binds map[string]string | 172 | Binds map[string]string | ||
| @@ -257,3 +260,20 @@ type SlotConfig struct { | |||||
| 257 | Width string | 260 | Width string | ||
| 258 | Argv []string | 261 | 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 | + | } | |||