detached 720cbc3

feat(config): parse rule.<name>.* window rules

commit
720cbc3f06b8f4e3001417673811de727f3c0a70
author
brice · · 2026-10-08 10:03 UTC
parent
4fd770d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
33
44 import (
55 "bufio"
6+ "errors"
67 "fmt"
78 "io"
89 "math"
@@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
326327 relKeys := map[string]string{} // output -> key of its relation
327328 offsetLines := map[string]int{} // output -> line of its offset
328329 workspaces := map[string]int{}
330+ rules := map[string]*ruleEntry{}
331+ var ruleOrder []*ruleEntry
329332 scanner := bufio.NewScanner(r)
330333 for n := 1; scanner.Scan(); n++ {
331334 line := strings.TrimSpace(scanner.Text())
@@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
510513 }
511514 continue
512515 }
516+ if rest, ok := strings.CutPrefix(key, "rule."); ok {
517+ name, field, _ := strings.Cut(rest, ".")
518+ if !workspaceName.MatchString(name) {
519+ warn("%s: rule name must be letters, digits, - or _", key)
520+ continue
521+ }
522+ e := rules[name]
523+ if e == nil {
524+ e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n}
525+ rules[name] = e
526+ ruleOrder = append(ruleOrder, e)
527+ }
528+ if err := setRule(&e.rule, field, value); err != nil {
529+ warn("%s: %v", key, err)
530+ if !errors.Is(err, errUnknownRuleField) {
531+ e.dropped = true
532+ }
533+ continue
534+ }
535+ override()
536+ continue
537+ }
513538 if err := set(&c, key, value); err != nil {
514539 warn("%s: %v", key, err)
515540 continue
@@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
526551 }
527552 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
528553 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
554+ var ruleWarnings []Warning
555+ c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen)
556+ warnings = append(warnings, ruleWarnings...)
557+ if len(c.Rules) > 0 {
558+ // Rules merge in file order: moving one is a change.
559+ names := make([]string, len(c.Rules))
560+ for i, r := range c.Rules {
561+ names[i] = r.Name
562+ }
563+ raw["rules"] = strings.Join(names, ",")
564+ }
529565 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
530566 // A user bind on a digit (cmd+1, even "none") replaces the default bound
531567 // to the same physical key (cmd+code:2), so older configs keep working.
@@ -972,6 +1008,88 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
9721008
9731009 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
9741010
1011+// ruleEntry is a rule.<name>.* group while the file is read.
1012+type ruleEntry struct {
1013+ rule ports.WindowRule
1014+ line int // of its first key
1015+ // dropped is set by an invalid value: the whole rule is ignored.
1016+ dropped bool
1017+}
1018+
1019+var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)")
1020+
1021+// setRule applies one rule.<name>.<field> key.
1022+func setRule(r *ports.WindowRule, field, v string) error {
1023+ switch field {
1024+ case "app-id":
1025+ if v == "" {
1026+ return fmt.Errorf("needs a regular expression")
1027+ }
1028+ // 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+
9751093 // setWorkspace applies one workspace.<name>.<field> key.
9761094 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
9771095 switch field {
@@ -3,6 +3,7 @@ package config
3 3
4 import (4 import (
5 "bufio"5 "bufio"
6+ "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "math"9 "math"
@@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
326 relKeys := map[string]string{} // output -> key of its relation327 relKeys := map[string]string{} // output -> key of its relation
327 offsetLines := map[string]int{} // output -> line of its offset328 offsetLines := map[string]int{} // output -> line of its offset
328 workspaces := map[string]int{}329 workspaces := map[string]int{}
330+ rules := map[string]*ruleEntry{}
331+ var ruleOrder []*ruleEntry
329 scanner := bufio.NewScanner(r)332 scanner := bufio.NewScanner(r)
330 for n := 1; scanner.Scan(); n++ {333 for n := 1; scanner.Scan(); n++ {
331 line := strings.TrimSpace(scanner.Text())334 line := strings.TrimSpace(scanner.Text())
@@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
510 }513 }
511 continue514 continue
512 }515 }
516+ if rest, ok := strings.CutPrefix(key, "rule."); ok {
517+ name, field, _ := strings.Cut(rest, ".")
518+ if !workspaceName.MatchString(name) {
519+ warn("%s: rule name must be letters, digits, - or _", key)
520+ continue
521+ }
522+ e := rules[name]
523+ if e == nil {
524+ e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n}
525+ rules[name] = e
526+ ruleOrder = append(ruleOrder, e)
527+ }
528+ if err := setRule(&e.rule, field, value); err != nil {
529+ warn("%s: %v", key, err)
530+ if !errors.Is(err, errUnknownRuleField) {
531+ e.dropped = true
532+ }
533+ continue
534+ }
535+ override()
536+ continue
537+ }
513 if err := set(&c, key, value); err != nil {538 if err := set(&c, key, value); err != nil {
514 warn("%s: %v", key, err)539 warn("%s: %v", key, err)
515 continue540 continue
@@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
526 }551 }
527 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)552 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
528 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)553 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
554+ var ruleWarnings []Warning
555+ c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen)
556+ warnings = append(warnings, ruleWarnings...)
557+ if len(c.Rules) > 0 {
558+ // Rules merge in file order: moving one is a change.
559+ names := make([]string, len(c.Rules))
560+ for i, r := range c.Rules {
561+ names[i] = r.Name
562+ }
563+ raw["rules"] = strings.Join(names, ",")
564+ }
529 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })565 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
530 // A user bind on a digit (cmd+1, even "none") replaces the default bound566 // A user bind on a digit (cmd+1, even "none") replaces the default bound
531 // to the same physical key (cmd+code:2), so older configs keep working.567 // to the same physical key (cmd+code:2), so older configs keep working.
@@ -972,6 +1008,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
22
33 import (
44 "image"
5+ "regexp"
56 "strings"
67 "time"
78 )
@@ -164,6 +165,8 @@ type Config struct {
164165 }
165166 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
166167 Workspaces []WorkspaceConfig
168+ // Rules are the window rules (rule.<name>.* keys), in first-seen order.
169+ Rules []WindowRule
167170 // Outputs selects and configures physical displays (drm backend).
168171 Outputs []OutputConfig
169172 Binds map[string]string
@@ -257,3 +260,20 @@ type SlotConfig struct {
257260 Width string
258261 Argv []string
259262 }
263+
264+// WindowRule places the windows whose app ID it matches when they first map.
265+// Matching rules merge in config order, a later rule winning per field.
266+type WindowRule struct {
267+ Name string
268+ // AppID matches the whole app ID (the config anchors it).
269+ AppID *regexp.Regexp
270+ // Floating forces a window to float (true) or to tile (false); nil leaves it.
271+ Floating *bool
272+ // Workspace is a 1-based number of a numbered workspace or the name of a
273+ // declared one; empty leaves it.
274+ Workspace string
275+ // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276+ Monitor string
277+ // Width is the layout width of the new column of a tiled window.
278+ Width string
279+}
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "image"4 "image"
5+ "regexp"
5 "strings"6 "strings"
6 "time"7 "time"
7 )8 )
@@ -164,6 +165,8 @@ type Config struct {
164 }165 }
165 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.166 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
166 Workspaces []WorkspaceConfig167 Workspaces []WorkspaceConfig
168+ // Rules are the window rules (rule.<name>.* keys), in first-seen order.
169+ Rules []WindowRule
167 // Outputs selects and configures physical displays (drm backend).170 // Outputs selects and configures physical displays (drm backend).
168 Outputs []OutputConfig171 Outputs []OutputConfig
169 Binds map[string]string172 Binds map[string]string
@@ -257,3 +260,20 @@ type SlotConfig struct {
257 Width string260 Width string
258 Argv []string261 Argv []string
259 }262 }
263+
264+// WindowRule places the windows whose app ID it matches when they first map.
265+// Matching rules merge in config order, a later rule winning per field.
266+type WindowRule struct {
267+ Name string
268+ // AppID matches the whole app ID (the config anchors it).
269+ AppID *regexp.Regexp
270+ // Floating forces a window to float (true) or to tile (false); nil leaves it.
271+ Floating *bool
272+ // Workspace is a 1-based number of a numbered workspace or the name of a
273+ // declared one; empty leaves it.
274+ Workspace string
275+ // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276+ Monitor string
277+ // Width is the layout width of the new column of a tiled window.
278+ Width string
279+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close