detached dbfc794

Merge branch 'main' into fix/cascade-overview-maximized-ratio

commit
dbfc79477dcc7f8be357d6d3d85a8533a60a36c1
author
bnema · · 2026-10-08 12:52 UTC
merge of
ad2b049 + 1806e6d · diff against the first parent
signature
unsigned
31 files · +2156 -80
M docs/config.md +36 -0
@@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
5555 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
5656 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
5757 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
58+| `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) |
5859 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
5960 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
6061 | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height |
@@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
7071 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
7172 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
7273
74+## Window rules
75+
76+A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`):
77+
78+| Key | Meaning |
79+|---|---|
80+| `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does |
81+| `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float |
82+| `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace |
83+| `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected |
84+| `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only |
85+
86+```ini
87+rule.games.app-id = steam_app_.*
88+rule.games.workspace = 3
89+rule.games.monitor = DP-2
90+rule.chat.app-id = discord|org\.telegram\..*
91+rule.chat.workspace = chat
92+rule.chat.width = 1/3
93+rule.calc.app-id = org\.gnome\.Calculator
94+rule.calc.floating = on
95+```
96+
97+- Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`.
98+- Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards.
99+- A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does.
100+- A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.
101+- A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.
102+- `width` is ignored under `fixed` and `cascade` overflow, where columns share the width.
103+- An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored.
104+
105+To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.
106+
73107 ## Output placement
74108
75109 Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet.
@@ -152,6 +186,8 @@ Over the shown stash a sideways swipe slides it with the fingers, with a stop on
152186
153187 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
154188
189+Three- and four-finger swipes belong to the compositor. Every other touchpad gesture goes to the window under the pointer through `zwp_pointer_gestures_v1` (version 3): swipes of other finger counts, pinch (zoom and rotation) and hold. A gesture stays with the window that had the pointer when it began; it ends as cancelled if the pointer moves to another window or the window closes. Nothing is sent to windows while the session is locked.
190+
155191 ## HDR
156192
157193 `output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits.
@@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
55 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |55 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
56 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |56 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
57 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |57 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
58+| `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) |
58 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |59 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
59 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |60 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
60 | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height |61 | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height |
@@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
70 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |71 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
71 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |72 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
72 73
74+## Window rules
75+
76+A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`):
77+
78+| Key | Meaning |
79+|---|---|
80+| `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does |
81+| `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float |
82+| `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace |
83+| `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected |
84+| `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only |
85+
86+```ini
87+rule.games.app-id = steam_app_.*
88+rule.games.workspace = 3
89+rule.games.monitor = DP-2
90+rule.chat.app-id = discord|org\.telegram\..*
91+rule.chat.workspace = chat
92+rule.chat.width = 1/3
93+rule.calc.app-id = org\.gnome\.Calculator
94+rule.calc.floating = on
95+```
96+
97+- Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`.
98+- Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards.
99+- A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does.
100+- A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.
101+- A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.
102+- `width` is ignored under `fixed` and `cascade` overflow, where columns share the width.
103+- An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored.
104+
105+To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.
106+
73 ## Output placement107 ## Output placement
74 108
75 Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet.109 Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet.
@@ -152,6 +186,8 @@ Over the shown stash a sideways swipe slides it with the fingers, with a stop on
152 186
153 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.187 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
154 188
189+Three- and four-finger swipes belong to the compositor. Every other touchpad gesture goes to the window under the pointer through `zwp_pointer_gestures_v1` (version 3): swipes of other finger counts, pinch (zoom and rotation) and hold. A gesture stays with the window that had the pointer when it began; it ends as cancelled if the pointer moves to another window or the window closes. Nothing is sent to windows while the session is locked.
190+
155 ## HDR191 ## HDR
156 192
157 `output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits.193 `output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits.
M examples/config +11 -0
@@ -106,6 +106,17 @@ focus.strength = 0.04
106106 # workspace.dev.column.2 = 33%, foot
107107 # bind.cmd+d = workspace dev
108108
109+# Window rules place an app's windows when they first open, without moving
110+# the focus or the view. app-id is a regular expression matching the whole
111+# app ID (see `neferwl state`); matching rules merge, a later one winning.
112+# floating = on|off, workspace = N or a declared name, monitor = DP-2 or a
113+# monitor key, width = like a column width.
114+# rule.games.app-id = steam_app_.*
115+# rule.games.workspace = 3
116+# rule.games.monitor = DP-2
117+# rule.calc.app-id = org\.gnome\.Calculator
118+# rule.calc.floating = on
119+
109120 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
110121 # The first listed display that is connected and not off is used.
111122 # output.DP-2 = 5120x2160@165.058
@@ -106,6 +106,17 @@ focus.strength = 0.04
106 # workspace.dev.column.2 = 33%, foot106 # workspace.dev.column.2 = 33%, foot
107 # bind.cmd+d = workspace dev107 # bind.cmd+d = workspace dev
108 108
109+# Window rules place an app's windows when they first open, without moving
110+# the focus or the view. app-id is a regular expression matching the whole
111+# app ID (see `neferwl state`); matching rules merge, a later one winning.
112+# floating = on|off, workspace = N or a declared name, monitor = DP-2 or a
113+# monitor key, width = like a column width.
114+# rule.games.app-id = steam_app_.*
115+# rule.games.workspace = 3
116+# rule.games.monitor = DP-2
117+# rule.calc.app-id = org\.gnome\.Calculator
118+# rule.calc.floating = on
119+
109 # Displays (drm backend): WxH, WxH@Hz, preferred or off.120 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
110 # The first listed display that is connected and not off is used.121 # The first listed display that is connected and not off is used.
111 # output.DP-2 = 5120x2160@165.058122 # output.DP-2 = 5120x2160@165.058
M internal/adapters/config/config.go +119 -0
@@ -3,6 +3,7 @@ package config
33
44 import (
55 "bufio"
6+ "errors"
67 "fmt"
78 "io"
89 "math"
@@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
326327 relKeys := map[string]string{} // output -> key of its relation
327328 offsetLines := map[string]int{} // output -> line of its offset
328329 workspaces := map[string]int{}
330+ rules := map[string]*ruleEntry{}
331+ var ruleOrder []*ruleEntry
329332 scanner := bufio.NewScanner(r)
330333 for n := 1; scanner.Scan(); n++ {
331334 line := strings.TrimSpace(scanner.Text())
@@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
510513 }
511514 continue
512515 }
516+ if rest, ok := strings.CutPrefix(key, "rule."); ok {
517+ name, field, _ := strings.Cut(rest, ".")
518+ if !workspaceName.MatchString(name) {
519+ warn("%s: rule name must be letters, digits, - or _", key)
520+ continue
521+ }
522+ e := rules[name]
523+ if e == nil {
524+ e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n}
525+ rules[name] = e
526+ ruleOrder = append(ruleOrder, e)
527+ }
528+ if err := setRule(&e.rule, field, value); err != nil {
529+ warn("%s: %v", key, err)
530+ if !errors.Is(err, errUnknownRuleField) {
531+ e.dropped = true
532+ }
533+ continue
534+ }
535+ override()
536+ continue
537+ }
513538 if err := set(&c, key, value); err != nil {
514539 warn("%s: %v", key, err)
515540 continue
@@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
526551 }
527552 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
528553 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
554+ var ruleWarnings []Warning
555+ c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen)
556+ warnings = append(warnings, ruleWarnings...)
557+ if len(c.Rules) > 0 {
558+ // Rules merge in file order: moving one is a change.
559+ names := make([]string, len(c.Rules))
560+ for i, r := range c.Rules {
561+ names[i] = r.Name
562+ }
563+ raw["rules"] = strings.Join(names, ",")
564+ }
529565 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
530566 // A user bind on a digit (cmd+1, even "none") replaces the default bound
531567 // to the same physical key (cmd+code:2), so older configs keep working.
@@ -972,6 +1008,89 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
9721008
9731009 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
9741010
1011+// ruleEntry is a rule.<name>.* group while the file is read.
1012+type ruleEntry struct {
1013+ rule ports.WindowRule
1014+ line int // of its first key
1015+ // dropped is set by an invalid value: the whole rule is ignored.
1016+ dropped bool
1017+}
1018+
1019+var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)")
1020+
1021+// setRule applies one rule.<name>.<field> key.
1022+func setRule(r *ports.WindowRule, field, v string) error {
1023+ switch field {
1024+ case "app-id":
1025+ if v == "" {
1026+ return fmt.Errorf("needs a regular expression")
1027+ }
1028+ // Compiled alone first, so a value cannot close the anchoring
1029+ // group ("a)|(b").
1030+ if _, err := regexp.Compile(v); err != nil {
1031+ return fmt.Errorf("invalid regular expression: %v", err)
1032+ }
1033+ // The whole app ID must match: "steam" does not match "steam_app_1".
1034+ r.AppID = regexp.MustCompile("^(?:" + v + ")$")
1035+ case "floating":
1036+ b, err := onOff(v)
1037+ if err != nil {
1038+ return err
1039+ }
1040+ r.Floating = &b
1041+ case "workspace":
1042+ if n, err := strconv.Atoi(v); err == nil {
1043+ if n < 1 || n > maxRuleWorkspace {
1044+ return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace)
1045+ }
1046+ } else if !workspaceName.MatchString(v) {
1047+ return fmt.Errorf("must be a workspace number or the name of a declared workspace")
1048+ }
1049+ r.Workspace = v
1050+ case "monitor":
1051+ if v == "" {
1052+ return fmt.Errorf("needs a connector (DP-2) or a monitor key")
1053+ }
1054+ r.Monitor = v
1055+ case "width":
1056+ if !width(v) {
1057+ return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")
1058+ }
1059+ r.Width = v
1060+ default:
1061+ return errUnknownRuleField
1062+ }
1063+ return nil
1064+}
1065+
1066+// maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the
1067+// workspaces that exist.
1068+const maxRuleWorkspace = 1000
1069+
1070+// finishRules keeps the valid rules in file order, warning about those
1071+// without an app-id (dropped) and about named workspaces nobody declared
1072+// (kept: core falls back to the default place).
1073+func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) {
1074+ var rules []ports.WindowRule
1075+ var warnings []Warning
1076+ for _, e := range entries {
1077+ if e.dropped {
1078+ continue
1079+ }
1080+ if e.rule.AppID == nil {
1081+ warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)})
1082+ continue
1083+ }
1084+ if w := e.rule.Workspace; w != "" {
1085+ if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) {
1086+ warnings = append(warnings, Warning{Line: seen["rule."+e.rule.Name+".workspace"], Msg: fmt.Sprintf("rule.%s.workspace: no workspace.%s.* declared", e.rule.Name, w)})
1087+ }
1088+ }
1089+ rules = append(rules, e.rule)
1090+ }
1091+ return rules, warnings
1092+}
1093+
9751094 // setWorkspace applies one workspace.<name>.<field> key.
9761095 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
9771096 switch field {
@@ -3,6 +3,7 @@ package config
3 3
4 import (4 import (
5 "bufio"5 "bufio"
6+ "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "math"9 "math"
@@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
326 relKeys := map[string]string{} // output -> key of its relation327 relKeys := map[string]string{} // output -> key of its relation
327 offsetLines := map[string]int{} // output -> line of its offset328 offsetLines := map[string]int{} // output -> line of its offset
328 workspaces := map[string]int{}329 workspaces := map[string]int{}
330+ rules := map[string]*ruleEntry{}
331+ var ruleOrder []*ruleEntry
329 scanner := bufio.NewScanner(r)332 scanner := bufio.NewScanner(r)
330 for n := 1; scanner.Scan(); n++ {333 for n := 1; scanner.Scan(); n++ {
331 line := strings.TrimSpace(scanner.Text())334 line := strings.TrimSpace(scanner.Text())
@@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
510 }513 }
511 continue514 continue
512 }515 }
516+ if rest, ok := strings.CutPrefix(key, "rule."); ok {
517+ name, field, _ := strings.Cut(rest, ".")
518+ if !workspaceName.MatchString(name) {
519+ warn("%s: rule name must be letters, digits, - or _", key)
520+ continue
521+ }
522+ e := rules[name]
523+ if e == nil {
524+ e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n}
525+ rules[name] = e
526+ ruleOrder = append(ruleOrder, e)
527+ }
528+ if err := setRule(&e.rule, field, value); err != nil {
529+ warn("%s: %v", key, err)
530+ if !errors.Is(err, errUnknownRuleField) {
531+ e.dropped = true
532+ }
533+ continue
534+ }
535+ override()
536+ continue
537+ }
513 if err := set(&c, key, value); err != nil {538 if err := set(&c, key, value); err != nil {
514 warn("%s: %v", key, err)539 warn("%s: %v", key, err)
515 continue540 continue
@@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
526 }551 }
527 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)552 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
528 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)553 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
554+ var ruleWarnings []Warning
555+ c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen)
556+ warnings = append(warnings, ruleWarnings...)
557+ if len(c.Rules) > 0 {
558+ // Rules merge in file order: moving one is a change.
559+ names := make([]string, len(c.Rules))
560+ for i, r := range c.Rules {
561+ names[i] = r.Name
562+ }
563+ raw["rules"] = strings.Join(names, ",")
564+ }
529 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })565 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
530 // A user bind on a digit (cmd+1, even "none") replaces the default bound566 // A user bind on a digit (cmd+1, even "none") replaces the default bound
531 // to the same physical key (cmd+code:2), so older configs keep working.567 // to the same physical key (cmd+code:2), so older configs keep working.
@@ -972,6 +1008,89 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
972 1008
973 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)1009 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
974 1010
1011+// ruleEntry is a rule.<name>.* group while the file is read.
1012+type ruleEntry struct {
1013+ rule ports.WindowRule
1014+ line int // of its first key
1015+ // dropped is set by an invalid value: the whole rule is ignored.
1016+ dropped bool
1017+}
1018+
1019+var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)")
1020+
1021+// setRule applies one rule.<name>.<field> key.
1022+func setRule(r *ports.WindowRule, field, v string) error {
1023+ switch field {
1024+ case "app-id":
1025+ if v == "" {
1026+ return fmt.Errorf("needs a regular expression")
1027+ }
1028+ // Compiled alone first, so a value cannot close the anchoring
1029+ // group ("a)|(b").
1030+ if _, err := regexp.Compile(v); err != nil {
1031+ return fmt.Errorf("invalid regular expression: %v", err)
1032+ }
1033+ // The whole app ID must match: "steam" does not match "steam_app_1".
1034+ r.AppID = regexp.MustCompile("^(?:" + v + ")$")
1035+ case "floating":
1036+ b, err := onOff(v)
1037+ if err != nil {
1038+ return err
1039+ }
1040+ r.Floating = &b
1041+ case "workspace":
1042+ if n, err := strconv.Atoi(v); err == nil {
1043+ if n < 1 || n > maxRuleWorkspace {
1044+ return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace)
1045+ }
1046+ } else if !workspaceName.MatchString(v) {
1047+ return fmt.Errorf("must be a workspace number or the name of a declared workspace")
1048+ }
1049+ r.Workspace = v
1050+ case "monitor":
1051+ if v == "" {
1052+ return fmt.Errorf("needs a connector (DP-2) or a monitor key")
1053+ }
1054+ r.Monitor = v
1055+ case "width":
1056+ if !width(v) {
1057+ return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")
1058+ }
1059+ r.Width = v
1060+ default:
1061+ return errUnknownRuleField
1062+ }
1063+ return nil
1064+}
1065+
1066+// maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the
1067+// workspaces that exist.
1068+const maxRuleWorkspace = 1000
1069+
1070+// finishRules keeps the valid rules in file order, warning about those
1071+// without an app-id (dropped) and about named workspaces nobody declared
1072+// (kept: core falls back to the default place).
1073+func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) {
1074+ var rules []ports.WindowRule
1075+ var warnings []Warning
1076+ for _, e := range entries {
1077+ if e.dropped {
1078+ continue
1079+ }
1080+ if e.rule.AppID == nil {
1081+ warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)})
1082+ continue
1083+ }
1084+ if w := e.rule.Workspace; w != "" {
1085+ if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) {
1086+ warnings = append(warnings, Warning{Line: seen["rule."+e.rule.Name+".workspace"], Msg: fmt.Sprintf("rule.%s.workspace: no workspace.%s.* declared", e.rule.Name, w)})
1087+ }
1088+ }
1089+ rules = append(rules, e.rule)
1090+ }
1091+ return rules, warnings
1092+}
1093+
975 // setWorkspace applies one workspace.<name>.<field> key.1094 // setWorkspace applies one workspace.<name>.<field> key.
976 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {1095 func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
977 switch field {1096 switch field {
A internal/adapters/config/rules_test.go +117 -0
@@ -0,0 +1,117 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRulesInFileOrder(t *testing.T) {
9+ c, w := parseString(t, `
10+workspace.chat.max-columns = 2
11+bind.cmd+c = workspace chat
12+rule.games.app-id = steam_app_.*
13+rule.games.floating = on
14+rule.games.workspace = 3
15+rule.chat.app-id = org\.telegram\..*|discord
16+rule.chat.workspace = chat
17+rule.chat.monitor = DP-2
18+rule.chat.width = 1/3
19+rule.games.width = 50%
20+`)
21+ if len(w) != 0 {
22+ t.Fatal(w)
23+ }
24+ if len(c.Rules) != 2 || c.Rules[0].Name != "games" || c.Rules[1].Name != "chat" {
25+ t.Fatalf("%+v", c.Rules)
26+ }
27+ g, ch := c.Rules[0], c.Rules[1]
28+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {
29+ t.Fatalf("%+v", g)
30+ }
31+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {
32+ t.Fatalf("%+v", ch)
33+ }
34+ if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
35+ t.Fatal("app-id did not match")
36+ }
37+}
38+
39+func TestRuleAppIDIsAnchored(t *testing.T) {
40+ c, _ := parseString(t, "rule.a.app-id = steam\nrule.b.app-id = steam|foot\n")
41+ for _, tc := range []struct {
42+ rule int
43+ id string
44+ want bool
45+ }{
46+ {0, "steam", true}, {0, "steam_app_1", false}, {0, "xsteam", false}, {0, "", false},
47+ // The alternation is anchored as a whole, not per branch.
48+ {1, "foot", true}, {1, "footclient", false}, {1, "steamfoot", false},
49+ } {
50+ if got := c.Rules[tc.rule].AppID.MatchString(tc.id); got != tc.want {
51+ t.Errorf("rule %d on %q = %v, want %v", tc.rule, tc.id, got, tc.want)
52+ }
53+ }
54+}
55+
56+func TestRuleWarnings(t *testing.T) {
57+ for _, tc := range []struct {
58+ name, text, warn string
59+ line, rules int
60+ }{
61+ {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0},
62+ // Closing the anchoring group would match any app ID.
63+ {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0},
64+ {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},
65+ {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},
66+ {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0},
67+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
68+ {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
69+ {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
70+ {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
71+ {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0},
72+ {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1},
73+ } {
74+ t.Run(tc.name, func(t *testing.T) {
75+ c, w := parseString(t, tc.text)
76+ if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) {
77+ t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line)
78+ }
79+ if len(c.Rules) != tc.rules {
80+ t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules)
81+ }
82+ })
83+ }
84+}
85+
86+func TestRuleWidthReusesColumnWidth(t *testing.T) {
87+ for _, v := range []string{"1", "33%", "1/3", "640px"} {
88+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")
89+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {
90+ t.Fatalf("%s: %v %+v", v, w, c.Rules)
91+ }
92+ }
93+}
94+
95+func TestRuleReloadDiff(t *testing.T) {
96+ _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n")
97+ _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n")
98+ _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n")
99+ if d := diff(a, b); len(d) == 0 {
100+ t.Fatal("reordering rules is not a change")
101+ }
102+ if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" {
103+ t.Fatal(d)
104+ }
105+ if d := diff(a, a); len(d) != 0 {
106+ t.Fatal(d)
107+ }
108+}
109+
110+func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
111+ t.Helper()
112+ c, raw, w, err := parse(strings.NewReader(s))
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ return c, raw, w
117+}
@@ -0,0 +1,117 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRulesInFileOrder(t *testing.T) {
9+ c, w := parseString(t, `
10+workspace.chat.max-columns = 2
11+bind.cmd+c = workspace chat
12+rule.games.app-id = steam_app_.*
13+rule.games.floating = on
14+rule.games.workspace = 3
15+rule.chat.app-id = org\.telegram\..*|discord
16+rule.chat.workspace = chat
17+rule.chat.monitor = DP-2
18+rule.chat.width = 1/3
19+rule.games.width = 50%
20+`)
21+ if len(w) != 0 {
22+ t.Fatal(w)
23+ }
24+ if len(c.Rules) != 2 || c.Rules[0].Name != "games" || c.Rules[1].Name != "chat" {
25+ t.Fatalf("%+v", c.Rules)
26+ }
27+ g, ch := c.Rules[0], c.Rules[1]
28+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {
29+ t.Fatalf("%+v", g)
30+ }
31+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {
32+ t.Fatalf("%+v", ch)
33+ }
34+ if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
35+ t.Fatal("app-id did not match")
36+ }
37+}
38+
39+func TestRuleAppIDIsAnchored(t *testing.T) {
40+ c, _ := parseString(t, "rule.a.app-id = steam\nrule.b.app-id = steam|foot\n")
41+ for _, tc := range []struct {
42+ rule int
43+ id string
44+ want bool
45+ }{
46+ {0, "steam", true}, {0, "steam_app_1", false}, {0, "xsteam", false}, {0, "", false},
47+ // The alternation is anchored as a whole, not per branch.
48+ {1, "foot", true}, {1, "footclient", false}, {1, "steamfoot", false},
49+ } {
50+ if got := c.Rules[tc.rule].AppID.MatchString(tc.id); got != tc.want {
51+ t.Errorf("rule %d on %q = %v, want %v", tc.rule, tc.id, got, tc.want)
52+ }
53+ }
54+}
55+
56+func TestRuleWarnings(t *testing.T) {
57+ for _, tc := range []struct {
58+ name, text, warn string
59+ line, rules int
60+ }{
61+ {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0},
62+ // Closing the anchoring group would match any app ID.
63+ {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0},
64+ {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},
65+ {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},
66+ {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0},
67+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
68+ {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
69+ {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
70+ {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
71+ {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0},
72+ {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1},
73+ } {
74+ t.Run(tc.name, func(t *testing.T) {
75+ c, w := parseString(t, tc.text)
76+ if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) {
77+ t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line)
78+ }
79+ if len(c.Rules) != tc.rules {
80+ t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules)
81+ }
82+ })
83+ }
84+}
85+
86+func TestRuleWidthReusesColumnWidth(t *testing.T) {
87+ for _, v := range []string{"1", "33%", "1/3", "640px"} {
88+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")
89+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {
90+ t.Fatalf("%s: %v %+v", v, w, c.Rules)
91+ }
92+ }
93+}
94+
95+func TestRuleReloadDiff(t *testing.T) {
96+ _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n")
97+ _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n")
98+ _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n")
99+ if d := diff(a, b); len(d) == 0 {
100+ t.Fatal("reordering rules is not a change")
101+ }
102+ if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" {
103+ t.Fatal(d)
104+ }
105+ if d := diff(a, a); len(d) != 0 {
106+ t.Fatal(d)
107+ }
108+}
109+
110+func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
111+ t.Helper()
112+ c, raw, w, err := parse(strings.NewReader(s))
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ return c, raw, w
117+}
M internal/adapters/libinput/libinput.go +96 -27
@@ -33,6 +33,11 @@ const (
3333 evSwipeBegin = 800
3434 evSwipeUpdate = 801
3535 evSwipeEnd = 802
36+ evPinchBegin = 803
37+ evPinchUpdate = 804
38+ evPinchEnd = 805
39+ evHoldBegin = 806
40+ evHoldEnd = 807
3641 )
3742
3843 var (
@@ -75,7 +80,11 @@ var (
7580 gestureCanceled func(gev uintptr) int32
7681 gestureDX func(gev uintptr) float64
7782 gestureDY func(gev uintptr) float64
83+ gestureAccelDX func(gev uintptr) float64
84+ gestureAccelDY func(gev uintptr) float64
7885 gestureUsec func(gev uintptr) uint64
86+ gestureScale func(gev uintptr) float64
87+ gestureAngle func(gev uintptr) float64
7988 deviceRef func(dev uintptr) uintptr
8089 deviceUnref func(dev uintptr) uintptr
8190 hasNatural func(dev uintptr) int32
@@ -148,7 +157,11 @@ func load() error {
148157 reg(&gestureCanceled, "event_gesture_get_cancelled")
149158 reg(&gestureDX, "event_gesture_get_dx_unaccelerated")
150159 reg(&gestureDY, "event_gesture_get_dy_unaccelerated")
160+ reg(&gestureAccelDX, "event_gesture_get_dx")
161+ reg(&gestureAccelDY, "event_gesture_get_dy")
151162 reg(&gestureUsec, "event_gesture_get_time_usec")
163+ reg(&gestureScale, "event_gesture_get_scale")
164+ reg(&gestureAngle, "event_gesture_get_angle_delta")
152165 reg(&deviceRef, "device_ref")
153166 reg(&deviceUnref, "device_unref")
154167 reg(&hasNatural, "device_config_scroll_has_natural_scroll")
@@ -325,14 +338,14 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
325338 }
326339
327340 // inputState is the device state Run owns: the device config, the devices
328-// it configures (referenced until removed) and the swipe in progress on
329-// each touchpad. dev applies the config to libinput; it is a zero-size
341+// it configures (referenced until removed) and the gesture in progress on
342+// the touchpads. dev applies the config to libinput; it is a zero-size
330343 // type, so storing it in the interface allocates nothing.
331344 type inputState struct {
332- cfg ports.InputDevicesConfig
333- dev deviceConfig
334- devices map[uintptr]bool
335- swipes swipes
345+ cfg ports.InputDevicesConfig
346+ dev deviceConfig
347+ devices map[uintptr]bool
348+ gestures gestures
336349 }
337350
338351 // deviceConfig is libinput's device configuration side: what a device is
@@ -478,33 +491,74 @@ func (s *inputState) release() {
478491 clear(s.devices)
479492 }
480493
481-// swipes streams one three- or four-finger swipe at a time to core, which
482-// follows
483-// the fingers. owner is the touchpad of the swipe in progress (0: none);
484-// another touchpad's swipe is ignored until it ends, so core never sees
485-// two streams interleaved.
486-type swipes struct{ owner uintptr }
494+// gestures streams one touchpad gesture at a time to core: a swipe of any
495+// finger count, a pinch or a hold. Core follows the fingers of a three- or
496+// four-finger swipe and hands the other gestures to the window under the
497+// pointer. owner is the touchpad of the gesture in progress (0: none) and
498+// kind its kind; another touchpad's gesture, or another kind's event, is
499+// ignored until it ends, so core never sees two streams interleaved.
500+type gestures struct {
501+ owner uintptr
502+ kind ports.GestureKind
503+}
487504
488-func (s *swipes) begin(dev uintptr, fingers int, at time.Duration) ports.InputEvent {
489- if (fingers != 3 && fingers != 4) || s.owner != 0 {
505+func (g *gestures) begin(dev uintptr, kind ports.GestureKind, fingers int, at time.Duration) ports.InputEvent {
506+ if dev == 0 || fingers <= 0 || g.owner != 0 {
490507 return nil
491508 }
492- s.owner = dev
509+ g.owner, g.kind = dev, kind
510+ switch kind {
511+ case ports.GesturePinch:
512+ return ports.PinchBegin{Fingers: fingers, Time: at}
513+ case ports.GestureHold:
514+ return ports.HoldBegin{Fingers: fingers, Time: at}
515+ }
493516 return ports.SwipeBegin{Fingers: fingers, Time: at}
494517 }
495518
496-func (s *swipes) update(dev uintptr, dx, dy float64, at time.Duration) ports.InputEvent {
497- if dev == 0 || s.owner != dev {
519+// running reports whether dev has a gesture of the given kind in progress.
520+func (g *gestures) running(dev uintptr, kind ports.GestureKind) bool {
521+ return dev != 0 && g.owner == dev && g.kind == kind
522+}
523+
524+func (g *gestures) swipeUpdate(dev uintptr, dx, dy, accelDX, accelDY float64, at time.Duration) ports.InputEvent {
525+ if !g.running(dev, ports.GestureSwipe) {
526+ return nil
527+ }
528+ return ports.SwipeUpdate{DX: dx, DY: dy, AccelDX: accelDX, AccelDY: accelDY, Time: at}
529+}
530+
531+func (g *gestures) pinchUpdate(dev uintptr, dx, dy, scale, rotation float64, at time.Duration) ports.InputEvent {
532+ if !g.running(dev, ports.GesturePinch) {
498533 return nil
499534 }
500- return ports.SwipeUpdate{DX: dx, DY: dy, Time: at}
535+ return ports.PinchUpdate{DX: dx, DY: dy, Scale: scale, Rotation: rotation, Time: at}
501536 }
502537
503-func (s *swipes) end(dev uintptr, cancelled bool, at time.Duration) ports.InputEvent {
504- if dev == 0 || s.owner != dev {
538+// end finishes the gesture of the given kind on dev.
539+func (g *gestures) end(dev uintptr, kind ports.GestureKind, cancelled bool, at time.Duration) ports.InputEvent {
540+ if !g.running(dev, kind) {
505541 return nil
506542 }
507- s.owner = 0
543+ return g.finish(cancelled, at)
544+}
545+
546+// abort cancels whatever gesture dev has in progress (the device went).
547+func (g *gestures) abort(dev uintptr) ports.InputEvent {
548+ if dev == 0 || g.owner != dev {
549+ return nil
550+ }
551+ return g.finish(true, 0)
552+}
553+
554+func (g *gestures) finish(cancelled bool, at time.Duration) ports.InputEvent {
555+ g.owner = 0
556+ switch g.kind {
557+ case ports.GesturePinch:
558+ return ports.PinchEnd{Cancelled: cancelled, Time: at}
559+ case ports.GestureHold:
560+ return ports.HoldEnd{Cancelled: cancelled, Time: at}
561+ }
508562 return ports.SwipeEnd{Cancelled: cancelled, Time: at}
509563 }
510564
@@ -552,17 +606,32 @@ func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state
552606 delete(in.devices, dev)
553607 deviceUnref(dev)
554608 }
555- // Core waits for the end of a swipe the device began.
556- return in.swipes.end(dev, true, 0), nil
557- case evSwipeBegin:
609+ // Core waits for the end of a gesture the device began.
610+ return in.gestures.abort(dev), nil
611+ case evSwipeBegin, evPinchBegin, evHoldBegin:
558612 ge := gestureEvent(ev)
559- return in.swipes.begin(eventDevice(ev), int(gestureFingers(ge)), usec(gestureUsec(ge))), nil
613+ kind := ports.GestureSwipe
614+ if eventType(ev) == evPinchBegin {
615+ kind = ports.GesturePinch
616+ } else if eventType(ev) == evHoldBegin {
617+ kind = ports.GestureHold
618+ }
619+ return in.gestures.begin(eventDevice(ev), kind, int(gestureFingers(ge)), usec(gestureUsec(ge))), nil
560620 case evSwipeUpdate:
561621 ge := gestureEvent(ev)
562- return in.swipes.update(eventDevice(ev), gestureDX(ge), gestureDY(ge), usec(gestureUsec(ge))), nil
622+ return in.gestures.swipeUpdate(eventDevice(ev), gestureDX(ge), gestureDY(ge), gestureAccelDX(ge), gestureAccelDY(ge), usec(gestureUsec(ge))), nil
623+ case evPinchUpdate:
624+ ge := gestureEvent(ev)
625+ return in.gestures.pinchUpdate(eventDevice(ev), gestureAccelDX(ge), gestureAccelDY(ge), gestureScale(ge), gestureAngle(ge), usec(gestureUsec(ge))), nil
563626 case evSwipeEnd:
564627 ge := gestureEvent(ev)
565- return in.swipes.end(eventDevice(ev), gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil
628+ return in.gestures.end(eventDevice(ev), ports.GestureSwipe, gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil
629+ case evPinchEnd:
630+ ge := gestureEvent(ev)
631+ return in.gestures.end(eventDevice(ev), ports.GesturePinch, gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil
632+ case evHoldEnd:
633+ ge := gestureEvent(ev)
634+ return in.gestures.end(eventDevice(ev), ports.GestureHold, gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil
566635 case evKeyboardKey:
567636 k := keyboardEvent(ev)
568637 code, pressed := keyboardKey(k), keyboardState(k) == 1
@@ -33,6 +33,11 @@ const (
33 evSwipeBegin = 80033 evSwipeBegin = 800
34 evSwipeUpdate = 80134 evSwipeUpdate = 801
35 evSwipeEnd = 80235 evSwipeEnd = 802
36+ evPinchBegin = 803
37+ evPinchUpdate = 804
38+ evPinchEnd = 805
39+ evHoldBegin = 806
40+ evHoldEnd = 807
36 )41 )
37 42
38 var (43 var (
@@ -75,7 +80,11 @@ var (
75 gestureCanceled func(gev uintptr) int3280 gestureCanceled func(gev uintptr) int32
76 gestureDX func(gev uintptr) float6481 gestureDX func(gev uintptr) float64
77 gestureDY func(gev uintptr) float6482 gestureDY func(gev uintptr) float64
83+ gestureAccelDX func(gev uintptr) float64
84+ gestureAccelDY func(gev uintptr) float64
78 gestureUsec func(gev uintptr) uint6485 gestureUsec func(gev uintptr) uint64
86+ gestureScale func(gev uintptr) float64
87+ gestureAngle func(gev uintptr) float64
79 deviceRef func(dev uintptr) uintptr88 deviceRef func(dev uintptr) uintptr
80 deviceUnref func(dev uintptr) uintptr89 deviceUnref func(dev uintptr) uintptr
81 hasNatural func(dev uintptr) int3290 hasNatural func(dev uintptr) int32
@@ -148,7 +157,11 @@ func load() error {
148 reg(&gestureCanceled, "event_gesture_get_cancelled")157 reg(&gestureCanceled, "event_gesture_get_cancelled")
149 reg(&gestureDX, "event_gesture_get_dx_unaccelerated")158 reg(&gestureDX, "event_gesture_get_dx_unaccelerated")
150 reg(&gestureDY, "event_gesture_get_dy_unaccelerated")159 reg(&gestureDY, "event_gesture_get_dy_unaccelerated")
160+ reg(&gestureAccelDX, "event_gesture_get_dx")
161+ reg(&gestureAccelDY, "event_gesture_get_dy")
151 reg(&gestureUsec, "event_gesture_get_time_usec")162 reg(&gestureUsec, "event_gesture_get_time_usec")
163+ reg(&gestureScale, "event_gesture_get_scale")
164+ reg(&gestureAngle, "event_gesture_get_angle_delta")
152 reg(&deviceRef, "device_ref")165 reg(&deviceRef, "device_ref")
153 reg(&deviceUnref, "device_unref")166 reg(&deviceUnref, "device_unref")
154 reg(&hasNatural, "device_config_scroll_has_natural_scroll")167 reg(&hasNatural, "device_config_scroll_has_natural_scroll")
@@ -325,14 +338,14 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
325 }338 }
326 339
327 // inputState is the device state Run owns: the device config, the devices340 // inputState is the device state Run owns: the device config, the devices
328-// it configures (referenced until removed) and the swipe in progress on341+// it configures (referenced until removed) and the gesture in progress on
329-// each touchpad. dev applies the config to libinput; it is a zero-size342+// the touchpads. dev applies the config to libinput; it is a zero-size
330 // type, so storing it in the interface allocates nothing.343 // type, so storing it in the interface allocates nothing.
331 type inputState struct {344 type inputState struct {
332- cfg ports.InputDevicesConfig345+ cfg ports.InputDevicesConfig
333- dev deviceConfig346+ dev deviceConfig
334- devices map[uintptr]bool347+ devices map[uintptr]bool
335- swipes swipes348+ gestures gestures
336 }349 }
337 350
338 // deviceConfig is libinput's device configuration side: what a device is351 // deviceConfig is libinput's device configuration side: what a device is
@@ -478,33 +491,74 @@ func (s *inputState) release() {
478 clear(s.devices)491 clear(s.devices)
479 }492 }
480 493
481-// swipes streams one three- or four-finger swipe at a time to core, which494+// gestures streams one touchpad gesture at a time to core: a swipe of any
482-// follows495+// finger count, a pinch or a hold. Core follows the fingers of a three- or
483-// the fingers. owner is the touchpad of the swipe in progress (0: none);496+// four-finger swipe and hands the other gestures to the window under the
484-// another touchpad's swipe is ignored until it ends, so core never sees497+// pointer. owner is the touchpad of the gesture in progress (0: none) and
485-// two streams interleaved.498+// kind its kind; another touchpad's gesture, or another kind's event, is
486-type swipes struct{ owner uintptr }499+// ignored until it ends, so core never sees two streams interleaved.
500+type gestures struct {
501+ owner uintptr
502+ kind ports.GestureKind
503+}
487 504
488-func (s *swipes) begin(dev uintptr, fingers int, at time.Duration) ports.InputEvent {505+func (g *gestures) begin(dev uintptr, kind ports.GestureKind, fingers int, at time.Duration) ports.InputEvent {
489- if (fingers != 3 && fingers != 4) || s.owner != 0 {506+ if dev == 0 || fingers <= 0 || g.owner != 0 {
490 return nil507 return nil
491 }508 }
492- s.owner = dev509+ g.owner, g.kind = dev, kind
510+ switch kind {
511+ case ports.GesturePinch:
512+ return ports.PinchBegin{Fingers: fingers, Time: at}
513+ case ports.GestureHold:
514+ return ports.HoldBegin{Fingers: fingers, Time: at}
515+ }
493 return ports.SwipeBegin{Fingers: fingers, Time: at}516 return ports.SwipeBegin{Fingers: fingers, Time: at}
494 }517 }
495 518
496-func (s *swipes) update(dev uintptr, dx, dy float64, at time.Duration) ports.InputEvent {519+// running reports whether dev has a gesture of the given kind in progress.
497- if dev == 0 || s.owner != dev {520+func (g *gestures) running(dev uintptr, kind ports.GestureKind) bool {
521+ return dev != 0 && g.owner == dev && g.kind == kind
522+}
523+
524+func (g *gestures) swipeUpdate(dev uintptr, dx, dy, accelDX, accelDY float64, at time.Duration) ports.InputEvent {
525+ if !g.running(dev, ports.GestureSwipe) {
526+ return nil
527+ }
528+ return ports.SwipeUpdate{DX: dx, DY: dy, AccelDX: accelDX, AccelDY: accelDY, Time: at}
529+}
530+
531+func (g *gestures) pinchUpdate(dev uintptr, dx, dy, scale, rotation float64, at time.Duration) ports.InputEvent {
532+ if !g.running(dev, ports.GesturePinch) {
498 return nil533 return nil
499 }534 }
500- return ports.SwipeUpdate{DX: dx, DY: dy, Time: at}535+ return ports.PinchUpdate{DX: dx, DY: dy, Scale: scale, Rotation: rotation, Time: at}
501 }536 }
502 537
503-func (s *swipes) end(dev uintptr, cancelled bool, at time.Duration) ports.InputEvent {538+// end finishes the gesture of the given kind on dev.
504- if dev == 0 || s.owner != dev {539+func (g *gestures) end(dev uintptr, kind ports.GestureKind, cancelled bool, at time.Duration) ports.InputEvent {
540+ if !g.running(dev, kind) {
505 return nil541 return nil
506 }542 }
507- s.owner = 0543+ return g.finish(cancelled, at)
544+}
545+
546+// abort cancels whatever gesture dev has in progress (the device went).
547+func (g *gestures) abort(dev uintptr) ports.InputEvent {
548+ if dev == 0 || g.owner != dev {
549+ return nil
550+ }
551+ return g.finish(true, 0)
552+}
553+
554+func (g *gestures) finish(cancelled bool, at time.Duration) ports.InputEvent {
555+ g.owner = 0
556+ switch g.kind {
557+ case ports.GesturePinch:
558+ return ports.PinchEnd{Cancelled: cancelled, Time: at}
559+ case ports.GestureHold:
560+ return ports.HoldEnd{Cancelled: cancelled, Time: at}
561+ }
508 return ports.SwipeEnd{Cancelled: cancelled, Time: at}562 return ports.SwipeEnd{Cancelled: cancelled, Time: at}
509 }563 }
510 564
@@ -552,17 +606,32 @@ func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state
552 delete(in.devices, dev)606 delete(in.devices, dev)
553 deviceUnref(dev)607 deviceUnref(dev)
554 }608 }
555- // Core waits for the end of a swipe the device began.609+ // Core waits for the end of a gesture the device began.
556- return in.swipes.end(dev, true, 0), nil610+ return in.gestures.abort(dev), nil
557- case evSwipeBegin:611+ case evSwipeBegin, evPinchBegin, evHoldBegin:
558 ge := gestureEvent(ev)612 ge := gestureEvent(ev)
559- return in.swipes.begin(eventDevice(ev), int(gestureFingers(ge)), usec(gestureUsec(ge))), nil613+ kind := ports.GestureSwipe
614+ if eventType(ev) == evPinchBegin {
615+ kind = ports.GesturePinch
616+ } else if eventType(ev) == evHoldBegin {
617+ kind = ports.GestureHold
618+ }
619+ return in.gestures.begin(eventDevice(ev), kind, int(gestureFingers(ge)), usec(gestureUsec(ge))), nil
560 case evSwipeUpdate:620 case evSwipeUpdate:
561 ge := gestureEvent(ev)621 ge := gestureEvent(ev)
562- return in.swipes.update(eventDevice(ev), gestureDX(ge), gestureDY(ge), usec(gestureUsec(ge))), nil622+ return in.gestures.swipeUpdate(eventDevice(ev), gestureDX(ge), gestureDY(ge), gestureAccelDX(ge), gestureAccelDY(ge), usec(gestureUsec(ge))), nil
623+ case evPinchUpdate:
624+ ge := gestureEvent(ev)
625+ return in.gestures.pinchUpdate(eventDevice(ev), gestureAccelDX(ge), gestureAccelDY(ge), gestureScale(ge), gestureAngle(ge), usec(gestureUsec(ge))), nil
563 case evSwipeEnd:626 case evSwipeEnd:
564 ge := gestureEvent(ev)627 ge := gestureEvent(ev)
565- return in.swipes.end(eventDevice(ev), gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil628+ return in.gestures.end(eventDevice(ev), ports.GestureSwipe, gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil
629+ case evPinchEnd:
630+ ge := gestureEvent(ev)
631+ return in.gestures.end(eventDevice(ev), ports.GesturePinch, gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil
632+ case evHoldEnd:
633+ ge := gestureEvent(ev)
634+ return in.gestures.end(eventDevice(ev), ports.GestureHold, gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil
566 case evKeyboardKey:635 case evKeyboardKey:
567 k := keyboardEvent(ev)636 k := keyboardEvent(ev)
568 code, pressed := keyboardKey(k), keyboardState(k) == 1637 code, pressed := keyboardKey(k), keyboardState(k) == 1
M internal/adapters/libinput/libinput_test.go +91 -25
@@ -42,61 +42,126 @@ func TestHotkey(t *testing.T) {
4242 }
4343 }
4444
45-func TestSwipesIgnoreSecondTouchpad(t *testing.T) {
46- var s swipes
45+func TestGesturesIgnoreSecondTouchpad(t *testing.T) {
46+ var g gestures
4747 const pad, other = 1, 2
48- s.begin(pad, 3, time.Second)
49- if ev := s.begin(other, 3, time.Second); ev != nil {
48+ g.begin(pad, ports.GestureSwipe, 3, time.Second)
49+ if ev := g.begin(other, ports.GestureSwipe, 3, time.Second); ev != nil {
5050 t.Fatalf("second touchpad began: %v", ev)
5151 }
52- if ev := s.update(other, 1, 1, time.Second); ev != nil {
52+ if ev := g.swipeUpdate(other, 1, 1, 1, 1, time.Second); ev != nil {
5353 t.Fatalf("second touchpad moved: %v", ev)
5454 }
55- if ev := s.end(other, false, time.Second); ev != nil {
55+ if ev := g.end(other, ports.GestureSwipe, false, time.Second); ev != nil {
5656 t.Fatalf("second touchpad ended the swipe: %v", ev)
5757 }
58- if ev := s.update(pad, 1, 1, 2*time.Second); ev == nil {
58+ if ev := g.swipeUpdate(pad, 1, 1, 1, 1, 2*time.Second); ev == nil {
5959 t.Fatal("first touchpad lost its swipe")
6060 }
61- s.end(pad, false, 3*time.Second)
62- if ev := s.begin(other, 3, 4*time.Second); ev == nil {
61+ g.end(pad, ports.GestureSwipe, false, 3*time.Second)
62+ if ev := g.begin(other, ports.GestureSwipe, 3, 4*time.Second); ev == nil {
6363 t.Fatal("second touchpad cannot swipe after the first ended")
6464 }
6565 }
6666
67-func TestSwipesStreamThreeFingers(t *testing.T) {
68- var s swipes
67+func TestGesturesStreamSwipe(t *testing.T) {
68+ var g gestures
6969 const pad, other = 1, 2
70- if ev := s.begin(pad, 2, time.Second); ev != nil {
71- t.Fatalf("two fingers: %v", ev)
70+ if ev := g.begin(pad, ports.GestureSwipe, 0, time.Second); ev != nil {
71+ t.Fatalf("no fingers: %v", ev)
7272 }
73- if ev := s.update(pad, 1, 1, time.Second); ev != nil {
73+ if ev := g.swipeUpdate(pad, 1, 1, 1, 1, time.Second); ev != nil {
7474 t.Fatalf("update without a swipe: %v", ev)
7575 }
76- if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Fingers: 3, Time: time.Second}) {
76+ if ev := g.begin(pad, ports.GestureSwipe, 3, time.Second); ev != (ports.SwipeBegin{Fingers: 3, Time: time.Second}) {
7777 t.Fatalf("begin: %v", ev)
7878 }
79- if ev := s.update(pad, -3, 2, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, Time: 2 * time.Second}) {
79+ if ev := g.swipeUpdate(pad, -3, 2, -6, 4, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, AccelDX: -6, AccelDY: 4, Time: 2 * time.Second}) {
8080 t.Fatalf("update: %v", ev)
8181 }
82- if ev := s.end(other, false, 0); ev != nil {
82+ if ev := g.end(other, ports.GestureSwipe, false, 0); ev != nil {
8383 t.Fatalf("end on another device: %v", ev)
8484 }
85- if ev := s.end(pad, true, 3*time.Second); ev != (ports.SwipeEnd{Cancelled: true, Time: 3 * time.Second}) {
85+ if ev := g.end(pad, ports.GestureSwipe, true, 3*time.Second); ev != (ports.SwipeEnd{Cancelled: true, Time: 3 * time.Second}) {
8686 t.Fatalf("end: %v", ev)
8787 }
88- if ev := s.end(pad, false, 0); ev != nil {
88+ if ev := g.end(pad, ports.GestureSwipe, false, 0); ev != nil {
8989 t.Fatalf("second end: %v", ev)
9090 }
9191 }
9292
93-func TestSwipesStreamFourFingers(t *testing.T) {
94- var s swipes
95- if ev := s.begin(1, 4, time.Second); ev != (ports.SwipeBegin{Fingers: 4, Time: time.Second}) {
96- t.Fatalf("four fingers: %v", ev)
93+// Swipes of every finger count stream; core picks which ones it keeps.
94+func TestGesturesStreamAnyFingerCount(t *testing.T) {
95+ for _, fingers := range []int{2, 3, 4, 5} {
96+ var g gestures
97+ if ev := g.begin(1, ports.GestureSwipe, fingers, time.Second); ev != (ports.SwipeBegin{Fingers: fingers, Time: time.Second}) {
98+ t.Fatalf("%d fingers: %v", fingers, ev)
99+ }
100+ if ev := g.begin(1, ports.GestureSwipe, 5, time.Second); ev != nil {
101+ t.Fatalf("a second swipe began during a swipe: %v", ev)
102+ }
103+ }
104+}
105+
106+func TestGesturesStreamPinch(t *testing.T) {
107+ var g gestures
108+ const pad = 1
109+ if ev := g.begin(pad, ports.GesturePinch, 2, time.Second); ev != (ports.PinchBegin{Fingers: 2, Time: time.Second}) {
110+ t.Fatalf("begin: %v", ev)
111+ }
112+ if ev := g.swipeUpdate(pad, 1, 1, 1, 1, time.Second); ev != nil {
113+ t.Fatalf("swipe update during a pinch: %v", ev)
97114 }
98- if ev := s.begin(1, 5, time.Second); ev != nil {
99- t.Fatalf("five fingers during a swipe: %v", ev)
115+ if ev := g.end(pad, ports.GestureHold, false, time.Second); ev != nil {
116+ t.Fatalf("hold end during a pinch: %v", ev)
117+ }
118+ want := ports.PinchUpdate{DX: 1, DY: -2, Scale: 1.5, Rotation: -10, Time: 2 * time.Second}
119+ if ev := g.pinchUpdate(pad, 1, -2, 1.5, -10, 2*time.Second); ev != want {
120+ t.Fatalf("update: %v", ev)
121+ }
122+ if ev := g.end(pad, ports.GesturePinch, false, 3*time.Second); ev != (ports.PinchEnd{Time: 3 * time.Second}) {
123+ t.Fatalf("end: %v", ev)
124+ }
125+ if ev := g.pinchUpdate(pad, 1, 1, 1, 0, 4*time.Second); ev != nil {
126+ t.Fatalf("update after the end: %v", ev)
127+ }
128+}
129+
130+func TestGesturesStreamHold(t *testing.T) {
131+ var g gestures
132+ const pad = 1
133+ if ev := g.begin(pad, ports.GestureHold, 3, time.Second); ev != (ports.HoldBegin{Fingers: 3, Time: time.Second}) {
134+ t.Fatalf("begin: %v", ev)
135+ }
136+ if ev := g.begin(pad, ports.GesturePinch, 2, time.Second); ev != nil {
137+ t.Fatalf("pinch began during a hold: %v", ev)
138+ }
139+ if ev := g.end(pad, ports.GestureHold, true, 2*time.Second); ev != (ports.HoldEnd{Cancelled: true, Time: 2 * time.Second}) {
140+ t.Fatalf("end: %v", ev)
141+ }
142+}
143+
144+// A device that goes during a gesture ends it cancelled, whatever its kind.
145+func TestGesturesAbortOnRemoval(t *testing.T) {
146+ for kind, want := range map[ports.GestureKind]ports.InputEvent{
147+ ports.GestureSwipe: ports.SwipeEnd{Cancelled: true},
148+ ports.GesturePinch: ports.PinchEnd{Cancelled: true},
149+ ports.GestureHold: ports.HoldEnd{Cancelled: true},
150+ } {
151+ var g gestures
152+ g.begin(1, kind, 3, time.Second)
153+ if ev := g.abort(2); ev != nil {
154+ t.Fatalf("kind %d: another device aborted: %v", kind, ev)
155+ }
156+ if ev := g.abort(1); ev != want {
157+ t.Fatalf("kind %d: abort %v, want %v", kind, ev, want)
158+ }
159+ if ev := g.abort(1); ev != nil {
160+ t.Fatalf("kind %d: second abort %v", kind, ev)
161+ }
162+ if ev := g.begin(1, kind, 3, time.Second); ev == nil {
163+ t.Fatalf("kind %d: no gesture after the abort", kind)
164+ }
100165 }
101166 }
102167
@@ -262,6 +327,7 @@ func TestSecurityInputAllKindsProductionEpoch(t *testing.T) {
262327 ports.KeyEvent{Keysym: "secret", Pressed: true}, ports.PointerMotion{X: 1},
263328 ports.PointerButton{Button: 272}, ports.PointerAxis{},
264329 ports.SwipeBegin{}, ports.SwipeUpdate{}, ports.SwipeEnd{},
330+ ports.PinchBegin{}, ports.PinchUpdate{}, ports.PinchEnd{}, ports.HoldBegin{}, ports.HoldEnd{},
265331 }
266332 security.EXPECT().Snapshot().Return(old).Times(len(events))
267333 security.EXPECT().Snapshot().Return(newState).Once()
@@ -42,61 +42,126 @@ func TestHotkey(t *testing.T) {
42 }42 }
43 }43 }
44 44
45-func TestSwipesIgnoreSecondTouchpad(t *testing.T) {45+func TestGesturesIgnoreSecondTouchpad(t *testing.T) {
46- var s swipes46+ var g gestures
47 const pad, other = 1, 247 const pad, other = 1, 2
48- s.begin(pad, 3, time.Second)48+ g.begin(pad, ports.GestureSwipe, 3, time.Second)
49- if ev := s.begin(other, 3, time.Second); ev != nil {49+ if ev := g.begin(other, ports.GestureSwipe, 3, time.Second); ev != nil {
50 t.Fatalf("second touchpad began: %v", ev)50 t.Fatalf("second touchpad began: %v", ev)
51 }51 }
52- if ev := s.update(other, 1, 1, time.Second); ev != nil {52+ if ev := g.swipeUpdate(other, 1, 1, 1, 1, time.Second); ev != nil {
53 t.Fatalf("second touchpad moved: %v", ev)53 t.Fatalf("second touchpad moved: %v", ev)
54 }54 }
55- if ev := s.end(other, false, time.Second); ev != nil {55+ if ev := g.end(other, ports.GestureSwipe, false, time.Second); ev != nil {
56 t.Fatalf("second touchpad ended the swipe: %v", ev)56 t.Fatalf("second touchpad ended the swipe: %v", ev)
57 }57 }
58- if ev := s.update(pad, 1, 1, 2*time.Second); ev == nil {58+ if ev := g.swipeUpdate(pad, 1, 1, 1, 1, 2*time.Second); ev == nil {
59 t.Fatal("first touchpad lost its swipe")59 t.Fatal("first touchpad lost its swipe")
60 }60 }
61- s.end(pad, false, 3*time.Second)61+ g.end(pad, ports.GestureSwipe, false, 3*time.Second)
62- if ev := s.begin(other, 3, 4*time.Second); ev == nil {62+ if ev := g.begin(other, ports.GestureSwipe, 3, 4*time.Second); ev == nil {
63 t.Fatal("second touchpad cannot swipe after the first ended")63 t.Fatal("second touchpad cannot swipe after the first ended")
64 }64 }
65 }65 }
66 66
67-func TestSwipesStreamThreeFingers(t *testing.T) {67+func TestGesturesStreamSwipe(t *testing.T) {
68- var s swipes68+ var g gestures
69 const pad, other = 1, 269 const pad, other = 1, 2
70- if ev := s.begin(pad, 2, time.Second); ev != nil {70+ if ev := g.begin(pad, ports.GestureSwipe, 0, time.Second); ev != nil {
71- t.Fatalf("two fingers: %v", ev)71+ t.Fatalf("no fingers: %v", ev)
72 }72 }
73- if ev := s.update(pad, 1, 1, time.Second); ev != nil {73+ if ev := g.swipeUpdate(pad, 1, 1, 1, 1, time.Second); ev != nil {
74 t.Fatalf("update without a swipe: %v", ev)74 t.Fatalf("update without a swipe: %v", ev)
75 }75 }
76- if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Fingers: 3, Time: time.Second}) {76+ if ev := g.begin(pad, ports.GestureSwipe, 3, time.Second); ev != (ports.SwipeBegin{Fingers: 3, Time: time.Second}) {
77 t.Fatalf("begin: %v", ev)77 t.Fatalf("begin: %v", ev)
78 }78 }
79- if ev := s.update(pad, -3, 2, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, Time: 2 * time.Second}) {79+ if ev := g.swipeUpdate(pad, -3, 2, -6, 4, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, AccelDX: -6, AccelDY: 4, Time: 2 * time.Second}) {
80 t.Fatalf("update: %v", ev)80 t.Fatalf("update: %v", ev)
81 }81 }
82- if ev := s.end(other, false, 0); ev != nil {82+ if ev := g.end(other, ports.GestureSwipe, false, 0); ev != nil {
83 t.Fatalf("end on another device: %v", ev)83 t.Fatalf("end on another device: %v", ev)
84 }84 }
85- if ev := s.end(pad, true, 3*time.Second); ev != (ports.SwipeEnd{Cancelled: true, Time: 3 * time.Second}) {85+ if ev := g.end(pad, ports.GestureSwipe, true, 3*time.Second); ev != (ports.SwipeEnd{Cancelled: true, Time: 3 * time.Second}) {
86 t.Fatalf("end: %v", ev)86 t.Fatalf("end: %v", ev)
87 }87 }
88- if ev := s.end(pad, false, 0); ev != nil {88+ if ev := g.end(pad, ports.GestureSwipe, false, 0); ev != nil {
89 t.Fatalf("second end: %v", ev)89 t.Fatalf("second end: %v", ev)
90 }90 }
91 }91 }
92 92
93-func TestSwipesStreamFourFingers(t *testing.T) {93+// Swipes of every finger count stream; core picks which ones it keeps.
94- var s swipes94+func TestGesturesStreamAnyFingerCount(t *testing.T) {
95- if ev := s.begin(1, 4, time.Second); ev != (ports.SwipeBegin{Fingers: 4, Time: time.Second}) {95+ for _, fingers := range []int{2, 3, 4, 5} {
96- t.Fatalf("four fingers: %v", ev)96+ var g gestures
97+ if ev := g.begin(1, ports.GestureSwipe, fingers, time.Second); ev != (ports.SwipeBegin{Fingers: fingers, Time: time.Second}) {
98+ t.Fatalf("%d fingers: %v", fingers, ev)
99+ }
100+ if ev := g.begin(1, ports.GestureSwipe, 5, time.Second); ev != nil {
101+ t.Fatalf("a second swipe began during a swipe: %v", ev)
102+ }
103+ }
104+}
105+
106+func TestGesturesStreamPinch(t *testing.T) {
107+ var g gestures
108+ const pad = 1
109+ if ev := g.begin(pad, ports.GesturePinch, 2, time.Second); ev != (ports.PinchBegin{Fingers: 2, Time: time.Second}) {
110+ t.Fatalf("begin: %v", ev)
111+ }
112+ if ev := g.swipeUpdate(pad, 1, 1, 1, 1, time.Second); ev != nil {
113+ t.Fatalf("swipe update during a pinch: %v", ev)
97 }114 }
98- if ev := s.begin(1, 5, time.Second); ev != nil {115+ if ev := g.end(pad, ports.GestureHold, false, time.Second); ev != nil {
99- t.Fatalf("five fingers during a swipe: %v", ev)116+ t.Fatalf("hold end during a pinch: %v", ev)
117+ }
118+ want := ports.PinchUpdate{DX: 1, DY: -2, Scale: 1.5, Rotation: -10, Time: 2 * time.Second}
119+ if ev := g.pinchUpdate(pad, 1, -2, 1.5, -10, 2*time.Second); ev != want {
120+ t.Fatalf("update: %v", ev)
121+ }
122+ if ev := g.end(pad, ports.GesturePinch, false, 3*time.Second); ev != (ports.PinchEnd{Time: 3 * time.Second}) {
123+ t.Fatalf("end: %v", ev)
124+ }
125+ if ev := g.pinchUpdate(pad, 1, 1, 1, 0, 4*time.Second); ev != nil {
126+ t.Fatalf("update after the end: %v", ev)
127+ }
128+}
129+
130+func TestGesturesStreamHold(t *testing.T) {
131+ var g gestures
132+ const pad = 1
133+ if ev := g.begin(pad, ports.GestureHold, 3, time.Second); ev != (ports.HoldBegin{Fingers: 3, Time: time.Second}) {
134+ t.Fatalf("begin: %v", ev)
135+ }
136+ if ev := g.begin(pad, ports.GesturePinch, 2, time.Second); ev != nil {
137+ t.Fatalf("pinch began during a hold: %v", ev)
138+ }
139+ if ev := g.end(pad, ports.GestureHold, true, 2*time.Second); ev != (ports.HoldEnd{Cancelled: true, Time: 2 * time.Second}) {
140+ t.Fatalf("end: %v", ev)
141+ }
142+}
143+
144+// A device that goes during a gesture ends it cancelled, whatever its kind.
145+func TestGesturesAbortOnRemoval(t *testing.T) {
146+ for kind, want := range map[ports.GestureKind]ports.InputEvent{
147+ ports.GestureSwipe: ports.SwipeEnd{Cancelled: true},
148+ ports.GesturePinch: ports.PinchEnd{Cancelled: true},
149+ ports.GestureHold: ports.HoldEnd{Cancelled: true},
150+ } {
151+ var g gestures
152+ g.begin(1, kind, 3, time.Second)
153+ if ev := g.abort(2); ev != nil {
154+ t.Fatalf("kind %d: another device aborted: %v", kind, ev)
155+ }
156+ if ev := g.abort(1); ev != want {
157+ t.Fatalf("kind %d: abort %v, want %v", kind, ev, want)
158+ }
159+ if ev := g.abort(1); ev != nil {
160+ t.Fatalf("kind %d: second abort %v", kind, ev)
161+ }
162+ if ev := g.begin(1, kind, 3, time.Second); ev == nil {
163+ t.Fatalf("kind %d: no gesture after the abort", kind)
164+ }
100 }165 }
101 }166 }
102 167
@@ -262,6 +327,7 @@ func TestSecurityInputAllKindsProductionEpoch(t *testing.T) {
262 ports.KeyEvent{Keysym: "secret", Pressed: true}, ports.PointerMotion{X: 1},327 ports.KeyEvent{Keysym: "secret", Pressed: true}, ports.PointerMotion{X: 1},
263 ports.PointerButton{Button: 272}, ports.PointerAxis{},328 ports.PointerButton{Button: 272}, ports.PointerAxis{},
264 ports.SwipeBegin{}, ports.SwipeUpdate{}, ports.SwipeEnd{},329 ports.SwipeBegin{}, ports.SwipeUpdate{}, ports.SwipeEnd{},
330+ ports.PinchBegin{}, ports.PinchUpdate{}, ports.PinchEnd{}, ports.HoldBegin{}, ports.HoldEnd{},
265 }331 }
266 security.EXPECT().Snapshot().Return(old).Times(len(events))332 security.EXPECT().Snapshot().Return(old).Times(len(events))
267 security.EXPECT().Snapshot().Return(newState).Once()333 security.EXPECT().Snapshot().Return(newState).Once()
M internal/adapters/wayland/foreigntoplevel_test.go +6 -2
@@ -157,8 +157,12 @@ func TestForeignToplevel(t *testing.T) {
157157 registerWireProxy(c, xp)
158158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
159159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)
160- if again := mapped(t, events, 2*time.Second); again.ID != w.ID {
161- t.Fatalf("remapped as %d, want %d", again.ID, w.ID)
160+ // Core learns it is a remap: window rules only place a first map.
161+ if w.Remap {
162+ t.Fatal("first map flagged as a remap")
163+ }
164+ if again := mapped(t, events, 2*time.Second); again.ID != w.ID || !again.Remap {
165+ t.Fatalf("remapped as %+v, want ID %d and Remap", again, w.ID)
162166 }
163167 if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" {
164168 t.Fatalf("remap = %q", got)
@@ -157,8 +157,12 @@ func TestForeignToplevel(t *testing.T) {
157 registerWireProxy(c, xp)157 registerWireProxy(c, xp)
158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)
160- if again := mapped(t, events, 2*time.Second); again.ID != w.ID {160+ // Core learns it is a remap: window rules only place a first map.
161- t.Fatalf("remapped as %d, want %d", again.ID, w.ID)161+ if w.Remap {
162+ t.Fatal("first map flagged as a remap")
163+ }
164+ if again := mapped(t, events, 2*time.Second); again.ID != w.ID || !again.Remap {
165+ t.Fatalf("remapped as %+v, want ID %d and Remap", again, w.ID)
162 }166 }
163 if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" {167 if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" {
164 t.Fatalf("remap = %q", got)168 t.Fatalf("remap = %q", got)
A internal/adapters/wayland/gestures.go +220 -0
@@ -0,0 +1,220 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/go-wayland-bindings/server/pointergestures"
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// zwp_pointer_gestures_v1: touchpad swipes (other than the compositor's own
13+// three and four fingers), pinches and holds go to the client under the
14+// pointer. A gesture belongs to the window that had the pointer when it
15+// began; it ends cancelled when the pointer leaves that window.
16+
17+// gestureObject is one swipe, pinch or hold object of a client. Exactly one
18+// of the three is set.
19+type gestureObject struct {
20+ swipe *pointergestures.ZwpPointerGestureSwipeV1
21+ pinch *pointergestures.ZwpPointerGesturePinchV1
22+ hold *pointergestures.ZwpPointerGestureHoldV1
23+}
24+
25+func (g *gestureObject) alive() bool {
26+ switch {
27+ case g.swipe != nil:
28+ return g.swipe.Resource.Alive()
29+ case g.pinch != nil:
30+ return g.pinch.Resource.Alive()
31+ case g.hold != nil:
32+ return g.hold.Resource.Alive()
33+ }
34+ return false
35+}
36+
37+func (g *gestureObject) kind() ports.GestureKind {
38+ switch {
39+ case g.pinch != nil:
40+ return ports.GesturePinch
41+ case g.hold != nil:
42+ return ports.GestureHold
43+ }
44+ return ports.GestureSwipe
45+}
46+
47+func (g *gestureObject) begin(serial uint32, at uint32, surf *wayland.Surface, fingers uint32) {
48+ switch {
49+ case g.swipe != nil:
50+ g.swipe.SendBegin(serial, at, surf, fingers)
51+ case g.pinch != nil:
52+ g.pinch.SendBegin(serial, at, surf, fingers)
53+ case g.hold != nil:
54+ g.hold.SendBegin(serial, at, surf, fingers)
55+ }
56+}
57+
58+func (g *gestureObject) update(u ports.GestureUpdateTo) {
59+ at := wireMsec(u.Time)
60+ dx, dy := server.FixedFromFloat(u.DX), server.FixedFromFloat(u.DY)
61+ switch {
62+ case g.swipe != nil:
63+ g.swipe.SendUpdate(at, dx, dy)
64+ case g.pinch != nil:
65+ g.pinch.SendUpdate(at, dx, dy, server.FixedFromFloat(u.Scale), server.FixedFromFloat(u.Rotation))
66+ }
67+}
68+
69+func (g *gestureObject) end(serial uint32, at uint32, cancelled int32) {
70+ switch {
71+ case g.swipe != nil:
72+ g.swipe.SendEnd(serial, at, cancelled)
73+ case g.pinch != nil:
74+ g.pinch.SendEnd(serial, at, cancelled)
75+ case g.hold != nil:
76+ g.hold.SendEnd(serial, at, cancelled)
77+ }
78+}
79+
80+// activeGesture is the gesture sent to a window: the objects that got its
81+// begin are the ones that get the rest, even when a client makes more
82+// meanwhile.
83+type activeGesture struct {
84+ id ports.WindowID
85+ kind ports.GestureKind
86+ objs []*gestureObject
87+ // at is the latest device time sent, for a cancellation.
88+ at time.Duration
89+}
90+
91+func registerPointerGestures(d *server.Display, s *Server) error {
92+ return pointergestures.NewZwpPointerGesturesV1Global(d, 3, func(c server.Client, v, id uint32) {
93+ _, _ = pointergestures.NewZwpPointerGesturesV1(c, int32(v), id, gestureManager{s})
94+ })
95+}
96+
97+// gestureManager is zwp_pointer_gestures_v1. Releasing it leaves the
98+// gesture objects it made alive, as the protocol says.
99+type gestureManager struct{ server *Server }
100+
101+func (gestureManager) Release(*pointergestures.ZwpPointerGesturesV1) {}
102+
103+// gestureDestroy handles the destroy request of the three gesture objects;
104+// the resource's OnDestroy forgets the object.
105+type gestureDestroy struct{}
106+
107+func (gestureDestroy) Destroy(*pointergestures.ZwpPointerGestureSwipeV1) {}
108+
109+type pinchDestroy struct{}
110+
111+func (pinchDestroy) Destroy(*pointergestures.ZwpPointerGesturePinchV1) {}
112+
113+type holdDestroy struct{}
114+
115+func (holdDestroy) Destroy(*pointergestures.ZwpPointerGestureHoldV1) {}
116+
117+func (m gestureManager) GetSwipeGesture(r *pointergestures.ZwpPointerGesturesV1, id uint32, _ *wayland.Pointer) {
118+ if res, err := pointergestures.NewZwpPointerGestureSwipeV1(r.Client(), r.Version(), id, gestureDestroy{}); err == nil {
119+ m.server.addGesture(r.Client(), &gestureObject{swipe: res}, res.Resource)
120+ }
121+}
122+
123+func (m gestureManager) GetPinchGesture(r *pointergestures.ZwpPointerGesturesV1, id uint32, _ *wayland.Pointer) {
124+ if res, err := pointergestures.NewZwpPointerGesturePinchV1(r.Client(), r.Version(), id, pinchDestroy{}); err == nil {
125+ m.server.addGesture(r.Client(), &gestureObject{pinch: res}, res.Resource)
126+ }
127+}
128+
129+func (m gestureManager) GetHoldGesture(r *pointergestures.ZwpPointerGesturesV1, id uint32, _ *wayland.Pointer) {
130+ if res, err := pointergestures.NewZwpPointerGestureHoldV1(r.Client(), r.Version(), id, holdDestroy{}); err == nil {
131+ m.server.addGesture(r.Client(), &gestureObject{hold: res}, res.Resource)
132+ }
133+}
134+
135+// addGesture lists a client's gesture object until its resource dies.
136+func (s *Server) addGesture(c server.Client, o *gestureObject, res *server.Resource) {
137+ s.gestures[c] = append(s.gestures[c], o)
138+ res.OnDestroy = func() {
139+ if list := removeItem(s.gestures[c], o); len(list) > 0 {
140+ s.gestures[c] = list
141+ } else {
142+ delete(s.gestures, c)
143+ }
144+ }
145+}
146+
147+// beginGesture sends the begin of a gesture to the objects of the kind that
148+// the client of the pointer-focus window made. A gesture still running
149+// ends cancelled first. Nothing is recorded when the client has no object.
150+func (s *Server) beginGesture(c ports.GestureBeginTo) {
151+ s.cancelGesture()
152+ if c.ID != s.seat.pointerFocus || c.Fingers <= 0 {
153+ return
154+ }
155+ surf, _, _, _ := s.pointerSurface(c.ID, 0, 0)
156+ if surf == nil {
157+ return
158+ }
159+ var objs []*gestureObject
160+ for _, o := range s.gestures[surf.Client()] {
161+ if o.kind() == c.Kind && o.alive() {
162+ objs = append(objs, o)
163+ }
164+ }
165+ if len(objs) == 0 {
166+ return
167+ }
168+ s.seat.gesture = &activeGesture{id: c.ID, kind: c.Kind, objs: objs, at: c.Time}
169+ s.serial++
170+ for _, o := range objs {
171+ o.begin(s.serial, wireMsec(c.Time), surf, uint32(c.Fingers))
172+ }
173+}
174+
175+// updateGesture moves the running gesture of the window.
176+func (s *Server) updateGesture(c ports.GestureUpdateTo) {
177+ g := s.seat.gesture
178+ if g == nil || g.id != c.ID || g.kind != c.Kind {
179+ return
180+ }
181+ g.at = c.Time
182+ for _, o := range g.objs {
183+ if o.alive() {
184+ o.update(c)
185+ }
186+ }
187+}
188+
189+// endGesture ends the running gesture of the window.
190+func (s *Server) endGesture(c ports.GestureEndTo) {
191+ g := s.seat.gesture
192+ if g == nil || g.id != c.ID || g.kind != c.Kind {
193+ return
194+ }
195+ g.at = c.Time
196+ s.finishGesture(c.Cancelled)
197+}
198+
199+// cancelGesture ends the running gesture, if any, as cancelled: the
200+// pointer left its window or the window went.
201+func (s *Server) cancelGesture() {
202+ if s.seat.gesture != nil {
203+ s.finishGesture(true)
204+ }
205+}
206+
207+func (s *Server) finishGesture(cancelled bool) {
208+ g := s.seat.gesture
209+ s.seat.gesture = nil
210+ state := int32(0)
211+ if cancelled {
212+ state = 1
213+ }
214+ s.serial++
215+ for _, o := range g.objs {
216+ if o.alive() {
217+ o.end(s.serial, wireMsec(g.at), state)
218+ }
219+ }
220+}
@@ -0,0 +1,220 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/go-wayland-bindings/server/pointergestures"
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// zwp_pointer_gestures_v1: touchpad swipes (other than the compositor's own
13+// three and four fingers), pinches and holds go to the client under the
14+// pointer. A gesture belongs to the window that had the pointer when it
15+// began; it ends cancelled when the pointer leaves that window.
16+
17+// gestureObject is one swipe, pinch or hold object of a client. Exactly one
18+// of the three is set.
19+type gestureObject struct {
20+ swipe *pointergestures.ZwpPointerGestureSwipeV1
21+ pinch *pointergestures.ZwpPointerGesturePinchV1
22+ hold *pointergestures.ZwpPointerGestureHoldV1
23+}
24+
25+func (g *gestureObject) alive() bool {
26+ switch {
27+ case g.swipe != nil:
28+ return g.swipe.Resource.Alive()
29+ case g.pinch != nil:
30+ return g.pinch.Resource.Alive()
31+ case g.hold != nil:
32+ return g.hold.Resource.Alive()
33+ }
34+ return false
35+}
36+
37+func (g *gestureObject) kind() ports.GestureKind {
38+ switch {
39+ case g.pinch != nil:
40+ return ports.GesturePinch
41+ case g.hold != nil:
42+ return ports.GestureHold
43+ }
44+ return ports.GestureSwipe
45+}
46+
47+func (g *gestureObject) begin(serial uint32, at uint32, surf *wayland.Surface, fingers uint32) {
48+ switch {
49+ case g.swipe != nil:
50+ g.swipe.SendBegin(serial, at, surf, fingers)
51+ case g.pinch != nil:
52+ g.pinch.SendBegin(serial, at, surf, fingers)
53+ case g.hold != nil:
54+ g.hold.SendBegin(serial, at, surf, fingers)
55+ }
56+}
57+
58+func (g *gestureObject) update(u ports.GestureUpdateTo) {
59+ at := wireMsec(u.Time)
60+ dx, dy := server.FixedFromFloat(u.DX), server.FixedFromFloat(u.DY)
61+ switch {
62+ case g.swipe != nil:
63+ g.swipe.SendUpdate(at, dx, dy)
64+ case g.pinch != nil:
65+ g.pinch.SendUpdate(at, dx, dy, server.FixedFromFloat(u.Scale), server.FixedFromFloat(u.Rotation))
66+ }
67+}
68+
69+func (g *gestureObject) end(serial uint32, at uint32, cancelled int32) {
70+ switch {
71+ case g.swipe != nil:
72+ g.swipe.SendEnd(serial, at, cancelled)
73+ case g.pinch != nil:
74+ g.pinch.SendEnd(serial, at, cancelled)
75+ case g.hold != nil:
76+ g.hold.SendEnd(serial, at, cancelled)
77+ }
78+}
79+
80+// activeGesture is the gesture sent to a window: the objects that got its
81+// begin are the ones that get the rest, even when a client makes more
82+// meanwhile.
83+type activeGesture struct {
84+ id ports.WindowID
85+ kind ports.GestureKind
86+ objs []*gestureObject
87+ // at is the latest device time sent, for a cancellation.
88+ at time.Duration
89+}
90+
91+func registerPointerGestures(d *server.Display, s *Server) error {
92+ return pointergestures.NewZwpPointerGesturesV1Global(d, 3, func(c server.Client, v, id uint32) {
93+ _, _ = pointergestures.NewZwpPointerGesturesV1(c, int32(v), id, gestureManager{s})
94+ })
95+}
96+
97+// gestureManager is zwp_pointer_gestures_v1. Releasing it leaves the
98+// gesture objects it made alive, as the protocol says.
99+type gestureManager struct{ server *Server }
100+
101+func (gestureManager) Release(*pointergestures.ZwpPointerGesturesV1) {}
102+
103+// gestureDestroy handles the destroy request of the three gesture objects;
104+// the resource's OnDestroy forgets the object.
105+type gestureDestroy struct{}
106+
107+func (gestureDestroy) Destroy(*pointergestures.ZwpPointerGestureSwipeV1) {}
108+
109+type pinchDestroy struct{}
110+
111+func (pinchDestroy) Destroy(*pointergestures.ZwpPointerGesturePinchV1) {}
112+
113+type holdDestroy struct{}
114+
115+func (holdDestroy) Destroy(*pointergestures.ZwpPointerGestureHoldV1) {}
116+
117+func (m gestureManager) GetSwipeGesture(r *pointergestures.ZwpPointerGesturesV1, id uint32, _ *wayland.Pointer) {
118+ if res, err := pointergestures.NewZwpPointerGestureSwipeV1(r.Client(), r.Version(), id, gestureDestroy{}); err == nil {
119+ m.server.addGesture(r.Client(), &gestureObject{swipe: res}, res.Resource)
120+ }
121+}
122+
123+func (m gestureManager) GetPinchGesture(r *pointergestures.ZwpPointerGesturesV1, id uint32, _ *wayland.Pointer) {
124+ if res, err := pointergestures.NewZwpPointerGesturePinchV1(r.Client(), r.Version(), id, pinchDestroy{}); err == nil {
125+ m.server.addGesture(r.Client(), &gestureObject{pinch: res}, res.Resource)
126+ }
127+}
128+
129+func (m gestureManager) GetHoldGesture(r *pointergestures.ZwpPointerGesturesV1, id uint32, _ *wayland.Pointer) {
130+ if res, err := pointergestures.NewZwpPointerGestureHoldV1(r.Client(), r.Version(), id, holdDestroy{}); err == nil {
131+ m.server.addGesture(r.Client(), &gestureObject{hold: res}, res.Resource)
132+ }
133+}
134+
135+// addGesture lists a client's gesture object until its resource dies.
136+func (s *Server) addGesture(c server.Client, o *gestureObject, res *server.Resource) {
137+ s.gestures[c] = append(s.gestures[c], o)
138+ res.OnDestroy = func() {
139+ if list := removeItem(s.gestures[c], o); len(list) > 0 {
140+ s.gestures[c] = list
141+ } else {
142+ delete(s.gestures, c)
143+ }
144+ }
145+}
146+
147+// beginGesture sends the begin of a gesture to the objects of the kind that
148+// the client of the pointer-focus window made. A gesture still running
149+// ends cancelled first. Nothing is recorded when the client has no object.
150+func (s *Server) beginGesture(c ports.GestureBeginTo) {
151+ s.cancelGesture()
152+ if c.ID != s.seat.pointerFocus || c.Fingers <= 0 {
153+ return
154+ }
155+ surf, _, _, _ := s.pointerSurface(c.ID, 0, 0)
156+ if surf == nil {
157+ return
158+ }
159+ var objs []*gestureObject
160+ for _, o := range s.gestures[surf.Client()] {
161+ if o.kind() == c.Kind && o.alive() {
162+ objs = append(objs, o)
163+ }
164+ }
165+ if len(objs) == 0 {
166+ return
167+ }
168+ s.seat.gesture = &activeGesture{id: c.ID, kind: c.Kind, objs: objs, at: c.Time}
169+ s.serial++
170+ for _, o := range objs {
171+ o.begin(s.serial, wireMsec(c.Time), surf, uint32(c.Fingers))
172+ }
173+}
174+
175+// updateGesture moves the running gesture of the window.
176+func (s *Server) updateGesture(c ports.GestureUpdateTo) {
177+ g := s.seat.gesture
178+ if g == nil || g.id != c.ID || g.kind != c.Kind {
179+ return
180+ }
181+ g.at = c.Time
182+ for _, o := range g.objs {
183+ if o.alive() {
184+ o.update(c)
185+ }
186+ }
187+}
188+
189+// endGesture ends the running gesture of the window.
190+func (s *Server) endGesture(c ports.GestureEndTo) {
191+ g := s.seat.gesture
192+ if g == nil || g.id != c.ID || g.kind != c.Kind {
193+ return
194+ }
195+ g.at = c.Time
196+ s.finishGesture(c.Cancelled)
197+}
198+
199+// cancelGesture ends the running gesture, if any, as cancelled: the
200+// pointer left its window or the window went.
201+func (s *Server) cancelGesture() {
202+ if s.seat.gesture != nil {
203+ s.finishGesture(true)
204+ }
205+}
206+
207+func (s *Server) finishGesture(cancelled bool) {
208+ g := s.seat.gesture
209+ s.seat.gesture = nil
210+ state := int32(0)
211+ if cancelled {
212+ state = 1
213+ }
214+ s.serial++
215+ for _, o := range g.objs {
216+ if o.alive() {
217+ o.end(s.serial, wireMsec(g.at), state)
218+ }
219+ }
220+}
A internal/adapters/wayland/gestures_test.go +180 -0
@@ -0,0 +1,180 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/go-wayland-bindings/server/pointergestures"
8+ "github.com/bnema/go-wayland-bindings/server/wayland"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/wlturbo"
11+)
12+
13+// gestureLog records the events of a zwp_pointer_gesture_*_v1 object as
14+// floats: the opcode, then its arguments (fixed values as they are).
15+type gestureLog struct {
16+ wlturbo.BaseProxy
17+ kind ports.GestureKind
18+ events chan []float64
19+}
20+
21+func (p *gestureLog) Dispatch(e *wlturbo.Event) {
22+ ev := []float64{float64(e.Opcode)}
23+ // Hold has no update: its end is opcode 1.
24+ op := e.Opcode
25+ if p.kind == ports.GestureHold && op == 1 {
26+ op = uint16(pointergestures.ZwpPointerGestureSwipeV1EventEnd)
27+ }
28+ switch op {
29+ case uint16(pointergestures.ZwpPointerGestureSwipeV1EventBegin):
30+ // serial, time, surface, fingers
31+ for range 4 {
32+ ev = append(ev, float64(e.Uint32()))
33+ }
34+ case uint16(pointergestures.ZwpPointerGestureSwipeV1EventUpdate):
35+ // time, then dx and dy (and scale and rotation of a pinch)
36+ fixed := 2
37+ if p.kind == ports.GesturePinch {
38+ fixed = 4
39+ }
40+ ev = append(ev, float64(e.Uint32()))
41+ for range fixed {
42+ ev = append(ev, e.Fixed().Float64())
43+ }
44+ case uint16(pointergestures.ZwpPointerGestureSwipeV1EventEnd):
45+ ev = append(ev, float64(e.Uint32()), float64(e.Uint32()), float64(e.Int32()))
46+ }
47+ p.events <- ev
48+}
49+
50+func gestureObjects(t *testing.T, c *wlturbo.Display, manager uint32) (swipe, pinch, hold chan []float64) {
51+ t.Helper()
52+ seat := bindProtocol(t, c, "wl_seat")
53+ registerProtocol(t, c, seat)
54+ pointer := c.AllocateID()
55+ registerProtocol(t, c, pointer)
56+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
57+ var logs [3]chan []float64
58+ for i, op := range []uint32{pointergestures.ZwpPointerGesturesV1RequestGetSwipeGesture, pointergestures.ZwpPointerGesturesV1RequestGetPinchGesture, pointergestures.ZwpPointerGesturesV1RequestGetHoldGesture} {
59+ id := c.AllocateID()
60+ p := &gestureLog{kind: ports.GestureKind(i), events: make(chan []float64, 16)}
61+ p.SetID(id)
62+ registerWireProxy(c, p)
63+ requestProtocol(t, c, manager, op, id, pointer)
64+ logs[i] = p.events
65+ }
66+ return logs[0], logs[1], logs[2]
67+}
68+
69+// quiet fails when the log got an event after a roundtrip.
70+func quiet(t *testing.T, c *wlturbo.Display, events chan []float64) {
71+ t.Helper()
72+ if err := c.Roundtrip(); err != nil {
73+ t.Fatal(err)
74+ }
75+ select {
76+ case ev := <-events:
77+ t.Fatalf("unexpected event %v", ev)
78+ default:
79+ }
80+}
81+
82+func TestPointerGestures(t *testing.T) {
83+ s, events, commands, dir := lifecycleServer(t)
84+ c := protocolClient(t, s, dir)
85+ manager := bindVersion(t, c, "zwp_pointer_gestures_v1", 3)
86+ swipe, pinch, hold := gestureObjects(t, c, manager)
87+ w, surf, _ := surfaceMapper(t, c, events)()
88+ // Another client with gestures never has the pointer.
89+ other := protocolClient(t, s, dir)
90+ oSwipe, oPinch, oHold := gestureObjects(t, other, bindVersion(t, other, "zwp_pointer_gestures_v1", 3))
91+
92+ // No gesture reaches a client before it has the pointer.
93+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2}
94+ commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 10}
95+ quiet(t, c, pinch)
96+
97+ // A pinch: begin carries the serial, time, surface and fingers; updates
98+ // the centre, scale and rotation; the end a later serial.
99+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 1000 * 1e6}
100+ begin := next(t, c, pinch)
101+ if begin[0] != 0 || begin[2] != 1000 || begin[3] != float64(surf) || begin[4] != 2 {
102+ t.Fatalf("pinch begin %v", begin)
103+ }
104+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GesturePinch, DX: 1.5, DY: -2, Scale: 1.25, Rotation: 10, Time: 1008 * 1e6}
105+ if ev := next(t, c, pinch); !slices.Equal(ev, []float64{1, 1008, 1.5, -2, 1.25, 10}) {
106+ t.Fatalf("pinch update %v", ev)
107+ }
108+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GesturePinch, Time: 1016 * 1e6}
109+ end := next(t, c, pinch)
110+ if end[0] != 2 || end[1] <= begin[1] || end[2] != 1016 || end[3] != 0 {
111+ t.Fatalf("pinch end %v (begin %v)", end, begin)
112+ }
113+
114+ // A swipe and a hold, the hold ended cancelled.
115+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GestureSwipe, Fingers: 5, Time: 2000 * 1e6}
116+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GestureSwipe, DX: 3, DY: 4, Time: 2008 * 1e6}
117+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GestureSwipe, Time: 2016 * 1e6}
118+ if ev := next(t, c, swipe); ev[0] != 0 || ev[4] != 5 {
119+ t.Fatalf("swipe begin %v", ev)
120+ }
121+ if ev := next(t, c, swipe); !slices.Equal(ev, []float64{1, 2008, 3, 4}) {
122+ t.Fatalf("swipe update %v", ev)
123+ }
124+ if ev := next(t, c, swipe); ev[0] != 2 || ev[3] != 0 {
125+ t.Fatalf("swipe end %v", ev)
126+ }
127+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GestureHold, Fingers: 3, Time: 3000 * 1e6}
128+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GestureHold, Cancelled: true, Time: 3008 * 1e6}
129+ if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 3 {
130+ t.Fatalf("hold begin %v", ev)
131+ }
132+ if ev := next(t, c, hold); ev[0] != 1 || ev[3] != 1 {
133+ t.Fatalf("hold end %v", ev)
134+ }
135+
136+ // A gesture of one kind does not reach the objects of the others, and an
137+ // update of a gesture that is not running is dropped.
138+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GesturePinch, Scale: 2}
139+ quiet(t, c, pinch)
140+ quiet(t, c, swipe)
141+ quiet(t, c, hold)
142+
143+ // The pointer leaving the window cancels the gesture.
144+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 4000 * 1e6}
145+ next(t, c, pinch)
146+ commands <- ports.PointerFocus{}
147+ if ev := next(t, c, pinch); ev[0] != 2 || ev[3] != 1 {
148+ t.Fatalf("pinch not cancelled on leave: %v", ev)
149+ }
150+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GesturePinch, Time: 4008 * 1e6}
151+ quiet(t, c, pinch)
152+
153+ // Releasing the manager keeps its objects.
154+ commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 10}
155+ requestProtocol(t, c, manager, pointergestures.ZwpPointerGesturesV1RequestRelease)
156+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GestureHold, Fingers: 4, Time: 5000 * 1e6}
157+ if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 4 {
158+ t.Fatalf("hold begin after release %v", ev)
159+ }
160+
161+ // The client without the pointer saw nothing.
162+ quiet(t, other, oSwipe)
163+ quiet(t, other, oPinch)
164+ quiet(t, other, oHold)
165+}
166+
167+// A window with the pointer but no gesture object gets nothing, and a
168+// gesture on a window that does not have the pointer is refused.
169+func TestPointerGesturesWithoutObjects(t *testing.T) {
170+ s, events, commands, dir := lifecycleServer(t)
171+ c := protocolClient(t, s, dir)
172+ w, _, _ := surfaceMapper(t, c, events)()
173+ commands <- ports.PointerFocus{ID: w.ID, X: 1, Y: 1}
174+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2}
175+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GesturePinch, Scale: 2}
176+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GesturePinch}
177+ if err := c.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+}
@@ -0,0 +1,180 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/go-wayland-bindings/server/pointergestures"
8+ "github.com/bnema/go-wayland-bindings/server/wayland"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/wlturbo"
11+)
12+
13+// gestureLog records the events of a zwp_pointer_gesture_*_v1 object as
14+// floats: the opcode, then its arguments (fixed values as they are).
15+type gestureLog struct {
16+ wlturbo.BaseProxy
17+ kind ports.GestureKind
18+ events chan []float64
19+}
20+
21+func (p *gestureLog) Dispatch(e *wlturbo.Event) {
22+ ev := []float64{float64(e.Opcode)}
23+ // Hold has no update: its end is opcode 1.
24+ op := e.Opcode
25+ if p.kind == ports.GestureHold && op == 1 {
26+ op = uint16(pointergestures.ZwpPointerGestureSwipeV1EventEnd)
27+ }
28+ switch op {
29+ case uint16(pointergestures.ZwpPointerGestureSwipeV1EventBegin):
30+ // serial, time, surface, fingers
31+ for range 4 {
32+ ev = append(ev, float64(e.Uint32()))
33+ }
34+ case uint16(pointergestures.ZwpPointerGestureSwipeV1EventUpdate):
35+ // time, then dx and dy (and scale and rotation of a pinch)
36+ fixed := 2
37+ if p.kind == ports.GesturePinch {
38+ fixed = 4
39+ }
40+ ev = append(ev, float64(e.Uint32()))
41+ for range fixed {
42+ ev = append(ev, e.Fixed().Float64())
43+ }
44+ case uint16(pointergestures.ZwpPointerGestureSwipeV1EventEnd):
45+ ev = append(ev, float64(e.Uint32()), float64(e.Uint32()), float64(e.Int32()))
46+ }
47+ p.events <- ev
48+}
49+
50+func gestureObjects(t *testing.T, c *wlturbo.Display, manager uint32) (swipe, pinch, hold chan []float64) {
51+ t.Helper()
52+ seat := bindProtocol(t, c, "wl_seat")
53+ registerProtocol(t, c, seat)
54+ pointer := c.AllocateID()
55+ registerProtocol(t, c, pointer)
56+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
57+ var logs [3]chan []float64
58+ for i, op := range []uint32{pointergestures.ZwpPointerGesturesV1RequestGetSwipeGesture, pointergestures.ZwpPointerGesturesV1RequestGetPinchGesture, pointergestures.ZwpPointerGesturesV1RequestGetHoldGesture} {
59+ id := c.AllocateID()
60+ p := &gestureLog{kind: ports.GestureKind(i), events: make(chan []float64, 16)}
61+ p.SetID(id)
62+ registerWireProxy(c, p)
63+ requestProtocol(t, c, manager, op, id, pointer)
64+ logs[i] = p.events
65+ }
66+ return logs[0], logs[1], logs[2]
67+}
68+
69+// quiet fails when the log got an event after a roundtrip.
70+func quiet(t *testing.T, c *wlturbo.Display, events chan []float64) {
71+ t.Helper()
72+ if err := c.Roundtrip(); err != nil {
73+ t.Fatal(err)
74+ }
75+ select {
76+ case ev := <-events:
77+ t.Fatalf("unexpected event %v", ev)
78+ default:
79+ }
80+}
81+
82+func TestPointerGestures(t *testing.T) {
83+ s, events, commands, dir := lifecycleServer(t)
84+ c := protocolClient(t, s, dir)
85+ manager := bindVersion(t, c, "zwp_pointer_gestures_v1", 3)
86+ swipe, pinch, hold := gestureObjects(t, c, manager)
87+ w, surf, _ := surfaceMapper(t, c, events)()
88+ // Another client with gestures never has the pointer.
89+ other := protocolClient(t, s, dir)
90+ oSwipe, oPinch, oHold := gestureObjects(t, other, bindVersion(t, other, "zwp_pointer_gestures_v1", 3))
91+
92+ // No gesture reaches a client before it has the pointer.
93+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2}
94+ commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 10}
95+ quiet(t, c, pinch)
96+
97+ // A pinch: begin carries the serial, time, surface and fingers; updates
98+ // the centre, scale and rotation; the end a later serial.
99+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 1000 * 1e6}
100+ begin := next(t, c, pinch)
101+ if begin[0] != 0 || begin[2] != 1000 || begin[3] != float64(surf) || begin[4] != 2 {
102+ t.Fatalf("pinch begin %v", begin)
103+ }
104+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GesturePinch, DX: 1.5, DY: -2, Scale: 1.25, Rotation: 10, Time: 1008 * 1e6}
105+ if ev := next(t, c, pinch); !slices.Equal(ev, []float64{1, 1008, 1.5, -2, 1.25, 10}) {
106+ t.Fatalf("pinch update %v", ev)
107+ }
108+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GesturePinch, Time: 1016 * 1e6}
109+ end := next(t, c, pinch)
110+ if end[0] != 2 || end[1] <= begin[1] || end[2] != 1016 || end[3] != 0 {
111+ t.Fatalf("pinch end %v (begin %v)", end, begin)
112+ }
113+
114+ // A swipe and a hold, the hold ended cancelled.
115+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GestureSwipe, Fingers: 5, Time: 2000 * 1e6}
116+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GestureSwipe, DX: 3, DY: 4, Time: 2008 * 1e6}
117+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GestureSwipe, Time: 2016 * 1e6}
118+ if ev := next(t, c, swipe); ev[0] != 0 || ev[4] != 5 {
119+ t.Fatalf("swipe begin %v", ev)
120+ }
121+ if ev := next(t, c, swipe); !slices.Equal(ev, []float64{1, 2008, 3, 4}) {
122+ t.Fatalf("swipe update %v", ev)
123+ }
124+ if ev := next(t, c, swipe); ev[0] != 2 || ev[3] != 0 {
125+ t.Fatalf("swipe end %v", ev)
126+ }
127+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GestureHold, Fingers: 3, Time: 3000 * 1e6}
128+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GestureHold, Cancelled: true, Time: 3008 * 1e6}
129+ if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 3 {
130+ t.Fatalf("hold begin %v", ev)
131+ }
132+ if ev := next(t, c, hold); ev[0] != 1 || ev[3] != 1 {
133+ t.Fatalf("hold end %v", ev)
134+ }
135+
136+ // A gesture of one kind does not reach the objects of the others, and an
137+ // update of a gesture that is not running is dropped.
138+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GesturePinch, Scale: 2}
139+ quiet(t, c, pinch)
140+ quiet(t, c, swipe)
141+ quiet(t, c, hold)
142+
143+ // The pointer leaving the window cancels the gesture.
144+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 4000 * 1e6}
145+ next(t, c, pinch)
146+ commands <- ports.PointerFocus{}
147+ if ev := next(t, c, pinch); ev[0] != 2 || ev[3] != 1 {
148+ t.Fatalf("pinch not cancelled on leave: %v", ev)
149+ }
150+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GesturePinch, Time: 4008 * 1e6}
151+ quiet(t, c, pinch)
152+
153+ // Releasing the manager keeps its objects.
154+ commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 10}
155+ requestProtocol(t, c, manager, pointergestures.ZwpPointerGesturesV1RequestRelease)
156+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GestureHold, Fingers: 4, Time: 5000 * 1e6}
157+ if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 4 {
158+ t.Fatalf("hold begin after release %v", ev)
159+ }
160+
161+ // The client without the pointer saw nothing.
162+ quiet(t, other, oSwipe)
163+ quiet(t, other, oPinch)
164+ quiet(t, other, oHold)
165+}
166+
167+// A window with the pointer but no gesture object gets nothing, and a
168+// gesture on a window that does not have the pointer is refused.
169+func TestPointerGesturesWithoutObjects(t *testing.T) {
170+ s, events, commands, dir := lifecycleServer(t)
171+ c := protocolClient(t, s, dir)
172+ w, _, _ := surfaceMapper(t, c, events)()
173+ commands <- ports.PointerFocus{ID: w.ID, X: 1, Y: 1}
174+ commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2}
175+ commands <- ports.GestureUpdateTo{ID: w.ID, Kind: ports.GesturePinch, Scale: 2}
176+ commands <- ports.GestureEndTo{ID: w.ID, Kind: ports.GesturePinch}
177+ if err := c.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+}
M internal/adapters/wayland/globals.go +1 -0
@@ -33,6 +33,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3333 func() error { return registerDMABuf(d, s, o.DMABuf) },
3434 func() error { return registerSinglePixelBuffer(d, s) },
3535 func() error { return registerPointerConstraints(d, s) },
36+ func() error { return registerPointerGestures(d, s) },
3637 func() error { return registerTearing(d, s) },
3738 func() error { return registerColorManagement(d, s) },
3839 func() error { return registerColorRepresentation(d, s) },
@@ -33,6 +33,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
33 func() error { return registerDMABuf(d, s, o.DMABuf) },33 func() error { return registerDMABuf(d, s, o.DMABuf) },
34 func() error { return registerSinglePixelBuffer(d, s) },34 func() error { return registerSinglePixelBuffer(d, s) },
35 func() error { return registerPointerConstraints(d, s) },35 func() error { return registerPointerConstraints(d, s) },
36+ func() error { return registerPointerGestures(d, s) },
36 func() error { return registerTearing(d, s) },37 func() error { return registerTearing(d, s) },
37 func() error { return registerColorManagement(d, s) },38 func() error { return registerColorManagement(d, s) },
38 func() error { return registerColorRepresentation(d, s) },39 func() error { return registerColorRepresentation(d, s) },
M internal/adapters/wayland/seat.go +17 -1
@@ -23,7 +23,9 @@ type seatState struct {
2323 pointerX, pointerY float64
2424 // enters holds the serial of the enter event each wl_pointer received
2525 // for pointerFocus; a pointer warp must name its pointer's serial.
26- enters map[*server.Resource]uint32
26+ enters map[*server.Resource]uint32
27+ // gesture is the touchpad gesture sent to pointerFocus's client.
28+ gesture *activeGesture
2729 wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis
2830 wheelHeld [2]float64 // axis value held back with it for pre-v8 clients
2931 pointers map[server.Client][]*wayland.Pointer
@@ -64,6 +66,12 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
6466 target = c.ID
6567 case ports.PointerAxisTo:
6668 target = c.ID
69+ case ports.GestureBeginTo:
70+ target = c.ID
71+ case ports.GestureUpdateTo:
72+ target = c.ID
73+ case ports.GestureEndTo:
74+ target = c.ID
6775 case ports.FocusWindow:
6876 target = c.ID
6977 case ports.ForwardKey:
@@ -142,6 +150,12 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
142150 for _, p := range pointers {
143151 s.sendAxis(p, c.Axis, steps, values)
144152 }
153+ case ports.GestureBeginTo:
154+ s.beginGesture(c)
155+ case ports.GestureUpdateTo:
156+ s.updateGesture(c)
157+ case ports.GestureEndTo:
158+ s.endGesture(c)
145159 case ports.SetKeymap:
146160 s.setKeymap(c)
147161 case ports.FocusWindow:
@@ -486,6 +500,8 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
486500 return
487501 }
488502 s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}
503+ // A gesture does not follow the pointer to another surface.
504+ s.cancelGesture()
489505 // The new client sets its own cursor on enter; until then, the arrow.
490506 s.cursorSurface = nil
491507 s.setCursor(ports.CursorChange{})
@@ -23,7 +23,9 @@ type seatState struct {
23 pointerX, pointerY float6423 pointerX, pointerY float64
24 // enters holds the serial of the enter event each wl_pointer received24 // enters holds the serial of the enter event each wl_pointer received
25 // for pointerFocus; a pointer warp must name its pointer's serial.25 // for pointerFocus; a pointer warp must name its pointer's serial.
26- enters map[*server.Resource]uint3226+ enters map[*server.Resource]uint32
27+ // gesture is the touchpad gesture sent to pointerFocus's client.
28+ gesture *activeGesture
27 wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis29 wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis
28 wheelHeld [2]float64 // axis value held back with it for pre-v8 clients30 wheelHeld [2]float64 // axis value held back with it for pre-v8 clients
29 pointers map[server.Client][]*wayland.Pointer31 pointers map[server.Client][]*wayland.Pointer
@@ -64,6 +66,12 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
64 target = c.ID66 target = c.ID
65 case ports.PointerAxisTo:67 case ports.PointerAxisTo:
66 target = c.ID68 target = c.ID
69+ case ports.GestureBeginTo:
70+ target = c.ID
71+ case ports.GestureUpdateTo:
72+ target = c.ID
73+ case ports.GestureEndTo:
74+ target = c.ID
67 case ports.FocusWindow:75 case ports.FocusWindow:
68 target = c.ID76 target = c.ID
69 case ports.ForwardKey:77 case ports.ForwardKey:
@@ -142,6 +150,12 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
142 for _, p := range pointers {150 for _, p := range pointers {
143 s.sendAxis(p, c.Axis, steps, values)151 s.sendAxis(p, c.Axis, steps, values)
144 }152 }
153+ case ports.GestureBeginTo:
154+ s.beginGesture(c)
155+ case ports.GestureUpdateTo:
156+ s.updateGesture(c)
157+ case ports.GestureEndTo:
158+ s.endGesture(c)
145 case ports.SetKeymap:159 case ports.SetKeymap:
146 s.setKeymap(c)160 s.setKeymap(c)
147 case ports.FocusWindow:161 case ports.FocusWindow:
@@ -486,6 +500,8 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
486 return500 return
487 }501 }
488 s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}502 s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}
503+ // A gesture does not follow the pointer to another surface.
504+ s.cancelGesture()
489 // The new client sets its own cursor on enter; until then, the arrow.505 // The new client sets its own cursor on enter; until then, the arrow.
490 s.cursorSurface = nil506 s.cursorSurface = nil
491 s.setCursor(ports.CursorChange{})507 s.setCursor(ports.CursorChange{})
M internal/adapters/wayland/server.go +7 -4
@@ -256,8 +256,10 @@ type Server struct {
256256 clipDevices []*clipDevice
257257 dataSources, primarySources, controlSources map[*server.Resource]*clipSource
258258 // Pointer constraints (constraints.go).
259- regions map[*server.Resource]*region
260- relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1
259+ regions map[*server.Resource]*region
260+ relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1
261+ // gestures are each client's zwp_pointer_gestures_v1 objects (gestures.go).
262+ gestures map[server.Client][]*gestureObject
261263 constraints map[*surface]*constraint
262264 constraint *constraint // the active one
263265 positioners map[*server.Resource]*positioner
@@ -330,6 +332,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
330332 contentNotify: make(chan struct{}),
331333 regions: map[*server.Resource]*region{},
332334 relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{},
335+ gestures: map[server.Client][]*gestureObject{},
333336 constraints: map[*surface]*constraint{},
334337 positioners: map[*server.Resource]*positioner{},
335338 seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay},
@@ -737,12 +740,12 @@ func (s *Server) apply(cmd ports.ClientCommand) {
737740 // Only input/focus commands require an owner envelope. Config relay
738741 // also produces SetKeymap and other epoch-independent commands.
739742 switch cmd.(type) {
740- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.FocusWindow, ports.ForwardKey:
743+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey:
741744 return
742745 }
743746 }
744747 switch c := cmd.(type) {
745- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
748+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
746749 s.applyInput(c)
747750 case ports.ConfigurePopup:
748751 if w := s.windows[c.ID]; w != nil && w.popup != nil {
@@ -256,8 +256,10 @@ type Server struct {
256 clipDevices []*clipDevice256 clipDevices []*clipDevice
257 dataSources, primarySources, controlSources map[*server.Resource]*clipSource257 dataSources, primarySources, controlSources map[*server.Resource]*clipSource
258 // Pointer constraints (constraints.go).258 // Pointer constraints (constraints.go).
259- regions map[*server.Resource]*region259+ regions map[*server.Resource]*region
260- relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1260+ relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1
261+ // gestures are each client's zwp_pointer_gestures_v1 objects (gestures.go).
262+ gestures map[server.Client][]*gestureObject
261 constraints map[*surface]*constraint263 constraints map[*surface]*constraint
262 constraint *constraint // the active one264 constraint *constraint // the active one
263 positioners map[*server.Resource]*positioner265 positioners map[*server.Resource]*positioner
@@ -330,6 +332,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
330 contentNotify: make(chan struct{}),332 contentNotify: make(chan struct{}),
331 regions: map[*server.Resource]*region{},333 regions: map[*server.Resource]*region{},
332 relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{},334 relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{},
335+ gestures: map[server.Client][]*gestureObject{},
333 constraints: map[*surface]*constraint{},336 constraints: map[*surface]*constraint{},
334 positioners: map[*server.Resource]*positioner{},337 positioners: map[*server.Resource]*positioner{},
335 seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay},338 seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay},
@@ -737,12 +740,12 @@ func (s *Server) apply(cmd ports.ClientCommand) {
737 // Only input/focus commands require an owner envelope. Config relay740 // Only input/focus commands require an owner envelope. Config relay
738 // also produces SetKeymap and other epoch-independent commands.741 // also produces SetKeymap and other epoch-independent commands.
739 switch cmd.(type) {742 switch cmd.(type) {
740- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.FocusWindow, ports.ForwardKey:743+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey:
741 return744 return
742 }745 }
743 }746 }
744 switch c := cmd.(type) {747 switch c := cmd.(type) {
745- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:748+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
746 s.applyInput(c)749 s.applyInput(c)
747 case ports.ConfigurePopup:750 case ports.ConfigurePopup:
748 if w := s.windows[c.ID]; w != nil && w.popup != nil {751 if w := s.windows[c.ID]; w != nil && w.popup != nil {
M internal/adapters/wayland/wire_client_test.go +5 -0
@@ -32,6 +32,7 @@ import (
3232 wire_linuxdmabuf "github.com/bnema/go-wayland-bindings/server/linuxdmabuf"
3333 wire_linuxdrmsyncobj "github.com/bnema/go-wayland-bindings/server/linuxdrmsyncobj"
3434 wire_pointerconstraints "github.com/bnema/go-wayland-bindings/server/pointerconstraints"
35+ wire_pointergestures "github.com/bnema/go-wayland-bindings/server/pointergestures"
3536 wire_pointerwarp "github.com/bnema/go-wayland-bindings/server/pointerwarp"
3637 wire_presentationtime "github.com/bnema/go-wayland-bindings/server/presentationtime"
3738 wire_primaryselection "github.com/bnema/go-wayland-bindings/server/primaryselection"
@@ -239,6 +240,10 @@ var wireSchemas = map[string]wireSchema{
239240 "zwp_primary_selection_device_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceV1Interface, &client_primaryselection.PrimarySelectionDevice{}, map[uint16]bool{1: true}, nil},
240241 "zwp_primary_selection_offer_v1": {wire_primaryselection.ZwpPrimarySelectionOfferV1Interface, &client_primaryselection.PrimarySelectionOffer{}, map[uint16]bool{1: true}, nil},
241242 "zwp_primary_selection_source_v1": {wire_primaryselection.ZwpPrimarySelectionSourceV1Interface, &client_primaryselection.PrimarySelectionSource{}, map[uint16]bool{1: true}, nil},
243+ "zwp_pointer_gestures_v1": {wire_pointergestures.ZwpPointerGesturesV1Interface, nil, map[uint16]bool{2: true}, nil},
244+ "zwp_pointer_gesture_swipe_v1": {wire_pointergestures.ZwpPointerGestureSwipeV1Interface, nil, map[uint16]bool{0: true}, nil},
245+ "zwp_pointer_gesture_pinch_v1": {wire_pointergestures.ZwpPointerGesturePinchV1Interface, nil, map[uint16]bool{0: true}, nil},
246+ "zwp_pointer_gesture_hold_v1": {wire_pointergestures.ZwpPointerGestureHoldV1Interface, nil, map[uint16]bool{0: true}, nil},
242247 "zwp_relative_pointer_manager_v1": {wire_relativepointer.ZwpRelativePointerManagerV1Interface, &client_relativepointer.RelativePointerManager{}, map[uint16]bool{0: true}, nil},
243248 "zwp_relative_pointer_v1": {wire_relativepointer.ZwpRelativePointerV1Interface, &client_relativepointer.RelativePointer{}, map[uint16]bool{0: true}, nil},
244249 "zwp_text_input_manager_v3": {wire_textinput.ZwpTextInputManagerV3Interface, &client_textinput.TextInputManagerV3{}, map[uint16]bool{0: true}, nil},
@@ -32,6 +32,7 @@ import (
32 wire_linuxdmabuf "github.com/bnema/go-wayland-bindings/server/linuxdmabuf"32 wire_linuxdmabuf "github.com/bnema/go-wayland-bindings/server/linuxdmabuf"
33 wire_linuxdrmsyncobj "github.com/bnema/go-wayland-bindings/server/linuxdrmsyncobj"33 wire_linuxdrmsyncobj "github.com/bnema/go-wayland-bindings/server/linuxdrmsyncobj"
34 wire_pointerconstraints "github.com/bnema/go-wayland-bindings/server/pointerconstraints"34 wire_pointerconstraints "github.com/bnema/go-wayland-bindings/server/pointerconstraints"
35+ wire_pointergestures "github.com/bnema/go-wayland-bindings/server/pointergestures"
35 wire_pointerwarp "github.com/bnema/go-wayland-bindings/server/pointerwarp"36 wire_pointerwarp "github.com/bnema/go-wayland-bindings/server/pointerwarp"
36 wire_presentationtime "github.com/bnema/go-wayland-bindings/server/presentationtime"37 wire_presentationtime "github.com/bnema/go-wayland-bindings/server/presentationtime"
37 wire_primaryselection "github.com/bnema/go-wayland-bindings/server/primaryselection"38 wire_primaryselection "github.com/bnema/go-wayland-bindings/server/primaryselection"
@@ -239,6 +240,10 @@ var wireSchemas = map[string]wireSchema{
239 "zwp_primary_selection_device_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceV1Interface, &client_primaryselection.PrimarySelectionDevice{}, map[uint16]bool{1: true}, nil},240 "zwp_primary_selection_device_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceV1Interface, &client_primaryselection.PrimarySelectionDevice{}, map[uint16]bool{1: true}, nil},
240 "zwp_primary_selection_offer_v1": {wire_primaryselection.ZwpPrimarySelectionOfferV1Interface, &client_primaryselection.PrimarySelectionOffer{}, map[uint16]bool{1: true}, nil},241 "zwp_primary_selection_offer_v1": {wire_primaryselection.ZwpPrimarySelectionOfferV1Interface, &client_primaryselection.PrimarySelectionOffer{}, map[uint16]bool{1: true}, nil},
241 "zwp_primary_selection_source_v1": {wire_primaryselection.ZwpPrimarySelectionSourceV1Interface, &client_primaryselection.PrimarySelectionSource{}, map[uint16]bool{1: true}, nil},242 "zwp_primary_selection_source_v1": {wire_primaryselection.ZwpPrimarySelectionSourceV1Interface, &client_primaryselection.PrimarySelectionSource{}, map[uint16]bool{1: true}, nil},
243+ "zwp_pointer_gestures_v1": {wire_pointergestures.ZwpPointerGesturesV1Interface, nil, map[uint16]bool{2: true}, nil},
244+ "zwp_pointer_gesture_swipe_v1": {wire_pointergestures.ZwpPointerGestureSwipeV1Interface, nil, map[uint16]bool{0: true}, nil},
245+ "zwp_pointer_gesture_pinch_v1": {wire_pointergestures.ZwpPointerGesturePinchV1Interface, nil, map[uint16]bool{0: true}, nil},
246+ "zwp_pointer_gesture_hold_v1": {wire_pointergestures.ZwpPointerGestureHoldV1Interface, nil, map[uint16]bool{0: true}, nil},
242 "zwp_relative_pointer_manager_v1": {wire_relativepointer.ZwpRelativePointerManagerV1Interface, &client_relativepointer.RelativePointerManager{}, map[uint16]bool{0: true}, nil},247 "zwp_relative_pointer_manager_v1": {wire_relativepointer.ZwpRelativePointerManagerV1Interface, &client_relativepointer.RelativePointerManager{}, map[uint16]bool{0: true}, nil},
243 "zwp_relative_pointer_v1": {wire_relativepointer.ZwpRelativePointerV1Interface, &client_relativepointer.RelativePointer{}, map[uint16]bool{0: true}, nil},248 "zwp_relative_pointer_v1": {wire_relativepointer.ZwpRelativePointerV1Interface, &client_relativepointer.RelativePointer{}, map[uint16]bool{0: true}, nil},
244 "zwp_text_input_manager_v3": {wire_textinput.ZwpTextInputManagerV3Interface, &client_textinput.TextInputManagerV3{}, map[uint16]bool{0: true}, nil},249 "zwp_text_input_manager_v3": {wire_textinput.ZwpTextInputManagerV3Interface, &client_textinput.TextInputManagerV3{}, map[uint16]bool{0: true}, nil},
M internal/adapters/wayland/xdg.go +1 -1
@@ -243,7 +243,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
243243 if w.parent != nil && w.parent.mapped {
244244 parent = w.parent.id
245245 }
246- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent})
246+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1})
247247 x.server.syncInhibitors()
248248 x.server.toplevelChanged(w)
249249 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
@@ -243,7 +243,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
243 if w.parent != nil && w.parent.mapped {243 if w.parent != nil && w.parent.mapped {
244 parent = w.parent.id244 parent = w.parent.id
245 }245 }
246- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent})246+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1})
247 x.server.syncInhibitors()247 x.server.syncInhibitors()
248 x.server.toplevelChanged(w)248 x.server.toplevelChanged(w)
249 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")249 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
M internal/core/client.go +1 -1
@@ -114,7 +114,7 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
114114 c.releaseSlots()
115115 if c.pointer == v.ID {
116116 c.pointer = 0
117- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
117+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
118118 return err
119119 }
120120 }
@@ -114,7 +114,7 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
114 c.releaseSlots()114 c.releaseSlots()
115 if c.pointer == v.ID {115 if c.pointer == v.ID {
116 c.pointer = 0116 c.pointer = 0
117- if err := c.command(ctx, ports.PointerFocus{}); err != nil {117+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
118 return err118 return err
119 }119 }
120 }120 }
A internal/core/clientgesture.go +114 -0
@@ -0,0 +1,114 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Touchpad gestures the compositor does not use go to the client under the
11+// pointer (zwp_pointer_gestures_v1): swipes of any finger count but three
12+// and four, every pinch and every hold. Like scrolling, they follow the
13+// pointer focus. Three- and four-finger swipes stay with the compositor
14+// (gesture.go).
15+
16+// clientGesture is the gesture being forwarded to a client. Input streams
17+// one gesture at a time. A gesture that begins with no pointer focus, or
18+// whose focus moved, still takes its stream (on stays set) so the rest of
19+// it never reaches the compositor or another window.
20+type clientGesture struct {
21+ on bool
22+ kind ports.GestureKind
23+ // id is the window that got the begin; 0 once cancelled or when none had
24+ // the pointer.
25+ id WindowID
26+ // at is the device time of the latest event, for a cancellation.
27+ at time.Duration
28+}
29+
30+// forwardsSwipe reports whether the compositor leaves a swipe of that many
31+// fingers to the client.
32+func forwardsSwipe(fingers int) bool { return fingers != 3 && fingers != 4 }
33+
34+// gestureBegin starts forwarding a gesture to the window with the pointer
35+// focus. One still running (its end was lost) ends cancelled first.
36+func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers int, at time.Duration) error {
37+ if err := c.gestureAbort(ctx, at); err != nil {
38+ return err
39+ }
40+ c.gesture = clientGesture{on: true, kind: kind, at: at}
41+ if c.pointer == 0 || c.drag != nil {
42+ return nil
43+ }
44+ c.gesture.id = c.pointer
45+ return c.command(ctx, ports.GestureBeginTo{ID: c.pointer, Kind: kind, Fingers: fingers, Time: at})
46+}
47+
48+// gestureUpdate moves the forwarded gesture of that kind. A window that
49+// lost the pointer meanwhile gets its gesture cancelled.
50+func (c *Core) gestureUpdate(ctx context.Context, u ports.GestureUpdateTo) error {
51+ g := &c.gesture
52+ if !g.on || g.kind != u.Kind {
53+ return nil
54+ }
55+ g.at = u.Time
56+ if g.id == 0 {
57+ return nil
58+ }
59+ if g.id != c.pointer {
60+ return c.gestureCancel(ctx)
61+ }
62+ u.ID = g.id
63+ return c.command(ctx, u)
64+}
65+
66+// gestureEnd ends the forwarded gesture of that kind.
67+func (c *Core) gestureEnd(ctx context.Context, kind ports.GestureKind, cancelled bool, at time.Duration) error {
68+ g := c.gesture
69+ if !g.on || g.kind != kind {
70+ return nil
71+ }
72+ c.gesture = clientGesture{}
73+ if g.id == 0 {
74+ return nil
75+ }
76+ if at == 0 {
77+ // A removed device's end has no time: the client's clock must
78+ // not run backwards.
79+ at = g.at
80+ }
81+ return c.command(ctx, ports.GestureEndTo{ID: g.id, Kind: kind, Cancelled: cancelled || g.id != c.pointer, Time: at})
82+}
83+
84+// gestureAbort ends whatever gesture is forwarded, cancelled.
85+func (c *Core) gestureAbort(ctx context.Context, at time.Duration) error {
86+ if !c.gesture.on {
87+ return nil
88+ }
89+ return c.gestureEnd(ctx, c.gesture.kind, true, at)
90+}
91+
92+// gestureCancel ends the gesture of the window that lost the pointer, as
93+// cancelled. The rest of the device's gesture is swallowed.
94+func (c *Core) gestureCancel(ctx context.Context) error {
95+ g := &c.gesture
96+ if !g.on || g.id == 0 {
97+ return nil
98+ }
99+ id := g.id
100+ g.id = 0
101+ return c.command(ctx, ports.GestureEndTo{ID: id, Kind: g.kind, Cancelled: true, Time: g.at})
102+}
103+
104+// pointerFocus sends a pointer focus change; the window leaving the
105+// pointer first gets its gesture cancelled, so its client sees the end
106+// before the leave.
107+func (c *Core) pointerFocus(ctx context.Context, f ports.PointerFocus) error {
108+ if c.gesture.id != 0 && c.gesture.id != f.ID {
109+ if err := c.gestureCancel(ctx); err != nil {
110+ return err
111+ }
112+ }
113+ return c.command(ctx, f)
114+}
@@ -0,0 +1,114 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Touchpad gestures the compositor does not use go to the client under the
11+// pointer (zwp_pointer_gestures_v1): swipes of any finger count but three
12+// and four, every pinch and every hold. Like scrolling, they follow the
13+// pointer focus. Three- and four-finger swipes stay with the compositor
14+// (gesture.go).
15+
16+// clientGesture is the gesture being forwarded to a client. Input streams
17+// one gesture at a time. A gesture that begins with no pointer focus, or
18+// whose focus moved, still takes its stream (on stays set) so the rest of
19+// it never reaches the compositor or another window.
20+type clientGesture struct {
21+ on bool
22+ kind ports.GestureKind
23+ // id is the window that got the begin; 0 once cancelled or when none had
24+ // the pointer.
25+ id WindowID
26+ // at is the device time of the latest event, for a cancellation.
27+ at time.Duration
28+}
29+
30+// forwardsSwipe reports whether the compositor leaves a swipe of that many
31+// fingers to the client.
32+func forwardsSwipe(fingers int) bool { return fingers != 3 && fingers != 4 }
33+
34+// gestureBegin starts forwarding a gesture to the window with the pointer
35+// focus. One still running (its end was lost) ends cancelled first.
36+func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers int, at time.Duration) error {
37+ if err := c.gestureAbort(ctx, at); err != nil {
38+ return err
39+ }
40+ c.gesture = clientGesture{on: true, kind: kind, at: at}
41+ if c.pointer == 0 || c.drag != nil {
42+ return nil
43+ }
44+ c.gesture.id = c.pointer
45+ return c.command(ctx, ports.GestureBeginTo{ID: c.pointer, Kind: kind, Fingers: fingers, Time: at})
46+}
47+
48+// gestureUpdate moves the forwarded gesture of that kind. A window that
49+// lost the pointer meanwhile gets its gesture cancelled.
50+func (c *Core) gestureUpdate(ctx context.Context, u ports.GestureUpdateTo) error {
51+ g := &c.gesture
52+ if !g.on || g.kind != u.Kind {
53+ return nil
54+ }
55+ g.at = u.Time
56+ if g.id == 0 {
57+ return nil
58+ }
59+ if g.id != c.pointer {
60+ return c.gestureCancel(ctx)
61+ }
62+ u.ID = g.id
63+ return c.command(ctx, u)
64+}
65+
66+// gestureEnd ends the forwarded gesture of that kind.
67+func (c *Core) gestureEnd(ctx context.Context, kind ports.GestureKind, cancelled bool, at time.Duration) error {
68+ g := c.gesture
69+ if !g.on || g.kind != kind {
70+ return nil
71+ }
72+ c.gesture = clientGesture{}
73+ if g.id == 0 {
74+ return nil
75+ }
76+ if at == 0 {
77+ // A removed device's end has no time: the client's clock must
78+ // not run backwards.
79+ at = g.at
80+ }
81+ return c.command(ctx, ports.GestureEndTo{ID: g.id, Kind: kind, Cancelled: cancelled || g.id != c.pointer, Time: at})
82+}
83+
84+// gestureAbort ends whatever gesture is forwarded, cancelled.
85+func (c *Core) gestureAbort(ctx context.Context, at time.Duration) error {
86+ if !c.gesture.on {
87+ return nil
88+ }
89+ return c.gestureEnd(ctx, c.gesture.kind, true, at)
90+}
91+
92+// gestureCancel ends the gesture of the window that lost the pointer, as
93+// cancelled. The rest of the device's gesture is swallowed.
94+func (c *Core) gestureCancel(ctx context.Context) error {
95+ g := &c.gesture
96+ if !g.on || g.id == 0 {
97+ return nil
98+ }
99+ id := g.id
100+ g.id = 0
101+ return c.command(ctx, ports.GestureEndTo{ID: id, Kind: g.kind, Cancelled: true, Time: g.at})
102+}
103+
104+// pointerFocus sends a pointer focus change; the window leaving the
105+// pointer first gets its gesture cancelled, so its client sees the end
106+// before the leave.
107+func (c *Core) pointerFocus(ctx context.Context, f ports.PointerFocus) error {
108+ if c.gesture.id != 0 && c.gesture.id != f.ID {
109+ if err := c.gestureCancel(ctx); err != nil {
110+ return err
111+ }
112+ }
113+ return c.command(ctx, f)
114+}
A internal/core/clientgesture_test.go +298 -0
@@ -0,0 +1,298 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// gestureRig is a rig with window 1 mapped on the left output and the
16+// pointer on it. The right output is empty: the pointer there has no focus.
17+func gestureRig(t *testing.T) *multiRig {
18+ t.Helper()
19+ r := startMulti(t, nil, left, right)
20+ r.mapWindow(t, 1)
21+ r.input <- ports.PointerMotion{X: 50, Y: 50}
22+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
23+ t.Fatal(v)
24+ }
25+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
26+ t.Fatal(v)
27+ }
28+ return r
29+}
30+
31+// isPointerCommand reports whether a command is one the gesture tests follow:
32+// the pointer's and the gestures'.
33+func isPointerCommand(cmd ports.ClientCommand) bool {
34+ switch cmd.(type) {
35+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
36+ return true
37+ }
38+ return false
39+}
40+
41+// pointerCommand receives the next pointer or gesture command, skipping
42+// the configures and focus changes between them.
43+func pointerCommand(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
44+ t.Helper()
45+ for {
46+ if v := command(t, ch); isPointerCommand(v) {
47+ return v
48+ }
49+ }
50+}
51+
52+// idle fails the test when core sent a command meanwhile. A marker input
53+// goes first, so the check waits for core to have handled what came before.
54+func (r *multiRig) idle(t *testing.T) {
55+ t.Helper()
56+ r.input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
57+ if v := pointerCommand(t, r.commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}}) {
58+ t.Fatalf("unexpected command %#v", v)
59+ }
60+}
61+
62+func TestPinchGoesToPointerFocus(t *testing.T) {
63+ r := gestureRig(t)
64+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
65+ r.input <- ports.PinchUpdate{DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second}
66+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
67+ for _, want := range []ports.ClientCommand{
68+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
69+ ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second},
70+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Time: 3 * time.Second},
71+ } {
72+ if v := pointerCommand(t, r.commands); v != want {
73+ t.Fatalf("got %#v, want %#v", v, want)
74+ }
75+ }
76+}
77+
78+// A removed touchpad ends its gesture with no time: the client gets the
79+// last one, so its clock never runs backwards.
80+func TestGestureEndWithoutTime(t *testing.T) {
81+ r := gestureRig(t)
82+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
83+ r.input <- ports.PinchUpdate{Scale: 1, Time: 2 * time.Second}
84+ r.input <- ports.PinchEnd{Cancelled: true}
85+ pointerCommand(t, r.commands)
86+ pointerCommand(t, r.commands)
87+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second}) {
88+ t.Fatal(v)
89+ }
90+}
91+
92+func TestHoldGoesToPointerFocus(t *testing.T) {
93+ r := gestureRig(t)
94+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
95+ r.input <- ports.HoldEnd{Cancelled: true, Time: 2 * time.Second}
96+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
97+ t.Fatal(v)
98+ }
99+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: 2 * time.Second}) {
100+ t.Fatal(v)
101+ }
102+}
103+
104+// Swipes of other finger counts than three and four go to the client; the
105+// compositor keeps those.
106+func TestSwipeFingerCounts(t *testing.T) {
107+ for _, fingers := range []int{2, 5} {
108+ r := gestureRig(t)
109+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
110+ // The client gets the accelerated deltas.
111+ r.input <- ports.SwipeUpdate{DX: 3, DY: 4, AccelDX: 6, AccelDY: 8, Time: 2 * time.Second}
112+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
113+ for _, want := range []ports.ClientCommand{
114+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureSwipe, Fingers: fingers, Time: time.Second},
115+ ports.GestureUpdateTo{ID: 1, Kind: ports.GestureSwipe, DX: 6, DY: 8, Time: 2 * time.Second},
116+ ports.GestureEndTo{ID: 1, Kind: ports.GestureSwipe, Time: 3 * time.Second},
117+ } {
118+ if v := pointerCommand(t, r.commands); v != want {
119+ t.Fatalf("%d fingers: got %#v, want %#v", fingers, v, want)
120+ }
121+ }
122+ }
123+ for _, fingers := range []int{3, 4} {
124+ r := gestureRig(t)
125+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
126+ r.input <- ports.SwipeUpdate{DX: 40, DY: 0, Time: 2 * time.Second}
127+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
128+ r.idle(t)
129+ }
130+}
131+
132+// A gesture that began with no window under the pointer reaches nobody,
133+// even when the pointer enters a window meanwhile.
134+func TestGestureWithoutPointerFocus(t *testing.T) {
135+ r := gestureRig(t)
136+ r.input <- ports.PointerMotion{X: 300, Y: 50}
137+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
138+ t.Fatal(v)
139+ }
140+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
141+ r.input <- ports.PointerMotion{X: 50, Y: 50}
142+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
143+ t.Fatal(v)
144+ }
145+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
146+ t.Fatal(v)
147+ }
148+ r.input <- ports.PinchUpdate{Scale: 2, Time: 2 * time.Second}
149+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
150+ r.idle(t)
151+}
152+
153+// The window losing the pointer focus gets its gesture cancelled before the
154+// leave, and nothing of the rest.
155+func TestGestureCancelledOnFocusChange(t *testing.T) {
156+ r := gestureRig(t)
157+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
158+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second}) {
159+ t.Fatal(v)
160+ }
161+ r.input <- ports.PinchUpdate{Scale: 1.1, Time: 2 * time.Second}
162+ if v := pointerCommand(t, r.commands); v != (ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, Scale: 1.1, Time: 2 * time.Second}) {
163+ t.Fatal(v)
164+ }
165+ r.input <- ports.PointerMotion{X: 300, Y: 50}
166+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second}) {
167+ t.Fatal(v)
168+ }
169+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
170+ t.Fatal(v)
171+ }
172+ r.input <- ports.PinchUpdate{Scale: 1.2, Time: 3 * time.Second}
173+ r.input <- ports.PinchEnd{Time: 4 * time.Second}
174+ r.input <- ports.PointerMotion{X: 50, Y: 50}
175+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
176+ t.Fatal(v)
177+ }
178+}
179+
180+// A window that goes away mid-gesture has its gesture cancelled with the
181+// pointer focus.
182+func TestGestureCancelledOnUnmap(t *testing.T) {
183+ r := gestureRig(t)
184+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
185+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
186+ t.Fatal(v)
187+ }
188+ r.client <- ports.WindowUnmapped{ID: 1}
189+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: time.Second}) {
190+ t.Fatal(v)
191+ }
192+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
193+ t.Fatal(v)
194+ }
195+}
196+
197+// A gesture whose end was lost ends cancelled when the next one begins.
198+func TestGestureBeginEndsPrevious(t *testing.T) {
199+ r := gestureRig(t)
200+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
201+ r.input <- ports.HoldBegin{Fingers: 3, Time: 2 * time.Second}
202+ for _, want := range []ports.ClientCommand{
203+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
204+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second},
205+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: 2 * time.Second},
206+ } {
207+ if v := pointerCommand(t, r.commands); v != want {
208+ t.Fatalf("got %#v, want %#v", v, want)
209+ }
210+ }
211+}
212+
213+// A protected session forwards no gesture to a window; one running when
214+// the session locks ends with the pointer focus.
215+func TestGesturesRefusedWhileLocked(t *testing.T) {
216+ var epoch atomic.Value
217+ epoch.Store(ports.SecurityState{})
218+ gate := portsmocks.NewMockSessionSecurity(t)
219+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
220+ r := &multiRig{
221+ client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 32),
222+ output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
223+ commands: make(chan ports.ClientCommand, 256), scenes: make(chan []ports.Scene, 1),
224+ }
225+ cfg := config.Defaults()
226+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Constraints: make(chan ports.PointerConstraint, 1)}, core.Options{Security: gate})
227+ if err != nil {
228+ t.Fatal(err)
229+ }
230+ ctx, cancel := context.WithCancel(context.Background())
231+ done := make(chan error, 1)
232+ go func() { done <- c.Run(ctx) }()
233+ t.Cleanup(func() { cancel(); <-done })
234+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 300, Height: 200}}
235+ scene(t, r.scenes)
236+ r.client <- ports.WindowMapped{ID: 1}
237+ scene(t, r.scenes)
238+ unlocked := ports.SecurityState{}
239+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PointerMotion{X: 50, Y: 50}}
240+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PinchBegin{Fingers: 2, Time: time.Second}}
241+ for _, want := range []ports.ClientCommand{
242+ ports.PointerFocus{ID: 1, X: 50, Y: 50},
243+ ports.PointerMotionTo{ID: 1, X: 50, Y: 50},
244+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
245+ } {
246+ if v := unwrap(t, r.commands, unlocked); v != want {
247+ t.Fatalf("got %#v, want %#v", v, want)
248+ }
249+ }
250+
251+ // The lock clears the pointer focus, which cancels the gesture in wayland.
252+ locked := ports.SecurityState{Generation: 1, Protected: true}
253+ epoch.Store(locked)
254+ r.client <- ports.SessionLockChanged{State: locked, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}}}
255+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return s.Security == locked && len(s.Windows) == 1 })
256+ r.input <- ports.SecurityInput{State: locked, Event: ports.PointerMotion{X: 50, Y: 50}}
257+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchBegin{Fingers: 2, Time: 2 * time.Second}}
258+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchUpdate{Scale: 2, Time: 3 * time.Second}}
259+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchEnd{Time: 4 * time.Second}}
260+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldBegin{Fingers: 3, Time: 5 * time.Second}}
261+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldEnd{Time: 6 * time.Second}}
262+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeBegin{Fingers: 2, Time: 7 * time.Second}}
263+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeEnd{Time: 8 * time.Second}}
264+ r.input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "marker", Keycode: 30, Pressed: true}}
265+ for {
266+ wrapped, ok := receive(t, r.commands).(ports.SecurityCommand)
267+ if !ok {
268+ t.Fatal("unwrapped command")
269+ }
270+ switch v := wrapped.Command.(type) {
271+ case ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
272+ if wrapped.State == locked {
273+ t.Fatalf("gesture forwarded while locked: %#v", v)
274+ }
275+ case ports.ForwardKey:
276+ if wrapped.State == locked {
277+ return
278+ }
279+ }
280+ }
281+}
282+
283+// unwrap returns the next pointer command of an envelope of the given epoch.
284+func unwrap(t *testing.T, ch <-chan ports.ClientCommand, state ports.SecurityState) ports.ClientCommand {
285+ t.Helper()
286+ for {
287+ wrapped, ok := receive(t, ch).(ports.SecurityCommand)
288+ if !ok {
289+ t.Fatal("unwrapped command")
290+ }
291+ if wrapped.State != state {
292+ t.Fatalf("epoch %+v, want %+v", wrapped.State, state)
293+ }
294+ if isPointerCommand(wrapped.Command) {
295+ return wrapped.Command
296+ }
297+ }
298+}
@@ -0,0 +1,298 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// gestureRig is a rig with window 1 mapped on the left output and the
16+// pointer on it. The right output is empty: the pointer there has no focus.
17+func gestureRig(t *testing.T) *multiRig {
18+ t.Helper()
19+ r := startMulti(t, nil, left, right)
20+ r.mapWindow(t, 1)
21+ r.input <- ports.PointerMotion{X: 50, Y: 50}
22+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
23+ t.Fatal(v)
24+ }
25+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
26+ t.Fatal(v)
27+ }
28+ return r
29+}
30+
31+// isPointerCommand reports whether a command is one the gesture tests follow:
32+// the pointer's and the gestures'.
33+func isPointerCommand(cmd ports.ClientCommand) bool {
34+ switch cmd.(type) {
35+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
36+ return true
37+ }
38+ return false
39+}
40+
41+// pointerCommand receives the next pointer or gesture command, skipping
42+// the configures and focus changes between them.
43+func pointerCommand(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
44+ t.Helper()
45+ for {
46+ if v := command(t, ch); isPointerCommand(v) {
47+ return v
48+ }
49+ }
50+}
51+
52+// idle fails the test when core sent a command meanwhile. A marker input
53+// goes first, so the check waits for core to have handled what came before.
54+func (r *multiRig) idle(t *testing.T) {
55+ t.Helper()
56+ r.input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
57+ if v := pointerCommand(t, r.commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}}) {
58+ t.Fatalf("unexpected command %#v", v)
59+ }
60+}
61+
62+func TestPinchGoesToPointerFocus(t *testing.T) {
63+ r := gestureRig(t)
64+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
65+ r.input <- ports.PinchUpdate{DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second}
66+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
67+ for _, want := range []ports.ClientCommand{
68+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
69+ ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second},
70+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Time: 3 * time.Second},
71+ } {
72+ if v := pointerCommand(t, r.commands); v != want {
73+ t.Fatalf("got %#v, want %#v", v, want)
74+ }
75+ }
76+}
77+
78+// A removed touchpad ends its gesture with no time: the client gets the
79+// last one, so its clock never runs backwards.
80+func TestGestureEndWithoutTime(t *testing.T) {
81+ r := gestureRig(t)
82+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
83+ r.input <- ports.PinchUpdate{Scale: 1, Time: 2 * time.Second}
84+ r.input <- ports.PinchEnd{Cancelled: true}
85+ pointerCommand(t, r.commands)
86+ pointerCommand(t, r.commands)
87+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second}) {
88+ t.Fatal(v)
89+ }
90+}
91+
92+func TestHoldGoesToPointerFocus(t *testing.T) {
93+ r := gestureRig(t)
94+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
95+ r.input <- ports.HoldEnd{Cancelled: true, Time: 2 * time.Second}
96+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
97+ t.Fatal(v)
98+ }
99+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: 2 * time.Second}) {
100+ t.Fatal(v)
101+ }
102+}
103+
104+// Swipes of other finger counts than three and four go to the client; the
105+// compositor keeps those.
106+func TestSwipeFingerCounts(t *testing.T) {
107+ for _, fingers := range []int{2, 5} {
108+ r := gestureRig(t)
109+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
110+ // The client gets the accelerated deltas.
111+ r.input <- ports.SwipeUpdate{DX: 3, DY: 4, AccelDX: 6, AccelDY: 8, Time: 2 * time.Second}
112+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
113+ for _, want := range []ports.ClientCommand{
114+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureSwipe, Fingers: fingers, Time: time.Second},
115+ ports.GestureUpdateTo{ID: 1, Kind: ports.GestureSwipe, DX: 6, DY: 8, Time: 2 * time.Second},
116+ ports.GestureEndTo{ID: 1, Kind: ports.GestureSwipe, Time: 3 * time.Second},
117+ } {
118+ if v := pointerCommand(t, r.commands); v != want {
119+ t.Fatalf("%d fingers: got %#v, want %#v", fingers, v, want)
120+ }
121+ }
122+ }
123+ for _, fingers := range []int{3, 4} {
124+ r := gestureRig(t)
125+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
126+ r.input <- ports.SwipeUpdate{DX: 40, DY: 0, Time: 2 * time.Second}
127+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
128+ r.idle(t)
129+ }
130+}
131+
132+// A gesture that began with no window under the pointer reaches nobody,
133+// even when the pointer enters a window meanwhile.
134+func TestGestureWithoutPointerFocus(t *testing.T) {
135+ r := gestureRig(t)
136+ r.input <- ports.PointerMotion{X: 300, Y: 50}
137+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
138+ t.Fatal(v)
139+ }
140+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
141+ r.input <- ports.PointerMotion{X: 50, Y: 50}
142+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
143+ t.Fatal(v)
144+ }
145+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
146+ t.Fatal(v)
147+ }
148+ r.input <- ports.PinchUpdate{Scale: 2, Time: 2 * time.Second}
149+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
150+ r.idle(t)
151+}
152+
153+// The window losing the pointer focus gets its gesture cancelled before the
154+// leave, and nothing of the rest.
155+func TestGestureCancelledOnFocusChange(t *testing.T) {
156+ r := gestureRig(t)
157+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
158+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second}) {
159+ t.Fatal(v)
160+ }
161+ r.input <- ports.PinchUpdate{Scale: 1.1, Time: 2 * time.Second}
162+ if v := pointerCommand(t, r.commands); v != (ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, Scale: 1.1, Time: 2 * time.Second}) {
163+ t.Fatal(v)
164+ }
165+ r.input <- ports.PointerMotion{X: 300, Y: 50}
166+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second}) {
167+ t.Fatal(v)
168+ }
169+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
170+ t.Fatal(v)
171+ }
172+ r.input <- ports.PinchUpdate{Scale: 1.2, Time: 3 * time.Second}
173+ r.input <- ports.PinchEnd{Time: 4 * time.Second}
174+ r.input <- ports.PointerMotion{X: 50, Y: 50}
175+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
176+ t.Fatal(v)
177+ }
178+}
179+
180+// A window that goes away mid-gesture has its gesture cancelled with the
181+// pointer focus.
182+func TestGestureCancelledOnUnmap(t *testing.T) {
183+ r := gestureRig(t)
184+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
185+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
186+ t.Fatal(v)
187+ }
188+ r.client <- ports.WindowUnmapped{ID: 1}
189+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: time.Second}) {
190+ t.Fatal(v)
191+ }
192+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
193+ t.Fatal(v)
194+ }
195+}
196+
197+// A gesture whose end was lost ends cancelled when the next one begins.
198+func TestGestureBeginEndsPrevious(t *testing.T) {
199+ r := gestureRig(t)
200+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
201+ r.input <- ports.HoldBegin{Fingers: 3, Time: 2 * time.Second}
202+ for _, want := range []ports.ClientCommand{
203+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
204+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second},
205+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: 2 * time.Second},
206+ } {
207+ if v := pointerCommand(t, r.commands); v != want {
208+ t.Fatalf("got %#v, want %#v", v, want)
209+ }
210+ }
211+}
212+
213+// A protected session forwards no gesture to a window; one running when
214+// the session locks ends with the pointer focus.
215+func TestGesturesRefusedWhileLocked(t *testing.T) {
216+ var epoch atomic.Value
217+ epoch.Store(ports.SecurityState{})
218+ gate := portsmocks.NewMockSessionSecurity(t)
219+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
220+ r := &multiRig{
221+ client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 32),
222+ output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
223+ commands: make(chan ports.ClientCommand, 256), scenes: make(chan []ports.Scene, 1),
224+ }
225+ cfg := config.Defaults()
226+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Constraints: make(chan ports.PointerConstraint, 1)}, core.Options{Security: gate})
227+ if err != nil {
228+ t.Fatal(err)
229+ }
230+ ctx, cancel := context.WithCancel(context.Background())
231+ done := make(chan error, 1)
232+ go func() { done <- c.Run(ctx) }()
233+ t.Cleanup(func() { cancel(); <-done })
234+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 300, Height: 200}}
235+ scene(t, r.scenes)
236+ r.client <- ports.WindowMapped{ID: 1}
237+ scene(t, r.scenes)
238+ unlocked := ports.SecurityState{}
239+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PointerMotion{X: 50, Y: 50}}
240+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PinchBegin{Fingers: 2, Time: time.Second}}
241+ for _, want := range []ports.ClientCommand{
242+ ports.PointerFocus{ID: 1, X: 50, Y: 50},
243+ ports.PointerMotionTo{ID: 1, X: 50, Y: 50},
244+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
245+ } {
246+ if v := unwrap(t, r.commands, unlocked); v != want {
247+ t.Fatalf("got %#v, want %#v", v, want)
248+ }
249+ }
250+
251+ // The lock clears the pointer focus, which cancels the gesture in wayland.
252+ locked := ports.SecurityState{Generation: 1, Protected: true}
253+ epoch.Store(locked)
254+ r.client <- ports.SessionLockChanged{State: locked, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}}}
255+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return s.Security == locked && len(s.Windows) == 1 })
256+ r.input <- ports.SecurityInput{State: locked, Event: ports.PointerMotion{X: 50, Y: 50}}
257+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchBegin{Fingers: 2, Time: 2 * time.Second}}
258+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchUpdate{Scale: 2, Time: 3 * time.Second}}
259+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchEnd{Time: 4 * time.Second}}
260+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldBegin{Fingers: 3, Time: 5 * time.Second}}
261+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldEnd{Time: 6 * time.Second}}
262+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeBegin{Fingers: 2, Time: 7 * time.Second}}
263+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeEnd{Time: 8 * time.Second}}
264+ r.input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "marker", Keycode: 30, Pressed: true}}
265+ for {
266+ wrapped, ok := receive(t, r.commands).(ports.SecurityCommand)
267+ if !ok {
268+ t.Fatal("unwrapped command")
269+ }
270+ switch v := wrapped.Command.(type) {
271+ case ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
272+ if wrapped.State == locked {
273+ t.Fatalf("gesture forwarded while locked: %#v", v)
274+ }
275+ case ports.ForwardKey:
276+ if wrapped.State == locked {
277+ return
278+ }
279+ }
280+ }
281+}
282+
283+// unwrap returns the next pointer command of an envelope of the given epoch.
284+func unwrap(t *testing.T, ch <-chan ports.ClientCommand, state ports.SecurityState) ports.ClientCommand {
285+ t.Helper()
286+ for {
287+ wrapped, ok := receive(t, ch).(ports.SecurityCommand)
288+ if !ok {
289+ t.Fatal("unwrapped command")
290+ }
291+ if wrapped.State != state {
292+ t.Fatalf("epoch %+v, want %+v", wrapped.State, state)
293+ }
294+ if isPointerCommand(wrapped.Command) {
295+ return wrapped.Command
296+ }
297+ }
298+}
M internal/core/core.go +12 -4
@@ -118,6 +118,7 @@ type Core struct {
118118 specs []NamedWorkspace
119119 presets []Width
120120 slots map[slotKey]*slotState
121+ rules []rule
121122 placement spawnPlacement
122123 // toSpawn holds slots to start; Run sends them (apply has no context).
123124 toSpawn []slotKey
@@ -134,7 +135,10 @@ type Core struct {
134135 // flip came in time to move a running slide; nil when not armed. It is
135136 // the channel of frameTimer, made once and reset per frame: an
136137 // animation frame allocates no timer.
137- swipe *swipeGesture
138+ swipe *swipeGesture
139+ // gesture is the touchpad gesture forwarded to a client
140+ // (clientgesture.go).
141+ gesture clientGesture
138142 frameC <-chan time.Time
139143 frameTimer ports.Timer
140144 // shots is the snapshot of what the screens show before an action
@@ -303,6 +307,10 @@ func (c *Core) apply(cfg ports.Config) error {
303307 if err != nil {
304308 return err
305309 }
310+ rules, err := parseRules(cfg.Rules)
311+ if err != nil {
312+ return err
313+ }
306314 // Reload is cold-path work; unrelated changes keep a scroll or gesture.
307315 axes := make(map[*Monitor]layoutPolicy, len(c.screens))
308316 for _, s := range c.screens {
@@ -311,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error {
311319 c.cfg = cfg
312320 c.binds = binds
313321 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
314- c.specs, c.presets = named, presets
322+ c.specs, c.presets, c.rules = named, presets, rules
315323 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
316324 c.named()
317325 for _, s := range c.screens {
@@ -621,7 +629,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
621629 if c.pointer != 0 && !c.visible(c.pointer) {
622630 c.pointer = 0
623631 if c.grab == 0 {
624- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
632+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
625633 return err
626634 }
627635 }
@@ -703,7 +711,7 @@ func (c *Core) rehit(ctx context.Context) error {
703711 id, x, y := c.hit(c.cursorX, c.cursorY)
704712 if id != c.pointer {
705713 c.pointer, c.pointerAt = id, [2]float64{x, y}
706- return c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y})
714+ return c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y})
707715 }
708716 if id == 0 || c.pointerAt == [2]float64{x, y} {
709717 return nil
@@ -118,6 +118,7 @@ type Core struct {
118 specs []NamedWorkspace118 specs []NamedWorkspace
119 presets []Width119 presets []Width
120 slots map[slotKey]*slotState120 slots map[slotKey]*slotState
121+ rules []rule
121 placement spawnPlacement122 placement spawnPlacement
122 // toSpawn holds slots to start; Run sends them (apply has no context).123 // toSpawn holds slots to start; Run sends them (apply has no context).
123 toSpawn []slotKey124 toSpawn []slotKey
@@ -134,7 +135,10 @@ type Core struct {
134 // flip came in time to move a running slide; nil when not armed. It is135 // flip came in time to move a running slide; nil when not armed. It is
135 // the channel of frameTimer, made once and reset per frame: an136 // the channel of frameTimer, made once and reset per frame: an
136 // animation frame allocates no timer.137 // animation frame allocates no timer.
137- swipe *swipeGesture138+ swipe *swipeGesture
139+ // gesture is the touchpad gesture forwarded to a client
140+ // (clientgesture.go).
141+ gesture clientGesture
138 frameC <-chan time.Time142 frameC <-chan time.Time
139 frameTimer ports.Timer143 frameTimer ports.Timer
140 // shots is the snapshot of what the screens show before an action144 // shots is the snapshot of what the screens show before an action
@@ -303,6 +307,10 @@ func (c *Core) apply(cfg ports.Config) error {
303 if err != nil {307 if err != nil {
304 return err308 return err
305 }309 }
310+ rules, err := parseRules(cfg.Rules)
311+ if err != nil {
312+ return err
313+ }
306 // 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.
307 axes := make(map[*Monitor]layoutPolicy, len(c.screens))315 axes := make(map[*Monitor]layoutPolicy, len(c.screens))
308 for _, s := range c.screens {316 for _, s := range c.screens {
@@ -311,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error {
311 c.cfg = cfg319 c.cfg = cfg
312 c.binds = binds320 c.binds = binds
313 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]
314- c.specs, c.presets = named, presets322+ c.specs, c.presets, c.rules = named, presets, rules
315 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)323 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
316 c.named()324 c.named()
317 for _, s := range c.screens {325 for _, s := range c.screens {
@@ -621,7 +629,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
621 if c.pointer != 0 && !c.visible(c.pointer) {629 if c.pointer != 0 && !c.visible(c.pointer) {
622 c.pointer = 0630 c.pointer = 0
623 if c.grab == 0 {631 if c.grab == 0 {
624- if err := c.command(ctx, ports.PointerFocus{}); err != nil {632+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
625 return err633 return err
626 }634 }
627 }635 }
@@ -703,7 +711,7 @@ func (c *Core) rehit(ctx context.Context) error {
703 id, x, y := c.hit(c.cursorX, c.cursorY)711 id, x, y := c.hit(c.cursorX, c.cursorY)
704 if id != c.pointer {712 if id != c.pointer {
705 c.pointer, c.pointerAt = id, [2]float64{x, y}713 c.pointer, c.pointerAt = id, [2]float64{x, y}
706- return c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y})714+ return c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y})
707 }715 }
708 if id == 0 || c.pointerAt == [2]float64{x, y} {716 if id == 0 || c.pointerAt == [2]float64{x, y} {
709 return nil717 return nil
M internal/core/drag.go +1 -1
@@ -165,7 +165,7 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize
165165 c.grab = 0
166166 if c.pointer != 0 {
167167 c.pointer = 0
168- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
168+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
169169 return err
170170 }
171171 }
@@ -165,7 +165,7 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize
165 c.grab = 0165 c.grab = 0
166 if c.pointer != 0 {166 if c.pointer != 0 {
167 c.pointer = 0167 c.pointer = 0
168- if err := c.command(ctx, ports.PointerFocus{}); err != nil {168+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
169 return err169 return err
170 }170 }
171 }171 }
M internal/core/input.go +23 -1
@@ -26,18 +26,40 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
2626 case ports.PointerAxis:
2727 return c.pointerAxis(ctx, v)
2828 case ports.SwipeBegin:
29+ if forwardsSwipe(v.Fingers) {
30+ return c.gestureBegin(ctx, ports.GestureSwipe, v.Fingers, v.Time)
31+ }
32+ if err := c.gestureAbort(ctx, v.Time); err != nil {
33+ return err
34+ }
2935 if c.swipeBegin(v) {
3036 return c.slid(ctx, false)
3137 }
3238 return nil
3339 case ports.SwipeUpdate:
40+ if c.gesture.on {
41+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GestureSwipe, DX: v.AccelDX, DY: v.AccelDY, Time: v.Time})
42+ }
3443 before := c.cur().mon.Current()
3544 if c.swipeUpdate(v) {
3645 return c.slid(ctx, c.cur().mon.Current() != before)
3746 }
3847 return nil
3948 case ports.SwipeEnd:
49+ if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
50+ return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
51+ }
4052 return c.slid(ctx, c.swipeEnd(v))
53+ case ports.PinchBegin:
54+ return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
55+ case ports.PinchUpdate:
56+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GesturePinch, DX: v.DX, DY: v.DY, Scale: v.Scale, Rotation: v.Rotation, Time: v.Time})
57+ case ports.PinchEnd:
58+ return c.gestureEnd(ctx, ports.GesturePinch, v.Cancelled, v.Time)
59+ case ports.HoldBegin:
60+ return c.gestureBegin(ctx, ports.GestureHold, v.Fingers, v.Time)
61+ case ports.HoldEnd:
62+ return c.gestureEnd(ctx, ports.GestureHold, v.Cancelled, v.Time)
4163 case ports.KeyEvent:
4264 return c.keyEvent(ctx, v)
4365 }
@@ -73,7 +95,7 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
7395 // Layout changes are intentionally re-hit-tested only on motion.
7496 if id != c.pointer {
7597 c.pointer = id
76- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
98+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
7799 return err
78100 }
79101 }
@@ -26,18 +26,40 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
26 case ports.PointerAxis:26 case ports.PointerAxis:
27 return c.pointerAxis(ctx, v)27 return c.pointerAxis(ctx, v)
28 case ports.SwipeBegin:28 case ports.SwipeBegin:
29+ if forwardsSwipe(v.Fingers) {
30+ return c.gestureBegin(ctx, ports.GestureSwipe, v.Fingers, v.Time)
31+ }
32+ if err := c.gestureAbort(ctx, v.Time); err != nil {
33+ return err
34+ }
29 if c.swipeBegin(v) {35 if c.swipeBegin(v) {
30 return c.slid(ctx, false)36 return c.slid(ctx, false)
31 }37 }
32 return nil38 return nil
33 case ports.SwipeUpdate:39 case ports.SwipeUpdate:
40+ if c.gesture.on {
41+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GestureSwipe, DX: v.AccelDX, DY: v.AccelDY, Time: v.Time})
42+ }
34 before := c.cur().mon.Current()43 before := c.cur().mon.Current()
35 if c.swipeUpdate(v) {44 if c.swipeUpdate(v) {
36 return c.slid(ctx, c.cur().mon.Current() != before)45 return c.slid(ctx, c.cur().mon.Current() != before)
37 }46 }
38 return nil47 return nil
39 case ports.SwipeEnd:48 case ports.SwipeEnd:
49+ if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
50+ return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
51+ }
40 return c.slid(ctx, c.swipeEnd(v))52 return c.slid(ctx, c.swipeEnd(v))
53+ case ports.PinchBegin:
54+ return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
55+ case ports.PinchUpdate:
56+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GesturePinch, DX: v.DX, DY: v.DY, Scale: v.Scale, Rotation: v.Rotation, Time: v.Time})
57+ case ports.PinchEnd:
58+ return c.gestureEnd(ctx, ports.GesturePinch, v.Cancelled, v.Time)
59+ case ports.HoldBegin:
60+ return c.gestureBegin(ctx, ports.GestureHold, v.Fingers, v.Time)
61+ case ports.HoldEnd:
62+ return c.gestureEnd(ctx, ports.GestureHold, v.Cancelled, v.Time)
41 case ports.KeyEvent:63 case ports.KeyEvent:
42 return c.keyEvent(ctx, v)64 return c.keyEvent(ctx, v)
43 }65 }
@@ -73,7 +95,7 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
73 // Layout changes are intentionally re-hit-tested only on motion.95 // Layout changes are intentionally re-hit-tested only on motion.
74 if id != c.pointer {96 if id != c.pointer {
75 c.pointer = id97 c.pointer = id
76- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {98+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
77 return err99 return err
78 }100 }
79 }101 }
M internal/core/layout.go +6 -3
@@ -492,7 +492,10 @@ func (w *Workspace) Focused() (WindowID, bool) {
492492 }
493493 return c.Windows[c.Focus], true
494494 }
495-func (w *Workspace) AddWindow(id WindowID) {
495+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }
496+
497+// addWindow is AddWindow with the width of the new column (zero: automatic).
498+func (w *Workspace) addWindow(id WindowID, width Width) {
496499 if id == 0 || w.has(id) {
497500 return
498501 }
@@ -504,10 +507,10 @@ func (w *Workspace) AddWindow(id WindowID) {
504507 // A covering float or stashed window may have no column under it.
505508 at = min(w.Focus+1, len(w.Columns))
506509 }
507- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}})
510+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})
508511 return
509512 }
510- w.addColumn(Column{Windows: []WindowID{id}})
513+ w.addColumn(Column{Windows: []WindowID{id}, Width: width})
511514 }
512515
513516 // addColumn places a new column like a new window and focuses it.
@@ -492,7 +492,10 @@ func (w *Workspace) Focused() (WindowID, bool) {
492 }492 }
493 return c.Windows[c.Focus], true493 return c.Windows[c.Focus], true
494 }494 }
495-func (w *Workspace) AddWindow(id WindowID) {495+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }
496+
497+// addWindow is AddWindow with the width of the new column (zero: automatic).
498+func (w *Workspace) addWindow(id WindowID, width Width) {
496 if id == 0 || w.has(id) {499 if id == 0 || w.has(id) {
497 return500 return
498 }501 }
@@ -504,10 +507,10 @@ func (w *Workspace) AddWindow(id WindowID) {
504 // A covering float or stashed window may have no column under it.507 // A covering float or stashed window may have no column under it.
505 at = min(w.Focus+1, len(w.Columns))508 at = min(w.Focus+1, len(w.Columns))
506 }509 }
507- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}})510+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})
508 return511 return
509 }512 }
510- w.addColumn(Column{Windows: []WindowID{id}})513+ w.addColumn(Column{Windows: []WindowID{id}, Width: width})
511 }514 }
512 515
513 // addColumn places a new column like a new window and focuses it.516 // addColumn places a new column like a new window and focuses it.
M internal/core/monitor.go +5 -2
@@ -302,12 +302,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
302302
303303 // AddWindow places a new window on the workspace on screen; with
304304 // stash.capture, in its stash when shown.
305-func (m *Monitor) AddWindow(id WindowID) {
305+func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) }
306+
307+// addWindow is AddWindow with the width of the new column (zero: automatic).
308+func (m *Monitor) addWindow(id WindowID, width Width) {
306309 if w, _ := m.find(id); w != nil {
307310 return
308311 }
309312 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
310- w.AddWindow(id)
313+ w.addWindow(id, width)
311314 }
312315 m.normalize()
313316 }
@@ -302,12 +302,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
302 302
303 // AddWindow places a new window on the workspace on screen; with303 // AddWindow places a new window on the workspace on screen; with
304 // stash.capture, in its stash when shown.304 // stash.capture, in its stash when shown.
305-func (m *Monitor) AddWindow(id WindowID) {305+func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) }
306+
307+// addWindow is AddWindow with the width of the new column (zero: automatic).
308+func (m *Monitor) addWindow(id WindowID, width Width) {
306 if w, _ := m.find(id); w != nil {309 if w, _ := m.find(id); w != nil {
307 return310 return
308 }311 }
309 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {312 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
310- w.AddWindow(id)313+ w.addWindow(id, width)
311 }314 }
312 m.normalize()315 m.normalize()
313 }316 }
A internal/core/rules.go +204 -0
@@ -0,0 +1,204 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "slices"
7+ "strconv"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// Window rules (rule.<name>.* keys): the app ID of a window that maps for
13+// the first time picks its place. Rules are evaluated once, at the first
14+// map, in spawnPlacement.place; a later app ID change, a re-map or a config
15+// reload moves nothing.
16+//
17+// A rule never moves the user (ADR 011): it does not take the focus and does
18+// not change which workspace or monitor is shown or focused. A window sent to
19+// another workspace joins it quietly, like a slot window. The one exception
20+// is the workspace on screen of the focused monitor, where the window
21+// behaves like any new window.
22+
23+// rule is one parsed window rule.
24+type rule struct {
25+ appID *regexp.Regexp
26+ floating *bool
27+ // number is the 1-based numbered workspace; workspace the name of a
28+ // named one. At most one is set.
29+ number int
30+ workspace string
31+ monitor string
32+ width Width // zero: automatic
33+}
34+
35+// ruleEffect is the merge of every rule matching a window: per field, the
36+// last rule in config order that sets it wins.
37+type ruleEffect struct {
38+ floating *bool
39+ number int
40+ workspace string
41+ monitor string
42+ width Width // zero: automatic
43+}
44+
45+func parseRules(rs []ports.WindowRule) ([]rule, error) {
46+ out := make([]rule, 0, len(rs))
47+ for _, r := range rs {
48+ if r.AppID == nil {
49+ return nil, fmt.Errorf("rule %s: no app-id", r.Name)
50+ }
51+ p := rule{appID: r.AppID, floating: r.Floating, monitor: r.Monitor}
52+ if r.Workspace != "" {
53+ if n, err := strconv.Atoi(r.Workspace); err == nil {
54+ if n < 1 {
55+ return nil, fmt.Errorf("rule %s: invalid workspace %q", r.Name, r.Workspace)
56+ }
57+ p.number = n
58+ } else {
59+ p.workspace = r.Workspace
60+ }
61+ }
62+ if r.Width != "" {
63+ width, err := ParseWidth(r.Width)
64+ if err != nil {
65+ return nil, fmt.Errorf("rule %s: %w", r.Name, err)
66+ }
67+ p.width = width
68+ }
69+ out = append(out, p)
70+ }
71+ return out, nil
72+}
73+
74+// ruleFor merges the rules matching v's app ID; ok is false when none does.
75+// A toplevel mapping again after an unmap (a tray app restored) opens as
76+// any other window: rules only place its first map.
77+func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
78+ if v.Remap {
79+ return eff, false
80+ }
81+ for i := range c.rules {
82+ r := &c.rules[i]
83+ if !r.appID.MatchString(v.AppID) {
84+ continue
85+ }
86+ ok = true
87+ if r.floating != nil {
88+ eff.floating = r.floating
89+ }
90+ if r.number > 0 || r.workspace != "" {
91+ eff.number, eff.workspace = r.number, r.workspace
92+ }
93+ if r.monitor != "" {
94+ eff.monitor = r.monitor
95+ }
96+ if r.width != (Width{}) {
97+ eff.width = r.width
98+ }
99+ }
100+ return eff, ok
101+}
102+
103+// ruleTarget is the screen and workspace a rule sends a window to. A named
104+// workspace lives where it lives, whatever the rule's monitor; an unknown one
105+// is ignored. A monitor that is not connected is ignored too. Without a
106+// workspace the monitor's workspace on screen is the target, and a number is
107+// clamped to the workspaces that exist (the last one is the spare empty one),
108+// so no empty workspace is created.
109+func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) {
110+ if eff.workspace != "" {
111+ if sc, w := c.byName(eff.workspace); w != nil {
112+ return sc, w
113+ }
114+ }
115+ sc := c.cur()
116+ if eff.monitor != "" {
117+ if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 {
118+ sc = c.screens[i]
119+ }
120+ }
121+ if eff.number > 0 {
122+ return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1]
123+ }
124+ return sc, sc.mon.Current()
125+}
126+
127+// placeByRule puts a window that maps for the first time where eff says.
128+func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
129+ float := v.Floating
130+ if eff.floating != nil {
131+ float = *eff.floating
132+ }
133+ sc, w := c.ruleTarget(eff)
134+ // The workspace the user looks at takes a new window as usual; any
135+ // other one gets it without a change of focus or view.
136+ quiet := sc != c.cur() || w != sc.mon.Current()
137+ switch {
138+ case float:
139+ // A window that already floats keeps the size it maps with; a tile
140+ // floated by a rule becomes a free float, core sizing it.
141+ w.addFloat(w.ruleFloat(v), quiet)
142+ case quiet:
143+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})
144+ default:
145+ sc.mon.addWindow(v.ID, eff.width)
146+ return
147+ }
148+ sc.mon.normalize()
149+}
150+
151+// ruleFloat is the float of a window a rule floats.
152+func (w *Workspace) ruleFloat(v ports.WindowMapped) Float {
153+ if v.Floating {
154+ return Float{ID: v.ID, W: v.Width, H: v.Height}
155+ }
156+ f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5}
157+ if w.Usable.W > 0 && w.Usable.H > 0 {
158+ // As makeFree: the size is core's, fixed now. Without an output
159+ // yet, floatRect's default (half the usable area) applies.
160+ r := w.floatRect(f)
161+ f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
162+ }
163+ return f
164+}
165+
166+// addFloat adds a new float; quiet puts it below the others without the
167+// float focus, so the focused window stays the same.
168+func (w *Workspace) addFloat(f Float, quiet bool) {
169+ if f.ID == 0 || w.has(f.ID) {
170+ return
171+ }
172+ if quiet {
173+ // A workspace with nothing else focuses it, so it has a focus when shown.
174+ w.floatFocus = w.floatFocus || w.empty()
175+ w.Floats = slices.Insert(w.Floats, 0, f)
176+ return
177+ }
178+ w.Floats = append(w.Floats, f)
179+ w.floatFocus = true
180+}
181+
182+// addColumnQuiet adds a column where a new window goes, keeping the focus
183+// where it is (ADR 011 golden rule).
184+func (w *Workspace) addColumnQuiet(col Column) {
185+ at := len(w.Columns)
186+ if w.cover() != 0 {
187+ // As AddWindow: under a covering fullscreen window the column
188+ // waits hidden, and a maximized layout stays as it is.
189+ if !w.policy().equalCells {
190+ at = min(w.Focus+1, at)
191+ }
192+ w.Columns = slices.Insert(w.Columns, at, col)
193+ return
194+ }
195+ if w.policy().equalCells {
196+ if at > 0 {
197+ w.unmaximize()
198+ }
199+ } else if at > 0 {
200+ at = w.Focus + 1
201+ }
202+ w.Columns = slices.Insert(w.Columns, at, col)
203+ w.scroll()
204+}
@@ -0,0 +1,204 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "slices"
7+ "strconv"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// Window rules (rule.<name>.* keys): the app ID of a window that maps for
13+// the first time picks its place. Rules are evaluated once, at the first
14+// map, in spawnPlacement.place; a later app ID change, a re-map or a config
15+// reload moves nothing.
16+//
17+// A rule never moves the user (ADR 011): it does not take the focus and does
18+// not change which workspace or monitor is shown or focused. A window sent to
19+// another workspace joins it quietly, like a slot window. The one exception
20+// is the workspace on screen of the focused monitor, where the window
21+// behaves like any new window.
22+
23+// rule is one parsed window rule.
24+type rule struct {
25+ appID *regexp.Regexp
26+ floating *bool
27+ // number is the 1-based numbered workspace; workspace the name of a
28+ // named one. At most one is set.
29+ number int
30+ workspace string
31+ monitor string
32+ width Width // zero: automatic
33+}
34+
35+// ruleEffect is the merge of every rule matching a window: per field, the
36+// last rule in config order that sets it wins.
37+type ruleEffect struct {
38+ floating *bool
39+ number int
40+ workspace string
41+ monitor string
42+ width Width // zero: automatic
43+}
44+
45+func parseRules(rs []ports.WindowRule) ([]rule, error) {
46+ out := make([]rule, 0, len(rs))
47+ for _, r := range rs {
48+ if r.AppID == nil {
49+ return nil, fmt.Errorf("rule %s: no app-id", r.Name)
50+ }
51+ p := rule{appID: r.AppID, floating: r.Floating, monitor: r.Monitor}
52+ if r.Workspace != "" {
53+ if n, err := strconv.Atoi(r.Workspace); err == nil {
54+ if n < 1 {
55+ return nil, fmt.Errorf("rule %s: invalid workspace %q", r.Name, r.Workspace)
56+ }
57+ p.number = n
58+ } else {
59+ p.workspace = r.Workspace
60+ }
61+ }
62+ if r.Width != "" {
63+ width, err := ParseWidth(r.Width)
64+ if err != nil {
65+ return nil, fmt.Errorf("rule %s: %w", r.Name, err)
66+ }
67+ p.width = width
68+ }
69+ out = append(out, p)
70+ }
71+ return out, nil
72+}
73+
74+// ruleFor merges the rules matching v's app ID; ok is false when none does.
75+// A toplevel mapping again after an unmap (a tray app restored) opens as
76+// any other window: rules only place its first map.
77+func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
78+ if v.Remap {
79+ return eff, false
80+ }
81+ for i := range c.rules {
82+ r := &c.rules[i]
83+ if !r.appID.MatchString(v.AppID) {
84+ continue
85+ }
86+ ok = true
87+ if r.floating != nil {
88+ eff.floating = r.floating
89+ }
90+ if r.number > 0 || r.workspace != "" {
91+ eff.number, eff.workspace = r.number, r.workspace
92+ }
93+ if r.monitor != "" {
94+ eff.monitor = r.monitor
95+ }
96+ if r.width != (Width{}) {
97+ eff.width = r.width
98+ }
99+ }
100+ return eff, ok
101+}
102+
103+// ruleTarget is the screen and workspace a rule sends a window to. A named
104+// workspace lives where it lives, whatever the rule's monitor; an unknown one
105+// is ignored. A monitor that is not connected is ignored too. Without a
106+// workspace the monitor's workspace on screen is the target, and a number is
107+// clamped to the workspaces that exist (the last one is the spare empty one),
108+// so no empty workspace is created.
109+func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) {
110+ if eff.workspace != "" {
111+ if sc, w := c.byName(eff.workspace); w != nil {
112+ return sc, w
113+ }
114+ }
115+ sc := c.cur()
116+ if eff.monitor != "" {
117+ if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 {
118+ sc = c.screens[i]
119+ }
120+ }
121+ if eff.number > 0 {
122+ return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1]
123+ }
124+ return sc, sc.mon.Current()
125+}
126+
127+// placeByRule puts a window that maps for the first time where eff says.
128+func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
129+ float := v.Floating
130+ if eff.floating != nil {
131+ float = *eff.floating
132+ }
133+ sc, w := c.ruleTarget(eff)
134+ // The workspace the user looks at takes a new window as usual; any
135+ // other one gets it without a change of focus or view.
136+ quiet := sc != c.cur() || w != sc.mon.Current()
137+ switch {
138+ case float:
139+ // A window that already floats keeps the size it maps with; a tile
140+ // floated by a rule becomes a free float, core sizing it.
141+ w.addFloat(w.ruleFloat(v), quiet)
142+ case quiet:
143+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})
144+ default:
145+ sc.mon.addWindow(v.ID, eff.width)
146+ return
147+ }
148+ sc.mon.normalize()
149+}
150+
151+// ruleFloat is the float of a window a rule floats.
152+func (w *Workspace) ruleFloat(v ports.WindowMapped) Float {
153+ if v.Floating {
154+ return Float{ID: v.ID, W: v.Width, H: v.Height}
155+ }
156+ f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5}
157+ if w.Usable.W > 0 && w.Usable.H > 0 {
158+ // As makeFree: the size is core's, fixed now. Without an output
159+ // yet, floatRect's default (half the usable area) applies.
160+ r := w.floatRect(f)
161+ f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
162+ }
163+ return f
164+}
165+
166+// addFloat adds a new float; quiet puts it below the others without the
167+// float focus, so the focused window stays the same.
168+func (w *Workspace) addFloat(f Float, quiet bool) {
169+ if f.ID == 0 || w.has(f.ID) {
170+ return
171+ }
172+ if quiet {
173+ // A workspace with nothing else focuses it, so it has a focus when shown.
174+ w.floatFocus = w.floatFocus || w.empty()
175+ w.Floats = slices.Insert(w.Floats, 0, f)
176+ return
177+ }
178+ w.Floats = append(w.Floats, f)
179+ w.floatFocus = true
180+}
181+
182+// addColumnQuiet adds a column where a new window goes, keeping the focus
183+// where it is (ADR 011 golden rule).
184+func (w *Workspace) addColumnQuiet(col Column) {
185+ at := len(w.Columns)
186+ if w.cover() != 0 {
187+ // As AddWindow: under a covering fullscreen window the column
188+ // waits hidden, and a maximized layout stays as it is.
189+ if !w.policy().equalCells {
190+ at = min(w.Focus+1, at)
191+ }
192+ w.Columns = slices.Insert(w.Columns, at, col)
193+ return
194+ }
195+ if w.policy().equalCells {
196+ if at > 0 {
197+ w.unmaximize()
198+ }
199+ } else if at > 0 {
200+ at = w.Focus + 1
201+ }
202+ w.Columns = slices.Insert(w.Columns, at, col)
203+ w.scroll()
204+}
A internal/core/rules_internal_test.go +29 -0
@@ -0,0 +1,29 @@
1+package core
2+
3+import "testing"
4+
5+// Under a covering fullscreen window, a rule's quiet column waits hidden:
6+// focus, fullscreen and a fixed-overflow maximized column stay as they are,
7+// as for any new window (AddWindow's cover branch).
8+func TestAddColumnQuietUnderCover(t *testing.T) {
9+ w := workspace()
10+ w.Overflow = OverflowFixed
11+ w.AddWindow(1)
12+ w.AddWindow(2)
13+ w.FocusID(1)
14+ w.maximize(w.Focus)
15+ w.ToggleFullscreen()
16+ if w.cover() != 1 {
17+ t.Fatalf("setup: cover %d", w.cover())
18+ }
19+ w.addColumnQuiet(Column{Windows: []WindowID{3}})
20+ if !w.has(3) {
21+ t.Fatal("column not added")
22+ }
23+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
24+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
25+ }
26+ if i := w.columnOf(1); i < 0 || !w.Columns[i].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 1 {
27+ t.Fatalf("maximized layout lost: %+v %v", w.Columns, w.maximized)
28+ }
29+}
@@ -0,0 +1,29 @@
1+package core
2+
3+import "testing"
4+
5+// Under a covering fullscreen window, a rule's quiet column waits hidden:
6+// focus, fullscreen and a fixed-overflow maximized column stay as they are,
7+// as for any new window (AddWindow's cover branch).
8+func TestAddColumnQuietUnderCover(t *testing.T) {
9+ w := workspace()
10+ w.Overflow = OverflowFixed
11+ w.AddWindow(1)
12+ w.AddWindow(2)
13+ w.FocusID(1)
14+ w.maximize(w.Focus)
15+ w.ToggleFullscreen()
16+ if w.cover() != 1 {
17+ t.Fatalf("setup: cover %d", w.cover())
18+ }
19+ w.addColumnQuiet(Column{Windows: []WindowID{3}})
20+ if !w.has(3) {
21+ t.Fatal("column not added")
22+ }
23+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
24+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
25+ }
26+ if i := w.columnOf(1); i < 0 || !w.Columns[i].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 1 {
27+ t.Fatalf("maximized layout lost: %+v %v", w.Columns, w.maximized)
28+ }
29+}
A internal/core/rules_test.go +411 -0
@@ -0,0 +1,411 @@
1+package core_test
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func winRule(name, appID string, edit func(*ports.WindowRule)) ports.WindowRule {
11+ r := ports.WindowRule{Name: name, AppID: regexp.MustCompile("^(?:" + appID + ")$")}
12+ if edit != nil {
13+ edit(&r)
14+ }
15+ return r
16+}
17+
18+func ruleOn() *bool { v := true; return &v }
19+func ruleOff() *bool { v := false; return &v }
20+
21+func declareDev(c *ports.Config) {
22+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev"}}
23+ c.Binds["Alt+d"] = "workspace dev"
24+}
25+
26+// mapApp maps a window with an app ID and returns the state once it is in.
27+func (r *multiRig) mapApp(t *testing.T, id ports.WindowID, appID string) ports.State {
28+ t.Helper()
29+ r.client <- ports.WindowMapped{ID: id, AppID: appID}
30+ return r.stateWith(t, id)
31+}
32+
33+func (r *multiRig) stateWith(t *testing.T, id ports.WindowID) ports.State {
34+ t.Helper()
35+ return stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, id) != nil })
36+}
37+
38+func stWindow(st ports.State, id ports.WindowID) *ports.WindowState {
39+ for i := range st.Windows {
40+ if st.Windows[i].ID == id {
41+ return &st.Windows[i]
42+ }
43+ }
44+ return nil
45+}
46+
47+func stFocus(st ports.State) ports.WindowID {
48+ if st.Window == nil {
49+ return 0
50+ }
51+ return st.Window.ID
52+}
53+
54+func stOutput(st ports.State, name string) ports.OutputState {
55+ for _, o := range st.Outputs {
56+ if o.Name == name {
57+ return o
58+ }
59+ }
60+ return ports.OutputState{}
61+}
62+
63+func TestRuleFloatingOnAndOff(t *testing.T) {
64+ r := startMulti(t, func(c *ports.Config) {
65+ c.Rules = []ports.WindowRule{
66+ winRule("float", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
67+ winRule("tile", "splash", func(r *ports.WindowRule) { r.Floating = ruleOff() }),
68+ }
69+ }, left)
70+ r.mapApp(t, 1, "term")
71+ st := r.mapApp(t, 2, "calc")
72+ if w := stWindow(st, 2); !w.Floating || stFocus(st) != 2 {
73+ t.Fatalf("calc not floating on the current workspace: %+v focus %d", w, stFocus(st))
74+ }
75+ if w := stWindow(st, 1); w.Floating {
76+ t.Fatal("unrelated window floats")
77+ }
78+ // A fixed-size window (Floating from the client) tiles under floating = off.
79+ r.client <- ports.WindowMapped{ID: 3, AppID: "splash", Floating: true, Width: 50, Height: 40}
80+ st = r.stateWith(t, 3)
81+ if w := stWindow(st, 3); w.Floating || w.Column == 0 {
82+ t.Fatalf("splash not tiled: %+v", w)
83+ }
84+ // Without a rule the same window floats as before.
85+ r.client <- ports.WindowMapped{ID: 4, AppID: "other", Floating: true, Width: 50, Height: 40}
86+ if w := stWindow(r.stateWith(t, 4), 4); !w.Floating {
87+ t.Fatalf("unmatched fixed-size window tiled: %+v", w)
88+ }
89+}
90+
91+func TestRuleNamedWorkspaceKeepsFocusAndView(t *testing.T) {
92+ r := startMulti(t, func(c *ports.Config) {
93+ declareDev(c)
94+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
95+ }, left)
96+ r.mapApp(t, 1, "term")
97+ st := r.mapApp(t, 2, "code")
98+ w := stWindow(st, 2)
99+ if w.WorkspaceName != "dev" || w.Workspace != 0 || w.Visible {
100+ t.Fatalf("window not quietly on dev: %+v", w)
101+ }
102+ if stFocus(st) != 1 || stOutput(st, "DP-1").Workspace != "" || stOutput(st, "DP-1").Active != 1 {
103+ t.Fatalf("rule moved focus or view: %+v", st)
104+ }
105+ // The workspace shows its window when the user goes there.
106+ r.key(t, "d", ports.ModAlt)
107+ st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
108+ if stFocus(st) != 2 {
109+ t.Fatalf("dev window not focused on arrival: %d", stFocus(st))
110+ }
111+}
112+
113+func TestRuleNamedWorkspaceFloat(t *testing.T) {
114+ r := startMulti(t, func(c *ports.Config) {
115+ declareDev(c)
116+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOn() })}
117+ }, left)
118+ r.mapApp(t, 1, "term")
119+ st := r.mapApp(t, 2, "code")
120+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating || stFocus(st) != 1 {
121+ t.Fatalf("%+v focus %d", w, stFocus(st))
122+ }
123+ r.key(t, "d", ports.ModAlt)
124+ st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
125+ if stFocus(st) != 2 {
126+ t.Fatalf("quiet float not focused on arrival: %d", stFocus(st))
127+ }
128+}
129+
130+func TestRuleUndeclaredWorkspaceFallsBack(t *testing.T) {
131+ r := startMulti(t, func(c *ports.Config) {
132+ c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "nope" })}
133+ }, left)
134+ r.mapApp(t, 1, "term")
135+ st := r.mapApp(t, 2, "code")
136+ if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 {
137+ t.Fatalf("%+v", w)
138+ }
139+}
140+
141+func TestRuleNumberedWorkspace(t *testing.T) {
142+ r := startMulti(t, func(c *ports.Config) {
143+ c.Rules = []ports.WindowRule{
144+ winRule("two", "two", func(r *ports.WindowRule) { r.Workspace = "2" }),
145+ winRule("far", "far", func(r *ports.WindowRule) { r.Workspace = "9" }),
146+ }
147+ }, left)
148+ r.mapApp(t, 1, "term")
149+ st := r.mapApp(t, 2, "two")
150+ if w := stWindow(st, 2); w.Workspace != 2 || stFocus(st) != 1 || stOutput(st, "DP-1").Active != 1 {
151+ t.Fatalf("%+v active %d focus %d", w, stOutput(st, "DP-1").Active, stFocus(st))
152+ }
153+ // 9 is clamped to the last workspace, the spare after workspace 2: no
154+ // pile of empty workspaces in between.
155+ st = r.mapApp(t, 3, "far")
156+ if w := stWindow(st, 3); w.Workspace != 3 {
157+ t.Fatalf("clamped to %d", w.Workspace)
158+ }
159+ if o := stOutput(st, "DP-1"); o.Count != 3 || o.Active != 1 || stFocus(st) != 1 {
160+ t.Fatalf("%+v focus %d", o, stFocus(st))
161+ }
162+}
163+
164+func TestRuleNumberOnCurrentWorkspaceActsAsNormalMap(t *testing.T) {
165+ r := startMulti(t, func(c *ports.Config) {
166+ c.Rules = []ports.WindowRule{winRule("one", "term", func(r *ports.WindowRule) { r.Workspace = "1" })}
167+ }, left)
168+ r.mapApp(t, 1, "other")
169+ st := r.mapApp(t, 2, "term")
170+ if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 {
171+ t.Fatalf("%+v focus %d", w, stFocus(st))
172+ }
173+}
174+
175+func TestRuleMonitor(t *testing.T) {
176+ for _, mon := range []string{"DP-2", "Acme B 2"} {
177+ t.Run(mon, func(t *testing.T) {
178+ r := startMulti(t, func(c *ports.Config) {
179+ c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = mon })}
180+ }, left, right)
181+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
182+ before := r.mapApp(t, 1, "term")
183+ if before.Output != "DP-1" {
184+ t.Fatalf("focused %s", before.Output)
185+ }
186+ st := r.mapApp(t, 2, "chat")
187+ w := stWindow(st, 2)
188+ if w.Output != "DP-2" || st.Output != "DP-1" || stFocus(st) != 1 {
189+ t.Fatalf("%+v focused output %s window %d", w, st.Output, stFocus(st))
190+ }
191+ if !w.Visible {
192+ t.Fatal("the window is not shown on the current workspace of DP-2")
193+ }
194+ })
195+ }
196+}
197+
198+func TestRuleMonitorWithNumberedWorkspace(t *testing.T) {
199+ r := startMulti(t, func(c *ports.Config) {
200+ c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Workspace = "2" })}
201+ }, left, right)
202+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
203+ r.mapApp(t, 3, "term") // DP-2 gets a window, so a second workspace
204+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
205+ r.mapApp(t, 1, "term")
206+ st := r.mapApp(t, 2, "chat")
207+ if w := stWindow(st, 2); w.Output != "DP-2" || w.Workspace != 2 || st.Output != "DP-1" || stFocus(st) != 1 || stOutput(st, "DP-2").Active != 1 {
208+ t.Fatalf("%+v", w)
209+ }
210+}
211+
212+func TestRuleDisconnectedMonitorFallsBack(t *testing.T) {
213+ r := startMulti(t, func(c *ports.Config) {
214+ c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "HDMI-9" })}
215+ }, left, right)
216+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
217+ r.mapApp(t, 1, "term")
218+ st := r.mapApp(t, 2, "chat")
219+ if w := stWindow(st, 2); w.Output != "DP-1" || stFocus(st) != 2 {
220+ t.Fatalf("%+v focus %d", w, stFocus(st))
221+ }
222+}
223+
224+func TestRulesMerge(t *testing.T) {
225+ r := startMulti(t, func(c *ports.Config) {
226+ c.Layout.MaxColumns = 4
227+ c.Rules = []ports.WindowRule{
228+ winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }),
229+ winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }),
230+ }
231+ }, left, right)
232+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
233+ r.client <- ports.WindowMapped{ID: 1, AppID: "chat"}
234+ r.client <- ports.WindowMapped{ID: 2, AppID: "term"}
235+ set := sceneMatch2(t, r, func(s ports.Scene) bool { return s.Output == "DP-2" && len(s.Windows) == 2 })
236+ got := map[ports.WindowID]int{}
237+ for _, w := range set.Windows {
238+ got[w.ID] = w.Rect.W
239+ }
240+ // DP-2 is 400 wide: the later rule wins the width, the generic monitor still applies.
241+ if got[1] != 200 || got[2] != 100 {
242+ t.Fatalf("widths %v", got)
243+ }
244+}
245+
246+// sceneMatch2 waits for a scene of the multi rig that ok accepts.
247+func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Scene {
248+ t.Helper()
249+ for range 50 {
250+ for _, s := range receive(t, r.scenes) {
251+ if ok(s) {
252+ return s
253+ }
254+ }
255+ }
256+ t.Fatal("no matching scene")
257+ return ports.Scene{}
258+}
259+
260+func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
261+ r := startMulti(t, func(c *ports.Config) {
262+ c.Layout.MaxColumns = 4
263+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })}
264+ }, left)
265+ r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}
266+ s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })
267+ if s.Windows[0].Rect.W != 100 {
268+ t.Fatalf("width %d", s.Windows[0].Rect.W)
269+ }
270+}
271+
272+func TestSlotWinsOverRule(t *testing.T) {
273+ r := startMulti(t, func(c *ports.Config) {
274+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}}
275+ c.Binds["Alt+d"] = "workspace dev"
276+ c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}
277+ }, left)
278+ req := receive(t, r.spawn)
279+ var slot string
280+ for _, e := range req.Env {
281+ if len(e) > len(ports.SlotEnv)+1 && e[:len(ports.SlotEnv)+1] == ports.SlotEnv+"=" {
282+ slot = e[len(ports.SlotEnv)+1:]
283+ }
284+ }
285+ r.client <- ports.WindowMapped{ID: 1, AppID: "code", Slot: slot}
286+ st := r.stateWith(t, 1)
287+ if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
288+ t.Fatalf("rule overrode the slot: %+v", w)
289+ }
290+}
291+
292+func TestDialogStaysOverParentDespiteRules(t *testing.T) {
293+ r := startMulti(t, func(c *ports.Config) {
294+ declareDev(c)
295+ c.Rules = []ports.WindowRule{winRule("x", ".*", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOff() })}
296+ }, left)
297+ r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
298+ st := r.stateWith(t, 1)
299+ if w := stWindow(st, 1); w.WorkspaceName != "dev" {
300+ t.Fatalf("parent: %+v", w)
301+ }
302+ r.key(t, "d", ports.ModAlt)
303+ stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
304+ r.client <- ports.WindowMapped{ID: 2, AppID: "term", Floating: true, Width: 30, Height: 30, Parent: 1}
305+ st = r.stateWith(t, 2)
306+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating {
307+ t.Fatalf("dialog moved or tiled: %+v", w)
308+ }
309+ // Same app ID without a parent: the rule applies (tiled on dev).
310+ r.client <- ports.WindowMapped{ID: 3, AppID: "term", Floating: true, Width: 30, Height: 30}
311+ if w := stWindow(r.stateWith(t, 3), 3); w.Floating {
312+ t.Fatalf("parentless fixed-size window ignores floating = off: %+v", w)
313+ }
314+}
315+
316+func TestAppIDChangeAfterMapMovesNothing(t *testing.T) {
317+ r := startMulti(t, func(c *ports.Config) {
318+ declareDev(c)
319+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
320+ }, left)
321+ r.mapApp(t, 1, "")
322+ r.client <- ports.WindowAppID{ID: 1, AppID: "code"}
323+ st := r.mapApp(t, 2, "term") // after the change, to know it was handled
324+ if w := stWindow(st, 1); w.Workspace != 1 || w.AppID != "code" {
325+ t.Fatalf("window re-placed: %+v", w)
326+ }
327+}
328+
329+func TestRemapKeepsPlace(t *testing.T) {
330+ r := startMulti(t, func(c *ports.Config) {
331+ declareDev(c)
332+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
333+ }, left)
334+ r.mapApp(t, 1, "term")
335+ r.client <- ports.WindowMapped{ID: 1, AppID: "code"}
336+ if w := stWindow(r.mapApp(t, 2, "term"), 1); w.Workspace != 1 {
337+ t.Fatalf("re-map moved the window: %+v", w)
338+ }
339+}
340+
341+// A toplevel that maps again after an unmap (a tray app restored) opens as
342+// any other window: on the workspace the user looks at, not its rule's.
343+func TestRuleSkipsRemapAfterUnmap(t *testing.T) {
344+ r := startMulti(t, func(c *ports.Config) {
345+ declareDev(c)
346+ c.Rules = []ports.WindowRule{winRule("dev", "discord", func(r *ports.WindowRule) { r.Workspace = "dev" })}
347+ }, left)
348+ if w := stWindow(r.mapApp(t, 1, "discord"), 1); w.WorkspaceName != "dev" {
349+ t.Fatalf("first map not placed by the rule: %+v", w)
350+ }
351+ r.client <- ports.WindowUnmapped{ID: 1}
352+ stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, 1) == nil })
353+ r.client <- ports.WindowMapped{ID: 1, AppID: "discord", Remap: true}
354+ st := r.stateWith(t, 1)
355+ if w := stWindow(st, 1); w.WorkspaceName != "" || w.Workspace != 1 || stFocus(st) != 1 {
356+ t.Fatalf("remap placed by the rule: %+v focus %d", w, stFocus(st))
357+ }
358+}
359+
360+func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) {
361+ r := startMulti(t, func(c *ports.Config) {
362+ declareDev(c)
363+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
364+ }, left)
365+ r.mapApp(t, 1, "code")
366+ cfg := r.cfg
367+ cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })}
368+ r.reload <- ports.ConfigChanged{Config: cfg}
369+ // Handled in order with the map below.
370+ st := r.mapApp(t, 2, "code")
371+ if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
372+ t.Fatalf("reload moved a mapped window: %+v", w)
373+ }
374+ if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating {
375+ t.Fatalf("new rules not applied: %+v", w)
376+ }
377+}
378+
379+func TestRuleQuietPlacementWithAnimations(t *testing.T) {
380+ r := startMulti(t, func(c *ports.Config) {
381+ declareDev(c)
382+ c.Animations.On = true
383+ c.Rules = []ports.WindowRule{
384+ winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }),
385+ winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
386+ }
387+ }, left)
388+ r.mapApp(t, 1, "term")
389+ st := r.mapApp(t, 2, "code")
390+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 {
391+ t.Fatalf("%+v focus %d", w, stFocus(st))
392+ }
393+ st = r.mapApp(t, 3, "calc")
394+ if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 {
395+ t.Fatalf("%+v focus %d", w, stFocus(st))
396+ }
397+}
398+
399+func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {
400+ r := startMulti(t, func(c *ports.Config) {
401+ c.Layout.MaxColumns = 2
402+ c.Layout.Overflow = "fixed"
403+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}
404+ }, left)
405+ r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406+ r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407+ s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408+ if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {
409+ t.Fatalf("%+v", s.Windows)
410+ }
411+}
@@ -0,0 +1,411 @@
1+package core_test
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func winRule(name, appID string, edit func(*ports.WindowRule)) ports.WindowRule {
11+ r := ports.WindowRule{Name: name, AppID: regexp.MustCompile("^(?:" + appID + ")$")}
12+ if edit != nil {
13+ edit(&r)
14+ }
15+ return r
16+}
17+
18+func ruleOn() *bool { v := true; return &v }
19+func ruleOff() *bool { v := false; return &v }
20+
21+func declareDev(c *ports.Config) {
22+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev"}}
23+ c.Binds["Alt+d"] = "workspace dev"
24+}
25+
26+// mapApp maps a window with an app ID and returns the state once it is in.
27+func (r *multiRig) mapApp(t *testing.T, id ports.WindowID, appID string) ports.State {
28+ t.Helper()
29+ r.client <- ports.WindowMapped{ID: id, AppID: appID}
30+ return r.stateWith(t, id)
31+}
32+
33+func (r *multiRig) stateWith(t *testing.T, id ports.WindowID) ports.State {
34+ t.Helper()
35+ return stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, id) != nil })
36+}
37+
38+func stWindow(st ports.State, id ports.WindowID) *ports.WindowState {
39+ for i := range st.Windows {
40+ if st.Windows[i].ID == id {
41+ return &st.Windows[i]
42+ }
43+ }
44+ return nil
45+}
46+
47+func stFocus(st ports.State) ports.WindowID {
48+ if st.Window == nil {
49+ return 0
50+ }
51+ return st.Window.ID
52+}
53+
54+func stOutput(st ports.State, name string) ports.OutputState {
55+ for _, o := range st.Outputs {
56+ if o.Name == name {
57+ return o
58+ }
59+ }
60+ return ports.OutputState{}
61+}
62+
63+func TestRuleFloatingOnAndOff(t *testing.T) {
64+ r := startMulti(t, func(c *ports.Config) {
65+ c.Rules = []ports.WindowRule{
66+ winRule("float", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
67+ winRule("tile", "splash", func(r *ports.WindowRule) { r.Floating = ruleOff() }),
68+ }
69+ }, left)
70+ r.mapApp(t, 1, "term")
71+ st := r.mapApp(t, 2, "calc")
72+ if w := stWindow(st, 2); !w.Floating || stFocus(st) != 2 {
73+ t.Fatalf("calc not floating on the current workspace: %+v focus %d", w, stFocus(st))
74+ }
75+ if w := stWindow(st, 1); w.Floating {
76+ t.Fatal("unrelated window floats")
77+ }
78+ // A fixed-size window (Floating from the client) tiles under floating = off.
79+ r.client <- ports.WindowMapped{ID: 3, AppID: "splash", Floating: true, Width: 50, Height: 40}
80+ st = r.stateWith(t, 3)
81+ if w := stWindow(st, 3); w.Floating || w.Column == 0 {
82+ t.Fatalf("splash not tiled: %+v", w)
83+ }
84+ // Without a rule the same window floats as before.
85+ r.client <- ports.WindowMapped{ID: 4, AppID: "other", Floating: true, Width: 50, Height: 40}
86+ if w := stWindow(r.stateWith(t, 4), 4); !w.Floating {
87+ t.Fatalf("unmatched fixed-size window tiled: %+v", w)
88+ }
89+}
90+
91+func TestRuleNamedWorkspaceKeepsFocusAndView(t *testing.T) {
92+ r := startMulti(t, func(c *ports.Config) {
93+ declareDev(c)
94+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
95+ }, left)
96+ r.mapApp(t, 1, "term")
97+ st := r.mapApp(t, 2, "code")
98+ w := stWindow(st, 2)
99+ if w.WorkspaceName != "dev" || w.Workspace != 0 || w.Visible {
100+ t.Fatalf("window not quietly on dev: %+v", w)
101+ }
102+ if stFocus(st) != 1 || stOutput(st, "DP-1").Workspace != "" || stOutput(st, "DP-1").Active != 1 {
103+ t.Fatalf("rule moved focus or view: %+v", st)
104+ }
105+ // The workspace shows its window when the user goes there.
106+ r.key(t, "d", ports.ModAlt)
107+ st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
108+ if stFocus(st) != 2 {
109+ t.Fatalf("dev window not focused on arrival: %d", stFocus(st))
110+ }
111+}
112+
113+func TestRuleNamedWorkspaceFloat(t *testing.T) {
114+ r := startMulti(t, func(c *ports.Config) {
115+ declareDev(c)
116+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOn() })}
117+ }, left)
118+ r.mapApp(t, 1, "term")
119+ st := r.mapApp(t, 2, "code")
120+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating || stFocus(st) != 1 {
121+ t.Fatalf("%+v focus %d", w, stFocus(st))
122+ }
123+ r.key(t, "d", ports.ModAlt)
124+ st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
125+ if stFocus(st) != 2 {
126+ t.Fatalf("quiet float not focused on arrival: %d", stFocus(st))
127+ }
128+}
129+
130+func TestRuleUndeclaredWorkspaceFallsBack(t *testing.T) {
131+ r := startMulti(t, func(c *ports.Config) {
132+ c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "nope" })}
133+ }, left)
134+ r.mapApp(t, 1, "term")
135+ st := r.mapApp(t, 2, "code")
136+ if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 {
137+ t.Fatalf("%+v", w)
138+ }
139+}
140+
141+func TestRuleNumberedWorkspace(t *testing.T) {
142+ r := startMulti(t, func(c *ports.Config) {
143+ c.Rules = []ports.WindowRule{
144+ winRule("two", "two", func(r *ports.WindowRule) { r.Workspace = "2" }),
145+ winRule("far", "far", func(r *ports.WindowRule) { r.Workspace = "9" }),
146+ }
147+ }, left)
148+ r.mapApp(t, 1, "term")
149+ st := r.mapApp(t, 2, "two")
150+ if w := stWindow(st, 2); w.Workspace != 2 || stFocus(st) != 1 || stOutput(st, "DP-1").Active != 1 {
151+ t.Fatalf("%+v active %d focus %d", w, stOutput(st, "DP-1").Active, stFocus(st))
152+ }
153+ // 9 is clamped to the last workspace, the spare after workspace 2: no
154+ // pile of empty workspaces in between.
155+ st = r.mapApp(t, 3, "far")
156+ if w := stWindow(st, 3); w.Workspace != 3 {
157+ t.Fatalf("clamped to %d", w.Workspace)
158+ }
159+ if o := stOutput(st, "DP-1"); o.Count != 3 || o.Active != 1 || stFocus(st) != 1 {
160+ t.Fatalf("%+v focus %d", o, stFocus(st))
161+ }
162+}
163+
164+func TestRuleNumberOnCurrentWorkspaceActsAsNormalMap(t *testing.T) {
165+ r := startMulti(t, func(c *ports.Config) {
166+ c.Rules = []ports.WindowRule{winRule("one", "term", func(r *ports.WindowRule) { r.Workspace = "1" })}
167+ }, left)
168+ r.mapApp(t, 1, "other")
169+ st := r.mapApp(t, 2, "term")
170+ if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 {
171+ t.Fatalf("%+v focus %d", w, stFocus(st))
172+ }
173+}
174+
175+func TestRuleMonitor(t *testing.T) {
176+ for _, mon := range []string{"DP-2", "Acme B 2"} {
177+ t.Run(mon, func(t *testing.T) {
178+ r := startMulti(t, func(c *ports.Config) {
179+ c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = mon })}
180+ }, left, right)
181+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
182+ before := r.mapApp(t, 1, "term")
183+ if before.Output != "DP-1" {
184+ t.Fatalf("focused %s", before.Output)
185+ }
186+ st := r.mapApp(t, 2, "chat")
187+ w := stWindow(st, 2)
188+ if w.Output != "DP-2" || st.Output != "DP-1" || stFocus(st) != 1 {
189+ t.Fatalf("%+v focused output %s window %d", w, st.Output, stFocus(st))
190+ }
191+ if !w.Visible {
192+ t.Fatal("the window is not shown on the current workspace of DP-2")
193+ }
194+ })
195+ }
196+}
197+
198+func TestRuleMonitorWithNumberedWorkspace(t *testing.T) {
199+ r := startMulti(t, func(c *ports.Config) {
200+ c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Workspace = "2" })}
201+ }, left, right)
202+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
203+ r.mapApp(t, 3, "term") // DP-2 gets a window, so a second workspace
204+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
205+ r.mapApp(t, 1, "term")
206+ st := r.mapApp(t, 2, "chat")
207+ if w := stWindow(st, 2); w.Output != "DP-2" || w.Workspace != 2 || st.Output != "DP-1" || stFocus(st) != 1 || stOutput(st, "DP-2").Active != 1 {
208+ t.Fatalf("%+v", w)
209+ }
210+}
211+
212+func TestRuleDisconnectedMonitorFallsBack(t *testing.T) {
213+ r := startMulti(t, func(c *ports.Config) {
214+ c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "HDMI-9" })}
215+ }, left, right)
216+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
217+ r.mapApp(t, 1, "term")
218+ st := r.mapApp(t, 2, "chat")
219+ if w := stWindow(st, 2); w.Output != "DP-1" || stFocus(st) != 2 {
220+ t.Fatalf("%+v focus %d", w, stFocus(st))
221+ }
222+}
223+
224+func TestRulesMerge(t *testing.T) {
225+ r := startMulti(t, func(c *ports.Config) {
226+ c.Layout.MaxColumns = 4
227+ c.Rules = []ports.WindowRule{
228+ winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }),
229+ winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }),
230+ }
231+ }, left, right)
232+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
233+ r.client <- ports.WindowMapped{ID: 1, AppID: "chat"}
234+ r.client <- ports.WindowMapped{ID: 2, AppID: "term"}
235+ set := sceneMatch2(t, r, func(s ports.Scene) bool { return s.Output == "DP-2" && len(s.Windows) == 2 })
236+ got := map[ports.WindowID]int{}
237+ for _, w := range set.Windows {
238+ got[w.ID] = w.Rect.W
239+ }
240+ // DP-2 is 400 wide: the later rule wins the width, the generic monitor still applies.
241+ if got[1] != 200 || got[2] != 100 {
242+ t.Fatalf("widths %v", got)
243+ }
244+}
245+
246+// sceneMatch2 waits for a scene of the multi rig that ok accepts.
247+func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Scene {
248+ t.Helper()
249+ for range 50 {
250+ for _, s := range receive(t, r.scenes) {
251+ if ok(s) {
252+ return s
253+ }
254+ }
255+ }
256+ t.Fatal("no matching scene")
257+ return ports.Scene{}
258+}
259+
260+func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
261+ r := startMulti(t, func(c *ports.Config) {
262+ c.Layout.MaxColumns = 4
263+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })}
264+ }, left)
265+ r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}
266+ s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })
267+ if s.Windows[0].Rect.W != 100 {
268+ t.Fatalf("width %d", s.Windows[0].Rect.W)
269+ }
270+}
271+
272+func TestSlotWinsOverRule(t *testing.T) {
273+ r := startMulti(t, func(c *ports.Config) {
274+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}}
275+ c.Binds["Alt+d"] = "workspace dev"
276+ c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}
277+ }, left)
278+ req := receive(t, r.spawn)
279+ var slot string
280+ for _, e := range req.Env {
281+ if len(e) > len(ports.SlotEnv)+1 && e[:len(ports.SlotEnv)+1] == ports.SlotEnv+"=" {
282+ slot = e[len(ports.SlotEnv)+1:]
283+ }
284+ }
285+ r.client <- ports.WindowMapped{ID: 1, AppID: "code", Slot: slot}
286+ st := r.stateWith(t, 1)
287+ if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
288+ t.Fatalf("rule overrode the slot: %+v", w)
289+ }
290+}
291+
292+func TestDialogStaysOverParentDespiteRules(t *testing.T) {
293+ r := startMulti(t, func(c *ports.Config) {
294+ declareDev(c)
295+ c.Rules = []ports.WindowRule{winRule("x", ".*", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOff() })}
296+ }, left)
297+ r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
298+ st := r.stateWith(t, 1)
299+ if w := stWindow(st, 1); w.WorkspaceName != "dev" {
300+ t.Fatalf("parent: %+v", w)
301+ }
302+ r.key(t, "d", ports.ModAlt)
303+ stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
304+ r.client <- ports.WindowMapped{ID: 2, AppID: "term", Floating: true, Width: 30, Height: 30, Parent: 1}
305+ st = r.stateWith(t, 2)
306+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating {
307+ t.Fatalf("dialog moved or tiled: %+v", w)
308+ }
309+ // Same app ID without a parent: the rule applies (tiled on dev).
310+ r.client <- ports.WindowMapped{ID: 3, AppID: "term", Floating: true, Width: 30, Height: 30}
311+ if w := stWindow(r.stateWith(t, 3), 3); w.Floating {
312+ t.Fatalf("parentless fixed-size window ignores floating = off: %+v", w)
313+ }
314+}
315+
316+func TestAppIDChangeAfterMapMovesNothing(t *testing.T) {
317+ r := startMulti(t, func(c *ports.Config) {
318+ declareDev(c)
319+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
320+ }, left)
321+ r.mapApp(t, 1, "")
322+ r.client <- ports.WindowAppID{ID: 1, AppID: "code"}
323+ st := r.mapApp(t, 2, "term") // after the change, to know it was handled
324+ if w := stWindow(st, 1); w.Workspace != 1 || w.AppID != "code" {
325+ t.Fatalf("window re-placed: %+v", w)
326+ }
327+}
328+
329+func TestRemapKeepsPlace(t *testing.T) {
330+ r := startMulti(t, func(c *ports.Config) {
331+ declareDev(c)
332+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
333+ }, left)
334+ r.mapApp(t, 1, "term")
335+ r.client <- ports.WindowMapped{ID: 1, AppID: "code"}
336+ if w := stWindow(r.mapApp(t, 2, "term"), 1); w.Workspace != 1 {
337+ t.Fatalf("re-map moved the window: %+v", w)
338+ }
339+}
340+
341+// A toplevel that maps again after an unmap (a tray app restored) opens as
342+// any other window: on the workspace the user looks at, not its rule's.
343+func TestRuleSkipsRemapAfterUnmap(t *testing.T) {
344+ r := startMulti(t, func(c *ports.Config) {
345+ declareDev(c)
346+ c.Rules = []ports.WindowRule{winRule("dev", "discord", func(r *ports.WindowRule) { r.Workspace = "dev" })}
347+ }, left)
348+ if w := stWindow(r.mapApp(t, 1, "discord"), 1); w.WorkspaceName != "dev" {
349+ t.Fatalf("first map not placed by the rule: %+v", w)
350+ }
351+ r.client <- ports.WindowUnmapped{ID: 1}
352+ stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, 1) == nil })
353+ r.client <- ports.WindowMapped{ID: 1, AppID: "discord", Remap: true}
354+ st := r.stateWith(t, 1)
355+ if w := stWindow(st, 1); w.WorkspaceName != "" || w.Workspace != 1 || stFocus(st) != 1 {
356+ t.Fatalf("remap placed by the rule: %+v focus %d", w, stFocus(st))
357+ }
358+}
359+
360+func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) {
361+ r := startMulti(t, func(c *ports.Config) {
362+ declareDev(c)
363+ c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
364+ }, left)
365+ r.mapApp(t, 1, "code")
366+ cfg := r.cfg
367+ cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })}
368+ r.reload <- ports.ConfigChanged{Config: cfg}
369+ // Handled in order with the map below.
370+ st := r.mapApp(t, 2, "code")
371+ if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
372+ t.Fatalf("reload moved a mapped window: %+v", w)
373+ }
374+ if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating {
375+ t.Fatalf("new rules not applied: %+v", w)
376+ }
377+}
378+
379+func TestRuleQuietPlacementWithAnimations(t *testing.T) {
380+ r := startMulti(t, func(c *ports.Config) {
381+ declareDev(c)
382+ c.Animations.On = true
383+ c.Rules = []ports.WindowRule{
384+ winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }),
385+ winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
386+ }
387+ }, left)
388+ r.mapApp(t, 1, "term")
389+ st := r.mapApp(t, 2, "code")
390+ if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 {
391+ t.Fatalf("%+v focus %d", w, stFocus(st))
392+ }
393+ st = r.mapApp(t, 3, "calc")
394+ if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 {
395+ t.Fatalf("%+v focus %d", w, stFocus(st))
396+ }
397+}
398+
399+func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {
400+ r := startMulti(t, func(c *ports.Config) {
401+ c.Layout.MaxColumns = 2
402+ c.Layout.Overflow = "fixed"
403+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}
404+ }, left)
405+ r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406+ r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407+ s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408+ if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {
409+ t.Fatalf("%+v", s.Windows)
410+ }
411+}
M internal/core/security.go +6 -3
@@ -41,6 +41,9 @@ func (c *Core) syncSecurity() bool {
4141 }
4242 c.dropSwipe()
4343 c.swipe = nil
44+ // The gesture ends with the pointer focus: wayland cancels it when the
45+ // protected epoch clears that focus.
46+ c.gesture = clientGesture{}
4447 c.stopAnimations()
4548 c.stopPulse()
4649 if state.Protected {
@@ -64,7 +67,7 @@ func (c *Core) securityCheckpoint(ctx context.Context) error {
6467 return nil
6568 }
6669 // Clear display focus even if the new epoch has no mapped lock surfaces.
67- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
70+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
6871 return err
6972 }
7073 if err := c.command(ctx, ports.FocusWindow{}); err != nil {
@@ -204,7 +207,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
204207 if !c.validLockID(c.pointer) {
205208 c.pointer = 0
206209 }
207- if err := c.command(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {
210+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {
208211 return err
209212 }
210213 latest(c.ch.Scenes, scenes)
@@ -259,7 +262,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
259262 id, x, y := c.lockHit(c.cursorX, c.cursorY)
260263 if id != c.pointer {
261264 c.pointer = id
262- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
265+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
263266 return err
264267 }
265268 }
@@ -41,6 +41,9 @@ func (c *Core) syncSecurity() bool {
41 }41 }
42 c.dropSwipe()42 c.dropSwipe()
43 c.swipe = nil43 c.swipe = nil
44+ // The gesture ends with the pointer focus: wayland cancels it when the
45+ // protected epoch clears that focus.
46+ c.gesture = clientGesture{}
44 c.stopAnimations()47 c.stopAnimations()
45 c.stopPulse()48 c.stopPulse()
46 if state.Protected {49 if state.Protected {
@@ -64,7 +67,7 @@ func (c *Core) securityCheckpoint(ctx context.Context) error {
64 return nil67 return nil
65 }68 }
66 // Clear display focus even if the new epoch has no mapped lock surfaces.69 // Clear display focus even if the new epoch has no mapped lock surfaces.
67- if err := c.command(ctx, ports.PointerFocus{}); err != nil {70+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
68 return err71 return err
69 }72 }
70 if err := c.command(ctx, ports.FocusWindow{}); err != nil {73 if err := c.command(ctx, ports.FocusWindow{}); err != nil {
@@ -204,7 +207,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
204 if !c.validLockID(c.pointer) {207 if !c.validLockID(c.pointer) {
205 c.pointer = 0208 c.pointer = 0
206 }209 }
207- if err := c.command(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {210+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {
208 return err211 return err
209 }212 }
210 latest(c.ch.Scenes, scenes)213 latest(c.ch.Scenes, scenes)
@@ -259,7 +262,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
259 id, x, y := c.lockHit(c.cursorX, c.cursorY)262 id, x, y := c.lockHit(c.cursorX, c.cursorY)
260 if id != c.pointer {263 if id != c.pointer {
261 c.pointer = id264 c.pointer = id
262- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {265+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
263 return err266 return err
264 }267 }
265 }268 }
M internal/core/spawn_placement.go +11 -0
@@ -70,6 +70,8 @@ func (p *spawnPlacement) expireTerminals(now time.Time) {
7070 }
7171
7272 // place claims a token once and puts its window in the target workspace.
73+// Without a slot, a window rule (rules.go) may choose its place; otherwise it
74+// opens on the focused screen's workspace.
7375 // Untagged windows return before any map lookup or allocation.
7476 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
7577 if v.Floating {
@@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
8284 ps.mon.normalize()
8385 return
8486 }
87+ // Rules come after the dialog: it stays over its parent.
88+ if eff, ok := c.ruleFor(v); ok {
89+ c.placeByRule(v, eff)
90+ return
91+ }
8592 c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
8693 return
8794 }
@@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
108115 }
109116 }
110117 if s, _ := c.screenOf(v.ID); s == nil {
118+ if eff, ok := c.ruleFor(v); ok {
119+ c.placeByRule(v, eff)
120+ return
121+ }
111122 c.cur().mon.AddWindow(v.ID)
112123 }
113124 }
@@ -70,6 +70,8 @@ func (p *spawnPlacement) expireTerminals(now time.Time) {
70 }70 }
71 71
72 // place claims a token once and puts its window in the target workspace.72 // place claims a token once and puts its window in the target workspace.
73+// Without a slot, a window rule (rules.go) may choose its place; otherwise it
74+// opens on the focused screen's workspace.
73 // Untagged windows return before any map lookup or allocation.75 // Untagged windows return before any map lookup or allocation.
74 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {76 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
75 if v.Floating {77 if v.Floating {
@@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
82 ps.mon.normalize()84 ps.mon.normalize()
83 return85 return
84 }86 }
87+ // Rules come after the dialog: it stays over its parent.
88+ if eff, ok := c.ruleFor(v); ok {
89+ c.placeByRule(v, eff)
90+ return
91+ }
85 c.cur().mon.AddFloating(v.ID, v.Width, v.Height)92 c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
86 return93 return
87 }94 }
@@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
108 }115 }
109 }116 }
110 if s, _ := c.screenOf(v.ID); s == nil {117 if s, _ := c.screenOf(v.ID); s == nil {
118+ if eff, ok := c.ruleFor(v); ok {
119+ c.placeByRule(v, eff)
120+ return
121+ }
111 c.cur().mon.AddWindow(v.ID)122 c.cur().mon.AddWindow(v.ID)
112 }123 }
113 }124 }
M internal/ports/client_commands.go +49 -0
@@ -192,6 +192,55 @@ type PointerAxisTo struct {
192192
193193 func (PointerAxisTo) clientCommand() {}
194194
195+// GestureKind is a touchpad gesture a client can receive
196+// (zwp_pointer_gestures_v1).
197+type GestureKind uint8
198+
199+const (
200+ GestureSwipe GestureKind = iota
201+ GesturePinch
202+ GestureHold
203+)
204+
205+// GestureBeginTo starts a gesture on a window with the pointer focus.
206+// Wayland assigns the serial. Time is the device timestamp
207+// (CLOCK_MONOTONIC), like the updates and the end of the gesture.
208+type GestureBeginTo struct {
209+ ID WindowID
210+ Kind GestureKind
211+ Fingers int
212+ Time time.Duration
213+}
214+
215+func (GestureBeginTo) clientCommand() {}
216+
217+// GestureUpdateTo moves the gesture of a window. DX and DY are the
218+// centre's movement; Scale and Rotation are only for a pinch (see
219+// PinchUpdate). A hold has no updates.
220+type GestureUpdateTo struct {
221+ ID WindowID
222+ Kind GestureKind
223+ DX, DY float64
224+ Scale float64
225+ Rotation float64
226+ Time time.Duration
227+}
228+
229+func (GestureUpdateTo) clientCommand() {}
230+
231+// GestureEndTo ends the gesture of a window. Cancelled is set when the
232+// gesture did not complete: the pointer focus moved, the window went away
233+// or the device cancelled it. Ending a gesture that is not running does
234+// nothing.
235+type GestureEndTo struct {
236+ ID WindowID
237+ Kind GestureKind
238+ Cancelled bool
239+ Time time.Duration
240+}
241+
242+func (GestureEndTo) clientCommand() {}
243+
195244 // ForwardKey carries core → wayland unbound keys.
196245 type ForwardKey struct {
197246 ID WindowID
@@ -192,6 +192,55 @@ type PointerAxisTo struct {
192 192
193 func (PointerAxisTo) clientCommand() {}193 func (PointerAxisTo) clientCommand() {}
194 194
195+// GestureKind is a touchpad gesture a client can receive
196+// (zwp_pointer_gestures_v1).
197+type GestureKind uint8
198+
199+const (
200+ GestureSwipe GestureKind = iota
201+ GesturePinch
202+ GestureHold
203+)
204+
205+// GestureBeginTo starts a gesture on a window with the pointer focus.
206+// Wayland assigns the serial. Time is the device timestamp
207+// (CLOCK_MONOTONIC), like the updates and the end of the gesture.
208+type GestureBeginTo struct {
209+ ID WindowID
210+ Kind GestureKind
211+ Fingers int
212+ Time time.Duration
213+}
214+
215+func (GestureBeginTo) clientCommand() {}
216+
217+// GestureUpdateTo moves the gesture of a window. DX and DY are the
218+// centre's movement; Scale and Rotation are only for a pinch (see
219+// PinchUpdate). A hold has no updates.
220+type GestureUpdateTo struct {
221+ ID WindowID
222+ Kind GestureKind
223+ DX, DY float64
224+ Scale float64
225+ Rotation float64
226+ Time time.Duration
227+}
228+
229+func (GestureUpdateTo) clientCommand() {}
230+
231+// GestureEndTo ends the gesture of a window. Cancelled is set when the
232+// gesture did not complete: the pointer focus moved, the window went away
233+// or the device cancelled it. Ending a gesture that is not running does
234+// nothing.
235+type GestureEndTo struct {
236+ ID WindowID
237+ Kind GestureKind
238+ Cancelled bool
239+ Time time.Duration
240+}
241+
242+func (GestureEndTo) clientCommand() {}
243+
195 // ForwardKey carries core → wayland unbound keys.244 // ForwardKey carries core → wayland unbound keys.
196 type ForwardKey struct {245 type ForwardKey struct {
197 ID WindowID246 ID WindowID
M internal/ports/client_events.go +3 -0
@@ -20,6 +20,9 @@ type WindowMapped struct {
2020 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
2121 // it.
2222 Parent WindowID
23+ // Remap is set when the toplevel mapped before and was unmapped (a tray
24+ // app restored): window rules only place its first map.
25+ Remap bool
2326 }
2427
2528 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -20,6 +20,9 @@ type WindowMapped struct {
20 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over20 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
21 // it.21 // it.
22 Parent WindowID22 Parent WindowID
23+ // Remap is set when the toplevel mapped before and was unmapped (a tray
24+ // app restored): window rules only place its first map.
25+ Remap bool
23 }26 }
24 27
25 // SlotEnv is the environment variable neferwl sets on processes it spawns28 // SlotEnv is the environment variable neferwl sets on processes it spawns
M internal/ports/config.go +20 -0
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "image"
5+ "regexp"
56 "strings"
67 "time"
78 )
@@ -164,6 +165,8 @@ type Config struct {
164165 }
165166 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
166167 Workspaces []WorkspaceConfig
168+ // Rules are the window rules (rule.<name>.* keys), in first-seen order.
169+ Rules []WindowRule
167170 // Outputs selects and configures physical displays (drm backend).
168171 Outputs []OutputConfig
169172 Binds map[string]string
@@ -257,3 +260,20 @@ type SlotConfig struct {
257260 Width string
258261 Argv []string
259262 }
263+
264+// WindowRule places the windows whose app ID it matches when they first map.
265+// Matching rules merge in config order, a later rule winning per field.
266+type WindowRule struct {
267+ Name string
268+ // AppID matches the whole app ID (the config anchors it).
269+ AppID *regexp.Regexp
270+ // Floating forces a window to float (true) or to tile (false); nil leaves it.
271+ Floating *bool
272+ // Workspace is a 1-based number of a numbered workspace or the name of a
273+ // declared one; empty leaves it.
274+ Workspace string
275+ // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276+ Monitor string
277+ // Width is the layout width of the new column of a tiled window.
278+ Width string
279+}
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "image"4 "image"
5+ "regexp"
5 "strings"6 "strings"
6 "time"7 "time"
7 )8 )
@@ -164,6 +165,8 @@ type Config struct {
164 }165 }
165 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.166 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
166 Workspaces []WorkspaceConfig167 Workspaces []WorkspaceConfig
168+ // Rules are the window rules (rule.<name>.* keys), in first-seen order.
169+ Rules []WindowRule
167 // Outputs selects and configures physical displays (drm backend).170 // Outputs selects and configures physical displays (drm backend).
168 Outputs []OutputConfig171 Outputs []OutputConfig
169 Binds map[string]string172 Binds map[string]string
@@ -257,3 +260,20 @@ type SlotConfig struct {
257 Width string260 Width string
258 Argv []string261 Argv []string
259 }262 }
263+
264+// WindowRule places the windows whose app ID it matches when they first map.
265+// Matching rules merge in config order, a later rule winning per field.
266+type WindowRule struct {
267+ Name string
268+ // AppID matches the whole app ID (the config anchors it).
269+ AppID *regexp.Regexp
270+ // Floating forces a window to float (true) or to tile (false); nil leaves it.
271+ Floating *bool
272+ // Workspace is a 1-based number of a numbered workspace or the name of a
273+ // declared one; empty leaves it.
274+ Workspace string
275+ // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276+ Monitor string
277+ // Width is the layout width of the new column of a tiled window.
278+ Width string
279+}
M internal/ports/input_events.go +56 -5
@@ -88,18 +88,23 @@ type PointerAxis struct {
8888
8989 func (PointerAxis) inputEvent() {}
9090
91-// SwipeBegin starts a three- or four-finger touchpad swipe. Time is the
92-// device timestamp (CLOCK_MONOTONIC), shared by the updates and the end.
91+// SwipeBegin starts a touchpad swipe of any finger count; core keeps three
92+// and four fingers and forwards the others to the window under the
93+// pointer. Time is the device timestamp (CLOCK_MONOTONIC), shared by the
94+// updates and the end.
9395 type SwipeBegin struct {
9496 Fingers int
9597 Time time.Duration
9698 }
9799
98100 // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY
99-// (touchpad units, as libinput reports them, not natural-scroll inverted).
101+// (touchpad units, as libinput reports them, not natural-scroll inverted),
102+// which the compositor's own swipes follow. AccelDX, AccelDY are the same
103+// move with pointer acceleration, what a client gets.
100104 type SwipeUpdate struct {
101- DX, DY float64
102- Time time.Duration
105+ DX, DY float64
106+ AccelDX, AccelDY float64
107+ Time time.Duration
103108 }
104109
105110 // SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled
@@ -112,3 +117,49 @@ type SwipeEnd struct {
112117 func (SwipeBegin) inputEvent() {}
113118 func (SwipeUpdate) inputEvent() {}
114119 func (SwipeEnd) inputEvent() {}
120+
121+// PinchBegin starts a touchpad pinch. Time is the device timestamp
122+// (CLOCK_MONOTONIC), shared by the updates and the end. A pinch always goes
123+// to the window under the pointer.
124+type PinchBegin struct {
125+ Fingers int
126+ Time time.Duration
127+}
128+
129+// PinchUpdate moves a pinch: the fingers' centre by the deltas DX, DY with
130+// pointer acceleration, as clients get them, Scale is the spread of the fingers
131+// relative to the begin (1.0 there) and Rotation the angle turned since the
132+// previous event, in degrees clockwise.
133+type PinchUpdate struct {
134+ DX, DY float64
135+ Scale float64
136+ Rotation float64
137+ Time time.Duration
138+}
139+
140+// PinchEnd lifts the fingers. Cancelled is set when libinput cancelled the
141+// pinch.
142+type PinchEnd struct {
143+ Cancelled bool
144+ Time time.Duration
145+}
146+
147+// HoldBegin starts a touchpad hold: fingers resting on the pad. Like a
148+// pinch it goes to the window under the pointer.
149+type HoldBegin struct {
150+ Fingers int
151+ Time time.Duration
152+}
153+
154+// HoldEnd lifts the fingers. Cancelled is set when the fingers moved or
155+// another finger landed, so the hold became something else.
156+type HoldEnd struct {
157+ Cancelled bool
158+ Time time.Duration
159+}
160+
161+func (PinchBegin) inputEvent() {}
162+func (PinchUpdate) inputEvent() {}
163+func (PinchEnd) inputEvent() {}
164+func (HoldBegin) inputEvent() {}
165+func (HoldEnd) inputEvent() {}
@@ -88,18 +88,23 @@ type PointerAxis struct {
88 88
89 func (PointerAxis) inputEvent() {}89 func (PointerAxis) inputEvent() {}
90 90
91-// SwipeBegin starts a three- or four-finger touchpad swipe. Time is the91+// SwipeBegin starts a touchpad swipe of any finger count; core keeps three
92-// device timestamp (CLOCK_MONOTONIC), shared by the updates and the end.92+// and four fingers and forwards the others to the window under the
93+// pointer. Time is the device timestamp (CLOCK_MONOTONIC), shared by the
94+// updates and the end.
93 type SwipeBegin struct {95 type SwipeBegin struct {
94 Fingers int96 Fingers int
95 Time time.Duration97 Time time.Duration
96 }98 }
97 99
98 // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY100 // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY
99-// (touchpad units, as libinput reports them, not natural-scroll inverted).101+// (touchpad units, as libinput reports them, not natural-scroll inverted),
102+// which the compositor's own swipes follow. AccelDX, AccelDY are the same
103+// move with pointer acceleration, what a client gets.
100 type SwipeUpdate struct {104 type SwipeUpdate struct {
101- DX, DY float64105+ DX, DY float64
102- Time time.Duration106+ AccelDX, AccelDY float64
107+ Time time.Duration
103 }108 }
104 109
105 // SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled110 // SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled
@@ -112,3 +117,49 @@ type SwipeEnd struct {
112 func (SwipeBegin) inputEvent() {}117 func (SwipeBegin) inputEvent() {}
113 func (SwipeUpdate) inputEvent() {}118 func (SwipeUpdate) inputEvent() {}
114 func (SwipeEnd) inputEvent() {}119 func (SwipeEnd) inputEvent() {}
120+
121+// PinchBegin starts a touchpad pinch. Time is the device timestamp
122+// (CLOCK_MONOTONIC), shared by the updates and the end. A pinch always goes
123+// to the window under the pointer.
124+type PinchBegin struct {
125+ Fingers int
126+ Time time.Duration
127+}
128+
129+// PinchUpdate moves a pinch: the fingers' centre by the deltas DX, DY with
130+// pointer acceleration, as clients get them, Scale is the spread of the fingers
131+// relative to the begin (1.0 there) and Rotation the angle turned since the
132+// previous event, in degrees clockwise.
133+type PinchUpdate struct {
134+ DX, DY float64
135+ Scale float64
136+ Rotation float64
137+ Time time.Duration
138+}
139+
140+// PinchEnd lifts the fingers. Cancelled is set when libinput cancelled the
141+// pinch.
142+type PinchEnd struct {
143+ Cancelled bool
144+ Time time.Duration
145+}
146+
147+// HoldBegin starts a touchpad hold: fingers resting on the pad. Like a
148+// pinch it goes to the window under the pointer.
149+type HoldBegin struct {
150+ Fingers int
151+ Time time.Duration
152+}
153+
154+// HoldEnd lifts the fingers. Cancelled is set when the fingers moved or
155+// another finger landed, so the hold became something else.
156+type HoldEnd struct {
157+ Cancelled bool
158+ Time time.Duration
159+}
160+
161+func (PinchBegin) inputEvent() {}
162+func (PinchUpdate) inputEvent() {}
163+func (PinchEnd) inputEvent() {}
164+func (HoldBegin) inputEvent() {}
165+func (HoldEnd) inputEvent() {}

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 26

Tags 10

↑↓ moveenter openesc close