detached 1e7d719

Merge pull request 'fix: v0.7.0 pre-release review fixes' (#30) from fix/v0.7.0-review into main

commit
1e7d719a98652006aea63a256c096566af8ad605
author
bnema · · 2026-10-08 20:36 UTC
merge of
d6a3812 + 7e3cc95 · diff against the first parent
signature
unsigned
refs
tag: v0.7.0
16 files · +142 -19
M docs/config.md +2 -2
@@ -100,7 +100,7 @@ rule.calc.floating = on
100100 - 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.
101101 - 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.
102102 - `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.
104104
105105 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.
106106
@@ -186,7 +186,7 @@ Over the shown stash a sideways swipe slides it with the fingers, with a stop on
186186
187187 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
188188
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.
190190
191191 ## HDR
192192
@@ -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 ## HDR191 ## HDR
192 192
M docs/desktop.md +1 -1
@@ -102,7 +102,7 @@ exec wlopm --off '*'
102102
103103 ## Power keys
104104
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.
106106
107107 | logind key | Kernel key | Bind |
108108 |---|---|---|
@@ -102,7 +102,7 @@ exec wlopm --off '*'
102 102
103 ## Power keys103 ## 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) {
2020 defer close(done)
2121 Run(ctx, time.Millisecond, "test feature", logging.For(ctx, "test"), func(context.Context) error {
2222 n++
23- calls <- n
23+ select {
24+ case calls <- n:
25+ case <-ctx.Done():
26+ }
2427 if n%2 == 0 {
2528 return nil
2629 }
@@ -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 <- n23+ select {
24+ case calls <- n:
25+ case <-ctx.Done():
26+ }
24 if n%2 == 0 {27 if n%2 == 0 {
25 return nil28 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) {
531531 }
532532 if err := setRule(&e.rule, field, value); err != nil {
533533 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) {
535536 e.dropped = true
536537 }
537538 continue
@@ -1016,7 +1017,8 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
10161017 type ruleEntry struct {
10171018 rule ports.WindowRule
10181019 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.
10201022 dropped bool
10211023 }
10221024
@@ -1035,7 +1037,12 @@ func setRule(r *ports.WindowRule, field, v string) error {
10351037 return fmt.Errorf("invalid regular expression: %v", err)
10361038 }
10371039 // 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
10391046 case "floating":
10401047 b, err := onOff(v)
10411048 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 = true536 e.dropped = true
536 }537 }
537 continue538 continue
@@ -1016,7 +1017,8 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
1016 type ruleEntry struct {1017 type ruleEntry struct {
1017 rule ports.WindowRule1018 rule ports.WindowRule
1018 line int // of its first key1019 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 bool1022 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) {
6161 {"bad regex", "rule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 1, 0},
6262 // Closing the anchoring group would match any app ID.
6363 {"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},
6466 {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},
6567 {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},
6668 {"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},
6772 {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
6873 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
6974 {"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
7373 scale = 1
7474 }
7575 var pick *ports.SceneWindow
76+ var pickAt int
7677 var mode colorMode
7778 for i := range s.Windows {
7879 w := &s.Windows[i]
@@ -95,7 +96,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9596 // lift; an overview preview is drawn smaller than its buffer.
9697 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)) {
9798 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
99100 continue
100101 }
101102 }
@@ -116,7 +117,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
116117 // The one window that could have gone on the plane fades out
117118 // after its close: composed from its kept content.
118119 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) {
120121 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving"
121122 }
122123 }
@@ -143,7 +144,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
143144 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border"
144145 }
145146 for _, sep := range s.Separators {
146- if sep.Rect.Overlaps(pick.Rect) {
147+ if sep.Rect.Overlaps(pick.Rect) && !linesUnder(s, sep, pick, pickAt) {
147148 // The overlay shows the buffer alone; the lines would go
148149 // under it.
149150 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border"
@@ -170,6 +171,25 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
170171 return *pick, c, mode, ""
171172 }
172173
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+
173193 // overlayProps puts ov on the overlay plane, or turns it off.
174194 func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {
175195 p := o.overlay
@@ -73,6 +73,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
73 scale = 173 scale = 1
74 }74 }
75 var pick *ports.SceneWindow75 var pick *ports.SceneWindow
76+ var pickAt int
76 var mode colorMode77 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, m99+ pick, pickAt, mode = w, i, m
99 continue100 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 out117 // 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 go148 // 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.overlay195 p := o.overlay
M internal/adapters/drm/overlay_test.go +28 -0
@@ -271,6 +271,34 @@ func TestOverlayPopupAndBorder(t *testing.T) {
271271 }
272272 }
273273
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+
274302 // What is drawn elsewhere on screen stays composed on the primary plane: a
275303 // bar, a popup, another window's lines or a window that do not touch the
276304 // 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: a302 // 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 the303 // 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
354354 fail(ErrSecurityState, reqs)
355355 return
356356 case s.Off:
357- fail(errors.New("output off"), reqs)
357+ fail(ErrOutputOff, reqs)
358358 return
359359 case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0 && s.Capture.Window == 0:
360360 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 return355 return
356 case s.Off:356 case s.Off:
357- fail(errors.New("output off"), reqs)357+ fail(ErrOutputOff, reqs)
358 return358 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) {
8181 Fail(r.ctx, q, ErrOutputStopped, r.replies)
8282 }
8383 }
84+ r.Reset()
8485 for {
8586 select {
8687 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) {
294294 }
295295 }
296296
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+
297301 // 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.
299305 func disjointRects(dst []image.Rectangle, rects []ports.Rect, w, h int) []image.Rectangle {
300306 bounds := image.Rect(0, 0, w, h)
307+ start := len(dst)
301308 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+ }
302316 pending := len(dst)
303317 dst = append(dst, image.Rect(rc.X, rc.Y, rc.X+rc.W, rc.Y+rc.H).Intersect(bounds))
304318 // 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, with301 // 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 are318 // 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 @@
11 package vulkan
22
33 import (
4+ "image"
45 "math/rand"
56 "testing"
67
@@ -46,6 +47,26 @@ func TestDisjointRectsCoverUnionOnce(t *testing.T) {
4647 }
4748 }
4849
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+
4970 // With a reused scratch slice the helper allocates nothing.
5071 func TestDisjointRectsNoAllocs(t *testing.T) {
5172 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 vulkan1 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) {
140140 quiet(t, c, swipe)
141141 quiet(t, c, hold)
142142
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.
144146 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+ }
146150 commands <- ports.PointerFocus{}
147151 if ev := next(t, c, pinch); ev[0] != 2 || ev[3] != 1 {
148152 t.Fatalf("pinch not cancelled on leave: %v", ev)
@@ -157,6 +161,12 @@ func TestPointerGestures(t *testing.T) {
157161 if ev := next(t, c, hold); ev[0] != 0 || ev[4] != 4 {
158162 t.Fatalf("hold begin after release %v", ev)
159163 }
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+ }
160170
161171 // The client without the pointer saw nothing.
162172 quiet(t, other, oSwipe)
@@ -164,8 +174,8 @@ func TestPointerGestures(t *testing.T) {
164174 quiet(t, other, oHold)
165175 }
166176
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.
169179 func TestPointerGesturesWithoutObjects(t *testing.T) {
170180 s, events, commands, dir := lifecycleServer(t)
171181 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 a177+// 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) {
469469 for {
470470 select {
471471 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)
473473 default:
474474 return
475475 }
@@ -485,7 +485,7 @@ func drainCaptures(ctx context.Context, incoming <-chan ports.CaptureRequest, re
485485 if !ok {
486486 return
487487 }
488- capture.Fail(ctx, q, fmt.Errorf("output stopped"), replies)
488+ capture.Fail(ctx, q, capture.ErrOutputStopped, replies)
489489 default:
490490 return
491491 }
@@ -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 return474 return
475 }475 }
@@ -485,7 +485,7 @@ func drainCaptures(ctx context.Context, incoming <-chan ports.CaptureRequest, re
485 if !ok {485 if !ok {
486 return486 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 return490 return
491 }491 }
M internal/core/layout.go +2 -0
@@ -1046,6 +1046,8 @@ func (w *Workspace) takeColumn() (Column, bool) {
10461046 if slices.Contains(col.Windows, w.left) {
10471047 w.left = 0
10481048 }
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) })
10491051 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
10501052 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
10511053 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 = 01047 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() {
5151 }
5252 col := w.Columns[w.Focus]
5353 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]) {
5555 w.recent[0] = id
5656 return
5757 }
@@ -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] = id55 w.recent[0] = id
56 return56 return
57 }57 }
M internal/core/switcher_test.go +12 -0
@@ -60,6 +60,18 @@ func TestMRUOrder(t *testing.T) {
6060 w.MoveColumn(1)
6161 w.noteFocus()
6262 }, []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}},
6375 }
6476 for _, tt := range tests {
6577 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) {

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close