Merge branch 'main' into fix/cascade-overview-maximized-ratio
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`). | |||
| 55 | 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | |
| 56 | 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| 57 | 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| 58 | + | | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) | | |
| 58 | 59 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 59 | 60 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | |
| 60 | 61 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | |
| @@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 70 | 71 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 71 | 72 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | |
| 72 | 73 | ||
| 74 | + | ## Window rules | |
| 75 | + | ||
| 76 | + | A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`): | |
| 77 | + | ||
| 78 | + | | Key | Meaning | | |
| 79 | + | |---|---| | |
| 80 | + | | `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does | | |
| 81 | + | | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float | | |
| 82 | + | | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace | | |
| 83 | + | | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected | | |
| 84 | + | | `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only | | |
| 85 | + | ||
| 86 | + | ```ini | |
| 87 | + | rule.games.app-id = steam_app_.* | |
| 88 | + | rule.games.workspace = 3 | |
| 89 | + | rule.games.monitor = DP-2 | |
| 90 | + | rule.chat.app-id = discord|org\.telegram\..* | |
| 91 | + | rule.chat.workspace = chat | |
| 92 | + | rule.chat.width = 1/3 | |
| 93 | + | rule.calc.app-id = org\.gnome\.Calculator | |
| 94 | + | rule.calc.floating = on | |
| 95 | + | ``` | |
| 96 | + | ||
| 97 | + | - Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`. | |
| 98 | + | - Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards. | |
| 99 | + | - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does. | |
| 100 | + | - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual. | |
| 101 | + | - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them. | |
| 102 | + | - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width. | |
| 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. | |
| 104 | + | ||
| 105 | + | To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched. | |
| 106 | + | ||
| 73 | 107 | ## Output placement | |
| 74 | 108 | ||
| 75 | 109 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | |
| @@ -152,6 +186,8 @@ Over the shown stash a sideways swipe slides it with the fingers, with a stop on | |||
| 152 | 186 | ||
| 153 | 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 | 191 | ## HDR | |
| 156 | 192 | ||
| 157 | 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. | |
| @@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | 55 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | ||
| 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | 56 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | ||
| 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | 57 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | ||
| 58 | + | | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) | | |||
| 58 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | 59 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | ||
| 59 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | 60 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | ||
| 60 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | 61 | | `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height | | ||
| @@ -70,6 +71,39 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 70 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | 71 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | ||
| 71 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | 72 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | ||
| 72 | 73 | ||||
| 74 | + | ## Window rules | |||
| 75 | + | ||||
| 76 | + | A window rule places the windows of an app when they first open. Rules are flat keys named by a rule name (letters, digits, `-` or `_`): | |||
| 77 | + | ||||
| 78 | + | | Key | Meaning | | |||
| 79 | + | |---|---| | |||
| 80 | + | | `rule.<name>.app-id` | Required. A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the whole app ID: `steam` does not match `steam_app_1`, `steam.*` does | | |||
| 81 | + | | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float | | |||
| 82 | + | | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace | | |||
| 83 | + | | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected | | |||
| 84 | + | | `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only | | |||
| 85 | + | ||||
| 86 | + | ```ini | |||
| 87 | + | rule.games.app-id = steam_app_.* | |||
| 88 | + | rule.games.workspace = 3 | |||
| 89 | + | rule.games.monitor = DP-2 | |||
| 90 | + | rule.chat.app-id = discord|org\.telegram\..* | |||
| 91 | + | rule.chat.workspace = chat | |||
| 92 | + | rule.chat.width = 1/3 | |||
| 93 | + | rule.calc.app-id = org\.gnome\.Calculator | |||
| 94 | + | rule.calc.floating = on | |||
| 95 | + | ``` | |||
| 96 | + | ||||
| 97 | + | - Every rule that matches applies, in file order; a later rule wins for each field it sets. A generic rule can set `monitor` and a specific one `width`. | |||
| 98 | + | - Rules apply once, when the window first maps. A later app ID change, a window that maps again, or a config reload moves nothing. Reloading changes rules for windows that open afterwards. | |||
| 99 | + | - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does. | |||
| 100 | + | - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual. | |||
| 101 | + | - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them. | |||
| 102 | + | - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width. | |||
| 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. | |||
| 104 | + | ||||
| 105 | + | To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched. | |||
| 106 | + | ||||
| 73 | ## Output placement | 107 | ## Output placement | ||
| 74 | 108 | ||||
| 75 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | 109 | Without relations, outputs sit left to right in config order, top-aligned. A relation places an output against another one; `offset` slides it along the shared edge so the pointer crosses where the screens really meet. | ||
| @@ -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 | ## HDR | 191 | ## 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 | |||
| 106 | 106 | # workspace.dev.column.2 = 33%, foot | |
| 107 | 107 | # bind.cmd+d = workspace dev | |
| 108 | 108 | ||
| 109 | + | # Window rules place an app's windows when they first open, without moving | |
| 110 | + | # the focus or the view. app-id is a regular expression matching the whole | |
| 111 | + | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |
| 112 | + | # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a | |
| 113 | + | # monitor key, width = like a column width. | |
| 114 | + | # rule.games.app-id = steam_app_.* | |
| 115 | + | # rule.games.workspace = 3 | |
| 116 | + | # rule.games.monitor = DP-2 | |
| 117 | + | # rule.calc.app-id = org\.gnome\.Calculator | |
| 118 | + | # rule.calc.floating = on | |
| 119 | + | ||
| 109 | 120 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | |
| 110 | 121 | # The first listed display that is connected and not off is used. | |
| 111 | 122 | # output.DP-2 = 5120x2160@165.058 | |
| @@ -106,6 +106,17 @@ focus.strength = 0.04 | |||||
| 106 | # workspace.dev.column.2 = 33%, foot | 106 | # workspace.dev.column.2 = 33%, foot | ||
| 107 | # bind.cmd+d = workspace dev | 107 | # bind.cmd+d = workspace dev | ||
| 108 | 108 | ||||
| 109 | + | # Window rules place an app's windows when they first open, without moving | |||
| 110 | + | # the focus or the view. app-id is a regular expression matching the whole | |||
| 111 | + | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |||
| 112 | + | # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a | |||
| 113 | + | # monitor key, width = like a column width. | |||
| 114 | + | # rule.games.app-id = steam_app_.* | |||
| 115 | + | # rule.games.workspace = 3 | |||
| 116 | + | # rule.games.monitor = DP-2 | |||
| 117 | + | # rule.calc.app-id = org\.gnome\.Calculator | |||
| 118 | + | # rule.calc.floating = on | |||
| 119 | + | ||||
| 109 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | 120 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | ||
| 110 | # The first listed display that is connected and not off is used. | 121 | # The first listed display that is connected and not off is used. | ||
| 111 | # output.DP-2 = 5120x2160@165.058 | 122 | # output.DP-2 = 5120x2160@165.058 | ||
M internal/adapters/config/config.go +119 -0
| @@ -3,6 +3,7 @@ package config | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bufio" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "math" | |
| @@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 326 | 327 | relKeys := map[string]string{} // output -> key of its relation | |
| 327 | 328 | offsetLines := map[string]int{} // output -> line of its offset | |
| 328 | 329 | workspaces := map[string]int{} | |
| 330 | + | rules := map[string]*ruleEntry{} | |
| 331 | + | var ruleOrder []*ruleEntry | |
| 329 | 332 | scanner := bufio.NewScanner(r) | |
| 330 | 333 | for n := 1; scanner.Scan(); n++ { | |
| 331 | 334 | line := strings.TrimSpace(scanner.Text()) | |
| @@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 510 | 513 | } | |
| 511 | 514 | continue | |
| 512 | 515 | } | |
| 516 | + | if rest, ok := strings.CutPrefix(key, "rule."); ok { | |
| 517 | + | name, field, _ := strings.Cut(rest, ".") | |
| 518 | + | if !workspaceName.MatchString(name) { | |
| 519 | + | warn("%s: rule name must be letters, digits, - or _", key) | |
| 520 | + | continue | |
| 521 | + | } | |
| 522 | + | e := rules[name] | |
| 523 | + | if e == nil { | |
| 524 | + | e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n} | |
| 525 | + | rules[name] = e | |
| 526 | + | ruleOrder = append(ruleOrder, e) | |
| 527 | + | } | |
| 528 | + | if err := setRule(&e.rule, field, value); err != nil { | |
| 529 | + | warn("%s: %v", key, err) | |
| 530 | + | if !errors.Is(err, errUnknownRuleField) { | |
| 531 | + | e.dropped = true | |
| 532 | + | } | |
| 533 | + | continue | |
| 534 | + | } | |
| 535 | + | override() | |
| 536 | + | continue | |
| 537 | + | } | |
| 513 | 538 | if err := set(&c, key, value); err != nil { | |
| 514 | 539 | warn("%s: %v", key, err) | |
| 515 | 540 | continue | |
| @@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 526 | 551 | } | |
| 527 | 552 | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | |
| 528 | 553 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |
| 554 | + | var ruleWarnings []Warning | |
| 555 | + | c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen) | |
| 556 | + | warnings = append(warnings, ruleWarnings...) | |
| 557 | + | if len(c.Rules) > 0 { | |
| 558 | + | // Rules merge in file order: moving one is a change. | |
| 559 | + | names := make([]string, len(c.Rules)) | |
| 560 | + | for i, r := range c.Rules { | |
| 561 | + | names[i] = r.Name | |
| 562 | + | } | |
| 563 | + | raw["rules"] = strings.Join(names, ",") | |
| 564 | + | } | |
| 529 | 565 | sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | |
| 530 | 566 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | |
| 531 | 567 | // to the same physical key (cmd+code:2), so older configs keep working. | |
| @@ -972,6 +1008,89 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||
| 972 | 1008 | ||
| 973 | 1009 | var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | |
| 974 | 1010 | ||
| 1011 | + | // ruleEntry is a rule.<name>.* group while the file is read. | |
| 1012 | + | type ruleEntry struct { | |
| 1013 | + | rule ports.WindowRule | |
| 1014 | + | line int // of its first key | |
| 1015 | + | // dropped is set by an invalid value: the whole rule is ignored. | |
| 1016 | + | dropped bool | |
| 1017 | + | } | |
| 1018 | + | ||
| 1019 | + | var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)") | |
| 1020 | + | ||
| 1021 | + | // setRule applies one rule.<name>.<field> key. | |
| 1022 | + | func setRule(r *ports.WindowRule, field, v string) error { | |
| 1023 | + | switch field { | |
| 1024 | + | case "app-id": | |
| 1025 | + | if v == "" { | |
| 1026 | + | return fmt.Errorf("needs a regular expression") | |
| 1027 | + | } | |
| 1028 | + | // Compiled alone first, so a value cannot close the anchoring | |
| 1029 | + | // group ("a)|(b"). | |
| 1030 | + | if _, err := regexp.Compile(v); err != nil { | |
| 1031 | + | return fmt.Errorf("invalid regular expression: %v", err) | |
| 1032 | + | } | |
| 1033 | + | // The whole app ID must match: "steam" does not match "steam_app_1". | |
| 1034 | + | r.AppID = regexp.MustCompile("^(?:" + v + ")$") | |
| 1035 | + | case "floating": | |
| 1036 | + | b, err := onOff(v) | |
| 1037 | + | if err != nil { | |
| 1038 | + | return err | |
| 1039 | + | } | |
| 1040 | + | r.Floating = &b | |
| 1041 | + | case "workspace": | |
| 1042 | + | if n, err := strconv.Atoi(v); err == nil { | |
| 1043 | + | if n < 1 || n > maxRuleWorkspace { | |
| 1044 | + | return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace) | |
| 1045 | + | } | |
| 1046 | + | } else if !workspaceName.MatchString(v) { | |
| 1047 | + | return fmt.Errorf("must be a workspace number or the name of a declared workspace") | |
| 1048 | + | } | |
| 1049 | + | r.Workspace = v | |
| 1050 | + | case "monitor": | |
| 1051 | + | if v == "" { | |
| 1052 | + | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | |
| 1053 | + | } | |
| 1054 | + | r.Monitor = v | |
| 1055 | + | case "width": | |
| 1056 | + | if !width(v) { | |
| 1057 | + | return fmt.Errorf("must be a width like 50%%, 1/3 or 800px") | |
| 1058 | + | } | |
| 1059 | + | r.Width = v | |
| 1060 | + | default: | |
| 1061 | + | return errUnknownRuleField | |
| 1062 | + | } | |
| 1063 | + | return nil | |
| 1064 | + | } | |
| 1065 | + | ||
| 1066 | + | // maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the | |
| 1067 | + | // workspaces that exist. | |
| 1068 | + | const maxRuleWorkspace = 1000 | |
| 1069 | + | ||
| 1070 | + | // finishRules keeps the valid rules in file order, warning about those | |
| 1071 | + | // without an app-id (dropped) and about named workspaces nobody declared | |
| 1072 | + | // (kept: core falls back to the default place). | |
| 1073 | + | func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) { | |
| 1074 | + | var rules []ports.WindowRule | |
| 1075 | + | var warnings []Warning | |
| 1076 | + | for _, e := range entries { | |
| 1077 | + | if e.dropped { | |
| 1078 | + | continue | |
| 1079 | + | } | |
| 1080 | + | if e.rule.AppID == nil { | |
| 1081 | + | warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)}) | |
| 1082 | + | continue | |
| 1083 | + | } | |
| 1084 | + | if w := e.rule.Workspace; w != "" { | |
| 1085 | + | if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) { | |
| 1086 | + | warnings = append(warnings, Warning{Line: seen["rule."+e.rule.Name+".workspace"], Msg: fmt.Sprintf("rule.%s.workspace: no workspace.%s.* declared", e.rule.Name, w)}) | |
| 1087 | + | } | |
| 1088 | + | } | |
| 1089 | + | rules = append(rules, e.rule) | |
| 1090 | + | } | |
| 1091 | + | return rules, warnings | |
| 1092 | + | } | |
| 1093 | + | ||
| 975 | 1094 | // setWorkspace applies one workspace.<name>.<field> key. | |
| 976 | 1095 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |
| 977 | 1096 | switch field { | |
| @@ -3,6 +3,7 @@ package config | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bufio" | 5 | "bufio" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "io" | 8 | "io" | ||
| 8 | "math" | 9 | "math" | ||
| @@ -326,6 +327,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 326 | relKeys := map[string]string{} // output -> key of its relation | 327 | relKeys := map[string]string{} // output -> key of its relation | ||
| 327 | offsetLines := map[string]int{} // output -> line of its offset | 328 | offsetLines := map[string]int{} // output -> line of its offset | ||
| 328 | workspaces := map[string]int{} | 329 | workspaces := map[string]int{} | ||
| 330 | + | rules := map[string]*ruleEntry{} | |||
| 331 | + | var ruleOrder []*ruleEntry | |||
| 329 | scanner := bufio.NewScanner(r) | 332 | scanner := bufio.NewScanner(r) | ||
| 330 | for n := 1; scanner.Scan(); n++ { | 333 | for n := 1; scanner.Scan(); n++ { | ||
| 331 | line := strings.TrimSpace(scanner.Text()) | 334 | line := strings.TrimSpace(scanner.Text()) | ||
| @@ -510,6 +513,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 510 | } | 513 | } | ||
| 511 | continue | 514 | continue | ||
| 512 | } | 515 | } | ||
| 516 | + | if rest, ok := strings.CutPrefix(key, "rule."); ok { | |||
| 517 | + | name, field, _ := strings.Cut(rest, ".") | |||
| 518 | + | if !workspaceName.MatchString(name) { | |||
| 519 | + | warn("%s: rule name must be letters, digits, - or _", key) | |||
| 520 | + | continue | |||
| 521 | + | } | |||
| 522 | + | e := rules[name] | |||
| 523 | + | if e == nil { | |||
| 524 | + | e = &ruleEntry{rule: ports.WindowRule{Name: name}, line: n} | |||
| 525 | + | rules[name] = e | |||
| 526 | + | ruleOrder = append(ruleOrder, e) | |||
| 527 | + | } | |||
| 528 | + | if err := setRule(&e.rule, field, value); err != nil { | |||
| 529 | + | warn("%s: %v", key, err) | |||
| 530 | + | if !errors.Is(err, errUnknownRuleField) { | |||
| 531 | + | e.dropped = true | |||
| 532 | + | } | |||
| 533 | + | continue | |||
| 534 | + | } | |||
| 535 | + | override() | |||
| 536 | + | continue | |||
| 537 | + | } | |||
| 513 | if err := set(&c, key, value); err != nil { | 538 | if err := set(&c, key, value); err != nil { | ||
| 514 | warn("%s: %v", key, err) | 539 | warn("%s: %v", key, err) | ||
| 515 | continue | 540 | continue | ||
| @@ -526,6 +551,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 526 | } | 551 | } | ||
| 527 | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | 552 | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | ||
| 528 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | 553 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | ||
| 554 | + | var ruleWarnings []Warning | |||
| 555 | + | c.Rules, ruleWarnings = finishRules(ruleOrder, c.Workspaces, seen) | |||
| 556 | + | warnings = append(warnings, ruleWarnings...) | |||
| 557 | + | if len(c.Rules) > 0 { | |||
| 558 | + | // Rules merge in file order: moving one is a change. | |||
| 559 | + | names := make([]string, len(c.Rules)) | |||
| 560 | + | for i, r := range c.Rules { | |||
| 561 | + | names[i] = r.Name | |||
| 562 | + | } | |||
| 563 | + | raw["rules"] = strings.Join(names, ",") | |||
| 564 | + | } | |||
| 529 | sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | 565 | sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | ||
| 530 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | 566 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | ||
| 531 | // to the same physical key (cmd+code:2), so older configs keep working. | 567 | // to the same physical key (cmd+code:2), so older configs keep working. | ||
| @@ -972,6 +1008,89 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||||
| 972 | 1008 | ||||
| 973 | var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | 1009 | var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | ||
| 974 | 1010 | ||||
| 1011 | + | // ruleEntry is a rule.<name>.* group while the file is read. | |||
| 1012 | + | type ruleEntry struct { | |||
| 1013 | + | rule ports.WindowRule | |||
| 1014 | + | line int // of its first key | |||
| 1015 | + | // dropped is set by an invalid value: the whole rule is ignored. | |||
| 1016 | + | dropped bool | |||
| 1017 | + | } | |||
| 1018 | + | ||||
| 1019 | + | var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)") | |||
| 1020 | + | ||||
| 1021 | + | // setRule applies one rule.<name>.<field> key. | |||
| 1022 | + | func setRule(r *ports.WindowRule, field, v string) error { | |||
| 1023 | + | switch field { | |||
| 1024 | + | case "app-id": | |||
| 1025 | + | if v == "" { | |||
| 1026 | + | return fmt.Errorf("needs a regular expression") | |||
| 1027 | + | } | |||
| 1028 | + | // Compiled alone first, so a value cannot close the anchoring | |||
| 1029 | + | // group ("a)|(b"). | |||
| 1030 | + | if _, err := regexp.Compile(v); err != nil { | |||
| 1031 | + | return fmt.Errorf("invalid regular expression: %v", err) | |||
| 1032 | + | } | |||
| 1033 | + | // The whole app ID must match: "steam" does not match "steam_app_1". | |||
| 1034 | + | r.AppID = regexp.MustCompile("^(?:" + v + ")$") | |||
| 1035 | + | case "floating": | |||
| 1036 | + | b, err := onOff(v) | |||
| 1037 | + | if err != nil { | |||
| 1038 | + | return err | |||
| 1039 | + | } | |||
| 1040 | + | r.Floating = &b | |||
| 1041 | + | case "workspace": | |||
| 1042 | + | if n, err := strconv.Atoi(v); err == nil { | |||
| 1043 | + | if n < 1 || n > maxRuleWorkspace { | |||
| 1044 | + | return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace) | |||
| 1045 | + | } | |||
| 1046 | + | } else if !workspaceName.MatchString(v) { | |||
| 1047 | + | return fmt.Errorf("must be a workspace number or the name of a declared workspace") | |||
| 1048 | + | } | |||
| 1049 | + | r.Workspace = v | |||
| 1050 | + | case "monitor": | |||
| 1051 | + | if v == "" { | |||
| 1052 | + | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | |||
| 1053 | + | } | |||
| 1054 | + | r.Monitor = v | |||
| 1055 | + | case "width": | |||
| 1056 | + | if !width(v) { | |||
| 1057 | + | return fmt.Errorf("must be a width like 50%%, 1/3 or 800px") | |||
| 1058 | + | } | |||
| 1059 | + | r.Width = v | |||
| 1060 | + | default: | |||
| 1061 | + | return errUnknownRuleField | |||
| 1062 | + | } | |||
| 1063 | + | return nil | |||
| 1064 | + | } | |||
| 1065 | + | ||||
| 1066 | + | // maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the | |||
| 1067 | + | // workspaces that exist. | |||
| 1068 | + | const maxRuleWorkspace = 1000 | |||
| 1069 | + | ||||
| 1070 | + | // finishRules keeps the valid rules in file order, warning about those | |||
| 1071 | + | // without an app-id (dropped) and about named workspaces nobody declared | |||
| 1072 | + | // (kept: core falls back to the default place). | |||
| 1073 | + | func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) { | |||
| 1074 | + | var rules []ports.WindowRule | |||
| 1075 | + | var warnings []Warning | |||
| 1076 | + | for _, e := range entries { | |||
| 1077 | + | if e.dropped { | |||
| 1078 | + | continue | |||
| 1079 | + | } | |||
| 1080 | + | if e.rule.AppID == nil { | |||
| 1081 | + | warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)}) | |||
| 1082 | + | continue | |||
| 1083 | + | } | |||
| 1084 | + | if w := e.rule.Workspace; w != "" { | |||
| 1085 | + | if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) { | |||
| 1086 | + | warnings = append(warnings, Warning{Line: seen["rule."+e.rule.Name+".workspace"], Msg: fmt.Sprintf("rule.%s.workspace: no workspace.%s.* declared", e.rule.Name, w)}) | |||
| 1087 | + | } | |||
| 1088 | + | } | |||
| 1089 | + | rules = append(rules, e.rule) | |||
| 1090 | + | } | |||
| 1091 | + | return rules, warnings | |||
| 1092 | + | } | |||
| 1093 | + | ||||
| 975 | // setWorkspace applies one workspace.<name>.<field> key. | 1094 | // setWorkspace applies one workspace.<name>.<field> key. | ||
| 976 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | 1095 | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | ||
| 977 | switch field { | 1096 | switch field { | ||
A internal/adapters/config/rules_test.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestRulesInFileOrder(t *testing.T) { | |
| 9 | + | c, w := parseString(t, ` | |
| 10 | + | workspace.chat.max-columns = 2 | |
| 11 | + | bind.cmd+c = workspace chat | |
| 12 | + | rule.games.app-id = steam_app_.* | |
| 13 | + | rule.games.floating = on | |
| 14 | + | rule.games.workspace = 3 | |
| 15 | + | rule.chat.app-id = org\.telegram\..*|discord | |
| 16 | + | rule.chat.workspace = chat | |
| 17 | + | rule.chat.monitor = DP-2 | |
| 18 | + | rule.chat.width = 1/3 | |
| 19 | + | rule.games.width = 50% | |
| 20 | + | `) | |
| 21 | + | if len(w) != 0 { | |
| 22 | + | t.Fatal(w) | |
| 23 | + | } | |
| 24 | + | if len(c.Rules) != 2 || c.Rules[0].Name != "games" || c.Rules[1].Name != "chat" { | |
| 25 | + | t.Fatalf("%+v", c.Rules) | |
| 26 | + | } | |
| 27 | + | g, ch := c.Rules[0], c.Rules[1] | |
| 28 | + | if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" { | |
| 29 | + | t.Fatalf("%+v", g) | |
| 30 | + | } | |
| 31 | + | if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" { | |
| 32 | + | t.Fatalf("%+v", ch) | |
| 33 | + | } | |
| 34 | + | if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") { | |
| 35 | + | t.Fatal("app-id did not match") | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestRuleAppIDIsAnchored(t *testing.T) { | |
| 40 | + | c, _ := parseString(t, "rule.a.app-id = steam\nrule.b.app-id = steam|foot\n") | |
| 41 | + | for _, tc := range []struct { | |
| 42 | + | rule int | |
| 43 | + | id string | |
| 44 | + | want bool | |
| 45 | + | }{ | |
| 46 | + | {0, "steam", true}, {0, "steam_app_1", false}, {0, "xsteam", false}, {0, "", false}, | |
| 47 | + | // The alternation is anchored as a whole, not per branch. | |
| 48 | + | {1, "foot", true}, {1, "footclient", false}, {1, "steamfoot", false}, | |
| 49 | + | } { | |
| 50 | + | if got := c.Rules[tc.rule].AppID.MatchString(tc.id); got != tc.want { | |
| 51 | + | t.Errorf("rule %d on %q = %v, want %v", tc.rule, tc.id, got, tc.want) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestRuleWarnings(t *testing.T) { | |
| 57 | + | for _, tc := range []struct { | |
| 58 | + | name, text, warn string | |
| 59 | + | line, rules int | |
| 60 | + | }{ | |
| 61 | + | {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0}, | |
| 62 | + | // Closing the anchoring group would match any app ID. | |
| 63 | + | {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0}, | |
| 64 | + | {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0}, | |
| 65 | + | {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1}, | |
| 66 | + | {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0}, | |
| 67 | + | {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0}, | |
| 68 | + | {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0}, | |
| 69 | + | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | |
| 70 | + | {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0}, | |
| 71 | + | {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0}, | |
| 72 | + | {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1}, | |
| 73 | + | } { | |
| 74 | + | t.Run(tc.name, func(t *testing.T) { | |
| 75 | + | c, w := parseString(t, tc.text) | |
| 76 | + | if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) { | |
| 77 | + | t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line) | |
| 78 | + | } | |
| 79 | + | if len(c.Rules) != tc.rules { | |
| 80 | + | t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestRuleWidthReusesColumnWidth(t *testing.T) { | |
| 87 | + | for _, v := range []string{"1", "33%", "1/3", "640px"} { | |
| 88 | + | c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n") | |
| 89 | + | if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v { | |
| 90 | + | t.Fatalf("%s: %v %+v", v, w, c.Rules) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestRuleReloadDiff(t *testing.T) { | |
| 96 | + | _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n") | |
| 97 | + | _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n") | |
| 98 | + | _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n") | |
| 99 | + | if d := diff(a, b); len(d) == 0 { | |
| 100 | + | t.Fatal("reordering rules is not a change") | |
| 101 | + | } | |
| 102 | + | if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" { | |
| 103 | + | t.Fatal(d) | |
| 104 | + | } | |
| 105 | + | if d := diff(a, a); len(d) != 0 { | |
| 106 | + | t.Fatal(d) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) { | |
| 111 | + | t.Helper() | |
| 112 | + | c, raw, w, err := parse(strings.NewReader(s)) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | return c, raw, w | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestRulesInFileOrder(t *testing.T) { | |||
| 9 | + | c, w := parseString(t, ` | |||
| 10 | + | workspace.chat.max-columns = 2 | |||
| 11 | + | bind.cmd+c = workspace chat | |||
| 12 | + | rule.games.app-id = steam_app_.* | |||
| 13 | + | rule.games.floating = on | |||
| 14 | + | rule.games.workspace = 3 | |||
| 15 | + | rule.chat.app-id = org\.telegram\..*|discord | |||
| 16 | + | rule.chat.workspace = chat | |||
| 17 | + | rule.chat.monitor = DP-2 | |||
| 18 | + | rule.chat.width = 1/3 | |||
| 19 | + | rule.games.width = 50% | |||
| 20 | + | `) | |||
| 21 | + | if len(w) != 0 { | |||
| 22 | + | t.Fatal(w) | |||
| 23 | + | } | |||
| 24 | + | if len(c.Rules) != 2 || c.Rules[0].Name != "games" || c.Rules[1].Name != "chat" { | |||
| 25 | + | t.Fatalf("%+v", c.Rules) | |||
| 26 | + | } | |||
| 27 | + | g, ch := c.Rules[0], c.Rules[1] | |||
| 28 | + | if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" { | |||
| 29 | + | t.Fatalf("%+v", g) | |||
| 30 | + | } | |||
| 31 | + | if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" { | |||
| 32 | + | t.Fatalf("%+v", ch) | |||
| 33 | + | } | |||
| 34 | + | if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") { | |||
| 35 | + | t.Fatal("app-id did not match") | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestRuleAppIDIsAnchored(t *testing.T) { | |||
| 40 | + | c, _ := parseString(t, "rule.a.app-id = steam\nrule.b.app-id = steam|foot\n") | |||
| 41 | + | for _, tc := range []struct { | |||
| 42 | + | rule int | |||
| 43 | + | id string | |||
| 44 | + | want bool | |||
| 45 | + | }{ | |||
| 46 | + | {0, "steam", true}, {0, "steam_app_1", false}, {0, "xsteam", false}, {0, "", false}, | |||
| 47 | + | // The alternation is anchored as a whole, not per branch. | |||
| 48 | + | {1, "foot", true}, {1, "footclient", false}, {1, "steamfoot", false}, | |||
| 49 | + | } { | |||
| 50 | + | if got := c.Rules[tc.rule].AppID.MatchString(tc.id); got != tc.want { | |||
| 51 | + | t.Errorf("rule %d on %q = %v, want %v", tc.rule, tc.id, got, tc.want) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestRuleWarnings(t *testing.T) { | |||
| 57 | + | for _, tc := range []struct { | |||
| 58 | + | name, text, warn string | |||
| 59 | + | line, rules int | |||
| 60 | + | }{ | |||
| 61 | + | {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0}, | |||
| 62 | + | // Closing the anchoring group would match any app ID. | |||
| 63 | + | {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0}, | |||
| 64 | + | {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0}, | |||
| 65 | + | {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1}, | |||
| 66 | + | {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0}, | |||
| 67 | + | {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0}, | |||
| 68 | + | {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0}, | |||
| 69 | + | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | |||
| 70 | + | {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0}, | |||
| 71 | + | {"bad rule name", "rule.a b.app-id = foo\n", "rule name must be", 1, 0}, | |||
| 72 | + | {"undeclared workspace", "rule.a.app-id = foo\nrule.a.workspace = web\n", "rule.a.workspace: no workspace.web.* declared", 2, 1}, | |||
| 73 | + | } { | |||
| 74 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 75 | + | c, w := parseString(t, tc.text) | |||
| 76 | + | if len(w) != 1 || w[0].Line != tc.line || !strings.Contains(w[0].Msg, tc.warn) { | |||
| 77 | + | t.Fatalf("warnings = %v, want %q at line %d", w, tc.warn, tc.line) | |||
| 78 | + | } | |||
| 79 | + | if len(c.Rules) != tc.rules { | |||
| 80 | + | t.Fatalf("rules = %+v, want %d", c.Rules, tc.rules) | |||
| 81 | + | } | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestRuleWidthReusesColumnWidth(t *testing.T) { | |||
| 87 | + | for _, v := range []string{"1", "33%", "1/3", "640px"} { | |||
| 88 | + | c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n") | |||
| 89 | + | if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v { | |||
| 90 | + | t.Fatalf("%s: %v %+v", v, w, c.Rules) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestRuleReloadDiff(t *testing.T) { | |||
| 96 | + | _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n") | |||
| 97 | + | _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n") | |||
| 98 | + | _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n") | |||
| 99 | + | if d := diff(a, b); len(d) == 0 { | |||
| 100 | + | t.Fatal("reordering rules is not a change") | |||
| 101 | + | } | |||
| 102 | + | if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" { | |||
| 103 | + | t.Fatal(d) | |||
| 104 | + | } | |||
| 105 | + | if d := diff(a, a); len(d) != 0 { | |||
| 106 | + | t.Fatal(d) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) { | |||
| 111 | + | t.Helper() | |||
| 112 | + | c, raw, w, err := parse(strings.NewReader(s)) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | return c, raw, w | |||
| 117 | + | } | |||
M internal/adapters/libinput/libinput.go +96 -27
| @@ -33,6 +33,11 @@ const ( | |||
| 33 | 33 | evSwipeBegin = 800 | |
| 34 | 34 | evSwipeUpdate = 801 | |
| 35 | 35 | evSwipeEnd = 802 | |
| 36 | + | evPinchBegin = 803 | |
| 37 | + | evPinchUpdate = 804 | |
| 38 | + | evPinchEnd = 805 | |
| 39 | + | evHoldBegin = 806 | |
| 40 | + | evHoldEnd = 807 | |
| 36 | 41 | ) | |
| 37 | 42 | ||
| 38 | 43 | var ( | |
| @@ -75,7 +80,11 @@ var ( | |||
| 75 | 80 | gestureCanceled func(gev uintptr) int32 | |
| 76 | 81 | gestureDX func(gev uintptr) float64 | |
| 77 | 82 | gestureDY func(gev uintptr) float64 | |
| 83 | + | gestureAccelDX func(gev uintptr) float64 | |
| 84 | + | gestureAccelDY func(gev uintptr) float64 | |
| 78 | 85 | gestureUsec func(gev uintptr) uint64 | |
| 86 | + | gestureScale func(gev uintptr) float64 | |
| 87 | + | gestureAngle func(gev uintptr) float64 | |
| 79 | 88 | deviceRef func(dev uintptr) uintptr | |
| 80 | 89 | deviceUnref func(dev uintptr) uintptr | |
| 81 | 90 | hasNatural func(dev uintptr) int32 | |
| @@ -148,7 +157,11 @@ func load() error { | |||
| 148 | 157 | reg(&gestureCanceled, "event_gesture_get_cancelled") | |
| 149 | 158 | reg(&gestureDX, "event_gesture_get_dx_unaccelerated") | |
| 150 | 159 | reg(&gestureDY, "event_gesture_get_dy_unaccelerated") | |
| 160 | + | reg(&gestureAccelDX, "event_gesture_get_dx") | |
| 161 | + | reg(&gestureAccelDY, "event_gesture_get_dy") | |
| 151 | 162 | reg(&gestureUsec, "event_gesture_get_time_usec") | |
| 163 | + | reg(&gestureScale, "event_gesture_get_scale") | |
| 164 | + | reg(&gestureAngle, "event_gesture_get_angle_delta") | |
| 152 | 165 | reg(&deviceRef, "device_ref") | |
| 153 | 166 | reg(&deviceUnref, "device_unref") | |
| 154 | 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 | 340 | // 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 | |
| 330 | 343 | // type, so storing it in the interface allocates nothing. | |
| 331 | 344 | 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 | |
| 336 | 349 | } | |
| 337 | 350 | ||
| 338 | 351 | // deviceConfig is libinput's device configuration side: what a device is | |
| @@ -478,33 +491,74 @@ func (s *inputState) release() { | |||
| 478 | 491 | clear(s.devices) | |
| 479 | 492 | } | |
| 480 | 493 | ||
| 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 | + | } | |
| 487 | 504 | ||
| 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 { | |
| 490 | 507 | return nil | |
| 491 | 508 | } | |
| 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 | + | } | |
| 493 | 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 { | |
| 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) { | |
| 498 | 533 | 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 { | |
| 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) { | |
| 505 | 541 | return nil | |
| 506 | 542 | } | |
| 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 | + | } | |
| 508 | 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 | 606 | delete(in.devices, dev) | |
| 553 | 607 | deviceUnref(dev) | |
| 554 | 608 | } | |
| 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: | |
| 558 | 612 | 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 | |
| 560 | 620 | case evSwipeUpdate: | |
| 561 | 621 | 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 | |
| 563 | 626 | case evSwipeEnd: | |
| 564 | 627 | 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 | |
| 566 | 635 | case evKeyboardKey: | |
| 567 | 636 | k := keyboardEvent(ev) | |
| 568 | 637 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | |
| @@ -33,6 +33,11 @@ const ( | |||||
| 33 | evSwipeBegin = 800 | 33 | evSwipeBegin = 800 | ||
| 34 | evSwipeUpdate = 801 | 34 | evSwipeUpdate = 801 | ||
| 35 | evSwipeEnd = 802 | 35 | 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) int32 | 80 | gestureCanceled func(gev uintptr) int32 | ||
| 76 | gestureDX func(gev uintptr) float64 | 81 | gestureDX func(gev uintptr) float64 | ||
| 77 | gestureDY func(gev uintptr) float64 | 82 | gestureDY func(gev uintptr) float64 | ||
| 83 | + | gestureAccelDX func(gev uintptr) float64 | |||
| 84 | + | gestureAccelDY func(gev uintptr) float64 | |||
| 78 | gestureUsec func(gev uintptr) uint64 | 85 | gestureUsec func(gev uintptr) uint64 | ||
| 86 | + | gestureScale func(gev uintptr) float64 | |||
| 87 | + | gestureAngle func(gev uintptr) float64 | |||
| 79 | deviceRef func(dev uintptr) uintptr | 88 | deviceRef func(dev uintptr) uintptr | ||
| 80 | deviceUnref func(dev uintptr) uintptr | 89 | deviceUnref func(dev uintptr) uintptr | ||
| 81 | hasNatural func(dev uintptr) int32 | 90 | 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 devices | 340 | // inputState is the device state Run owns: the device config, the devices | ||
| 328 | - | // it configures (referenced until removed) and the swipe in progress on | 341 | + | // it configures (referenced until removed) and the gesture in progress on |
| 329 | - | // each touchpad. dev applies the config to libinput; it is a zero-size | 342 | + | // 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.InputDevicesConfig | 345 | + | cfg ports.InputDevicesConfig |
| 333 | - | dev deviceConfig | 346 | + | dev deviceConfig |
| 334 | - | devices map[uintptr]bool | 347 | + | devices map[uintptr]bool |
| 335 | - | swipes swipes | 348 | + | gestures gestures |
| 336 | } | 349 | } | ||
| 337 | 350 | ||||
| 338 | // deviceConfig is libinput's device configuration side: what a device is | 351 | // 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, which | 494 | + | // gestures streams one touchpad gesture at a time to core: a swipe of any |
| 482 | - | // follows | 495 | + | // 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 sees | 497 | + | // 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 nil | 507 | return nil | ||
| 491 | } | 508 | } | ||
| 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 | + | } | |||
| 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 nil | 533 | 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 nil | 541 | return nil | ||
| 506 | } | 542 | } | ||
| 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 | + | } | |||
| 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), nil | 610 | + | 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))), 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 | |||
| 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))), 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 | |||
| 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))), 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 | |||
| 566 | case evKeyboardKey: | 635 | case evKeyboardKey: | ||
| 567 | k := keyboardEvent(ev) | 636 | k := keyboardEvent(ev) | ||
| 568 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | 637 | 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) { | |||
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | - | func TestSwipesIgnoreSecondTouchpad(t *testing.T) { | |
| 46 | - | var s swipes | |
| 45 | + | func TestGesturesIgnoreSecondTouchpad(t *testing.T) { | |
| 46 | + | var g gestures | |
| 47 | 47 | 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 { | |
| 50 | 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 | 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 | 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 | 59 | t.Fatal("first touchpad lost its swipe") | |
| 60 | 60 | } | |
| 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 { | |
| 63 | 63 | t.Fatal("second touchpad cannot swipe after the first ended") | |
| 64 | 64 | } | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | - | func TestSwipesStreamThreeFingers(t *testing.T) { | |
| 68 | - | var s swipes | |
| 67 | + | func TestGesturesStreamSwipe(t *testing.T) { | |
| 68 | + | var g gestures | |
| 69 | 69 | 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) | |
| 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 | 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 | 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 | 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 | 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 | 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 | 89 | t.Fatalf("second end: %v", ev) | |
| 90 | 90 | } | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 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) | |
| 97 | 114 | } | |
| 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 | + | } | |
| 100 | 165 | } | |
| 101 | 166 | } | |
| 102 | 167 | ||
| @@ -262,6 +327,7 @@ func TestSecurityInputAllKindsProductionEpoch(t *testing.T) { | |||
| 262 | 327 | ports.KeyEvent{Keysym: "secret", Pressed: true}, ports.PointerMotion{X: 1}, | |
| 263 | 328 | ports.PointerButton{Button: 272}, ports.PointerAxis{}, | |
| 264 | 329 | ports.SwipeBegin{}, ports.SwipeUpdate{}, ports.SwipeEnd{}, | |
| 330 | + | ports.PinchBegin{}, ports.PinchUpdate{}, ports.PinchEnd{}, ports.HoldBegin{}, ports.HoldEnd{}, | |
| 265 | 331 | } | |
| 266 | 332 | security.EXPECT().Snapshot().Return(old).Times(len(events)) | |
| 267 | 333 | 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 swipes | 46 | + | var g gestures |
| 47 | const pad, other = 1, 2 | 47 | 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 swipes | 68 | + | var g gestures |
| 69 | const pad, other = 1, 2 | 69 | 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 swipes | 94 | + | 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) { | |||
| 157 | 157 | registerWireProxy(c, xp) | |
| 158 | 158 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 159 | 159 | ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials) | |
| 160 | - | if again := mapped(t, events, 2*time.Second); again.ID != w.ID { | |
| 161 | - | t.Fatalf("remapped as %d, want %d", again.ID, w.ID) | |
| 160 | + | // Core learns it is a remap: window rules only place a first map. | |
| 161 | + | if w.Remap { | |
| 162 | + | t.Fatal("first map flagged as a remap") | |
| 163 | + | } | |
| 164 | + | if again := mapped(t, events, 2*time.Second); again.ID != w.ID || !again.Remap { | |
| 165 | + | t.Fatalf("remapped as %+v, want ID %d and Remap", again, w.ID) | |
| 162 | 166 | } | |
| 163 | 167 | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | |
| 164 | 168 | t.Fatalf("remap = %q", got) | |
| @@ -157,8 +157,12 @@ func TestForeignToplevel(t *testing.T) { | |||||
| 157 | registerWireProxy(c, xp) | 157 | registerWireProxy(c, xp) | ||
| 158 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | 158 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | ||
| 159 | ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials) | 159 | ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials) | ||
| 160 | - | if again := mapped(t, events, 2*time.Second); again.ID != w.ID { | 160 | + | // Core learns it is a remap: window rules only place a first map. |
| 161 | - | t.Fatalf("remapped as %d, want %d", again.ID, w.ID) | 161 | + | if w.Remap { |
| 162 | + | t.Fatal("first map flagged as a remap") | |||
| 163 | + | } | |||
| 164 | + | if again := mapped(t, events, 2*time.Second); again.ID != w.ID || !again.Remap { | |||
| 165 | + | t.Fatalf("remapped as %+v, want ID %d and Remap", again, w.ID) | |||
| 162 | } | 166 | } | ||
| 163 | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | 167 | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | ||
| 164 | t.Fatalf("remap = %q", got) | 168 | t.Fatalf("remap = %q", got) | ||
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 { | |||
| 33 | 33 | func() error { return registerDMABuf(d, s, o.DMABuf) }, | |
| 34 | 34 | func() error { return registerSinglePixelBuffer(d, s) }, | |
| 35 | 35 | func() error { return registerPointerConstraints(d, s) }, | |
| 36 | + | func() error { return registerPointerGestures(d, s) }, | |
| 36 | 37 | func() error { return registerTearing(d, s) }, | |
| 37 | 38 | func() error { return registerColorManagement(d, s) }, | |
| 38 | 39 | 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 { | |||
| 23 | 23 | pointerX, pointerY float64 | |
| 24 | 24 | // enters holds the serial of the enter event each wl_pointer received | |
| 25 | 25 | // 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 | |
| 27 | 29 | wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis | |
| 28 | 30 | wheelHeld [2]float64 // axis value held back with it for pre-v8 clients | |
| 29 | 31 | pointers map[server.Client][]*wayland.Pointer | |
| @@ -64,6 +66,12 @@ func (s *Server) applyInput(cmd ports.ClientCommand) { | |||
| 64 | 66 | target = c.ID | |
| 65 | 67 | case ports.PointerAxisTo: | |
| 66 | 68 | 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 | 75 | case ports.FocusWindow: | |
| 68 | 76 | target = c.ID | |
| 69 | 77 | case ports.ForwardKey: | |
| @@ -142,6 +150,12 @@ func (s *Server) applyInput(cmd ports.ClientCommand) { | |||
| 142 | 150 | for _, p := range pointers { | |
| 143 | 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 | 159 | case ports.SetKeymap: | |
| 146 | 160 | s.setKeymap(c) | |
| 147 | 161 | case ports.FocusWindow: | |
| @@ -486,6 +500,8 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | |||
| 486 | 500 | return | |
| 487 | 501 | } | |
| 488 | 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 | 505 | // The new client sets its own cursor on enter; until then, the arrow. | |
| 490 | 506 | s.cursorSurface = nil | |
| 491 | 507 | s.setCursor(ports.CursorChange{}) | |
| @@ -23,7 +23,9 @@ type seatState struct { | |||||
| 23 | pointerX, pointerY float64 | 23 | pointerX, pointerY float64 | ||
| 24 | // enters holds the serial of the enter event each wl_pointer received | 24 | // 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]uint32 | 26 | + | 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 axis | 29 | 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 clients | 30 | wheelHeld [2]float64 // axis value held back with it for pre-v8 clients | ||
| 29 | pointers map[server.Client][]*wayland.Pointer | 31 | pointers map[server.Client][]*wayland.Pointer | ||
| @@ -64,6 +66,12 @@ func (s *Server) applyInput(cmd ports.ClientCommand) { | |||||
| 64 | target = c.ID | 66 | target = c.ID | ||
| 65 | case ports.PointerAxisTo: | 67 | case ports.PointerAxisTo: | ||
| 66 | target = c.ID | 68 | 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.ID | 76 | 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 | return | 500 | 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 = nil | 506 | 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 { | |||
| 256 | 256 | clipDevices []*clipDevice | |
| 257 | 257 | dataSources, primarySources, controlSources map[*server.Resource]*clipSource | |
| 258 | 258 | // 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 | |
| 261 | 263 | constraints map[*surface]*constraint | |
| 262 | 264 | constraint *constraint // the active one | |
| 263 | 265 | positioners map[*server.Resource]*positioner | |
| @@ -330,6 +332,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 330 | 332 | contentNotify: make(chan struct{}), | |
| 331 | 333 | regions: map[*server.Resource]*region{}, | |
| 332 | 334 | relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, | |
| 335 | + | gestures: map[server.Client][]*gestureObject{}, | |
| 333 | 336 | constraints: map[*surface]*constraint{}, | |
| 334 | 337 | positioners: map[*server.Resource]*positioner{}, | |
| 335 | 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 | 740 | // Only input/focus commands require an owner envelope. Config relay | |
| 738 | 741 | // also produces SetKeymap and other epoch-independent commands. | |
| 739 | 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 | 744 | return | |
| 742 | 745 | } | |
| 743 | 746 | } | |
| 744 | 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 | 749 | s.applyInput(c) | |
| 747 | 750 | case ports.ConfigurePopup: | |
| 748 | 751 | if w := s.windows[c.ID]; w != nil && w.popup != nil { | |
| @@ -256,8 +256,10 @@ type Server struct { | |||||
| 256 | clipDevices []*clipDevice | 256 | clipDevices []*clipDevice | ||
| 257 | dataSources, primarySources, controlSources map[*server.Resource]*clipSource | 257 | dataSources, primarySources, controlSources map[*server.Resource]*clipSource | ||
| 258 | // Pointer constraints (constraints.go). | 258 | // Pointer constraints (constraints.go). | ||
| 259 | - | regions map[*server.Resource]*region | 259 | + | regions map[*server.Resource]*region |
| 260 | - | relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1 | 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 | |||
| 261 | constraints map[*surface]*constraint | 263 | constraints map[*surface]*constraint | ||
| 262 | constraint *constraint // the active one | 264 | constraint *constraint // the active one | ||
| 263 | positioners map[*server.Resource]*positioner | 265 | 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 relay | 740 | // 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 | return | 744 | 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 ( | |||
| 32 | 32 | wire_linuxdmabuf "github.com/bnema/go-wayland-bindings/server/linuxdmabuf" | |
| 33 | 33 | wire_linuxdrmsyncobj "github.com/bnema/go-wayland-bindings/server/linuxdrmsyncobj" | |
| 34 | 34 | wire_pointerconstraints "github.com/bnema/go-wayland-bindings/server/pointerconstraints" | |
| 35 | + | wire_pointergestures "github.com/bnema/go-wayland-bindings/server/pointergestures" | |
| 35 | 36 | wire_pointerwarp "github.com/bnema/go-wayland-bindings/server/pointerwarp" | |
| 36 | 37 | wire_presentationtime "github.com/bnema/go-wayland-bindings/server/presentationtime" | |
| 37 | 38 | wire_primaryselection "github.com/bnema/go-wayland-bindings/server/primaryselection" | |
| @@ -239,6 +240,10 @@ var wireSchemas = map[string]wireSchema{ | |||
| 239 | 240 | "zwp_primary_selection_device_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceV1Interface, &client_primaryselection.PrimarySelectionDevice{}, map[uint16]bool{1: true}, nil}, | |
| 240 | 241 | "zwp_primary_selection_offer_v1": {wire_primaryselection.ZwpPrimarySelectionOfferV1Interface, &client_primaryselection.PrimarySelectionOffer{}, map[uint16]bool{1: true}, nil}, | |
| 241 | 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 | 247 | "zwp_relative_pointer_manager_v1": {wire_relativepointer.ZwpRelativePointerManagerV1Interface, &client_relativepointer.RelativePointerManager{}, map[uint16]bool{0: true}, nil}, | |
| 243 | 248 | "zwp_relative_pointer_v1": {wire_relativepointer.ZwpRelativePointerV1Interface, &client_relativepointer.RelativePointer{}, map[uint16]bool{0: true}, nil}, | |
| 244 | 249 | "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) { | |||
| 243 | 243 | if w.parent != nil && w.parent.mapped { | |
| 244 | 244 | parent = w.parent.id | |
| 245 | 245 | } | |
| 246 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent}) | |
| 246 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1}) | |
| 247 | 247 | x.server.syncInhibitors() | |
| 248 | 248 | x.server.toplevelChanged(w) | |
| 249 | 249 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | |
| @@ -243,7 +243,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 243 | if w.parent != nil && w.parent.mapped { | 243 | if w.parent != nil && w.parent.mapped { | ||
| 244 | parent = w.parent.id | 244 | parent = w.parent.id | ||
| 245 | } | 245 | } | ||
| 246 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent}) | 246 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1}) |
| 247 | x.server.syncInhibitors() | 247 | x.server.syncInhibitors() | ||
| 248 | x.server.toplevelChanged(w) | 248 | x.server.toplevelChanged(w) | ||
| 249 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | 249 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | ||
M internal/core/client.go +1 -1
| @@ -114,7 +114,7 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error { | |||
| 114 | 114 | c.releaseSlots() | |
| 115 | 115 | if c.pointer == v.ID { | |
| 116 | 116 | c.pointer = 0 | |
| 117 | - | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |
| 117 | + | if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil { | |
| 118 | 118 | return err | |
| 119 | 119 | } | |
| 120 | 120 | } | |
| @@ -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 = 0 | 116 | 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 err | 118 | 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 { | |||
| 118 | 118 | specs []NamedWorkspace | |
| 119 | 119 | presets []Width | |
| 120 | 120 | slots map[slotKey]*slotState | |
| 121 | + | rules []rule | |
| 121 | 122 | placement spawnPlacement | |
| 122 | 123 | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 123 | 124 | toSpawn []slotKey | |
| @@ -134,7 +135,10 @@ type Core struct { | |||
| 134 | 135 | // flip came in time to move a running slide; nil when not armed. It is | |
| 135 | 136 | // the channel of frameTimer, made once and reset per frame: an | |
| 136 | 137 | // 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 | |
| 138 | 142 | frameC <-chan time.Time | |
| 139 | 143 | frameTimer ports.Timer | |
| 140 | 144 | // 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 | 307 | if err != nil { | |
| 304 | 308 | return err | |
| 305 | 309 | } | |
| 310 | + | rules, err := parseRules(cfg.Rules) | |
| 311 | + | if err != nil { | |
| 312 | + | return err | |
| 313 | + | } | |
| 306 | 314 | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | |
| 307 | 315 | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | |
| 308 | 316 | for _, s := range c.screens { | |
| @@ -311,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 311 | 319 | c.cfg = cfg | |
| 312 | 320 | c.binds = binds | |
| 313 | 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, presets | |
| 322 | + | c.specs, c.presets, c.rules = named, presets, rules | |
| 315 | 323 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 316 | 324 | c.named() | |
| 317 | 325 | for _, s := range c.screens { | |
| @@ -621,7 +629,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 621 | 629 | if c.pointer != 0 && !c.visible(c.pointer) { | |
| 622 | 630 | c.pointer = 0 | |
| 623 | 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 | 633 | return err | |
| 626 | 634 | } | |
| 627 | 635 | } | |
| @@ -703,7 +711,7 @@ func (c *Core) rehit(ctx context.Context) error { | |||
| 703 | 711 | id, x, y := c.hit(c.cursorX, c.cursorY) | |
| 704 | 712 | if id != c.pointer { | |
| 705 | 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 | 716 | if id == 0 || c.pointerAt == [2]float64{x, y} { | |
| 709 | 717 | return nil | |
| @@ -118,6 +118,7 @@ type Core struct { | |||||
| 118 | specs []NamedWorkspace | 118 | specs []NamedWorkspace | ||
| 119 | presets []Width | 119 | presets []Width | ||
| 120 | slots map[slotKey]*slotState | 120 | slots map[slotKey]*slotState | ||
| 121 | + | rules []rule | |||
| 121 | placement spawnPlacement | 122 | placement spawnPlacement | ||
| 122 | // toSpawn holds slots to start; Run sends them (apply has no context). | 123 | // toSpawn holds slots to start; Run sends them (apply has no context). | ||
| 123 | toSpawn []slotKey | 124 | toSpawn []slotKey | ||
| @@ -134,7 +135,10 @@ type Core struct { | |||||
| 134 | // flip came in time to move a running slide; nil when not armed. It is | 135 | // 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: an | 136 | // 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 *swipeGesture | 138 | + | swipe *swipeGesture |
| 139 | + | // gesture is the touchpad gesture forwarded to a client | |||
| 140 | + | // (clientgesture.go). | |||
| 141 | + | gesture clientGesture | |||
| 138 | frameC <-chan time.Time | 142 | frameC <-chan time.Time | ||
| 139 | frameTimer ports.Timer | 143 | frameTimer ports.Timer | ||
| 140 | // shots is the snapshot of what the screens show before an action | 144 | // 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 err | 308 | 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 = cfg | 319 | c.cfg = cfg | ||
| 312 | c.binds = binds | 320 | 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, presets | 322 | + | 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 = 0 | 630 | 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 err | 633 | 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 nil | 717 | 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 | |||
| 165 | 165 | c.grab = 0 | |
| 166 | 166 | if c.pointer != 0 { | |
| 167 | 167 | c.pointer = 0 | |
| 168 | - | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |
| 168 | + | if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil { | |
| 169 | 169 | return err | |
| 170 | 170 | } | |
| 171 | 171 | } | |
| @@ -165,7 +165,7 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize | |||||
| 165 | c.grab = 0 | 165 | c.grab = 0 | ||
| 166 | if c.pointer != 0 { | 166 | if c.pointer != 0 { | ||
| 167 | c.pointer = 0 | 167 | 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 err | 169 | 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 { | |||
| 26 | 26 | case ports.PointerAxis: | |
| 27 | 27 | return c.pointerAxis(ctx, v) | |
| 28 | 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 | 35 | if c.swipeBegin(v) { | |
| 30 | 36 | return c.slid(ctx, false) | |
| 31 | 37 | } | |
| 32 | 38 | return nil | |
| 33 | 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 | 43 | before := c.cur().mon.Current() | |
| 35 | 44 | if c.swipeUpdate(v) { | |
| 36 | 45 | return c.slid(ctx, c.cur().mon.Current() != before) | |
| 37 | 46 | } | |
| 38 | 47 | return nil | |
| 39 | 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 | 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 | 63 | case ports.KeyEvent: | |
| 42 | 64 | return c.keyEvent(ctx, v) | |
| 43 | 65 | } | |
| @@ -73,7 +95,7 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error { | |||
| 73 | 95 | // Layout changes are intentionally re-hit-tested only on motion. | |
| 74 | 96 | if id != c.pointer { | |
| 75 | 97 | 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 | 99 | return err | |
| 78 | 100 | } | |
| 79 | 101 | } | |
| @@ -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 nil | 38 | 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 nil | 47 | 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 = id | 97 | 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 err | 99 | return err | ||
| 78 | } | 100 | } | ||
| 79 | } | 101 | } | ||
M internal/core/layout.go +6 -3
| @@ -492,7 +492,10 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 492 | 492 | } | |
| 493 | 493 | return c.Windows[c.Focus], true | |
| 494 | 494 | } | |
| 495 | - | func (w *Workspace) AddWindow(id WindowID) { | |
| 495 | + | func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) } | |
| 496 | + | ||
| 497 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |
| 498 | + | func (w *Workspace) addWindow(id WindowID, width Width) { | |
| 496 | 499 | if id == 0 || w.has(id) { | |
| 497 | 500 | return | |
| 498 | 501 | } | |
| @@ -504,10 +507,10 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 504 | 507 | // A covering float or stashed window may have no column under it. | |
| 505 | 508 | at = min(w.Focus+1, len(w.Columns)) | |
| 506 | 509 | } | |
| 507 | - | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}}) | |
| 510 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width}) | |
| 508 | 511 | return | |
| 509 | 512 | } | |
| 510 | - | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 513 | + | w.addColumn(Column{Windows: []WindowID{id}, Width: width}) | |
| 511 | 514 | } | |
| 512 | 515 | ||
| 513 | 516 | // addColumn places a new column like a new window and focuses it. | |
| @@ -492,7 +492,10 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||||
| 492 | } | 492 | } | ||
| 493 | return c.Windows[c.Focus], true | 493 | return c.Windows[c.Focus], true | ||
| 494 | } | 494 | } | ||
| 495 | - | func (w *Workspace) AddWindow(id WindowID) { | 495 | + | func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) } |
| 496 | + | ||||
| 497 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |||
| 498 | + | func (w *Workspace) addWindow(id WindowID, width Width) { | |||
| 496 | if id == 0 || w.has(id) { | 499 | if id == 0 || w.has(id) { | ||
| 497 | return | 500 | return | ||
| 498 | } | 501 | } | ||
| @@ -504,10 +507,10 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 504 | // A covering float or stashed window may have no column under it. | 507 | // A covering float or stashed window may have no column under it. | ||
| 505 | at = min(w.Focus+1, len(w.Columns)) | 508 | at = min(w.Focus+1, len(w.Columns)) | ||
| 506 | } | 509 | } | ||
| 507 | - | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}}) | 510 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width}) |
| 508 | return | 511 | return | ||
| 509 | } | 512 | } | ||
| 510 | - | w.addColumn(Column{Windows: []WindowID{id}}) | 513 | + | w.addColumn(Column{Windows: []WindowID{id}, Width: width}) |
| 511 | } | 514 | } | ||
| 512 | 515 | ||||
| 513 | // addColumn places a new column like a new window and focuses it. | 516 | // addColumn places a new column like a new window and focuses it. | ||
M internal/core/monitor.go +5 -2
| @@ -302,12 +302,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||
| 302 | 302 | ||
| 303 | 303 | // AddWindow places a new window on the workspace on screen; with | |
| 304 | 304 | // stash.capture, in its stash when shown. | |
| 305 | - | func (m *Monitor) AddWindow(id WindowID) { | |
| 305 | + | func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) } | |
| 306 | + | ||
| 307 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |
| 308 | + | func (m *Monitor) addWindow(id WindowID, width Width) { | |
| 306 | 309 | if w, _ := m.find(id); w != nil { | |
| 307 | 310 | return | |
| 308 | 311 | } | |
| 309 | 312 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | |
| 310 | - | w.AddWindow(id) | |
| 313 | + | w.addWindow(id, width) | |
| 311 | 314 | } | |
| 312 | 315 | m.normalize() | |
| 313 | 316 | } | |
| @@ -302,12 +302,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||||
| 302 | 302 | ||||
| 303 | // AddWindow places a new window on the workspace on screen; with | 303 | // AddWindow places a new window on the workspace on screen; with | ||
| 304 | // stash.capture, in its stash when shown. | 304 | // stash.capture, in its stash when shown. | ||
| 305 | - | func (m *Monitor) AddWindow(id WindowID) { | 305 | + | func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) } |
| 306 | + | ||||
| 307 | + | // addWindow is AddWindow with the width of the new column (zero: automatic). | |||
| 308 | + | func (m *Monitor) addWindow(id WindowID, width Width) { | |||
| 306 | if w, _ := m.find(id); w != nil { | 309 | if w, _ := m.find(id); w != nil { | ||
| 307 | return | 310 | return | ||
| 308 | } | 311 | } | ||
| 309 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | 312 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | ||
| 310 | - | w.AddWindow(id) | 313 | + | w.addWindow(id, width) |
| 311 | } | 314 | } | ||
| 312 | m.normalize() | 315 | m.normalize() | ||
| 313 | } | 316 | } | ||
A internal/core/rules.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "regexp" | |
| 6 | + | "slices" | |
| 7 | + | "strconv" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Window rules (rule.<name>.* keys): the app ID of a window that maps for | |
| 13 | + | // the first time picks its place. Rules are evaluated once, at the first | |
| 14 | + | // map, in spawnPlacement.place; a later app ID change, a re-map or a config | |
| 15 | + | // reload moves nothing. | |
| 16 | + | // | |
| 17 | + | // A rule never moves the user (ADR 011): it does not take the focus and does | |
| 18 | + | // not change which workspace or monitor is shown or focused. A window sent to | |
| 19 | + | // another workspace joins it quietly, like a slot window. The one exception | |
| 20 | + | // is the workspace on screen of the focused monitor, where the window | |
| 21 | + | // behaves like any new window. | |
| 22 | + | ||
| 23 | + | // rule is one parsed window rule. | |
| 24 | + | type rule struct { | |
| 25 | + | appID *regexp.Regexp | |
| 26 | + | floating *bool | |
| 27 | + | // number is the 1-based numbered workspace; workspace the name of a | |
| 28 | + | // named one. At most one is set. | |
| 29 | + | number int | |
| 30 | + | workspace string | |
| 31 | + | monitor string | |
| 32 | + | width Width // zero: automatic | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ruleEffect is the merge of every rule matching a window: per field, the | |
| 36 | + | // last rule in config order that sets it wins. | |
| 37 | + | type ruleEffect struct { | |
| 38 | + | floating *bool | |
| 39 | + | number int | |
| 40 | + | workspace string | |
| 41 | + | monitor string | |
| 42 | + | width Width // zero: automatic | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func parseRules(rs []ports.WindowRule) ([]rule, error) { | |
| 46 | + | out := make([]rule, 0, len(rs)) | |
| 47 | + | for _, r := range rs { | |
| 48 | + | if r.AppID == nil { | |
| 49 | + | return nil, fmt.Errorf("rule %s: no app-id", r.Name) | |
| 50 | + | } | |
| 51 | + | p := rule{appID: r.AppID, floating: r.Floating, monitor: r.Monitor} | |
| 52 | + | if r.Workspace != "" { | |
| 53 | + | if n, err := strconv.Atoi(r.Workspace); err == nil { | |
| 54 | + | if n < 1 { | |
| 55 | + | return nil, fmt.Errorf("rule %s: invalid workspace %q", r.Name, r.Workspace) | |
| 56 | + | } | |
| 57 | + | p.number = n | |
| 58 | + | } else { | |
| 59 | + | p.workspace = r.Workspace | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if r.Width != "" { | |
| 63 | + | width, err := ParseWidth(r.Width) | |
| 64 | + | if err != nil { | |
| 65 | + | return nil, fmt.Errorf("rule %s: %w", r.Name, err) | |
| 66 | + | } | |
| 67 | + | p.width = width | |
| 68 | + | } | |
| 69 | + | out = append(out, p) | |
| 70 | + | } | |
| 71 | + | return out, nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // ruleFor merges the rules matching v's app ID; ok is false when none does. | |
| 75 | + | // A toplevel mapping again after an unmap (a tray app restored) opens as | |
| 76 | + | // any other window: rules only place its first map. | |
| 77 | + | func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) { | |
| 78 | + | if v.Remap { | |
| 79 | + | return eff, false | |
| 80 | + | } | |
| 81 | + | for i := range c.rules { | |
| 82 | + | r := &c.rules[i] | |
| 83 | + | if !r.appID.MatchString(v.AppID) { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | ok = true | |
| 87 | + | if r.floating != nil { | |
| 88 | + | eff.floating = r.floating | |
| 89 | + | } | |
| 90 | + | if r.number > 0 || r.workspace != "" { | |
| 91 | + | eff.number, eff.workspace = r.number, r.workspace | |
| 92 | + | } | |
| 93 | + | if r.monitor != "" { | |
| 94 | + | eff.monitor = r.monitor | |
| 95 | + | } | |
| 96 | + | if r.width != (Width{}) { | |
| 97 | + | eff.width = r.width | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | return eff, ok | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // ruleTarget is the screen and workspace a rule sends a window to. A named | |
| 104 | + | // workspace lives where it lives, whatever the rule's monitor; an unknown one | |
| 105 | + | // is ignored. A monitor that is not connected is ignored too. Without a | |
| 106 | + | // workspace the monitor's workspace on screen is the target, and a number is | |
| 107 | + | // clamped to the workspaces that exist (the last one is the spare empty one), | |
| 108 | + | // so no empty workspace is created. | |
| 109 | + | func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) { | |
| 110 | + | if eff.workspace != "" { | |
| 111 | + | if sc, w := c.byName(eff.workspace); w != nil { | |
| 112 | + | return sc, w | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | sc := c.cur() | |
| 116 | + | if eff.monitor != "" { | |
| 117 | + | if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 { | |
| 118 | + | sc = c.screens[i] | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | if eff.number > 0 { | |
| 122 | + | return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1] | |
| 123 | + | } | |
| 124 | + | return sc, sc.mon.Current() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // placeByRule puts a window that maps for the first time where eff says. | |
| 128 | + | func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) { | |
| 129 | + | float := v.Floating | |
| 130 | + | if eff.floating != nil { | |
| 131 | + | float = *eff.floating | |
| 132 | + | } | |
| 133 | + | sc, w := c.ruleTarget(eff) | |
| 134 | + | // The workspace the user looks at takes a new window as usual; any | |
| 135 | + | // other one gets it without a change of focus or view. | |
| 136 | + | quiet := sc != c.cur() || w != sc.mon.Current() | |
| 137 | + | switch { | |
| 138 | + | case float: | |
| 139 | + | // A window that already floats keeps the size it maps with; a tile | |
| 140 | + | // floated by a rule becomes a free float, core sizing it. | |
| 141 | + | w.addFloat(w.ruleFloat(v), quiet) | |
| 142 | + | case quiet: | |
| 143 | + | w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width}) | |
| 144 | + | default: | |
| 145 | + | sc.mon.addWindow(v.ID, eff.width) | |
| 146 | + | return | |
| 147 | + | } | |
| 148 | + | sc.mon.normalize() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // ruleFloat is the float of a window a rule floats. | |
| 152 | + | func (w *Workspace) ruleFloat(v ports.WindowMapped) Float { | |
| 153 | + | if v.Floating { | |
| 154 | + | return Float{ID: v.ID, W: v.Width, H: v.Height} | |
| 155 | + | } | |
| 156 | + | f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5} | |
| 157 | + | if w.Usable.W > 0 && w.Usable.H > 0 { | |
| 158 | + | // As makeFree: the size is core's, fixed now. Without an output | |
| 159 | + | // yet, floatRect's default (half the usable area) applies. | |
| 160 | + | r := w.floatRect(f) | |
| 161 | + | f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1) | |
| 162 | + | } | |
| 163 | + | return f | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // addFloat adds a new float; quiet puts it below the others without the | |
| 167 | + | // float focus, so the focused window stays the same. | |
| 168 | + | func (w *Workspace) addFloat(f Float, quiet bool) { | |
| 169 | + | if f.ID == 0 || w.has(f.ID) { | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | if quiet { | |
| 173 | + | // A workspace with nothing else focuses it, so it has a focus when shown. | |
| 174 | + | w.floatFocus = w.floatFocus || w.empty() | |
| 175 | + | w.Floats = slices.Insert(w.Floats, 0, f) | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | w.Floats = append(w.Floats, f) | |
| 179 | + | w.floatFocus = true | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // addColumnQuiet adds a column where a new window goes, keeping the focus | |
| 183 | + | // where it is (ADR 011 golden rule). | |
| 184 | + | func (w *Workspace) addColumnQuiet(col Column) { | |
| 185 | + | at := len(w.Columns) | |
| 186 | + | if w.cover() != 0 { | |
| 187 | + | // As AddWindow: under a covering fullscreen window the column | |
| 188 | + | // waits hidden, and a maximized layout stays as it is. | |
| 189 | + | if !w.policy().equalCells { | |
| 190 | + | at = min(w.Focus+1, at) | |
| 191 | + | } | |
| 192 | + | w.Columns = slices.Insert(w.Columns, at, col) | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | if w.policy().equalCells { | |
| 196 | + | if at > 0 { | |
| 197 | + | w.unmaximize() | |
| 198 | + | } | |
| 199 | + | } else if at > 0 { | |
| 200 | + | at = w.Focus + 1 | |
| 201 | + | } | |
| 202 | + | w.Columns = slices.Insert(w.Columns, at, col) | |
| 203 | + | w.scroll() | |
| 204 | + | } | |
| @@ -0,0 +1,204 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "regexp" | |||
| 6 | + | "slices" | |||
| 7 | + | "strconv" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Window rules (rule.<name>.* keys): the app ID of a window that maps for | |||
| 13 | + | // the first time picks its place. Rules are evaluated once, at the first | |||
| 14 | + | // map, in spawnPlacement.place; a later app ID change, a re-map or a config | |||
| 15 | + | // reload moves nothing. | |||
| 16 | + | // | |||
| 17 | + | // A rule never moves the user (ADR 011): it does not take the focus and does | |||
| 18 | + | // not change which workspace or monitor is shown or focused. A window sent to | |||
| 19 | + | // another workspace joins it quietly, like a slot window. The one exception | |||
| 20 | + | // is the workspace on screen of the focused monitor, where the window | |||
| 21 | + | // behaves like any new window. | |||
| 22 | + | ||||
| 23 | + | // rule is one parsed window rule. | |||
| 24 | + | type rule struct { | |||
| 25 | + | appID *regexp.Regexp | |||
| 26 | + | floating *bool | |||
| 27 | + | // number is the 1-based numbered workspace; workspace the name of a | |||
| 28 | + | // named one. At most one is set. | |||
| 29 | + | number int | |||
| 30 | + | workspace string | |||
| 31 | + | monitor string | |||
| 32 | + | width Width // zero: automatic | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ruleEffect is the merge of every rule matching a window: per field, the | |||
| 36 | + | // last rule in config order that sets it wins. | |||
| 37 | + | type ruleEffect struct { | |||
| 38 | + | floating *bool | |||
| 39 | + | number int | |||
| 40 | + | workspace string | |||
| 41 | + | monitor string | |||
| 42 | + | width Width // zero: automatic | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func parseRules(rs []ports.WindowRule) ([]rule, error) { | |||
| 46 | + | out := make([]rule, 0, len(rs)) | |||
| 47 | + | for _, r := range rs { | |||
| 48 | + | if r.AppID == nil { | |||
| 49 | + | return nil, fmt.Errorf("rule %s: no app-id", r.Name) | |||
| 50 | + | } | |||
| 51 | + | p := rule{appID: r.AppID, floating: r.Floating, monitor: r.Monitor} | |||
| 52 | + | if r.Workspace != "" { | |||
| 53 | + | if n, err := strconv.Atoi(r.Workspace); err == nil { | |||
| 54 | + | if n < 1 { | |||
| 55 | + | return nil, fmt.Errorf("rule %s: invalid workspace %q", r.Name, r.Workspace) | |||
| 56 | + | } | |||
| 57 | + | p.number = n | |||
| 58 | + | } else { | |||
| 59 | + | p.workspace = r.Workspace | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if r.Width != "" { | |||
| 63 | + | width, err := ParseWidth(r.Width) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return nil, fmt.Errorf("rule %s: %w", r.Name, err) | |||
| 66 | + | } | |||
| 67 | + | p.width = width | |||
| 68 | + | } | |||
| 69 | + | out = append(out, p) | |||
| 70 | + | } | |||
| 71 | + | return out, nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // ruleFor merges the rules matching v's app ID; ok is false when none does. | |||
| 75 | + | // A toplevel mapping again after an unmap (a tray app restored) opens as | |||
| 76 | + | // any other window: rules only place its first map. | |||
| 77 | + | func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) { | |||
| 78 | + | if v.Remap { | |||
| 79 | + | return eff, false | |||
| 80 | + | } | |||
| 81 | + | for i := range c.rules { | |||
| 82 | + | r := &c.rules[i] | |||
| 83 | + | if !r.appID.MatchString(v.AppID) { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | ok = true | |||
| 87 | + | if r.floating != nil { | |||
| 88 | + | eff.floating = r.floating | |||
| 89 | + | } | |||
| 90 | + | if r.number > 0 || r.workspace != "" { | |||
| 91 | + | eff.number, eff.workspace = r.number, r.workspace | |||
| 92 | + | } | |||
| 93 | + | if r.monitor != "" { | |||
| 94 | + | eff.monitor = r.monitor | |||
| 95 | + | } | |||
| 96 | + | if r.width != (Width{}) { | |||
| 97 | + | eff.width = r.width | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | return eff, ok | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // ruleTarget is the screen and workspace a rule sends a window to. A named | |||
| 104 | + | // workspace lives where it lives, whatever the rule's monitor; an unknown one | |||
| 105 | + | // is ignored. A monitor that is not connected is ignored too. Without a | |||
| 106 | + | // workspace the monitor's workspace on screen is the target, and a number is | |||
| 107 | + | // clamped to the workspaces that exist (the last one is the spare empty one), | |||
| 108 | + | // so no empty workspace is created. | |||
| 109 | + | func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) { | |||
| 110 | + | if eff.workspace != "" { | |||
| 111 | + | if sc, w := c.byName(eff.workspace); w != nil { | |||
| 112 | + | return sc, w | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | sc := c.cur() | |||
| 116 | + | if eff.monitor != "" { | |||
| 117 | + | if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 { | |||
| 118 | + | sc = c.screens[i] | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | if eff.number > 0 { | |||
| 122 | + | return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1] | |||
| 123 | + | } | |||
| 124 | + | return sc, sc.mon.Current() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // placeByRule puts a window that maps for the first time where eff says. | |||
| 128 | + | func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) { | |||
| 129 | + | float := v.Floating | |||
| 130 | + | if eff.floating != nil { | |||
| 131 | + | float = *eff.floating | |||
| 132 | + | } | |||
| 133 | + | sc, w := c.ruleTarget(eff) | |||
| 134 | + | // The workspace the user looks at takes a new window as usual; any | |||
| 135 | + | // other one gets it without a change of focus or view. | |||
| 136 | + | quiet := sc != c.cur() || w != sc.mon.Current() | |||
| 137 | + | switch { | |||
| 138 | + | case float: | |||
| 139 | + | // A window that already floats keeps the size it maps with; a tile | |||
| 140 | + | // floated by a rule becomes a free float, core sizing it. | |||
| 141 | + | w.addFloat(w.ruleFloat(v), quiet) | |||
| 142 | + | case quiet: | |||
| 143 | + | w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width}) | |||
| 144 | + | default: | |||
| 145 | + | sc.mon.addWindow(v.ID, eff.width) | |||
| 146 | + | return | |||
| 147 | + | } | |||
| 148 | + | sc.mon.normalize() | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // ruleFloat is the float of a window a rule floats. | |||
| 152 | + | func (w *Workspace) ruleFloat(v ports.WindowMapped) Float { | |||
| 153 | + | if v.Floating { | |||
| 154 | + | return Float{ID: v.ID, W: v.Width, H: v.Height} | |||
| 155 | + | } | |||
| 156 | + | f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5} | |||
| 157 | + | if w.Usable.W > 0 && w.Usable.H > 0 { | |||
| 158 | + | // As makeFree: the size is core's, fixed now. Without an output | |||
| 159 | + | // yet, floatRect's default (half the usable area) applies. | |||
| 160 | + | r := w.floatRect(f) | |||
| 161 | + | f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1) | |||
| 162 | + | } | |||
| 163 | + | return f | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // addFloat adds a new float; quiet puts it below the others without the | |||
| 167 | + | // float focus, so the focused window stays the same. | |||
| 168 | + | func (w *Workspace) addFloat(f Float, quiet bool) { | |||
| 169 | + | if f.ID == 0 || w.has(f.ID) { | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | if quiet { | |||
| 173 | + | // A workspace with nothing else focuses it, so it has a focus when shown. | |||
| 174 | + | w.floatFocus = w.floatFocus || w.empty() | |||
| 175 | + | w.Floats = slices.Insert(w.Floats, 0, f) | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | w.Floats = append(w.Floats, f) | |||
| 179 | + | w.floatFocus = true | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // addColumnQuiet adds a column where a new window goes, keeping the focus | |||
| 183 | + | // where it is (ADR 011 golden rule). | |||
| 184 | + | func (w *Workspace) addColumnQuiet(col Column) { | |||
| 185 | + | at := len(w.Columns) | |||
| 186 | + | if w.cover() != 0 { | |||
| 187 | + | // As AddWindow: under a covering fullscreen window the column | |||
| 188 | + | // waits hidden, and a maximized layout stays as it is. | |||
| 189 | + | if !w.policy().equalCells { | |||
| 190 | + | at = min(w.Focus+1, at) | |||
| 191 | + | } | |||
| 192 | + | w.Columns = slices.Insert(w.Columns, at, col) | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | if w.policy().equalCells { | |||
| 196 | + | if at > 0 { | |||
| 197 | + | w.unmaximize() | |||
| 198 | + | } | |||
| 199 | + | } else if at > 0 { | |||
| 200 | + | at = w.Focus + 1 | |||
| 201 | + | } | |||
| 202 | + | w.Columns = slices.Insert(w.Columns, at, col) | |||
| 203 | + | w.scroll() | |||
| 204 | + | } | |||
A internal/core/rules_internal_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // Under a covering fullscreen window, a rule's quiet column waits hidden: | |
| 6 | + | // focus, fullscreen and a fixed-overflow maximized column stay as they are, | |
| 7 | + | // as for any new window (AddWindow's cover branch). | |
| 8 | + | func TestAddColumnQuietUnderCover(t *testing.T) { | |
| 9 | + | w := workspace() | |
| 10 | + | w.Overflow = OverflowFixed | |
| 11 | + | w.AddWindow(1) | |
| 12 | + | w.AddWindow(2) | |
| 13 | + | w.FocusID(1) | |
| 14 | + | w.maximize(w.Focus) | |
| 15 | + | w.ToggleFullscreen() | |
| 16 | + | if w.cover() != 1 { | |
| 17 | + | t.Fatalf("setup: cover %d", w.cover()) | |
| 18 | + | } | |
| 19 | + | w.addColumnQuiet(Column{Windows: []WindowID{3}}) | |
| 20 | + | if !w.has(3) { | |
| 21 | + | t.Fatal("column not added") | |
| 22 | + | } | |
| 23 | + | if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 { | |
| 24 | + | t.Fatalf("focus %d fullscreen %d", id, w.fullscreen) | |
| 25 | + | } | |
| 26 | + | if i := w.columnOf(1); i < 0 || !w.Columns[i].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 1 { | |
| 27 | + | t.Fatalf("maximized layout lost: %+v %v", w.Columns, w.maximized) | |
| 28 | + | } | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // Under a covering fullscreen window, a rule's quiet column waits hidden: | |||
| 6 | + | // focus, fullscreen and a fixed-overflow maximized column stay as they are, | |||
| 7 | + | // as for any new window (AddWindow's cover branch). | |||
| 8 | + | func TestAddColumnQuietUnderCover(t *testing.T) { | |||
| 9 | + | w := workspace() | |||
| 10 | + | w.Overflow = OverflowFixed | |||
| 11 | + | w.AddWindow(1) | |||
| 12 | + | w.AddWindow(2) | |||
| 13 | + | w.FocusID(1) | |||
| 14 | + | w.maximize(w.Focus) | |||
| 15 | + | w.ToggleFullscreen() | |||
| 16 | + | if w.cover() != 1 { | |||
| 17 | + | t.Fatalf("setup: cover %d", w.cover()) | |||
| 18 | + | } | |||
| 19 | + | w.addColumnQuiet(Column{Windows: []WindowID{3}}) | |||
| 20 | + | if !w.has(3) { | |||
| 21 | + | t.Fatal("column not added") | |||
| 22 | + | } | |||
| 23 | + | if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 { | |||
| 24 | + | t.Fatalf("focus %d fullscreen %d", id, w.fullscreen) | |||
| 25 | + | } | |||
| 26 | + | if i := w.columnOf(1); i < 0 || !w.Columns[i].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 1 { | |||
| 27 | + | t.Fatalf("maximized layout lost: %+v %v", w.Columns, w.maximized) | |||
| 28 | + | } | |||
| 29 | + | } | |||
A internal/core/rules_test.go +411 -0
| @@ -0,0 +1,411 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func winRule(name, appID string, edit func(*ports.WindowRule)) ports.WindowRule { | |
| 11 | + | r := ports.WindowRule{Name: name, AppID: regexp.MustCompile("^(?:" + appID + ")$")} | |
| 12 | + | if edit != nil { | |
| 13 | + | edit(&r) | |
| 14 | + | } | |
| 15 | + | return r | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func ruleOn() *bool { v := true; return &v } | |
| 19 | + | func ruleOff() *bool { v := false; return &v } | |
| 20 | + | ||
| 21 | + | func declareDev(c *ports.Config) { | |
| 22 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev"}} | |
| 23 | + | c.Binds["Alt+d"] = "workspace dev" | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // mapApp maps a window with an app ID and returns the state once it is in. | |
| 27 | + | func (r *multiRig) mapApp(t *testing.T, id ports.WindowID, appID string) ports.State { | |
| 28 | + | t.Helper() | |
| 29 | + | r.client <- ports.WindowMapped{ID: id, AppID: appID} | |
| 30 | + | return r.stateWith(t, id) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (r *multiRig) stateWith(t *testing.T, id ports.WindowID) ports.State { | |
| 34 | + | t.Helper() | |
| 35 | + | return stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, id) != nil }) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func stWindow(st ports.State, id ports.WindowID) *ports.WindowState { | |
| 39 | + | for i := range st.Windows { | |
| 40 | + | if st.Windows[i].ID == id { | |
| 41 | + | return &st.Windows[i] | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func stFocus(st ports.State) ports.WindowID { | |
| 48 | + | if st.Window == nil { | |
| 49 | + | return 0 | |
| 50 | + | } | |
| 51 | + | return st.Window.ID | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func stOutput(st ports.State, name string) ports.OutputState { | |
| 55 | + | for _, o := range st.Outputs { | |
| 56 | + | if o.Name == name { | |
| 57 | + | return o | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return ports.OutputState{} | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestRuleFloatingOnAndOff(t *testing.T) { | |
| 64 | + | r := startMulti(t, func(c *ports.Config) { | |
| 65 | + | c.Rules = []ports.WindowRule{ | |
| 66 | + | winRule("float", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |
| 67 | + | winRule("tile", "splash", func(r *ports.WindowRule) { r.Floating = ruleOff() }), | |
| 68 | + | } | |
| 69 | + | }, left) | |
| 70 | + | r.mapApp(t, 1, "term") | |
| 71 | + | st := r.mapApp(t, 2, "calc") | |
| 72 | + | if w := stWindow(st, 2); !w.Floating || stFocus(st) != 2 { | |
| 73 | + | t.Fatalf("calc not floating on the current workspace: %+v focus %d", w, stFocus(st)) | |
| 74 | + | } | |
| 75 | + | if w := stWindow(st, 1); w.Floating { | |
| 76 | + | t.Fatal("unrelated window floats") | |
| 77 | + | } | |
| 78 | + | // A fixed-size window (Floating from the client) tiles under floating = off. | |
| 79 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "splash", Floating: true, Width: 50, Height: 40} | |
| 80 | + | st = r.stateWith(t, 3) | |
| 81 | + | if w := stWindow(st, 3); w.Floating || w.Column == 0 { | |
| 82 | + | t.Fatalf("splash not tiled: %+v", w) | |
| 83 | + | } | |
| 84 | + | // Without a rule the same window floats as before. | |
| 85 | + | r.client <- ports.WindowMapped{ID: 4, AppID: "other", Floating: true, Width: 50, Height: 40} | |
| 86 | + | if w := stWindow(r.stateWith(t, 4), 4); !w.Floating { | |
| 87 | + | t.Fatalf("unmatched fixed-size window tiled: %+v", w) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestRuleNamedWorkspaceKeepsFocusAndView(t *testing.T) { | |
| 92 | + | r := startMulti(t, func(c *ports.Config) { | |
| 93 | + | declareDev(c) | |
| 94 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 95 | + | }, left) | |
| 96 | + | r.mapApp(t, 1, "term") | |
| 97 | + | st := r.mapApp(t, 2, "code") | |
| 98 | + | w := stWindow(st, 2) | |
| 99 | + | if w.WorkspaceName != "dev" || w.Workspace != 0 || w.Visible { | |
| 100 | + | t.Fatalf("window not quietly on dev: %+v", w) | |
| 101 | + | } | |
| 102 | + | if stFocus(st) != 1 || stOutput(st, "DP-1").Workspace != "" || stOutput(st, "DP-1").Active != 1 { | |
| 103 | + | t.Fatalf("rule moved focus or view: %+v", st) | |
| 104 | + | } | |
| 105 | + | // The workspace shows its window when the user goes there. | |
| 106 | + | r.key(t, "d", ports.ModAlt) | |
| 107 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |
| 108 | + | if stFocus(st) != 2 { | |
| 109 | + | t.Fatalf("dev window not focused on arrival: %d", stFocus(st)) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestRuleNamedWorkspaceFloat(t *testing.T) { | |
| 114 | + | r := startMulti(t, func(c *ports.Config) { | |
| 115 | + | declareDev(c) | |
| 116 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOn() })} | |
| 117 | + | }, left) | |
| 118 | + | r.mapApp(t, 1, "term") | |
| 119 | + | st := r.mapApp(t, 2, "code") | |
| 120 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating || stFocus(st) != 1 { | |
| 121 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 122 | + | } | |
| 123 | + | r.key(t, "d", ports.ModAlt) | |
| 124 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |
| 125 | + | if stFocus(st) != 2 { | |
| 126 | + | t.Fatalf("quiet float not focused on arrival: %d", stFocus(st)) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestRuleUndeclaredWorkspaceFallsBack(t *testing.T) { | |
| 131 | + | r := startMulti(t, func(c *ports.Config) { | |
| 132 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "nope" })} | |
| 133 | + | }, left) | |
| 134 | + | r.mapApp(t, 1, "term") | |
| 135 | + | st := r.mapApp(t, 2, "code") | |
| 136 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |
| 137 | + | t.Fatalf("%+v", w) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestRuleNumberedWorkspace(t *testing.T) { | |
| 142 | + | r := startMulti(t, func(c *ports.Config) { | |
| 143 | + | c.Rules = []ports.WindowRule{ | |
| 144 | + | winRule("two", "two", func(r *ports.WindowRule) { r.Workspace = "2" }), | |
| 145 | + | winRule("far", "far", func(r *ports.WindowRule) { r.Workspace = "9" }), | |
| 146 | + | } | |
| 147 | + | }, left) | |
| 148 | + | r.mapApp(t, 1, "term") | |
| 149 | + | st := r.mapApp(t, 2, "two") | |
| 150 | + | if w := stWindow(st, 2); w.Workspace != 2 || stFocus(st) != 1 || stOutput(st, "DP-1").Active != 1 { | |
| 151 | + | t.Fatalf("%+v active %d focus %d", w, stOutput(st, "DP-1").Active, stFocus(st)) | |
| 152 | + | } | |
| 153 | + | // 9 is clamped to the last workspace, the spare after workspace 2: no | |
| 154 | + | // pile of empty workspaces in between. | |
| 155 | + | st = r.mapApp(t, 3, "far") | |
| 156 | + | if w := stWindow(st, 3); w.Workspace != 3 { | |
| 157 | + | t.Fatalf("clamped to %d", w.Workspace) | |
| 158 | + | } | |
| 159 | + | if o := stOutput(st, "DP-1"); o.Count != 3 || o.Active != 1 || stFocus(st) != 1 { | |
| 160 | + | t.Fatalf("%+v focus %d", o, stFocus(st)) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestRuleNumberOnCurrentWorkspaceActsAsNormalMap(t *testing.T) { | |
| 165 | + | r := startMulti(t, func(c *ports.Config) { | |
| 166 | + | c.Rules = []ports.WindowRule{winRule("one", "term", func(r *ports.WindowRule) { r.Workspace = "1" })} | |
| 167 | + | }, left) | |
| 168 | + | r.mapApp(t, 1, "other") | |
| 169 | + | st := r.mapApp(t, 2, "term") | |
| 170 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |
| 171 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestRuleMonitor(t *testing.T) { | |
| 176 | + | for _, mon := range []string{"DP-2", "Acme B 2"} { | |
| 177 | + | t.Run(mon, func(t *testing.T) { | |
| 178 | + | r := startMulti(t, func(c *ports.Config) { | |
| 179 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = mon })} | |
| 180 | + | }, left, right) | |
| 181 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 182 | + | before := r.mapApp(t, 1, "term") | |
| 183 | + | if before.Output != "DP-1" { | |
| 184 | + | t.Fatalf("focused %s", before.Output) | |
| 185 | + | } | |
| 186 | + | st := r.mapApp(t, 2, "chat") | |
| 187 | + | w := stWindow(st, 2) | |
| 188 | + | if w.Output != "DP-2" || st.Output != "DP-1" || stFocus(st) != 1 { | |
| 189 | + | t.Fatalf("%+v focused output %s window %d", w, st.Output, stFocus(st)) | |
| 190 | + | } | |
| 191 | + | if !w.Visible { | |
| 192 | + | t.Fatal("the window is not shown on the current workspace of DP-2") | |
| 193 | + | } | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestRuleMonitorWithNumberedWorkspace(t *testing.T) { | |
| 199 | + | r := startMulti(t, func(c *ports.Config) { | |
| 200 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Workspace = "2" })} | |
| 201 | + | }, left, right) | |
| 202 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 203 | + | r.mapApp(t, 3, "term") // DP-2 gets a window, so a second workspace | |
| 204 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 205 | + | r.mapApp(t, 1, "term") | |
| 206 | + | st := r.mapApp(t, 2, "chat") | |
| 207 | + | if w := stWindow(st, 2); w.Output != "DP-2" || w.Workspace != 2 || st.Output != "DP-1" || stFocus(st) != 1 || stOutput(st, "DP-2").Active != 1 { | |
| 208 | + | t.Fatalf("%+v", w) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestRuleDisconnectedMonitorFallsBack(t *testing.T) { | |
| 213 | + | r := startMulti(t, func(c *ports.Config) { | |
| 214 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "HDMI-9" })} | |
| 215 | + | }, left, right) | |
| 216 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 217 | + | r.mapApp(t, 1, "term") | |
| 218 | + | st := r.mapApp(t, 2, "chat") | |
| 219 | + | if w := stWindow(st, 2); w.Output != "DP-1" || stFocus(st) != 2 { | |
| 220 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestRulesMerge(t *testing.T) { | |
| 225 | + | r := startMulti(t, func(c *ports.Config) { | |
| 226 | + | c.Layout.MaxColumns = 4 | |
| 227 | + | c.Rules = []ports.WindowRule{ | |
| 228 | + | winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }), | |
| 229 | + | winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }), | |
| 230 | + | } | |
| 231 | + | }, left, right) | |
| 232 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 233 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "chat"} | |
| 234 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term"} | |
| 235 | + | set := sceneMatch2(t, r, func(s ports.Scene) bool { return s.Output == "DP-2" && len(s.Windows) == 2 }) | |
| 236 | + | got := map[ports.WindowID]int{} | |
| 237 | + | for _, w := range set.Windows { | |
| 238 | + | got[w.ID] = w.Rect.W | |
| 239 | + | } | |
| 240 | + | // DP-2 is 400 wide: the later rule wins the width, the generic monitor still applies. | |
| 241 | + | if got[1] != 200 || got[2] != 100 { | |
| 242 | + | t.Fatalf("widths %v", got) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // sceneMatch2 waits for a scene of the multi rig that ok accepts. | |
| 247 | + | func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Scene { | |
| 248 | + | t.Helper() | |
| 249 | + | for range 50 { | |
| 250 | + | for _, s := range receive(t, r.scenes) { | |
| 251 | + | if ok(s) { | |
| 252 | + | return s | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | t.Fatal("no matching scene") | |
| 257 | + | return ports.Scene{} | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestRuleWidthOnCurrentWorkspace(t *testing.T) { | |
| 261 | + | r := startMulti(t, func(c *ports.Config) { | |
| 262 | + | c.Layout.MaxColumns = 4 | |
| 263 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })} | |
| 264 | + | }, left) | |
| 265 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "wide"} | |
| 266 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |
| 267 | + | if s.Windows[0].Rect.W != 100 { | |
| 268 | + | t.Fatalf("width %d", s.Windows[0].Rect.W) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestSlotWinsOverRule(t *testing.T) { | |
| 273 | + | r := startMulti(t, func(c *ports.Config) { | |
| 274 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}} | |
| 275 | + | c.Binds["Alt+d"] = "workspace dev" | |
| 276 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })} | |
| 277 | + | }, left) | |
| 278 | + | req := receive(t, r.spawn) | |
| 279 | + | var slot string | |
| 280 | + | for _, e := range req.Env { | |
| 281 | + | if len(e) > len(ports.SlotEnv)+1 && e[:len(ports.SlotEnv)+1] == ports.SlotEnv+"=" { | |
| 282 | + | slot = e[len(ports.SlotEnv)+1:] | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code", Slot: slot} | |
| 286 | + | st := r.stateWith(t, 1) | |
| 287 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |
| 288 | + | t.Fatalf("rule overrode the slot: %+v", w) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestDialogStaysOverParentDespiteRules(t *testing.T) { | |
| 293 | + | r := startMulti(t, func(c *ports.Config) { | |
| 294 | + | declareDev(c) | |
| 295 | + | c.Rules = []ports.WindowRule{winRule("x", ".*", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOff() })} | |
| 296 | + | }, left) | |
| 297 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |
| 298 | + | st := r.stateWith(t, 1) | |
| 299 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" { | |
| 300 | + | t.Fatalf("parent: %+v", w) | |
| 301 | + | } | |
| 302 | + | r.key(t, "d", ports.ModAlt) | |
| 303 | + | stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |
| 304 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term", Floating: true, Width: 30, Height: 30, Parent: 1} | |
| 305 | + | st = r.stateWith(t, 2) | |
| 306 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating { | |
| 307 | + | t.Fatalf("dialog moved or tiled: %+v", w) | |
| 308 | + | } | |
| 309 | + | // Same app ID without a parent: the rule applies (tiled on dev). | |
| 310 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "term", Floating: true, Width: 30, Height: 30} | |
| 311 | + | if w := stWindow(r.stateWith(t, 3), 3); w.Floating { | |
| 312 | + | t.Fatalf("parentless fixed-size window ignores floating = off: %+v", w) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func TestAppIDChangeAfterMapMovesNothing(t *testing.T) { | |
| 317 | + | r := startMulti(t, func(c *ports.Config) { | |
| 318 | + | declareDev(c) | |
| 319 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 320 | + | }, left) | |
| 321 | + | r.mapApp(t, 1, "") | |
| 322 | + | r.client <- ports.WindowAppID{ID: 1, AppID: "code"} | |
| 323 | + | st := r.mapApp(t, 2, "term") // after the change, to know it was handled | |
| 324 | + | if w := stWindow(st, 1); w.Workspace != 1 || w.AppID != "code" { | |
| 325 | + | t.Fatalf("window re-placed: %+v", w) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestRemapKeepsPlace(t *testing.T) { | |
| 330 | + | r := startMulti(t, func(c *ports.Config) { | |
| 331 | + | declareDev(c) | |
| 332 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 333 | + | }, left) | |
| 334 | + | r.mapApp(t, 1, "term") | |
| 335 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code"} | |
| 336 | + | if w := stWindow(r.mapApp(t, 2, "term"), 1); w.Workspace != 1 { | |
| 337 | + | t.Fatalf("re-map moved the window: %+v", w) | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // A toplevel that maps again after an unmap (a tray app restored) opens as | |
| 342 | + | // any other window: on the workspace the user looks at, not its rule's. | |
| 343 | + | func TestRuleSkipsRemapAfterUnmap(t *testing.T) { | |
| 344 | + | r := startMulti(t, func(c *ports.Config) { | |
| 345 | + | declareDev(c) | |
| 346 | + | c.Rules = []ports.WindowRule{winRule("dev", "discord", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 347 | + | }, left) | |
| 348 | + | if w := stWindow(r.mapApp(t, 1, "discord"), 1); w.WorkspaceName != "dev" { | |
| 349 | + | t.Fatalf("first map not placed by the rule: %+v", w) | |
| 350 | + | } | |
| 351 | + | r.client <- ports.WindowUnmapped{ID: 1} | |
| 352 | + | stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, 1) == nil }) | |
| 353 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "discord", Remap: true} | |
| 354 | + | st := r.stateWith(t, 1) | |
| 355 | + | if w := stWindow(st, 1); w.WorkspaceName != "" || w.Workspace != 1 || stFocus(st) != 1 { | |
| 356 | + | t.Fatalf("remap placed by the rule: %+v focus %d", w, stFocus(st)) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) { | |
| 361 | + | r := startMulti(t, func(c *ports.Config) { | |
| 362 | + | declareDev(c) | |
| 363 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |
| 364 | + | }, left) | |
| 365 | + | r.mapApp(t, 1, "code") | |
| 366 | + | cfg := r.cfg | |
| 367 | + | cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })} | |
| 368 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 369 | + | // Handled in order with the map below. | |
| 370 | + | st := r.mapApp(t, 2, "code") | |
| 371 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |
| 372 | + | t.Fatalf("reload moved a mapped window: %+v", w) | |
| 373 | + | } | |
| 374 | + | if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating { | |
| 375 | + | t.Fatalf("new rules not applied: %+v", w) | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func TestRuleQuietPlacementWithAnimations(t *testing.T) { | |
| 380 | + | r := startMulti(t, func(c *ports.Config) { | |
| 381 | + | declareDev(c) | |
| 382 | + | c.Animations.On = true | |
| 383 | + | c.Rules = []ports.WindowRule{ | |
| 384 | + | winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }), | |
| 385 | + | winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |
| 386 | + | } | |
| 387 | + | }, left) | |
| 388 | + | r.mapApp(t, 1, "term") | |
| 389 | + | st := r.mapApp(t, 2, "code") | |
| 390 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 { | |
| 391 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 392 | + | } | |
| 393 | + | st = r.mapApp(t, 3, "calc") | |
| 394 | + | if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 { | |
| 395 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) { | |
| 400 | + | r := startMulti(t, func(c *ports.Config) { | |
| 401 | + | c.Layout.MaxColumns = 2 | |
| 402 | + | c.Layout.Overflow = "fixed" | |
| 403 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })} | |
| 404 | + | }, left) | |
| 405 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |
| 406 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "wide"} | |
| 407 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 408 | + | if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 { | |
| 409 | + | t.Fatalf("%+v", s.Windows) | |
| 410 | + | } | |
| 411 | + | } | |
| @@ -0,0 +1,411 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func winRule(name, appID string, edit func(*ports.WindowRule)) ports.WindowRule { | |||
| 11 | + | r := ports.WindowRule{Name: name, AppID: regexp.MustCompile("^(?:" + appID + ")$")} | |||
| 12 | + | if edit != nil { | |||
| 13 | + | edit(&r) | |||
| 14 | + | } | |||
| 15 | + | return r | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func ruleOn() *bool { v := true; return &v } | |||
| 19 | + | func ruleOff() *bool { v := false; return &v } | |||
| 20 | + | ||||
| 21 | + | func declareDev(c *ports.Config) { | |||
| 22 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev"}} | |||
| 23 | + | c.Binds["Alt+d"] = "workspace dev" | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // mapApp maps a window with an app ID and returns the state once it is in. | |||
| 27 | + | func (r *multiRig) mapApp(t *testing.T, id ports.WindowID, appID string) ports.State { | |||
| 28 | + | t.Helper() | |||
| 29 | + | r.client <- ports.WindowMapped{ID: id, AppID: appID} | |||
| 30 | + | return r.stateWith(t, id) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (r *multiRig) stateWith(t *testing.T, id ports.WindowID) ports.State { | |||
| 34 | + | t.Helper() | |||
| 35 | + | return stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, id) != nil }) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func stWindow(st ports.State, id ports.WindowID) *ports.WindowState { | |||
| 39 | + | for i := range st.Windows { | |||
| 40 | + | if st.Windows[i].ID == id { | |||
| 41 | + | return &st.Windows[i] | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func stFocus(st ports.State) ports.WindowID { | |||
| 48 | + | if st.Window == nil { | |||
| 49 | + | return 0 | |||
| 50 | + | } | |||
| 51 | + | return st.Window.ID | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func stOutput(st ports.State, name string) ports.OutputState { | |||
| 55 | + | for _, o := range st.Outputs { | |||
| 56 | + | if o.Name == name { | |||
| 57 | + | return o | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return ports.OutputState{} | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestRuleFloatingOnAndOff(t *testing.T) { | |||
| 64 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 65 | + | c.Rules = []ports.WindowRule{ | |||
| 66 | + | winRule("float", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |||
| 67 | + | winRule("tile", "splash", func(r *ports.WindowRule) { r.Floating = ruleOff() }), | |||
| 68 | + | } | |||
| 69 | + | }, left) | |||
| 70 | + | r.mapApp(t, 1, "term") | |||
| 71 | + | st := r.mapApp(t, 2, "calc") | |||
| 72 | + | if w := stWindow(st, 2); !w.Floating || stFocus(st) != 2 { | |||
| 73 | + | t.Fatalf("calc not floating on the current workspace: %+v focus %d", w, stFocus(st)) | |||
| 74 | + | } | |||
| 75 | + | if w := stWindow(st, 1); w.Floating { | |||
| 76 | + | t.Fatal("unrelated window floats") | |||
| 77 | + | } | |||
| 78 | + | // A fixed-size window (Floating from the client) tiles under floating = off. | |||
| 79 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "splash", Floating: true, Width: 50, Height: 40} | |||
| 80 | + | st = r.stateWith(t, 3) | |||
| 81 | + | if w := stWindow(st, 3); w.Floating || w.Column == 0 { | |||
| 82 | + | t.Fatalf("splash not tiled: %+v", w) | |||
| 83 | + | } | |||
| 84 | + | // Without a rule the same window floats as before. | |||
| 85 | + | r.client <- ports.WindowMapped{ID: 4, AppID: "other", Floating: true, Width: 50, Height: 40} | |||
| 86 | + | if w := stWindow(r.stateWith(t, 4), 4); !w.Floating { | |||
| 87 | + | t.Fatalf("unmatched fixed-size window tiled: %+v", w) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestRuleNamedWorkspaceKeepsFocusAndView(t *testing.T) { | |||
| 92 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 93 | + | declareDev(c) | |||
| 94 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 95 | + | }, left) | |||
| 96 | + | r.mapApp(t, 1, "term") | |||
| 97 | + | st := r.mapApp(t, 2, "code") | |||
| 98 | + | w := stWindow(st, 2) | |||
| 99 | + | if w.WorkspaceName != "dev" || w.Workspace != 0 || w.Visible { | |||
| 100 | + | t.Fatalf("window not quietly on dev: %+v", w) | |||
| 101 | + | } | |||
| 102 | + | if stFocus(st) != 1 || stOutput(st, "DP-1").Workspace != "" || stOutput(st, "DP-1").Active != 1 { | |||
| 103 | + | t.Fatalf("rule moved focus or view: %+v", st) | |||
| 104 | + | } | |||
| 105 | + | // The workspace shows its window when the user goes there. | |||
| 106 | + | r.key(t, "d", ports.ModAlt) | |||
| 107 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |||
| 108 | + | if stFocus(st) != 2 { | |||
| 109 | + | t.Fatalf("dev window not focused on arrival: %d", stFocus(st)) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestRuleNamedWorkspaceFloat(t *testing.T) { | |||
| 114 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 115 | + | declareDev(c) | |||
| 116 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOn() })} | |||
| 117 | + | }, left) | |||
| 118 | + | r.mapApp(t, 1, "term") | |||
| 119 | + | st := r.mapApp(t, 2, "code") | |||
| 120 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating || stFocus(st) != 1 { | |||
| 121 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 122 | + | } | |||
| 123 | + | r.key(t, "d", ports.ModAlt) | |||
| 124 | + | st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |||
| 125 | + | if stFocus(st) != 2 { | |||
| 126 | + | t.Fatalf("quiet float not focused on arrival: %d", stFocus(st)) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestRuleUndeclaredWorkspaceFallsBack(t *testing.T) { | |||
| 131 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 132 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "nope" })} | |||
| 133 | + | }, left) | |||
| 134 | + | r.mapApp(t, 1, "term") | |||
| 135 | + | st := r.mapApp(t, 2, "code") | |||
| 136 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |||
| 137 | + | t.Fatalf("%+v", w) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestRuleNumberedWorkspace(t *testing.T) { | |||
| 142 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 143 | + | c.Rules = []ports.WindowRule{ | |||
| 144 | + | winRule("two", "two", func(r *ports.WindowRule) { r.Workspace = "2" }), | |||
| 145 | + | winRule("far", "far", func(r *ports.WindowRule) { r.Workspace = "9" }), | |||
| 146 | + | } | |||
| 147 | + | }, left) | |||
| 148 | + | r.mapApp(t, 1, "term") | |||
| 149 | + | st := r.mapApp(t, 2, "two") | |||
| 150 | + | if w := stWindow(st, 2); w.Workspace != 2 || stFocus(st) != 1 || stOutput(st, "DP-1").Active != 1 { | |||
| 151 | + | t.Fatalf("%+v active %d focus %d", w, stOutput(st, "DP-1").Active, stFocus(st)) | |||
| 152 | + | } | |||
| 153 | + | // 9 is clamped to the last workspace, the spare after workspace 2: no | |||
| 154 | + | // pile of empty workspaces in between. | |||
| 155 | + | st = r.mapApp(t, 3, "far") | |||
| 156 | + | if w := stWindow(st, 3); w.Workspace != 3 { | |||
| 157 | + | t.Fatalf("clamped to %d", w.Workspace) | |||
| 158 | + | } | |||
| 159 | + | if o := stOutput(st, "DP-1"); o.Count != 3 || o.Active != 1 || stFocus(st) != 1 { | |||
| 160 | + | t.Fatalf("%+v focus %d", o, stFocus(st)) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestRuleNumberOnCurrentWorkspaceActsAsNormalMap(t *testing.T) { | |||
| 165 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 166 | + | c.Rules = []ports.WindowRule{winRule("one", "term", func(r *ports.WindowRule) { r.Workspace = "1" })} | |||
| 167 | + | }, left) | |||
| 168 | + | r.mapApp(t, 1, "other") | |||
| 169 | + | st := r.mapApp(t, 2, "term") | |||
| 170 | + | if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 { | |||
| 171 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestRuleMonitor(t *testing.T) { | |||
| 176 | + | for _, mon := range []string{"DP-2", "Acme B 2"} { | |||
| 177 | + | t.Run(mon, func(t *testing.T) { | |||
| 178 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 179 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = mon })} | |||
| 180 | + | }, left, right) | |||
| 181 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 182 | + | before := r.mapApp(t, 1, "term") | |||
| 183 | + | if before.Output != "DP-1" { | |||
| 184 | + | t.Fatalf("focused %s", before.Output) | |||
| 185 | + | } | |||
| 186 | + | st := r.mapApp(t, 2, "chat") | |||
| 187 | + | w := stWindow(st, 2) | |||
| 188 | + | if w.Output != "DP-2" || st.Output != "DP-1" || stFocus(st) != 1 { | |||
| 189 | + | t.Fatalf("%+v focused output %s window %d", w, st.Output, stFocus(st)) | |||
| 190 | + | } | |||
| 191 | + | if !w.Visible { | |||
| 192 | + | t.Fatal("the window is not shown on the current workspace of DP-2") | |||
| 193 | + | } | |||
| 194 | + | }) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestRuleMonitorWithNumberedWorkspace(t *testing.T) { | |||
| 199 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 200 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Workspace = "2" })} | |||
| 201 | + | }, left, right) | |||
| 202 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 203 | + | r.mapApp(t, 3, "term") // DP-2 gets a window, so a second workspace | |||
| 204 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 205 | + | r.mapApp(t, 1, "term") | |||
| 206 | + | st := r.mapApp(t, 2, "chat") | |||
| 207 | + | if w := stWindow(st, 2); w.Output != "DP-2" || w.Workspace != 2 || st.Output != "DP-1" || stFocus(st) != 1 || stOutput(st, "DP-2").Active != 1 { | |||
| 208 | + | t.Fatalf("%+v", w) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestRuleDisconnectedMonitorFallsBack(t *testing.T) { | |||
| 213 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 214 | + | c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "HDMI-9" })} | |||
| 215 | + | }, left, right) | |||
| 216 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 217 | + | r.mapApp(t, 1, "term") | |||
| 218 | + | st := r.mapApp(t, 2, "chat") | |||
| 219 | + | if w := stWindow(st, 2); w.Output != "DP-1" || stFocus(st) != 2 { | |||
| 220 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestRulesMerge(t *testing.T) { | |||
| 225 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 226 | + | c.Layout.MaxColumns = 4 | |||
| 227 | + | c.Rules = []ports.WindowRule{ | |||
| 228 | + | winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }), | |||
| 229 | + | winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }), | |||
| 230 | + | } | |||
| 231 | + | }, left, right) | |||
| 232 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 233 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "chat"} | |||
| 234 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term"} | |||
| 235 | + | set := sceneMatch2(t, r, func(s ports.Scene) bool { return s.Output == "DP-2" && len(s.Windows) == 2 }) | |||
| 236 | + | got := map[ports.WindowID]int{} | |||
| 237 | + | for _, w := range set.Windows { | |||
| 238 | + | got[w.ID] = w.Rect.W | |||
| 239 | + | } | |||
| 240 | + | // DP-2 is 400 wide: the later rule wins the width, the generic monitor still applies. | |||
| 241 | + | if got[1] != 200 || got[2] != 100 { | |||
| 242 | + | t.Fatalf("widths %v", got) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // sceneMatch2 waits for a scene of the multi rig that ok accepts. | |||
| 247 | + | func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Scene { | |||
| 248 | + | t.Helper() | |||
| 249 | + | for range 50 { | |||
| 250 | + | for _, s := range receive(t, r.scenes) { | |||
| 251 | + | if ok(s) { | |||
| 252 | + | return s | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | t.Fatal("no matching scene") | |||
| 257 | + | return ports.Scene{} | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestRuleWidthOnCurrentWorkspace(t *testing.T) { | |||
| 261 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 262 | + | c.Layout.MaxColumns = 4 | |||
| 263 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })} | |||
| 264 | + | }, left) | |||
| 265 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "wide"} | |||
| 266 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |||
| 267 | + | if s.Windows[0].Rect.W != 100 { | |||
| 268 | + | t.Fatalf("width %d", s.Windows[0].Rect.W) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestSlotWinsOverRule(t *testing.T) { | |||
| 273 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 274 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}} | |||
| 275 | + | c.Binds["Alt+d"] = "workspace dev" | |||
| 276 | + | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })} | |||
| 277 | + | }, left) | |||
| 278 | + | req := receive(t, r.spawn) | |||
| 279 | + | var slot string | |||
| 280 | + | for _, e := range req.Env { | |||
| 281 | + | if len(e) > len(ports.SlotEnv)+1 && e[:len(ports.SlotEnv)+1] == ports.SlotEnv+"=" { | |||
| 282 | + | slot = e[len(ports.SlotEnv)+1:] | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code", Slot: slot} | |||
| 286 | + | st := r.stateWith(t, 1) | |||
| 287 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |||
| 288 | + | t.Fatalf("rule overrode the slot: %+v", w) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func TestDialogStaysOverParentDespiteRules(t *testing.T) { | |||
| 293 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 294 | + | declareDev(c) | |||
| 295 | + | c.Rules = []ports.WindowRule{winRule("x", ".*", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOff() })} | |||
| 296 | + | }, left) | |||
| 297 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |||
| 298 | + | st := r.stateWith(t, 1) | |||
| 299 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" { | |||
| 300 | + | t.Fatalf("parent: %+v", w) | |||
| 301 | + | } | |||
| 302 | + | r.key(t, "d", ports.ModAlt) | |||
| 303 | + | stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" }) | |||
| 304 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "term", Floating: true, Width: 30, Height: 30, Parent: 1} | |||
| 305 | + | st = r.stateWith(t, 2) | |||
| 306 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating { | |||
| 307 | + | t.Fatalf("dialog moved or tiled: %+v", w) | |||
| 308 | + | } | |||
| 309 | + | // Same app ID without a parent: the rule applies (tiled on dev). | |||
| 310 | + | r.client <- ports.WindowMapped{ID: 3, AppID: "term", Floating: true, Width: 30, Height: 30} | |||
| 311 | + | if w := stWindow(r.stateWith(t, 3), 3); w.Floating { | |||
| 312 | + | t.Fatalf("parentless fixed-size window ignores floating = off: %+v", w) | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | func TestAppIDChangeAfterMapMovesNothing(t *testing.T) { | |||
| 317 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 318 | + | declareDev(c) | |||
| 319 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 320 | + | }, left) | |||
| 321 | + | r.mapApp(t, 1, "") | |||
| 322 | + | r.client <- ports.WindowAppID{ID: 1, AppID: "code"} | |||
| 323 | + | st := r.mapApp(t, 2, "term") // after the change, to know it was handled | |||
| 324 | + | if w := stWindow(st, 1); w.Workspace != 1 || w.AppID != "code" { | |||
| 325 | + | t.Fatalf("window re-placed: %+v", w) | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func TestRemapKeepsPlace(t *testing.T) { | |||
| 330 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 331 | + | declareDev(c) | |||
| 332 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 333 | + | }, left) | |||
| 334 | + | r.mapApp(t, 1, "term") | |||
| 335 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "code"} | |||
| 336 | + | if w := stWindow(r.mapApp(t, 2, "term"), 1); w.Workspace != 1 { | |||
| 337 | + | t.Fatalf("re-map moved the window: %+v", w) | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // A toplevel that maps again after an unmap (a tray app restored) opens as | |||
| 342 | + | // any other window: on the workspace the user looks at, not its rule's. | |||
| 343 | + | func TestRuleSkipsRemapAfterUnmap(t *testing.T) { | |||
| 344 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 345 | + | declareDev(c) | |||
| 346 | + | c.Rules = []ports.WindowRule{winRule("dev", "discord", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 347 | + | }, left) | |||
| 348 | + | if w := stWindow(r.mapApp(t, 1, "discord"), 1); w.WorkspaceName != "dev" { | |||
| 349 | + | t.Fatalf("first map not placed by the rule: %+v", w) | |||
| 350 | + | } | |||
| 351 | + | r.client <- ports.WindowUnmapped{ID: 1} | |||
| 352 | + | stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, 1) == nil }) | |||
| 353 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "discord", Remap: true} | |||
| 354 | + | st := r.stateWith(t, 1) | |||
| 355 | + | if w := stWindow(st, 1); w.WorkspaceName != "" || w.Workspace != 1 || stFocus(st) != 1 { | |||
| 356 | + | t.Fatalf("remap placed by the rule: %+v focus %d", w, stFocus(st)) | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) { | |||
| 361 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 362 | + | declareDev(c) | |||
| 363 | + | c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })} | |||
| 364 | + | }, left) | |||
| 365 | + | r.mapApp(t, 1, "code") | |||
| 366 | + | cfg := r.cfg | |||
| 367 | + | cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })} | |||
| 368 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 369 | + | // Handled in order with the map below. | |||
| 370 | + | st := r.mapApp(t, 2, "code") | |||
| 371 | + | if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating { | |||
| 372 | + | t.Fatalf("reload moved a mapped window: %+v", w) | |||
| 373 | + | } | |||
| 374 | + | if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating { | |||
| 375 | + | t.Fatalf("new rules not applied: %+v", w) | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | func TestRuleQuietPlacementWithAnimations(t *testing.T) { | |||
| 380 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 381 | + | declareDev(c) | |||
| 382 | + | c.Animations.On = true | |||
| 383 | + | c.Rules = []ports.WindowRule{ | |||
| 384 | + | winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }), | |||
| 385 | + | winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }), | |||
| 386 | + | } | |||
| 387 | + | }, left) | |||
| 388 | + | r.mapApp(t, 1, "term") | |||
| 389 | + | st := r.mapApp(t, 2, "code") | |||
| 390 | + | if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 { | |||
| 391 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 392 | + | } | |||
| 393 | + | st = r.mapApp(t, 3, "calc") | |||
| 394 | + | if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 { | |||
| 395 | + | t.Fatalf("%+v focus %d", w, stFocus(st)) | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) { | |||
| 400 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 401 | + | c.Layout.MaxColumns = 2 | |||
| 402 | + | c.Layout.Overflow = "fixed" | |||
| 403 | + | c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })} | |||
| 404 | + | }, left) | |||
| 405 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |||
| 406 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "wide"} | |||
| 407 | + | s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |||
| 408 | + | if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 { | |||
| 409 | + | t.Fatalf("%+v", s.Windows) | |||
| 410 | + | } | |||
| 411 | + | } | |||
M internal/core/security.go +6 -3
| @@ -41,6 +41,9 @@ func (c *Core) syncSecurity() bool { | |||
| 41 | 41 | } | |
| 42 | 42 | c.dropSwipe() | |
| 43 | 43 | 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 | 47 | c.stopAnimations() | |
| 45 | 48 | c.stopPulse() | |
| 46 | 49 | if state.Protected { | |
| @@ -64,7 +67,7 @@ func (c *Core) securityCheckpoint(ctx context.Context) error { | |||
| 64 | 67 | return nil | |
| 65 | 68 | } | |
| 66 | 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 | 71 | return err | |
| 69 | 72 | } | |
| 70 | 73 | if err := c.command(ctx, ports.FocusWindow{}); err != nil { | |
| @@ -204,7 +207,7 @@ func (c *Core) publishProtected(ctx context.Context) error { | |||
| 204 | 207 | if !c.validLockID(c.pointer) { | |
| 205 | 208 | 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 | 211 | return err | |
| 209 | 212 | } | |
| 210 | 213 | latest(c.ch.Scenes, scenes) | |
| @@ -259,7 +262,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 259 | 262 | id, x, y := c.lockHit(c.cursorX, c.cursorY) | |
| 260 | 263 | if id != c.pointer { | |
| 261 | 264 | 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 | 266 | return err | |
| 264 | 267 | } | |
| 265 | 268 | } | |
| @@ -41,6 +41,9 @@ func (c *Core) syncSecurity() bool { | |||||
| 41 | } | 41 | } | ||
| 42 | c.dropSwipe() | 42 | c.dropSwipe() | ||
| 43 | c.swipe = nil | 43 | 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 nil | 67 | 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 err | 71 | 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 = 0 | 208 | 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 err | 211 | 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 = id | 264 | 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 err | 266 | 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) { | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | // place claims a token once and puts its window in the target workspace. | |
| 73 | + | // Without a slot, a window rule (rules.go) may choose its place; otherwise it | |
| 74 | + | // opens on the focused screen's workspace. | |
| 73 | 75 | // Untagged windows return before any map lookup or allocation. | |
| 74 | 76 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |
| 75 | 77 | if v.Floating { | |
| @@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 82 | 84 | ps.mon.normalize() | |
| 83 | 85 | return | |
| 84 | 86 | } | |
| 87 | + | // Rules come after the dialog: it stays over its parent. | |
| 88 | + | if eff, ok := c.ruleFor(v); ok { | |
| 89 | + | c.placeByRule(v, eff) | |
| 90 | + | return | |
| 91 | + | } | |
| 85 | 92 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | |
| 86 | 93 | return | |
| 87 | 94 | } | |
| @@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 108 | 115 | } | |
| 109 | 116 | } | |
| 110 | 117 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 118 | + | if eff, ok := c.ruleFor(v); ok { | |
| 119 | + | c.placeByRule(v, eff) | |
| 120 | + | return | |
| 121 | + | } | |
| 111 | 122 | c.cur().mon.AddWindow(v.ID) | |
| 112 | 123 | } | |
| 113 | 124 | } | |
| @@ -70,6 +70,8 @@ func (p *spawnPlacement) expireTerminals(now time.Time) { | |||||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | // place claims a token once and puts its window in the target workspace. | 72 | // place claims a token once and puts its window in the target workspace. | ||
| 73 | + | // Without a slot, a window rule (rules.go) may choose its place; otherwise it | |||
| 74 | + | // opens on the focused screen's workspace. | |||
| 73 | // Untagged windows return before any map lookup or allocation. | 75 | // Untagged windows return before any map lookup or allocation. | ||
| 74 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | 76 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | ||
| 75 | if v.Floating { | 77 | if v.Floating { | ||
| @@ -82,6 +84,11 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 82 | ps.mon.normalize() | 84 | ps.mon.normalize() | ||
| 83 | return | 85 | return | ||
| 84 | } | 86 | } | ||
| 87 | + | // Rules come after the dialog: it stays over its parent. | |||
| 88 | + | if eff, ok := c.ruleFor(v); ok { | |||
| 89 | + | c.placeByRule(v, eff) | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 85 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | 92 | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | ||
| 86 | return | 93 | return | ||
| 87 | } | 94 | } | ||
| @@ -108,6 +115,10 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 108 | } | 115 | } | ||
| 109 | } | 116 | } | ||
| 110 | if s, _ := c.screenOf(v.ID); s == nil { | 117 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 118 | + | if eff, ok := c.ruleFor(v); ok { | |||
| 119 | + | c.placeByRule(v, eff) | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 111 | c.cur().mon.AddWindow(v.ID) | 122 | c.cur().mon.AddWindow(v.ID) | ||
| 112 | } | 123 | } | ||
| 113 | } | 124 | } | ||
M internal/ports/client_commands.go +49 -0
| @@ -192,6 +192,55 @@ type PointerAxisTo struct { | |||
| 192 | 192 | ||
| 193 | 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 | 244 | // ForwardKey carries core → wayland unbound keys. | |
| 196 | 245 | type ForwardKey struct { | |
| 197 | 246 | 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 WindowID | 246 | ID WindowID | ||
M internal/ports/client_events.go +3 -0
| @@ -20,6 +20,9 @@ type WindowMapped struct { | |||
| 20 | 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | |
| 21 | 21 | // it. | |
| 22 | 22 | Parent WindowID | |
| 23 | + | // Remap is set when the toplevel mapped before and was unmapped (a tray | |
| 24 | + | // app restored): window rules only place its first map. | |
| 25 | + | Remap bool | |
| 23 | 26 | } | |
| 24 | 27 | ||
| 25 | 28 | // SlotEnv is the environment variable neferwl sets on processes it spawns | |
| @@ -20,6 +20,9 @@ type WindowMapped struct { | |||||
| 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | ||
| 21 | // it. | 21 | // it. | ||
| 22 | Parent WindowID | 22 | Parent WindowID | ||
| 23 | + | // Remap is set when the toplevel mapped before and was unmapped (a tray | |||
| 24 | + | // app restored): window rules only place its first map. | |||
| 25 | + | Remap bool | |||
| 23 | } | 26 | } | ||
| 24 | 27 | ||||
| 25 | // SlotEnv is the environment variable neferwl sets on processes it spawns | 28 | // SlotEnv is the environment variable neferwl sets on processes it spawns | ||
M internal/ports/config.go +20 -0
| @@ -2,6 +2,7 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | + | "regexp" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ) | |
| @@ -164,6 +165,8 @@ type Config struct { | |||
| 164 | 165 | } | |
| 165 | 166 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 166 | 167 | Workspaces []WorkspaceConfig | |
| 168 | + | // Rules are the window rules (rule.<name>.* keys), in first-seen order. | |
| 169 | + | Rules []WindowRule | |
| 167 | 170 | // Outputs selects and configures physical displays (drm backend). | |
| 168 | 171 | Outputs []OutputConfig | |
| 169 | 172 | Binds map[string]string | |
| @@ -257,3 +260,20 @@ type SlotConfig struct { | |||
| 257 | 260 | Width string | |
| 258 | 261 | Argv []string | |
| 259 | 262 | } | |
| 263 | + | ||
| 264 | + | // WindowRule places the windows whose app ID it matches when they first map. | |
| 265 | + | // Matching rules merge in config order, a later rule winning per field. | |
| 266 | + | type WindowRule struct { | |
| 267 | + | Name string | |
| 268 | + | // AppID matches the whole app ID (the config anchors it). | |
| 269 | + | AppID *regexp.Regexp | |
| 270 | + | // Floating forces a window to float (true) or to tile (false); nil leaves it. | |
| 271 | + | Floating *bool | |
| 272 | + | // Workspace is a 1-based number of a numbered workspace or the name of a | |
| 273 | + | // declared one; empty leaves it. | |
| 274 | + | Workspace string | |
| 275 | + | // Monitor is a connector (DP-2) or a monitor key ("make model serial"). | |
| 276 | + | Monitor string | |
| 277 | + | // Width is the layout width of the new column of a tiled window. | |
| 278 | + | Width string | |
| 279 | + | } | |
| @@ -2,6 +2,7 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "image" | 4 | "image" | ||
| 5 | + | "regexp" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | ) | 8 | ) | ||
| @@ -164,6 +165,8 @@ type Config struct { | |||||
| 164 | } | 165 | } | ||
| 165 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | 166 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | ||
| 166 | Workspaces []WorkspaceConfig | 167 | Workspaces []WorkspaceConfig | ||
| 168 | + | // Rules are the window rules (rule.<name>.* keys), in first-seen order. | |||
| 169 | + | Rules []WindowRule | |||
| 167 | // Outputs selects and configures physical displays (drm backend). | 170 | // Outputs selects and configures physical displays (drm backend). | ||
| 168 | Outputs []OutputConfig | 171 | Outputs []OutputConfig | ||
| 169 | Binds map[string]string | 172 | Binds map[string]string | ||
| @@ -257,3 +260,20 @@ type SlotConfig struct { | |||||
| 257 | Width string | 260 | Width string | ||
| 258 | Argv []string | 261 | Argv []string | ||
| 259 | } | 262 | } | ||
| 263 | + | ||||
| 264 | + | // WindowRule places the windows whose app ID it matches when they first map. | |||
| 265 | + | // Matching rules merge in config order, a later rule winning per field. | |||
| 266 | + | type WindowRule struct { | |||
| 267 | + | Name string | |||
| 268 | + | // AppID matches the whole app ID (the config anchors it). | |||
| 269 | + | AppID *regexp.Regexp | |||
| 270 | + | // Floating forces a window to float (true) or to tile (false); nil leaves it. | |||
| 271 | + | Floating *bool | |||
| 272 | + | // Workspace is a 1-based number of a numbered workspace or the name of a | |||
| 273 | + | // declared one; empty leaves it. | |||
| 274 | + | Workspace string | |||
| 275 | + | // Monitor is a connector (DP-2) or a monitor key ("make model serial"). | |||
| 276 | + | Monitor string | |||
| 277 | + | // Width is the layout width of the new column of a tiled window. | |||
| 278 | + | Width string | |||
| 279 | + | } | |||
M internal/ports/input_events.go +56 -5
| @@ -88,18 +88,23 @@ type PointerAxis struct { | |||
| 88 | 88 | ||
| 89 | 89 | func (PointerAxis) inputEvent() {} | |
| 90 | 90 | ||
| 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. | |
| 93 | 95 | type SwipeBegin struct { | |
| 94 | 96 | Fingers int | |
| 95 | 97 | Time time.Duration | |
| 96 | 98 | } | |
| 97 | 99 | ||
| 98 | 100 | // 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 | 104 | type SwipeUpdate struct { | |
| 101 | - | DX, DY float64 | |
| 102 | - | Time time.Duration | |
| 105 | + | DX, DY float64 | |
| 106 | + | AccelDX, AccelDY float64 | |
| 107 | + | Time time.Duration | |
| 103 | 108 | } | |
| 104 | 109 | ||
| 105 | 110 | // SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled | |
| @@ -112,3 +117,49 @@ type SwipeEnd struct { | |||
| 112 | 117 | func (SwipeBegin) inputEvent() {} | |
| 113 | 118 | func (SwipeUpdate) inputEvent() {} | |
| 114 | 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() {} | |
| @@ -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 the | 91 | + | // 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 int | 96 | Fingers int | ||
| 95 | Time time.Duration | 97 | Time time.Duration | ||
| 96 | } | 98 | } | ||
| 97 | 99 | ||||
| 98 | // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY | 100 | // 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 float64 | 105 | + | DX, DY float64 |
| 102 | - | Time time.Duration | 106 | + | AccelDX, AccelDY float64 |
| 107 | + | Time time.Duration | |||
| 103 | } | 108 | } | ||
| 104 | 109 | ||||
| 105 | // SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled | 110 | // 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() {} | |||