feat(config): parse rule.<name>.* window rules
- commit
- 720cbc3f06b8f4e3001417673811de727f3c0a70
- author
- brice · · 2026-10-08 10:03 UTC
- parent
- 4fd770d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +253 -0
M internal/adapters/config/config.go +118 -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,88 @@ 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 | + | // The whole app ID must match: "steam" does not match "steam_app_1". | |
| 1029 | + | re, err := regexp.Compile("^(?:" + v + ")$") | |
| 1030 | + | if err != nil { | |
| 1031 | + | return fmt.Errorf("invalid regular expression: %v", err) | |
| 1032 | + | } | |
| 1033 | + | r.AppID = re | |
| 1034 | + | case "floating": | |
| 1035 | + | b, err := onOff(v) | |
| 1036 | + | if err != nil { | |
| 1037 | + | return err | |
| 1038 | + | } | |
| 1039 | + | r.Floating = &b | |
| 1040 | + | case "workspace": | |
| 1041 | + | if n, err := strconv.Atoi(v); err == nil { | |
| 1042 | + | if n < 1 || n > maxRuleWorkspace { | |
| 1043 | + | return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace) | |
| 1044 | + | } | |
| 1045 | + | } else if !workspaceName.MatchString(v) { | |
| 1046 | + | return fmt.Errorf("must be a workspace number or the name of a declared workspace") | |
| 1047 | + | } | |
| 1048 | + | r.Workspace = v | |
| 1049 | + | case "monitor": | |
| 1050 | + | if v == "" { | |
| 1051 | + | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | |
| 1052 | + | } | |
| 1053 | + | r.Monitor = v | |
| 1054 | + | case "width": | |
| 1055 | + | if !width(v) { | |
| 1056 | + | return fmt.Errorf("must be a width like 50%%, 1/3 or 800px") | |
| 1057 | + | } | |
| 1058 | + | r.Width = v | |
| 1059 | + | default: | |
| 1060 | + | return errUnknownRuleField | |
| 1061 | + | } | |
| 1062 | + | return nil | |
| 1063 | + | } | |
| 1064 | + | ||
| 1065 | + | // maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the | |
| 1066 | + | // workspaces that exist. | |
| 1067 | + | const maxRuleWorkspace = 1000 | |
| 1068 | + | ||
| 1069 | + | // finishRules keeps the valid rules in file order, warning about those | |
| 1070 | + | // without an app-id (dropped) and about named workspaces nobody declared | |
| 1071 | + | // (kept: core falls back to the default place). | |
| 1072 | + | func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) { | |
| 1073 | + | var rules []ports.WindowRule | |
| 1074 | + | var warnings []Warning | |
| 1075 | + | for _, e := range entries { | |
| 1076 | + | if e.dropped { | |
| 1077 | + | continue | |
| 1078 | + | } | |
| 1079 | + | if e.rule.AppID == nil { | |
| 1080 | + | warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)}) | |
| 1081 | + | continue | |
| 1082 | + | } | |
| 1083 | + | if w := e.rule.Workspace; w != "" { | |
| 1084 | + | if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) { | |
| 1085 | + | 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)}) | |
| 1086 | + | } | |
| 1087 | + | } | |
| 1088 | + | rules = append(rules, e.rule) | |
| 1089 | + | } | |
| 1090 | + | return rules, warnings | |
| 1091 | + | } | |
| 1092 | + | ||
| 975 | 1093 | // setWorkspace applies one workspace.<name>.<field> key. | |
| 976 | 1094 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |
| 977 | 1095 | 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,88 @@ 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 | + | // The whole app ID must match: "steam" does not match "steam_app_1". | |||
| 1029 | + | re, err := regexp.Compile("^(?:" + v + ")$") | |||
| 1030 | + | if err != nil { | |||
| 1031 | + | return fmt.Errorf("invalid regular expression: %v", err) | |||
| 1032 | + | } | |||
| 1033 | + | r.AppID = re | |||
| 1034 | + | case "floating": | |||
| 1035 | + | b, err := onOff(v) | |||
| 1036 | + | if err != nil { | |||
| 1037 | + | return err | |||
| 1038 | + | } | |||
| 1039 | + | r.Floating = &b | |||
| 1040 | + | case "workspace": | |||
| 1041 | + | if n, err := strconv.Atoi(v); err == nil { | |||
| 1042 | + | if n < 1 || n > maxRuleWorkspace { | |||
| 1043 | + | return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace) | |||
| 1044 | + | } | |||
| 1045 | + | } else if !workspaceName.MatchString(v) { | |||
| 1046 | + | return fmt.Errorf("must be a workspace number or the name of a declared workspace") | |||
| 1047 | + | } | |||
| 1048 | + | r.Workspace = v | |||
| 1049 | + | case "monitor": | |||
| 1050 | + | if v == "" { | |||
| 1051 | + | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | |||
| 1052 | + | } | |||
| 1053 | + | r.Monitor = v | |||
| 1054 | + | case "width": | |||
| 1055 | + | if !width(v) { | |||
| 1056 | + | return fmt.Errorf("must be a width like 50%%, 1/3 or 800px") | |||
| 1057 | + | } | |||
| 1058 | + | r.Width = v | |||
| 1059 | + | default: | |||
| 1060 | + | return errUnknownRuleField | |||
| 1061 | + | } | |||
| 1062 | + | return nil | |||
| 1063 | + | } | |||
| 1064 | + | ||||
| 1065 | + | // maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the | |||
| 1066 | + | // workspaces that exist. | |||
| 1067 | + | const maxRuleWorkspace = 1000 | |||
| 1068 | + | ||||
| 1069 | + | // finishRules keeps the valid rules in file order, warning about those | |||
| 1070 | + | // without an app-id (dropped) and about named workspaces nobody declared | |||
| 1071 | + | // (kept: core falls back to the default place). | |||
| 1072 | + | func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) { | |||
| 1073 | + | var rules []ports.WindowRule | |||
| 1074 | + | var warnings []Warning | |||
| 1075 | + | for _, e := range entries { | |||
| 1076 | + | if e.dropped { | |||
| 1077 | + | continue | |||
| 1078 | + | } | |||
| 1079 | + | if e.rule.AppID == nil { | |||
| 1080 | + | warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)}) | |||
| 1081 | + | continue | |||
| 1082 | + | } | |||
| 1083 | + | if w := e.rule.Workspace; w != "" { | |||
| 1084 | + | if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) { | |||
| 1085 | + | 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)}) | |||
| 1086 | + | } | |||
| 1087 | + | } | |||
| 1088 | + | rules = append(rules, e.rule) | |||
| 1089 | + | } | |||
| 1090 | + | return rules, warnings | |||
| 1091 | + | } | |||
| 1092 | + | ||||
| 975 | // setWorkspace applies one workspace.<name>.<field> key. | 1093 | // setWorkspace applies one workspace.<name>.<field> key. | ||
| 976 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | 1094 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | ||
| 977 | switch field { | 1095 | switch field { | ||
A internal/adapters/config/rules_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 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 | + | {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0}, | |
| 63 | + | {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1}, | |
| 64 | + | {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0}, | |
| 65 | + | {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0}, | |
| 66 | + | {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0}, | |
| 67 | + | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | |
| 68 | + | {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0}, | |
| 69 | + | {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0}, | |
| 70 | + | {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1}, | |
| 71 | + | } { | |
| 72 | + | t.Run(tc.name, func(t *testing.T) { | |
| 73 | + | c, w := parseString(t, tc.text) | |
| 74 | + | if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) { | |
| 75 | + | t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line) | |
| 76 | + | } | |
| 77 | + | if len(c.Rules) != tc.rules { | |
| 78 | + | t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules) | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestRuleWidthReusesColumnWidth(t *testing.T) { | |
| 85 | + | for _, v := range []string{"1", "33%", "1/3", "640px"} { | |
| 86 | + | c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n") | |
| 87 | + | if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v { | |
| 88 | + | t.Fatalf("%s: %v %+v", v, w, c.Rules) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestRuleReloadDiff(t *testing.T) { | |
| 94 | + | _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n") | |
| 95 | + | _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n") | |
| 96 | + | _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n") | |
| 97 | + | if d := diff(a, b); len(d) == 0 { | |
| 98 | + | t.Fatal("reordering rules is not a change") | |
| 99 | + | } | |
| 100 | + | if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" { | |
| 101 | + | t.Fatal(d) | |
| 102 | + | } | |
| 103 | + | if d := diff(a, a); len(d) != 0 { | |
| 104 | + | t.Fatal(d) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) { | |
| 109 | + | t.Helper() | |
| 110 | + | c, raw, w, err := parse(strings.NewReader(s)) | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | return c, raw, w | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 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 | + | {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0}, | |||
| 63 | + | {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1}, | |||
| 64 | + | {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0}, | |||
| 65 | + | {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0}, | |||
| 66 | + | {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0}, | |||
| 67 | + | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | |||
| 68 | + | {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0}, | |||
| 69 | + | {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0}, | |||
| 70 | + | {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1}, | |||
| 71 | + | } { | |||
| 72 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 73 | + | c, w := parseString(t, tc.text) | |||
| 74 | + | if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) { | |||
| 75 | + | t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line) | |||
| 76 | + | } | |||
| 77 | + | if len(c.Rules) != tc.rules { | |||
| 78 | + | t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules) | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestRuleWidthReusesColumnWidth(t *testing.T) { | |||
| 85 | + | for _, v := range []string{"1", "33%", "1/3", "640px"} { | |||
| 86 | + | c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n") | |||
| 87 | + | if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v { | |||
| 88 | + | t.Fatalf("%s: %v %+v", v, w, c.Rules) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestRuleReloadDiff(t *testing.T) { | |||
| 94 | + | _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n") | |||
| 95 | + | _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n") | |||
| 96 | + | _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n") | |||
| 97 | + | if d := diff(a, b); len(d) == 0 { | |||
| 98 | + | t.Fatal("reordering rules is not a change") | |||
| 99 | + | } | |||
| 100 | + | if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" { | |||
| 101 | + | t.Fatal(d) | |||
| 102 | + | } | |||
| 103 | + | if d := diff(a, a); len(d) != 0 { | |||
| 104 | + | t.Fatal(d) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) { | |||
| 109 | + | t.Helper() | |||
| 110 | + | c, raw, w, err := parse(strings.NewReader(s)) | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | return c, raw, w | |||
| 115 | + | } | |||
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 | + | } | |||