Merge pull request 'fix: v0.7.0 pre-release review fixes' (#30) from fix/v0.7.0-review into main
16 files · +142 -19
M docs/config.md +2 -2
| @@ -100,7 +100,7 @@ rule.calc.floating = on | |||
| 100 | 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 | 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 | 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. | |
| 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. An invalid value that repeats a key already set keeps the earlier value, like any other key. | |
| 104 | 104 | ||
| 105 | 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 | 106 | ||
| @@ -186,7 +186,7 @@ Over the shown stash a sideways swipe slides it with the fingers, with a stop on | |||
| 186 | 186 | ||
| 187 | 187 | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | |
| 188 | 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. | |
| 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. While the session is locked, only the lock screen gets gestures. | |
| 190 | 190 | ||
| 191 | 191 | ## HDR | |
| 192 | 192 | ||
| @@ -100,7 +100,7 @@ rule.calc.floating = on | |||||
| 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. | 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. | 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. | 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. | 103 | + | - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. An invalid value that repeats a key already set keeps the earlier value, like any other key. |
| 104 | 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. | 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 | 106 | ||||
| @@ -186,7 +186,7 @@ Over the shown stash a sideways swipe slides it with the fingers, with a stop on | |||||
| 186 | 186 | ||||
| 187 | 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. | ||
| 188 | 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. | 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. While the session is locked, only the lock screen gets gestures. |
| 190 | 190 | ||||
| 191 | ## HDR | 191 | ## HDR | ||
| 192 | 192 | ||||
M docs/desktop.md +1 -1
| @@ -102,7 +102,7 @@ exec wlopm --off '*' | |||
| 102 | 102 | ||
| 103 | 103 | ## Power keys | |
| 104 | 104 | ||
| 105 | - | NeferWL takes logind's `handle-power-key`, `handle-suspend-key`, `handle-hibernate-key` and `handle-reboot-key` inhibitor locks. logind then no longer powers off, suspends, hibernates or reboots when one of those keys is pressed: the key reaches NeferWL, and nothing happens unless a bind uses it. | |
| 105 | + | NeferWL takes logind's `handle-power-key`, `handle-suspend-key`, `handle-hibernate-key` and `handle-reboot-key` inhibitor locks. logind then no longer powers off, suspends, hibernates or reboots when one of those keys is pressed: the key reaches NeferWL, and nothing happens unless a bind uses it. Binds are off while the session is locked, so these keys do nothing on the lock screen. | |
| 106 | 106 | ||
| 107 | 107 | | logind key | Kernel key | Bind | | |
| 108 | 108 | |---|---|---| | |
| @@ -102,7 +102,7 @@ exec wlopm --off '*' | |||||
| 102 | 102 | ||||
| 103 | ## Power keys | 103 | ## Power keys | ||
| 104 | 104 | ||||
| 105 | - | NeferWL takes logind's `handle-power-key`, `handle-suspend-key`, `handle-hibernate-key` and `handle-reboot-key` inhibitor locks. logind then no longer powers off, suspends, hibernates or reboots when one of those keys is pressed: the key reaches NeferWL, and nothing happens unless a bind uses it. | 105 | + | NeferWL takes logind's `handle-power-key`, `handle-suspend-key`, `handle-hibernate-key` and `handle-reboot-key` inhibitor locks. logind then no longer powers off, suspends, hibernates or reboots when one of those keys is pressed: the key reaches NeferWL, and nothing happens unless a bind uses it. Binds are off while the session is locked, so these keys do nothing on the lock screen. |
| 106 | 106 | ||||
| 107 | | logind key | Kernel key | Bind | | 107 | | logind key | Kernel key | Bind | | ||
| 108 | |---|---|---| | 108 | |---|---|---| | ||
M internal/adapters/busretry/busretry_test.go +4 -1
| @@ -20,7 +20,10 @@ func TestRunRetriesUntilCancelled(t *testing.T) { | |||
| 20 | 20 | defer close(done) | |
| 21 | 21 | Run(ctx, time.Millisecond, "test feature", logging.For(ctx, "test"), func(context.Context) error { | |
| 22 | 22 | n++ | |
| 23 | - | calls <- n | |
| 23 | + | select { | |
| 24 | + | case calls <- n: | |
| 25 | + | case <-ctx.Done(): | |
| 26 | + | } | |
| 24 | 27 | if n%2 == 0 { | |
| 25 | 28 | return nil | |
| 26 | 29 | } | |
| @@ -20,7 +20,10 @@ func TestRunRetriesUntilCancelled(t *testing.T) { | |||||
| 20 | defer close(done) | 20 | defer close(done) | ||
| 21 | Run(ctx, time.Millisecond, "test feature", logging.For(ctx, "test"), func(context.Context) error { | 21 | Run(ctx, time.Millisecond, "test feature", logging.For(ctx, "test"), func(context.Context) error { | ||
| 22 | n++ | 22 | n++ | ||
| 23 | - | calls <- n | 23 | + | select { |
| 24 | + | case calls <- n: | |||
| 25 | + | case <-ctx.Done(): | |||
| 26 | + | } | |||
| 24 | if n%2 == 0 { | 27 | if n%2 == 0 { | ||
| 25 | return nil | 28 | return nil | ||
| 26 | } | 29 | } | ||
M internal/adapters/config/config.go +10 -3
| @@ -531,7 +531,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 531 | 531 | } | |
| 532 | 532 | if err := setRule(&e.rule, field, value); err != nil { | |
| 533 | 533 | warn("%s: %v", key, err) | |
| 534 | - | if !errors.Is(err, errUnknownRuleField) { | |
| 534 | + | // Like any key, an invalid duplicate keeps the earlier value. | |
| 535 | + | if _, dup := seen[key]; !dup && !errors.Is(err, errUnknownRuleField) { | |
| 535 | 536 | e.dropped = true | |
| 536 | 537 | } | |
| 537 | 538 | continue | |
| @@ -1016,7 +1017,8 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | |||
| 1016 | 1017 | type ruleEntry struct { | |
| 1017 | 1018 | rule ports.WindowRule | |
| 1018 | 1019 | line int // of its first key | |
| 1019 | - | // dropped is set by an invalid value: the whole rule is ignored. | |
| 1020 | + | // dropped is set by an invalid value with no valid one before it: the | |
| 1021 | + | // whole rule is ignored. | |
| 1020 | 1022 | dropped bool | |
| 1021 | 1023 | } | |
| 1022 | 1024 | ||
| @@ -1035,7 +1037,12 @@ func setRule(r *ports.WindowRule, field, v string) error { | |||
| 1035 | 1037 | return fmt.Errorf("invalid regular expression: %v", err) | |
| 1036 | 1038 | } | |
| 1037 | 1039 | // The whole app ID must match: "steam" does not match "steam_app_1". | |
| 1038 | - | r.AppID = regexp.MustCompile("^(?:" + v + ")$") | |
| 1040 | + | // A value can still break the wrapping ("\Qsteam" quotes the ")"). | |
| 1041 | + | re, err := regexp.Compile("^(?:" + v + ")$") | |
| 1042 | + | if err != nil { | |
| 1043 | + | return fmt.Errorf("invalid regular expression: %v", err) | |
| 1044 | + | } | |
| 1045 | + | r.AppID = re | |
| 1039 | 1046 | case "floating": | |
| 1040 | 1047 | b, err := onOff(v) | |
| 1041 | 1048 | if err != nil { | |
| @@ -531,7 +531,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 531 | } | 531 | } | ||
| 532 | if err := setRule(&e.rule, field, value); err != nil { | 532 | if err := setRule(&e.rule, field, value); err != nil { | ||
| 533 | warn("%s: %v", key, err) | 533 | warn("%s: %v", key, err) | ||
| 534 | - | if !errors.Is(err, errUnknownRuleField) { | 534 | + | // Like any key, an invalid duplicate keeps the earlier value. |
| 535 | + | if _, dup := seen[key]; !dup && !errors.Is(err, errUnknownRuleField) { | |||
| 535 | e.dropped = true | 536 | e.dropped = true | ||
| 536 | } | 537 | } | ||
| 537 | continue | 538 | continue | ||
| @@ -1016,7 +1017,8 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | |||||
| 1016 | type ruleEntry struct { | 1017 | type ruleEntry struct { | ||
| 1017 | rule ports.WindowRule | 1018 | rule ports.WindowRule | ||
| 1018 | line int // of its first key | 1019 | line int // of its first key | ||
| 1019 | - | // dropped is set by an invalid value: the whole rule is ignored. | 1020 | + | // dropped is set by an invalid value with no valid one before it: the |
| 1021 | + | // whole rule is ignored. | |||
| 1020 | dropped bool | 1022 | dropped bool | ||
| 1021 | } | 1023 | } | ||
| 1022 | 1024 | ||||
| @@ -1035,7 +1037,12 @@ func setRule(r *ports.WindowRule, field, v string) error { | |||||
| 1035 | return fmt.Errorf("invalid regular expression: %v", err) | 1037 | return fmt.Errorf("invalid regular expression: %v", err) | ||
| 1036 | } | 1038 | } | ||
| 1037 | // The whole app ID must match: "steam" does not match "steam_app_1". | 1039 | // The whole app ID must match: "steam" does not match "steam_app_1". | ||
| 1038 | - | r.AppID = regexp.MustCompile("^(?:" + v + ")$") | 1040 | + | // A value can still break the wrapping ("\Qsteam" quotes the ")"). |
| 1041 | + | re, err := regexp.Compile("^(?:" + v + ")$") | |||
| 1042 | + | if err != nil { | |||
| 1043 | + | return fmt.Errorf("invalid regular expression: %v", err) | |||
| 1044 | + | } | |||
| 1045 | + | r.AppID = re | |||
| 1039 | case "floating": | 1046 | case "floating": | ||
| 1040 | b, err := onOff(v) | 1047 | b, err := onOff(v) | ||
| 1041 | if err != nil { | 1048 | if err != nil { | ||
M internal/adapters/config/rules_test.go +5 -0
| @@ -61,9 +61,14 @@ func TestRuleWarnings(t *testing.T) { | |||
| 61 | 61 | {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0}, | |
| 62 | 62 | // Closing the anchoring group would match any app ID. | |
| 63 | 63 | {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0}, | |
| 64 | + | // Quoting the anchoring group's ")" must warn, not panic. | |
| 65 | + | {"quoted anchor", "rule.a.app-id = \\Qsteam\n", "rule.a.app-id: invalid regular expression", 1, 0}, | |
| 64 | 66 | {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0}, | |
| 65 | 67 | {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1}, | |
| 66 | 68 | {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0}, | |
| 69 | + | // An invalid duplicate keeps the earlier value and the rule. | |
| 70 | + | {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1}, | |
| 71 | + | {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1}, | |
| 67 | 72 | {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0}, | |
| 68 | 73 | {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0}, | |
| 69 | 74 | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | |
| @@ -61,9 +61,14 @@ func TestRuleWarnings(t *testing.T) { | |||||
| 61 | {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0}, | 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. | 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}, | 63 | {"anchor breakout", "rule.a.app-id = steam)|(.*\n", "rule.a.app-id: invalid regular expression", 1, 0}, | ||
| 64 | + | // Quoting the anchoring group's ")" must warn, not panic. | |||
| 65 | + | {"quoted anchor", "rule.a.app-id = \\Qsteam\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}, | 66 | {"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}, | 67 | {"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}, | 68 | {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0}, | ||
| 69 | + | // An invalid duplicate keeps the earlier value and the rule. | |||
| 70 | + | {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1}, | |||
| 71 | + | {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1}, | |||
| 67 | {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0}, | 72 | {"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}, | 73 | {"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}, | 74 | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | ||
M internal/adapters/drm/overlay.go +23 -3
| @@ -73,6 +73,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 73 | 73 | scale = 1 | |
| 74 | 74 | } | |
| 75 | 75 | var pick *ports.SceneWindow | |
| 76 | + | var pickAt int | |
| 76 | 77 | var mode colorMode | |
| 77 | 78 | for i := range s.Windows { | |
| 78 | 79 | w := &s.Windows[i] | |
| @@ -95,7 +96,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 95 | 96 | // lift; an overview preview is drawn smaller than its buffer. | |
| 96 | 97 | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | |
| 97 | 98 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | |
| 98 | - | pick, mode = w, m | |
| 99 | + | pick, pickAt, mode = w, i, m | |
| 99 | 100 | continue | |
| 100 | 101 | } | |
| 101 | 102 | } | |
| @@ -116,7 +117,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 116 | 117 | // The one window that could have gone on the plane fades out | |
| 117 | 118 | // after its close: composed from its kept content. | |
| 118 | 119 | for i := range s.Windows { | |
| 119 | - | if w := &s.Windows[i]; !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 && kept(w.ID) { | |
| 120 | + | if w := &s.Windows[i]; s.Draws(*w) && kept(w.ID) { | |
| 120 | 121 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving" | |
| 121 | 122 | } | |
| 122 | 123 | } | |
| @@ -143,7 +144,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 143 | 144 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border" | |
| 144 | 145 | } | |
| 145 | 146 | for _, sep := range s.Separators { | |
| 146 | - | if sep.Rect.Overlaps(pick.Rect) { | |
| 147 | + | if sep.Rect.Overlaps(pick.Rect) && !linesUnder(s, sep, pick, pickAt) { | |
| 147 | 148 | // The overlay shows the buffer alone; the lines would go | |
| 148 | 149 | // under it. | |
| 149 | 150 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border" | |
| @@ -170,6 +171,25 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 170 | 171 | return *pick, c, mode, "" | |
| 171 | 172 | } | |
| 172 | 173 | ||
| 174 | + | // linesUnder reports whether sep is painted before the candidate at index | |
| 175 | + | // pickAt, so stays composed under the plane: the tile lines and the lines | |
| 176 | + | // of windows listed before it, when the candidate is a float drawn above | |
| 177 | + | // the tiles. | |
| 178 | + | func linesUnder(s ports.Scene, sep ports.Separator, pick *ports.SceneWindow, pickAt int) bool { | |
| 179 | + | if !pick.Floating || pick.Below { | |
| 180 | + | return false | |
| 181 | + | } | |
| 182 | + | if sep.Window == 0 { | |
| 183 | + | return true | |
| 184 | + | } | |
| 185 | + | for _, w := range s.Windows[:pickAt] { | |
| 186 | + | if w.ID == sep.Window { | |
| 187 | + | return true | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | return false | |
| 191 | + | } | |
| 192 | + | ||
| 173 | 193 | // overlayProps puts ov on the overlay plane, or turns it off. | |
| 174 | 194 | func (o *Output) overlayProps(req *atomicReq, ov overlayWin) { | |
| 175 | 195 | p := o.overlay | |
| @@ -73,6 +73,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 73 | scale = 1 | 73 | scale = 1 | ||
| 74 | } | 74 | } | ||
| 75 | var pick *ports.SceneWindow | 75 | var pick *ports.SceneWindow | ||
| 76 | + | var pickAt int | |||
| 76 | var mode colorMode | 77 | var mode colorMode | ||
| 77 | for i := range s.Windows { | 78 | for i := range s.Windows { | ||
| 78 | w := &s.Windows[i] | 79 | w := &s.Windows[i] | ||
| @@ -95,7 +96,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 95 | // lift; an overview preview is drawn smaller than its buffer. | 96 | // lift; an overview preview is drawn smaller than its buffer. | ||
| 96 | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | 97 | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | ||
| 97 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | 98 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | ||
| 98 | - | pick, mode = w, m | 99 | + | pick, pickAt, mode = w, i, m |
| 99 | continue | 100 | continue | ||
| 100 | } | 101 | } | ||
| 101 | } | 102 | } | ||
| @@ -116,7 +117,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 116 | // The one window that could have gone on the plane fades out | 117 | // The one window that could have gone on the plane fades out | ||
| 117 | // after its close: composed from its kept content. | 118 | // after its close: composed from its kept content. | ||
| 118 | for i := range s.Windows { | 119 | for i := range s.Windows { | ||
| 119 | - | if w := &s.Windows[i]; !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 && kept(w.ID) { | 120 | + | if w := &s.Windows[i]; s.Draws(*w) && kept(w.ID) { |
| 120 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving" | 121 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving" | ||
| 121 | } | 122 | } | ||
| 122 | } | 123 | } | ||
| @@ -143,7 +144,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 143 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border" | 144 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border" | ||
| 144 | } | 145 | } | ||
| 145 | for _, sep := range s.Separators { | 146 | for _, sep := range s.Separators { | ||
| 146 | - | if sep.Rect.Overlaps(pick.Rect) { | 147 | + | if sep.Rect.Overlaps(pick.Rect) && !linesUnder(s, sep, pick, pickAt) { |
| 147 | // The overlay shows the buffer alone; the lines would go | 148 | // The overlay shows the buffer alone; the lines would go | ||
| 148 | // under it. | 149 | // under it. | ||
| 149 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border" | 150 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border" | ||
| @@ -170,6 +171,25 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 170 | return *pick, c, mode, "" | 171 | return *pick, c, mode, "" | ||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
| 174 | + | // linesUnder reports whether sep is painted before the candidate at index | |||
| 175 | + | // pickAt, so stays composed under the plane: the tile lines and the lines | |||
| 176 | + | // of windows listed before it, when the candidate is a float drawn above | |||
| 177 | + | // the tiles. | |||
| 178 | + | func linesUnder(s ports.Scene, sep ports.Separator, pick *ports.SceneWindow, pickAt int) bool { | |||
| 179 | + | if !pick.Floating || pick.Below { | |||
| 180 | + | return false | |||
| 181 | + | } | |||
| 182 | + | if sep.Window == 0 { | |||
| 183 | + | return true | |||
| 184 | + | } | |||
| 185 | + | for _, w := range s.Windows[:pickAt] { | |||
| 186 | + | if w.ID == sep.Window { | |||
| 187 | + | return true | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | return false | |||
| 191 | + | } | |||
| 192 | + | ||||
| 173 | // overlayProps puts ov on the overlay plane, or turns it off. | 193 | // overlayProps puts ov on the overlay plane, or turns it off. | ||
| 174 | func (o *Output) overlayProps(req *atomicReq, ov overlayWin) { | 194 | func (o *Output) overlayProps(req *atomicReq, ov overlayWin) { | ||
| 175 | p := o.overlay | 195 | p := o.overlay | ||
M internal/adapters/drm/overlay_test.go +28 -0
| @@ -271,6 +271,34 @@ func TestOverlayPopupAndBorder(t *testing.T) { | |||
| 271 | 271 | } | |
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | + | // A float above the tiles covers the tile lines and the lines of windows | |
| 275 | + | // listed before it; its own lines and those of later windows still block | |
| 276 | + | // the overlay. | |
| 277 | + | func TestOverlayFloatOverLines(t *testing.T) { | |
| 278 | + | for _, tc := range []struct { | |
| 279 | + | name string | |
| 280 | + | sep ports.WindowID | |
| 281 | + | below bool | |
| 282 | + | reason string | |
| 283 | + | }{ | |
| 284 | + | {"tile line", 0, false, ""}, | |
| 285 | + | {"earlier window line", 1, false, ""}, | |
| 286 | + | {"own line", 2, false, "border"}, | |
| 287 | + | {"later window line", 3, false, "border"}, | |
| 288 | + | {"tile line over a below float", 0, true, "border"}, | |
| 289 | + | } { | |
| 290 | + | t.Run(tc.name, func(t *testing.T) { | |
| 291 | + | s, c := overlayScene() | |
| 292 | + | s.Windows[1].Floating, s.Windows[1].Below = true, tc.below | |
| 293 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Floating: true, Rect: ports.Rect{W: 10, H: 10}}) | |
| 294 | + | s.Separators = []ports.Separator{{Rect: ports.Rect{X: 150, W: 2, H: 100}, Window: tc.sep}} | |
| 295 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != tc.reason { | |
| 296 | + | t.Fatalf("reason %q, want %q", reason, tc.reason) | |
| 297 | + | } | |
| 298 | + | }) | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 274 | 302 | // What is drawn elsewhere on screen stays composed on the primary plane: a | |
| 275 | 303 | // bar, a popup, another window's lines or a window that do not touch the | |
| 276 | 304 | // candidate leave it on the overlay. | |
| @@ -271,6 +271,34 @@ func TestOverlayPopupAndBorder(t *testing.T) { | |||||
| 271 | } | 271 | } | ||
| 272 | } | 272 | } | ||
| 273 | 273 | ||||
| 274 | + | // A float above the tiles covers the tile lines and the lines of windows | |||
| 275 | + | // listed before it; its own lines and those of later windows still block | |||
| 276 | + | // the overlay. | |||
| 277 | + | func TestOverlayFloatOverLines(t *testing.T) { | |||
| 278 | + | for _, tc := range []struct { | |||
| 279 | + | name string | |||
| 280 | + | sep ports.WindowID | |||
| 281 | + | below bool | |||
| 282 | + | reason string | |||
| 283 | + | }{ | |||
| 284 | + | {"tile line", 0, false, ""}, | |||
| 285 | + | {"earlier window line", 1, false, ""}, | |||
| 286 | + | {"own line", 2, false, "border"}, | |||
| 287 | + | {"later window line", 3, false, "border"}, | |||
| 288 | + | {"tile line over a below float", 0, true, "border"}, | |||
| 289 | + | } { | |||
| 290 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 291 | + | s, c := overlayScene() | |||
| 292 | + | s.Windows[1].Floating, s.Windows[1].Below = true, tc.below | |||
| 293 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Floating: true, Rect: ports.Rect{W: 10, H: 10}}) | |||
| 294 | + | s.Separators = []ports.Separator{{Rect: ports.Rect{X: 150, W: 2, H: 100}, Window: tc.sep}} | |||
| 295 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != tc.reason { | |||
| 296 | + | t.Fatalf("reason %q, want %q", reason, tc.reason) | |||
| 297 | + | } | |||
| 298 | + | }) | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | ||||
| 274 | // What is drawn elsewhere on screen stays composed on the primary plane: a | 302 | // What is drawn elsewhere on screen stays composed on the primary plane: a | ||
| 275 | // bar, a popup, another window's lines or a window that do not touch the | 303 | // bar, a popup, another window's lines or a window that do not touch the | ||
| 276 | // candidate leave it on the overlay. | 304 | // candidate leave it on the overlay. | ||
M internal/adapters/outputkit/capture/offscreen.go +1 -1
| @@ -354,7 +354,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||
| 354 | 354 | fail(ErrSecurityState, reqs) | |
| 355 | 355 | return | |
| 356 | 356 | case s.Off: | |
| 357 | - | fail(errors.New("output off"), reqs) | |
| 357 | + | fail(ErrOutputOff, reqs) | |
| 358 | 358 | return | |
| 359 | 359 | case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0 && s.Capture.Window == 0: | |
| 360 | 360 | fail(ErrOffscreenUnavailable, reqs) | |
| @@ -354,7 +354,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||||
| 354 | fail(ErrSecurityState, reqs) | 354 | fail(ErrSecurityState, reqs) | ||
| 355 | return | 355 | return | ||
| 356 | case s.Off: | 356 | case s.Off: | ||
| 357 | - | fail(errors.New("output off"), reqs) | 357 | + | fail(ErrOutputOff, reqs) |
| 358 | return | 358 | return | ||
| 359 | case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0 && s.Capture.Window == 0: | 359 | case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0 && s.Capture.Window == 0: | ||
| 360 | fail(ErrOffscreenUnavailable, reqs) | 360 | fail(ErrOffscreenUnavailable, reqs) | ||
M internal/adapters/outputkit/capture/requests.go +1 -0
| @@ -81,6 +81,7 @@ func (r *Requests) Stop(incoming <-chan ports.CaptureRequest) { | |||
| 81 | 81 | Fail(r.ctx, q, ErrOutputStopped, r.replies) | |
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | + | r.Reset() | |
| 84 | 85 | for { | |
| 85 | 86 | select { | |
| 86 | 87 | case q, ok := <-incoming: | |
| @@ -81,6 +81,7 @@ func (r *Requests) Stop(incoming <-chan ports.CaptureRequest) { | |||||
| 81 | Fail(r.ctx, q, ErrOutputStopped, r.replies) | 81 | Fail(r.ctx, q, ErrOutputStopped, r.replies) | ||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | + | r.Reset() | |||
| 84 | for { | 85 | for { | ||
| 85 | select { | 86 | select { | ||
| 86 | case q, ok := <-incoming: | 87 | case q, ok := <-incoming: | ||
M internal/adapters/vulkan/shm.go +15 -1
| @@ -294,11 +294,25 @@ func (r *Renderer) retireShm(s *shmSurface) { | |||
| 294 | 294 | } | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 297 | + | // maxDisjointRects bounds the pieces disjointRects cuts the damage into: the | |
| 298 | + | // rects come from the client, and each cut can add three pieces. | |
| 299 | + | const maxDisjointRects = 64 | |
| 300 | + | ||
| 297 | 301 | // disjointRects appends to dst the parts of rects inside a w×h buffer, with | |
| 298 | - | // no pixel in two of them. It reuses dst's capacity. | |
| 302 | + | // no pixel in two of them. Past maxDisjointRects pieces it appends their | |
| 303 | + | // bounding box instead: some undamaged pixels are copied again. It reuses | |
| 304 | + | // dst's capacity. | |
| 299 | 305 | func disjointRects(dst []image.Rectangle, rects []ports.Rect, w, h int) []image.Rectangle { | |
| 300 | 306 | bounds := image.Rect(0, 0, w, h) | |
| 307 | + | start := len(dst) | |
| 301 | 308 | for _, rc := range rects { | |
| 309 | + | if len(dst)-start > maxDisjointRects { | |
| 310 | + | var box image.Rectangle | |
| 311 | + | for _, rc := range rects { | |
| 312 | + | box = box.Union(image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H).Intersect(bounds)) | |
| 313 | + | } | |
| 314 | + | return append(dst[:start], box) | |
| 315 | + | } | |
| 302 | 316 | pending := len(dst) | |
| 303 | 317 | dst = append(dst, image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H).Intersect(bounds)) | |
| 304 | 318 | // Cut the new rect by each earlier one; the pieces it leaves are | |
| @@ -294,11 +294,25 @@ func (r *Renderer) retireShm(s *shmSurface) { | |||||
| 294 | } | 294 | } | ||
| 295 | } | 295 | } | ||
| 296 | 296 | ||||
| 297 | + | // maxDisjointRects bounds the pieces disjointRects cuts the damage into: the | |||
| 298 | + | // rects come from the client, and each cut can add three pieces. | |||
| 299 | + | const maxDisjointRects = 64 | |||
| 300 | + | ||||
| 297 | // disjointRects appends to dst the parts of rects inside a w×h buffer, with | 301 | // disjointRects appends to dst the parts of rects inside a w×h buffer, with | ||
| 298 | - | // no pixel in two of them. It reuses dst's capacity. | 302 | + | // no pixel in two of them. Past maxDisjointRects pieces it appends their |
| 303 | + | // bounding box instead: some undamaged pixels are copied again. It reuses | |||
| 304 | + | // dst's capacity. | |||
| 299 | func disjointRects(dst []image.Rectangle, rects []ports.Rect, w, h int) []image.Rectangle { | 305 | func disjointRects(dst []image.Rectangle, rects []ports.Rect, w, h int) []image.Rectangle { | ||
| 300 | bounds := image.Rect(0, 0, w, h) | 306 | bounds := image.Rect(0, 0, w, h) | ||
| 307 | + | start := len(dst) | |||
| 301 | for _, rc := range rects { | 308 | for _, rc := range rects { | ||
| 309 | + | if len(dst)-start > maxDisjointRects { | |||
| 310 | + | var box image.Rectangle | |||
| 311 | + | for _, rc := range rects { | |||
| 312 | + | box = box.Union(image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H).Intersect(bounds)) | |||
| 313 | + | } | |||
| 314 | + | return append(dst[:start], box) | |||
| 315 | + | } | |||
| 302 | pending := len(dst) | 316 | pending := len(dst) | ||
| 303 | dst = append(dst, image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H).Intersect(bounds)) | 317 | dst = append(dst, image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H).Intersect(bounds)) | ||
| 304 | // Cut the new rect by each earlier one; the pieces it leaves are | 318 | // Cut the new rect by each earlier one; the pieces it leaves are | ||
M internal/adapters/vulkan/shm_rects_test.go +21 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package vulkan | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "image" | |
| 4 | 5 | "math/rand" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -46,6 +47,26 @@ func TestDisjointRectsCoverUnionOnce(t *testing.T) { | |||
| 46 | 47 | } | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 50 | + | // Client damage that would cut into too many pieces falls back to its | |
| 51 | + | // bounding box: the work stays bounded and every damaged pixel is copied. | |
| 52 | + | func TestDisjointRectsBoundsAdversarialDamage(t *testing.T) { | |
| 53 | + | const w, h = 1024, 1024 | |
| 54 | + | rects := make([]ports.Rect, 128) | |
| 55 | + | for i := range rects { | |
| 56 | + | rects[i] = ports.Rect{X: i * 3, Y: i * 3, W: w - i*6, H: 4 + i} | |
| 57 | + | } | |
| 58 | + | got := disjointRects(nil, rects, w, h) | |
| 59 | + | if len(got) != 1 { | |
| 60 | + | t.Fatalf("%d pieces, want the bounding box", len(got)) | |
| 61 | + | } | |
| 62 | + | for _, rc := range rects { | |
| 63 | + | r := image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H) | |
| 64 | + | if !r.In(got[0]) { | |
| 65 | + | t.Fatalf("damage %v outside %v", r, got[0]) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 49 | 70 | // With a reused scratch slice the helper allocates nothing. | |
| 50 | 71 | func TestDisjointRectsNoAllocs(t *testing.T) { | |
| 51 | 72 | rects := []ports.Rect{{X: 0, Y: 0, W: 10, H: 10}, {X: 5, Y: 5, W: 10, H: 10}, {X: 2, Y: 8, W: 4, H: 4}} | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package vulkan | 1 | package vulkan | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "image" | |||
| 4 | "math/rand" | 5 | "math/rand" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| @@ -46,6 +47,26 @@ func TestDisjointRectsCoverUnionOnce(t *testing.T) { | |||||
| 46 | } | 47 | } | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 50 | + | // Client damage that would cut into too many pieces falls back to its | |||
| 51 | + | // bounding box: the work stays bounded and every damaged pixel is copied. | |||
| 52 | + | func TestDisjointRectsBoundsAdversarialDamage(t *testing.T) { | |||
| 53 | + | const w, h = 1024, 1024 | |||
| 54 | + | rects := make([]ports.Rect, 128) | |||
| 55 | + | for i := range rects { | |||
| 56 | + | rects[i] = ports.Rect{X: i * 3, Y: i * 3, W: w - i*6, H: 4 + i} | |||
| 57 | + | } | |||
| 58 | + | got := disjointRects(nil, rects, w, h) | |||
| 59 | + | if len(got) != 1 { | |||
| 60 | + | t.Fatalf("%d pieces, want the bounding box", len(got)) | |||
| 61 | + | } | |||
| 62 | + | for _, rc := range rects { | |||
| 63 | + | r := image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H) | |||
| 64 | + | if !r.In(got[0]) { | |||
| 65 | + | t.Fatalf("damage %v outside %v", r, got[0]) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 49 | // With a reused scratch slice the helper allocates nothing. | 70 | // With a reused scratch slice the helper allocates nothing. | ||
| 50 | func TestDisjointRectsNoAllocs(t *testing.T) { | 71 | func TestDisjointRectsNoAllocs(t *testing.T) { | ||
| 51 | rects := []ports.Rect{{X: 0, Y: 0, W: 10, H: 10}, {X: 5, Y: 5, W: 10, H: 10}, {X: 2, Y: 8, W: 4, H: 4}} | 72 | rects := []ports.Rect{{X: 0, Y: 0, W: 10, H: 10}, {X: 5, Y: 5, W: 10, H: 10}, {X: 2, Y: 8, W: 4, H: 4}} | ||
M internal/adapters/wayland/gestures_test.go +14 -4
| @@ -140,9 +140,13 @@ func TestPointerGestures(t *testing.T) { | |||
| 140 | 140 | quiet(t, c, swipe) | |
| 141 | 141 | quiet(t, c, hold) | |
| 142 | 142 | ||
| 143 | - | // The pointer leaving the window cancels the gesture. | |
| 143 | + | // The pointer leaving the window cancels the gesture. Commands run in | |
| 144 | + | // order: the next pinch event being this begin proves the dropped | |
| 145 | + | // update above sent nothing. | |
| 144 | 146 | commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 4000 * 1e6} | |
| 145 | - | next(t, c, pinch) | |
| 147 | + | if ev := next(t, c, pinch); ev[0] != 0 || ev[2] != 4000 { | |
| 148 | + | t.Fatalf("pinch begin %v", ev) | |
| 149 | + | } | |
| 146 | 150 | commands <- ports.PointerFocus{} | |
| 147 | 151 | if ev := next(t, c, pinch); ev[0] != 2 || ev[3] != 1 { | |
| 148 | 152 | t.Fatalf("pinch not cancelled on leave: %v", ev) | |
| @@ -157,6 +161,12 @@ func TestPointerGestures(t *testing.T) { | |||
| 157 | 161 | if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 4 { | |
| 158 | 162 | t.Fatalf("hold begin after release %v", ev) | |
| 159 | 163 | } | |
| 164 | + | // The end after the cancel sent nothing: the next pinch event is this | |
| 165 | + | // begin. | |
| 166 | + | commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 6000 * 1e6} | |
| 167 | + | if ev := next(t, c, pinch); ev[0] != 0 || ev[2] != 6000 { | |
| 168 | + | t.Fatalf("pinch begin after cancelled end %v", ev) | |
| 169 | + | } | |
| 160 | 170 | ||
| 161 | 171 | // The client without the pointer saw nothing. | |
| 162 | 172 | quiet(t, other, oSwipe) | |
| @@ -164,8 +174,8 @@ func TestPointerGestures(t *testing.T) { | |||
| 164 | 174 | quiet(t, other, oHold) | |
| 165 | 175 | } | |
| 166 | 176 | ||
| 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. | |
| 177 | + | // A window with the pointer but no gesture object gets gesture commands | |
| 178 | + | // without the server failing or the client being disconnected. | |
| 169 | 179 | func TestPointerGesturesWithoutObjects(t *testing.T) { | |
| 170 | 180 | s, events, commands, dir := lifecycleServer(t) | |
| 171 | 181 | c := protocolClient(t, s, dir) | |
| @@ -140,9 +140,13 @@ func TestPointerGestures(t *testing.T) { | |||||
| 140 | quiet(t, c, swipe) | 140 | quiet(t, c, swipe) | ||
| 141 | quiet(t, c, hold) | 141 | quiet(t, c, hold) | ||
| 142 | 142 | ||||
| 143 | - | // The pointer leaving the window cancels the gesture. | 143 | + | // The pointer leaving the window cancels the gesture. Commands run in |
| 144 | + | // order: the next pinch event being this begin proves the dropped | |||
| 145 | + | // update above sent nothing. | |||
| 144 | commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 4000 * 1e6} | 146 | commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 4000 * 1e6} | ||
| 145 | - | next(t, c, pinch) | 147 | + | if ev := next(t, c, pinch); ev[0] != 0 || ev[2] != 4000 { |
| 148 | + | t.Fatalf("pinch begin %v", ev) | |||
| 149 | + | } | |||
| 146 | commands <- ports.PointerFocus{} | 150 | commands <- ports.PointerFocus{} | ||
| 147 | if ev := next(t, c, pinch); ev[0] != 2 || ev[3] != 1 { | 151 | if ev := next(t, c, pinch); ev[0] != 2 || ev[3] != 1 { | ||
| 148 | t.Fatalf("pinch not cancelled on leave: %v", ev) | 152 | t.Fatalf("pinch not cancelled on leave: %v", ev) | ||
| @@ -157,6 +161,12 @@ func TestPointerGestures(t *testing.T) { | |||||
| 157 | if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 4 { | 161 | if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 4 { | ||
| 158 | t.Fatalf("hold begin after release %v", ev) | 162 | t.Fatalf("hold begin after release %v", ev) | ||
| 159 | } | 163 | } | ||
| 164 | + | // The end after the cancel sent nothing: the next pinch event is this | |||
| 165 | + | // begin. | |||
| 166 | + | commands <- ports.GestureBeginTo{ID: w.ID, Kind: ports.GesturePinch, Fingers: 2, Time: 6000 * 1e6} | |||
| 167 | + | if ev := next(t, c, pinch); ev[0] != 0 || ev[2] != 6000 { | |||
| 168 | + | t.Fatalf("pinch begin after cancelled end %v", ev) | |||
| 169 | + | } | |||
| 160 | 170 | ||||
| 161 | // The client without the pointer saw nothing. | 171 | // The client without the pointer saw nothing. | ||
| 162 | quiet(t, other, oSwipe) | 172 | quiet(t, other, oSwipe) | ||
| @@ -164,8 +174,8 @@ func TestPointerGestures(t *testing.T) { | |||||
| 164 | quiet(t, other, oHold) | 174 | quiet(t, other, oHold) | ||
| 165 | } | 175 | } | ||
| 166 | 176 | ||||
| 167 | - | // A window with the pointer but no gesture object gets nothing, and a | 177 | + | // A window with the pointer but no gesture object gets gesture commands |
| 168 | - | // gesture on a window that does not have the pointer is refused. | 178 | + | // without the server failing or the client being disconnected. |
| 169 | func TestPointerGesturesWithoutObjects(t *testing.T) { | 179 | func TestPointerGesturesWithoutObjects(t *testing.T) { | ||
| 170 | s, events, commands, dir := lifecycleServer(t) | 180 | s, events, commands, dir := lifecycleServer(t) | ||
| 171 | c := protocolClient(t, s, dir) | 181 | c := protocolClient(t, s, dir) | ||
M internal/app/outputs.go +2 -2
| @@ -469,7 +469,7 @@ func (s *outputSet) failQueued(r *runningOutput) { | |||
| 469 | 469 | for { | |
| 470 | 470 | select { | |
| 471 | 471 | case req := <-r.captures: | |
| 472 | - | capture.Fail(s.ctx, req, fmt.Errorf("output stopped"), s.captured) | |
| 472 | + | capture.Fail(s.ctx, req, capture.ErrOutputStopped, s.captured) | |
| 473 | 473 | default: | |
| 474 | 474 | return | |
| 475 | 475 | } | |
| @@ -485,7 +485,7 @@ func drainCaptures(ctx context.Context, incoming <-chan ports.CaptureRequest, re | |||
| 485 | 485 | if !ok { | |
| 486 | 486 | return | |
| 487 | 487 | } | |
| 488 | - | capture.Fail(ctx, q, fmt.Errorf("output stopped"), replies) | |
| 488 | + | capture.Fail(ctx, q, capture.ErrOutputStopped, replies) | |
| 489 | 489 | default: | |
| 490 | 490 | return | |
| 491 | 491 | } | |
| @@ -469,7 +469,7 @@ func (s *outputSet) failQueued(r *runningOutput) { | |||||
| 469 | for { | 469 | for { | ||
| 470 | select { | 470 | select { | ||
| 471 | case req := <-r.captures: | 471 | case req := <-r.captures: | ||
| 472 | - | capture.Fail(s.ctx, req, fmt.Errorf("output stopped"), s.captured) | 472 | + | capture.Fail(s.ctx, req, capture.ErrOutputStopped, s.captured) |
| 473 | default: | 473 | default: | ||
| 474 | return | 474 | return | ||
| 475 | } | 475 | } | ||
| @@ -485,7 +485,7 @@ func drainCaptures(ctx context.Context, incoming <-chan ports.CaptureRequest, re | |||||
| 485 | if !ok { | 485 | if !ok { | ||
| 486 | return | 486 | return | ||
| 487 | } | 487 | } | ||
| 488 | - | capture.Fail(ctx, q, fmt.Errorf("output stopped"), replies) | 488 | + | capture.Fail(ctx, q, capture.ErrOutputStopped, replies) |
| 489 | default: | 489 | default: | ||
| 490 | return | 490 | return | ||
| 491 | } | 491 | } | ||
M internal/core/layout.go +2 -0
| @@ -1046,6 +1046,8 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 1046 | 1046 | if slices.Contains(col.Windows, w.left) { | |
| 1047 | 1047 | w.left = 0 | |
| 1048 | 1048 | } | |
| 1049 | + | // Its windows leave this workspace's recent use with it. | |
| 1050 | + | w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) }) | |
| 1049 | 1051 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |
| 1050 | 1052 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | |
| 1051 | 1053 | if len(w.Columns) == 0 { | |
| @@ -1046,6 +1046,8 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 1046 | if slices.Contains(col.Windows, w.left) { | 1046 | if slices.Contains(col.Windows, w.left) { | ||
| 1047 | w.left = 0 | 1047 | w.left = 0 | ||
| 1048 | } | 1048 | } | ||
| 1049 | + | // Its windows leave this workspace's recent use with it. | |||
| 1050 | + | w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) }) | |||
| 1049 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | 1051 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | ||
| 1050 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | 1052 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | ||
| 1051 | if len(w.Columns) == 0 { | 1053 | if len(w.Columns) == 0 { | ||
M internal/core/switcher.go +1 -1
| @@ -51,7 +51,7 @@ func (w *Workspace) noteFocus() { | |||
| 51 | 51 | } | |
| 52 | 52 | col := w.Columns[w.Focus] | |
| 53 | 53 | id := col.Windows[col.Focus] | |
| 54 | - | if len(w.recent) > 0 && w.columnOf(w.recent[0]) == w.Focus { | |
| 54 | + | if len(w.recent) > 0 && slices.Contains(col.Windows, w.recent[0]) { | |
| 55 | 55 | w.recent[0] = id | |
| 56 | 56 | return | |
| 57 | 57 | } | |
| @@ -51,7 +51,7 @@ func (w *Workspace) noteFocus() { | |||||
| 51 | } | 51 | } | ||
| 52 | col := w.Columns[w.Focus] | 52 | col := w.Columns[w.Focus] | ||
| 53 | id := col.Windows[col.Focus] | 53 | id := col.Windows[col.Focus] | ||
| 54 | - | if len(w.recent) > 0 && w.columnOf(w.recent[0]) == w.Focus { | 54 | + | if len(w.recent) > 0 && slices.Contains(col.Windows, w.recent[0]) { |
| 55 | w.recent[0] = id | 55 | w.recent[0] = id | ||
| 56 | return | 56 | return | ||
| 57 | } | 57 | } | ||
M internal/core/switcher_test.go +12 -0
| @@ -60,6 +60,18 @@ func TestMRUOrder(t *testing.T) { | |||
| 60 | 60 | w.MoveColumn(1) | |
| 61 | 61 | w.noteFocus() | |
| 62 | 62 | }, []WindowID{2, 3, 1}}, | |
| 63 | + | {"a column taken to another workspace leaves recent", func(w *Workspace) { | |
| 64 | + | focusNote(w, 1) | |
| 65 | + | focusNote(w, 3) | |
| 66 | + | focusNote(w, 2) | |
| 67 | + | if _, ok := w.takeColumn(); !ok { | |
| 68 | + | t.Fatal("no column taken") | |
| 69 | + | } | |
| 70 | + | if slices.Contains(w.recent, 2) { | |
| 71 | + | t.Fatalf("recent %v keeps the taken window", w.recent) | |
| 72 | + | } | |
| 73 | + | w.noteFocus() | |
| 74 | + | }, []WindowID{3, 1}}, | |
| 63 | 75 | } | |
| 64 | 76 | for _, tt := range tests { | |
| 65 | 77 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -60,6 +60,18 @@ func TestMRUOrder(t *testing.T) { | |||||
| 60 | w.MoveColumn(1) | 60 | w.MoveColumn(1) | ||
| 61 | w.noteFocus() | 61 | w.noteFocus() | ||
| 62 | }, []WindowID{2, 3, 1}}, | 62 | }, []WindowID{2, 3, 1}}, | ||
| 63 | + | {"a column taken to another workspace leaves recent", func(w *Workspace) { | |||
| 64 | + | focusNote(w, 1) | |||
| 65 | + | focusNote(w, 3) | |||
| 66 | + | focusNote(w, 2) | |||
| 67 | + | if _, ok := w.takeColumn(); !ok { | |||
| 68 | + | t.Fatal("no column taken") | |||
| 69 | + | } | |||
| 70 | + | if slices.Contains(w.recent, 2) { | |||
| 71 | + | t.Fatalf("recent %v keeps the taken window", w.recent) | |||
| 72 | + | } | |||
| 73 | + | w.noteFocus() | |||
| 74 | + | }, []WindowID{3, 1}}, | |||
| 63 | } | 75 | } | ||
| 64 | for _, tt := range tests { | 76 | for _, tt := range tests { | ||
| 65 | t.Run(tt.name, func(t *testing.T) { | 77 | t.Run(tt.name, func(t *testing.T) { | ||