detached e0a8611

feat(hints): add hint.capture to show hint labels in client captures

With hint.capture on, Wayland client captures (screencopy, screencast) keep the hint labels; the capture indicator and position hint stay out. Compositor-internal captures (hint scan) never see labels: on a frame that keeps them, Split fails them with a transient error so a frame still needs at most two capture batches.
commit
e0a861187399be3c440f515e867abc7866b40953
author
brice · · 2026-10-21 09:52 UTC
committer
brice · · 2026-10-21 09:52 UTC
parent
cee25ed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
2/2
14 files · +473 -40
M docs/config.md +7 -0
@@ -62,6 +62,7 @@ See [examples/config](../examples/config) for every key with its default.
6262 | `hint.enable` | `off` | Turn on the experimental [hint mode](#hint-mode) (restart required) |
6363 | `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) |
6464 | `hint.text-click` | `ctrl+shift` | Modifiers held for clicks on links found by OCR (terminals open links on a modified click); `none` sends a plain click |
65+| `hint.capture` | `off` | Show hint labels in captures requested by Wayland clients (screenshots, screencasts); the compositor's own hint scans never see them; applies on reload |
6566 | `hint.size` | `auto` | Label text size in logical pixels (6 to 72), or `auto` for screen pixel density |
6667 | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` |
6768 | `hint.foreground` | empty | Label text (`#rrggbb`); empty chooses a contrasting colour |
@@ -362,6 +363,12 @@ the top-left corner of its target. Links found by OCR (terminal text) are
362363 clicked with the `hint.text-click` modifiers held, which is how terminals open
363364 a link; accessibility targets get a plain click.
364365
366+Labels are not part of screenshots and screencasts by default. With
367+`hint.capture = on` they appear in the captures that Wayland clients request
368+(including Exclude captures); NeferWL's own text recognition never sees them.
369+The capture indicator and the position hint stay out of every capture.
370+A reload applies `hint.capture` to the next frame.
371+
365372 ### Where targets come from
366373
367374 | Source | Apps | Accuracy | Speed |
@@ -62,6 +62,7 @@ See [examples/config](../examples/config) for every key with its default.
62 | `hint.enable` | `off` | Turn on the experimental [hint mode](#hint-mode) (restart required) |62 | `hint.enable` | `off` | Turn on the experimental [hint mode](#hint-mode) (restart required) |
63 | `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) |63 | `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) |
64 | `hint.text-click` | `ctrl+shift` | Modifiers held for clicks on links found by OCR (terminals open links on a modified click); `none` sends a plain click |64 | `hint.text-click` | `ctrl+shift` | Modifiers held for clicks on links found by OCR (terminals open links on a modified click); `none` sends a plain click |
65+| `hint.capture` | `off` | Show hint labels in captures requested by Wayland clients (screenshots, screencasts); the compositor's own hint scans never see them; applies on reload |
65 | `hint.size` | `auto` | Label text size in logical pixels (6 to 72), or `auto` for screen pixel density |66 | `hint.size` | `auto` | Label text size in logical pixels (6 to 72), or `auto` for screen pixel density |
66 | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` |67 | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` |
67 | `hint.foreground` | empty | Label text (`#rrggbb`); empty chooses a contrasting colour |68 | `hint.foreground` | empty | Label text (`#rrggbb`); empty chooses a contrasting colour |
@@ -362,6 +363,12 @@ the top-left corner of its target. Links found by OCR (terminal text) are
362 clicked with the `hint.text-click` modifiers held, which is how terminals open363 clicked with the `hint.text-click` modifiers held, which is how terminals open
363 a link; accessibility targets get a plain click.364 a link; accessibility targets get a plain click.
364 365
366+Labels are not part of screenshots and screencasts by default. With
367+`hint.capture = on` they appear in the captures that Wayland clients request
368+(including Exclude captures); NeferWL's own text recognition never sees them.
369+The capture indicator and the position hint stay out of every capture.
370+A reload applies `hint.capture` to the next frame.
371+
365 ### Where targets come from372 ### Where targets come from
366 373
367 | Source | Apps | Accuracy | Speed |374 | Source | Apps | Accuracy | Speed |
M examples/config +2 -0
@@ -101,6 +101,8 @@ focus.flash-color =
101101 # Hint mode labels the links, URLs and buttons on the focused screen:
102102 # clickable (links, URLs and buttons) or links (links and URLs only).
103103 # hint.targets = clickable
104+# Show the labels in screenshots and screencasts taken by clients.
105+# hint.capture = off
104106
105107 # Named workspaces are outside the numbered list and require a bind. The bind
106108 # shows one; pressing it again returns to the previous workspace.
@@ -101,6 +101,8 @@ focus.flash-color =
101 # Hint mode labels the links, URLs and buttons on the focused screen:101 # Hint mode labels the links, URLs and buttons on the focused screen:
102 # clickable (links, URLs and buttons) or links (links and URLs only).102 # clickable (links, URLs and buttons) or links (links and URLs only).
103 # hint.targets = clickable103 # hint.targets = clickable
104+# Show the labels in screenshots and screencasts taken by clients.
105+# hint.capture = off
104 106
105 # Named workspaces are outside the numbered list and require a bind. The bind107 # Named workspaces are outside the numbered list and require a bind. The bind
106 # shows one; pressing it again returns to the previous workspace.108 # shows one; pressing it again returns to the previous workspace.
M internal/adapters/config/config.go +6 -0
@@ -870,6 +870,12 @@ func set(c *ports.Config, key, v string) error {
870870 return err
871871 }
872872 c.Hint.Enable = b
873+ case "hint.capture":
874+ b, err := onOff(v)
875+ if err != nil {
876+ return err
877+ }
878+ c.Hint.Capture = b
873879 case "hint.size":
874880 if v == "auto" {
875881 c.Hint.Size = 0
@@ -870,6 +870,12 @@ func set(c *ports.Config, key, v string) error {
870 return err870 return err
871 }871 }
872 c.Hint.Enable = b872 c.Hint.Enable = b
873+ case "hint.capture":
874+ b, err := onOff(v)
875+ if err != nil {
876+ return err
877+ }
878+ c.Hint.Capture = b
873 case "hint.size":879 case "hint.size":
874 if v == "auto" {880 if v == "auto" {
875 c.Hint.Size = 0881 c.Hint.Size = 0
M internal/adapters/config/config_test.go +18 -0
@@ -994,6 +994,24 @@ func TestHintStyleConfig(t *testing.T) {
994994 }
995995 }
996996
997+func TestHintCapture(t *testing.T) {
998+ if Defaults().Hint.Capture {
999+ t.Fatal("hint.capture defaults to off")
1000+ }
1001+ c, w := parseString(t, "hint.capture = on\n")
1002+ if !c.Hint.Capture || len(w) != 0 {
1003+ t.Fatal(c.Hint, w)
1004+ }
1005+ c, w = parseString(t, "hint.capture = off\n")
1006+ if c.Hint.Capture || len(w) != 0 {
1007+ t.Fatal(c.Hint, w)
1008+ }
1009+ c, w = parseString(t, "hint.capture = maybe\n")
1010+ if c.Hint.Capture || len(w) != 1 {
1011+ t.Fatal(c.Hint, w)
1012+ }
1013+}
1014+
9971015 func TestHintSizeBounds(t *testing.T) {
9981016 for _, v := range []string{"6", "72", "13"} {
9991017 c, w := parseString(t, "hint.size = "+v+"\n")
@@ -994,6 +994,24 @@ func TestHintStyleConfig(t *testing.T) {
994 }994 }
995 }995 }
996 996
997+func TestHintCapture(t *testing.T) {
998+ if Defaults().Hint.Capture {
999+ t.Fatal("hint.capture defaults to off")
1000+ }
1001+ c, w := parseString(t, "hint.capture = on\n")
1002+ if !c.Hint.Capture || len(w) != 0 {
1003+ t.Fatal(c.Hint, w)
1004+ }
1005+ c, w = parseString(t, "hint.capture = off\n")
1006+ if c.Hint.Capture || len(w) != 0 {
1007+ t.Fatal(c.Hint, w)
1008+ }
1009+ c, w = parseString(t, "hint.capture = maybe\n")
1010+ if c.Hint.Capture || len(w) != 1 {
1011+ t.Fatal(c.Hint, w)
1012+ }
1013+}
1014+
997 func TestHintSizeBounds(t *testing.T) {1015 func TestHintSizeBounds(t *testing.T) {
998 for _, v := range []string{"6", "72", "13"} {1016 for _, v := range []string{"6", "72", "13"} {
999 c, w := parseString(t, "hint.size = "+v+"\n")1017 c, w := parseString(t, "hint.size = "+v+"\n")
M internal/adapters/drm/frame_submit.go +4 -1
@@ -106,7 +106,8 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
106106 }
107107 }
108108 r.UseTarget(o.back)
109- // Captures never show the capture indicator: their frame goes first.
109+ // Captures never show the capture indicator or the position hint, and show
110+ // hint labels only to clients with hint.capture: their frame goes first.
110111 took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal)
111112 if perr != nil {
112113 o.holdRead(pdone)
@@ -136,6 +137,8 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
136137 // The excluded frame goes first to the same target and is copied by
137138 // the capture; the displayed frame is drawn over it in queue order.
138139 // Cost: a second composition on frames with an excluded request.
140+ // It has no indicator or position hint; it keeps hint labels with
141+ // hint.capture (Split refuses internal requests there).
139142 xdone, xerr := r.Render(pipeline.ExcludedScene(composed), surfaces)
140143 if xerr != nil {
141144 o.holdRead(xdone)
@@ -106,7 +106,8 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
106 }106 }
107 }107 }
108 r.UseTarget(o.back)108 r.UseTarget(o.back)
109- // Captures never show the capture indicator: their frame goes first.109+ // Captures never show the capture indicator or the position hint, and show
110+ // hint labels only to clients with hint.capture: their frame goes first.
110 took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal)111 took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal)
111 if perr != nil {112 if perr != nil {
112 o.holdRead(pdone)113 o.holdRead(pdone)
@@ -136,6 +137,8 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
136 // The excluded frame goes first to the same target and is copied by137 // The excluded frame goes first to the same target and is copied by
137 // the capture; the displayed frame is drawn over it in queue order.138 // the capture; the displayed frame is drawn over it in queue order.
138 // Cost: a second composition on frames with an excluded request.139 // Cost: a second composition on frames with an excluded request.
140+ // It has no indicator or position hint; it keeps hint labels with
141+ // hint.capture (Split refuses internal requests there).
139 xdone, xerr := r.Render(pipeline.ExcludedScene(composed), surfaces)142 xdone, xerr := r.Render(pipeline.ExcludedScene(composed), surfaces)
140 if xerr != nil {143 if xerr != nil {
141 o.holdRead(xdone)144 o.holdRead(xdone)
M internal/adapters/headless/output.go +5 -1
@@ -466,7 +466,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
466466 return fmt.Errorf("workspace frame fence: %w", err)
467467 }
468468 }
469- // Captures never show the capture indicator: their frame goes first.
469+ // Captures never show the capture indicator or the position hint, and
470+ // show hint labels only to clients with hint.capture: their frame goes
471+ // first.
470472 took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)
471473 if pdone != nil {
472474 // The plain render reads client buffers even when its requests
@@ -490,6 +492,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
490492 if len(excluded) > 0 {
491493 // Exclude requests: the frame without the excluded surfaces is
492494 // drawn first (same renderer, queue order), then the displayed one.
495+ // It has no indicator or position hint; it keeps hint labels with
496+ // hint.capture (Split refuses internal requests there).
493497 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
494498 failRequests(capture.ErrEpochChanged)
495499 continue
@@ -466,7 +466,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
466 return fmt.Errorf("workspace frame fence: %w", err)466 return fmt.Errorf("workspace frame fence: %w", err)
467 }467 }
468 }468 }
469- // Captures never show the capture indicator: their frame goes first.469+ // Captures never show the capture indicator or the position hint, and
470+ // show hint labels only to clients with hint.capture: their frame goes
471+ // first.
470 took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)472 took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)
471 if pdone != nil {473 if pdone != nil {
472 // The plain render reads client buffers even when its requests474 // The plain render reads client buffers even when its requests
@@ -490,6 +492,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
490 if len(excluded) > 0 {492 if len(excluded) > 0 {
491 // Exclude requests: the frame without the excluded surfaces is493 // Exclude requests: the frame without the excluded surfaces is
492 // drawn first (same renderer, queue order), then the displayed one.494 // drawn first (same renderer, queue order), then the displayed one.
495+ // It has no indicator or position hint; it keeps hint labels with
496+ // hint.capture (Split refuses internal requests there).
493 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {497 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
494 failRequests(capture.ErrEpochChanged)498 failRequests(capture.ErrEpochChanged)
495 continue499 continue
M internal/adapters/outputkit/capture/excluded.go +73 -23
@@ -10,9 +10,31 @@ import (
1010
1111 // A request with Exclude leaves out the exclusion's HUD layers and popups;
1212 // every other capture shows them. No capture shows the capture indicator
13-// (Scene.CaptureIndicators): a scene that carries it, or hint labels, is never the source of a
14-// capture image. Requests of the displayed frame are then served from the
15-// frame rendered without it first (SubmitPlain, ExcludedScene).
13+// (Scene.CaptureIndicators) or the position hint. Hint labels are shown to
14+// the captures of Wayland clients only when hint.capture is on
15+// (HintStyle.Capture), never to the compositor's own captures
16+// (CaptureIDInternal) and never with hint.capture off. A scene that carries
17+// something a capture must not show is never the source of its image:
18+// requests of the displayed frame are served from a frame rendered without
19+// it first (SubmitPlain, ExcludedScene).
20+//
21+// A frame needs at most two batches: one for the displayed-frame requests
22+// (served from the displayed or from the plain frame, never both) and one
23+// for the Exclude requests. While labels are kept for clients, an internal
24+// capture would need a third, labelless one: Split fails it instead.
25+
26+// ErrInternalLabelled fails an internal capture (CaptureIDInternal) of a frame
27+// that shows hint labels kept for client captures (hint.capture): serving it
28+// without labels would need a third capture batch, and serving it with them
29+// would make the hint scanner read its own labels. Core only starts a scan
30+// from the idle hint mode (core/hint.go hintRequest), so labels and a scan in
31+// flight coincide only on a stale scene; the scan fails with a transient
32+// error and the user retries.
33+var ErrInternalLabelled = fmt.Errorf("%w: internal capture on a frame that keeps hint labels for clients", ports.ErrCaptureTransient)
34+
35+// ErrInternalExclude fails an internal Exclude capture of a frame that keeps
36+// hint labels: its excluded frame carries them too.
37+var ErrInternalExclude = fmt.Errorf("%w: internal capture cannot read the excluded frame, which keeps hint labels", ports.ErrCaptureTransient)
1638
1739 // ErrSessionInactive fails an Exclude request whose session is not the one
1840 // the scene carries. Serving it as a normal capture would leak its HUD.
@@ -64,21 +86,37 @@ func excludedShown(s ports.Scene) bool {
6486 return false
6587 }
6688
67-// plain returns s without its capture state, indicators and hint labels, Seq 0 so the
68-// renderer redraws the whole target instead of reusing a displayed frame.
89+// keepsLabels reports whether the scene shows hint labels that client
90+// captures keep (hint.capture): the frames derived for captures keep them.
91+func keepsLabels(s ports.Scene) bool { return s.HintStyle.Capture && len(s.HintLabels) > 0 }
92+
93+// internal reports a capture requested by the compositor itself (hint scan).
94+func internal(q ports.CaptureRequest) bool { return q.ID&ports.CaptureIDInternal != 0 }
95+
96+// plain returns s as a capture shows it: without its capture state,
97+// indicators and position hint, and without hint labels unless they are kept
98+// for clients (keepsLabels). Seq is 0 so the renderer redraws the whole
99+// target instead of reusing a displayed frame.
69100 func plain(s ports.Scene) ports.Scene {
70- s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
101+ s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 0
102+ if !s.HintStyle.Capture {
103+ s.HintLabels = nil
104+ }
71105 s.PositionHint = ports.PositionHint{}
72106 return s
73107 }
74108
75-// SubmitPlain serves the displayed-frame requests of a scene that carries the
76-// capture indicator: the frame is rendered without it, first (same renderer,
77-// queue order, like ExcludedScene), and copied before the displayed one is
78-// drawn. It reports whether it took the requests (and zeroed them); false
79-// when the scene has no indicator or there is nothing to serve, the caller
80-// submits them after the displayed frame as usual. On a render error the
81-// caller still owns the requests.
109+// SubmitPlain serves the displayed-frame requests of a scene that carries
110+// what captures must not show: the capture indicator, the position hint, or
111+// hint labels while they are not kept for clients. The frame is rendered
112+// without it, first (same renderer, queue order, like ExcludedScene), and
113+// copied before the displayed one is drawn. It reports whether it took the
114+// requests (and zeroed them); false when the displayed frame already shows
115+// what they may (no indicator, no position hint, and no labels or labels
116+// kept for clients) or there is nothing to serve, then the caller submits
117+// them after the displayed frame as usual. On a render error the caller still
118+// owns the requests. Internal requests never reach it on a frame that keeps
119+// labels: Split fails them.
82120 //
83121 // Admission is the session gate's: the requests are handed over with
84122 // SubmitScoped under the scene's epoch, so a locked or changed epoch fails
@@ -86,7 +124,7 @@ func plain(s ports.Scene) ports.Scene {
86124 // the plain render reads client buffers even when the requests are rejected,
87125 // so the caller owns it (hold or wait it) whatever happened, error included.
88126 func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) {
89- if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 && !s.PositionHint.Shown() {
127+ if len(normal) == 0 || len(s.CaptureIndicators) == 0 && (len(s.HintLabels) == 0 || keepsLabels(s)) && !s.PositionHint.Shown() {
90128 return false, nil, nil
91129 }
92130 done, err = r.Render(plain(s), surfaces)
@@ -130,14 +168,21 @@ func (p *Pipeline) ExcludedScene(s ports.Scene) ports.Scene { return p.excl.scen
130168
131169 // Split answers or orders the requests of one output frame, in place. No
132170 // allocation. Requests that cannot be served are failed and removed:
133-// Exclude requests of a session other than the scene's, or with no exclusion
134-// in it (ErrSessionInactive), and those newer than its Revision
135-// (ErrSessionStale); workspace requests whose workspace is not where they
136-// expected (ErrWorkspaceMoved). The rest is reordered:
137-// - normal is the prefix, drawn from the displayed frame;
138-// - excluded follows: Exclude requests, drawn from ExcludedScene rendered first,
139-// or from the displayed frame (they stay in normal) when it shows no
140-// excluded surface and no capture indicator;
171+// - internal requests of a frame that keeps hint labels for clients
172+// (ErrInternalLabelled, ErrInternalExclude);
173+// - Exclude requests of a session other than the scene's, or with no
174+// exclusion in it (ErrSessionInactive), and those newer than its Revision
175+// (ErrSessionStale);
176+// - workspace requests whose workspace is not where they expected
177+// (ErrWorkspaceMoved).
178+//
179+// The rest is reordered:
180+// - normal is the prefix, drawn from the displayed frame (or from the plain
181+// frame, see SubmitPlain);
182+// - excluded follows: Exclude requests, drawn from ExcludedScene rendered
183+// first, or from the displayed frame (they stay in normal) when it shows
184+// no excluded surface, no capture indicator or position hint, and no
185+// hint labels that must stay out of captures;
141186 // - hidden: the requests of the workspace rendered off screen
142187 // (OffScreen), served from CaptureScene (see SubmitHidden); they come last.
143188 //
@@ -147,6 +192,11 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
147192 for _, q := range reqs {
148193 var err error
149194 switch {
195+ case internal(q) && keepsLabels(s):
196+ err = ErrInternalLabelled
197+ if q.Exclude {
198+ err = ErrInternalExclude
199+ }
150200 case q.Exclude && (s.Capture == nil || s.Capture.Session == 0 || q.Session != s.Capture.Session):
151201 err = ErrSessionInactive
152202 case q.Exclude && s.Capture.Revision < q.CaptureRevision:
@@ -165,7 +215,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
165215 }
166216 clear(reqs[n:])
167217 reqs = reqs[:n]
168- excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 || s.PositionHint.Shown()
218+ excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || (len(s.HintLabels) > 0 && !s.HintStyle.Capture) || s.PositionHint.Shown()
169219 group := func(q ports.CaptureRequest) int {
170220 switch {
171221 case q.OffScreen:
@@ -10,9 +10,31 @@ import (
10 10
11 // A request with Exclude leaves out the exclusion's HUD layers and popups;11 // A request with Exclude leaves out the exclusion's HUD layers and popups;
12 // every other capture shows them. No capture shows the capture indicator12 // every other capture shows them. No capture shows the capture indicator
13-// (Scene.CaptureIndicators): a scene that carries it, or hint labels, is never the source of a13+// (Scene.CaptureIndicators) or the position hint. Hint labels are shown to
14-// capture image. Requests of the displayed frame are then served from the14+// the captures of Wayland clients only when hint.capture is on
15-// frame rendered without it first (SubmitPlain, ExcludedScene).15+// (HintStyle.Capture), never to the compositor's own captures
16+// (CaptureIDInternal) and never with hint.capture off. A scene that carries
17+// something a capture must not show is never the source of its image:
18+// requests of the displayed frame are served from a frame rendered without
19+// it first (SubmitPlain, ExcludedScene).
20+//
21+// A frame needs at most two batches: one for the displayed-frame requests
22+// (served from the displayed or from the plain frame, never both) and one
23+// for the Exclude requests. While labels are kept for clients, an internal
24+// capture would need a third, labelless one: Split fails it instead.
25+
26+// ErrInternalLabelled fails an internal capture (CaptureIDInternal) of a frame
27+// that shows hint labels kept for client captures (hint.capture): serving it
28+// without labels would need a third capture batch, and serving it with them
29+// would make the hint scanner read its own labels. Core only starts a scan
30+// from the idle hint mode (core/hint.go hintRequest), so labels and a scan in
31+// flight coincide only on a stale scene; the scan fails with a transient
32+// error and the user retries.
33+var ErrInternalLabelled = fmt.Errorf("%w: internal capture on a frame that keeps hint labels for clients", ports.ErrCaptureTransient)
34+
35+// ErrInternalExclude fails an internal Exclude capture of a frame that keeps
36+// hint labels: its excluded frame carries them too.
37+var ErrInternalExclude = fmt.Errorf("%w: internal capture cannot read the excluded frame, which keeps hint labels", ports.ErrCaptureTransient)
16 38
17 // ErrSessionInactive fails an Exclude request whose session is not the one39 // ErrSessionInactive fails an Exclude request whose session is not the one
18 // the scene carries. Serving it as a normal capture would leak its HUD.40 // the scene carries. Serving it as a normal capture would leak its HUD.
@@ -64,21 +86,37 @@ func excludedShown(s ports.Scene) bool {
64 return false86 return false
65 }87 }
66 88
67-// plain returns s without its capture state, indicators and hint labels, Seq 0 so the89+// keepsLabels reports whether the scene shows hint labels that client
68-// renderer redraws the whole target instead of reusing a displayed frame.90+// captures keep (hint.capture): the frames derived for captures keep them.
91+func keepsLabels(s ports.Scene) bool { return s.HintStyle.Capture && len(s.HintLabels) > 0 }
92+
93+// internal reports a capture requested by the compositor itself (hint scan).
94+func internal(q ports.CaptureRequest) bool { return q.ID&ports.CaptureIDInternal != 0 }
95+
96+// plain returns s as a capture shows it: without its capture state,
97+// indicators and position hint, and without hint labels unless they are kept
98+// for clients (keepsLabels). Seq is 0 so the renderer redraws the whole
99+// target instead of reusing a displayed frame.
69 func plain(s ports.Scene) ports.Scene {100 func plain(s ports.Scene) ports.Scene {
70- s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0101+ s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 0
102+ if !s.HintStyle.Capture {
103+ s.HintLabels = nil
104+ }
71 s.PositionHint = ports.PositionHint{}105 s.PositionHint = ports.PositionHint{}
72 return s106 return s
73 }107 }
74 108
75-// SubmitPlain serves the displayed-frame requests of a scene that carries the109+// SubmitPlain serves the displayed-frame requests of a scene that carries
76-// capture indicator: the frame is rendered without it, first (same renderer,110+// what captures must not show: the capture indicator, the position hint, or
77-// queue order, like ExcludedScene), and copied before the displayed one is111+// hint labels while they are not kept for clients. The frame is rendered
78-// drawn. It reports whether it took the requests (and zeroed them); false112+// without it, first (same renderer, queue order, like ExcludedScene), and
79-// when the scene has no indicator or there is nothing to serve, the caller113+// copied before the displayed one is drawn. It reports whether it took the
80-// submits them after the displayed frame as usual. On a render error the114+// requests (and zeroed them); false when the displayed frame already shows
81-// caller still owns the requests.115+// what they may (no indicator, no position hint, and no labels or labels
116+// kept for clients) or there is nothing to serve, then the caller submits
117+// them after the displayed frame as usual. On a render error the caller still
118+// owns the requests. Internal requests never reach it on a frame that keeps
119+// labels: Split fails them.
82 //120 //
83 // Admission is the session gate's: the requests are handed over with121 // Admission is the session gate's: the requests are handed over with
84 // SubmitScoped under the scene's epoch, so a locked or changed epoch fails122 // SubmitScoped under the scene's epoch, so a locked or changed epoch fails
@@ -86,7 +124,7 @@ func plain(s ports.Scene) ports.Scene {
86 // the plain render reads client buffers even when the requests are rejected,124 // the plain render reads client buffers even when the requests are rejected,
87 // so the caller owns it (hold or wait it) whatever happened, error included.125 // so the caller owns it (hold or wait it) whatever happened, error included.
88 func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) {126 func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) {
89- if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 && !s.PositionHint.Shown() {127+ if len(normal) == 0 || len(s.CaptureIndicators) == 0 && (len(s.HintLabels) == 0 || keepsLabels(s)) && !s.PositionHint.Shown() {
90 return false, nil, nil128 return false, nil, nil
91 }129 }
92 done, err = r.Render(plain(s), surfaces)130 done, err = r.Render(plain(s), surfaces)
@@ -130,14 +168,21 @@ func (p *Pipeline) ExcludedScene(s ports.Scene) ports.Scene { return p.excl.scen
130 168
131 // Split answers or orders the requests of one output frame, in place. No169 // Split answers or orders the requests of one output frame, in place. No
132 // allocation. Requests that cannot be served are failed and removed:170 // allocation. Requests that cannot be served are failed and removed:
133-// Exclude requests of a session other than the scene's, or with no exclusion171+// - internal requests of a frame that keeps hint labels for clients
134-// in it (ErrSessionInactive), and those newer than its Revision172+// (ErrInternalLabelled, ErrInternalExclude);
135-// (ErrSessionStale); workspace requests whose workspace is not where they173+// - Exclude requests of a session other than the scene's, or with no
136-// expected (ErrWorkspaceMoved). The rest is reordered:174+// exclusion in it (ErrSessionInactive), and those newer than its Revision
137-// - normal is the prefix, drawn from the displayed frame;175+// (ErrSessionStale);
138-// - excluded follows: Exclude requests, drawn from ExcludedScene rendered first,176+// - workspace requests whose workspace is not where they expected
139-// or from the displayed frame (they stay in normal) when it shows no177+// (ErrWorkspaceMoved).
140-// excluded surface and no capture indicator;178+//
179+// The rest is reordered:
180+// - normal is the prefix, drawn from the displayed frame (or from the plain
181+// frame, see SubmitPlain);
182+// - excluded follows: Exclude requests, drawn from ExcludedScene rendered
183+// first, or from the displayed frame (they stay in normal) when it shows
184+// no excluded surface, no capture indicator or position hint, and no
185+// hint labels that must stay out of captures;
141 // - hidden: the requests of the workspace rendered off screen186 // - hidden: the requests of the workspace rendered off screen
142 // (OffScreen), served from CaptureScene (see SubmitHidden); they come last.187 // (OffScreen), served from CaptureScene (see SubmitHidden); they come last.
143 //188 //
@@ -147,6 +192,11 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
147 for _, q := range reqs {192 for _, q := range reqs {
148 var err error193 var err error
149 switch {194 switch {
195+ case internal(q) && keepsLabels(s):
196+ err = ErrInternalLabelled
197+ if q.Exclude {
198+ err = ErrInternalExclude
199+ }
150 case q.Exclude && (s.Capture == nil || s.Capture.Session == 0 || q.Session != s.Capture.Session):200 case q.Exclude && (s.Capture == nil || s.Capture.Session == 0 || q.Session != s.Capture.Session):
151 err = ErrSessionInactive201 err = ErrSessionInactive
152 case q.Exclude && s.Capture.Revision < q.CaptureRevision:202 case q.Exclude && s.Capture.Revision < q.CaptureRevision:
@@ -165,7 +215,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
165 }215 }
166 clear(reqs[n:])216 clear(reqs[n:])
167 reqs = reqs[:n]217 reqs = reqs[:n]
168- excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 || s.PositionHint.Shown()218+ excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || (len(s.HintLabels) > 0 && !s.HintStyle.Capture) || s.PositionHint.Shown()
169 group := func(q ports.CaptureRequest) int {219 group := func(q ports.CaptureRequest) int {
170 switch {220 switch {
171 case q.OffScreen:221 case q.OffScreen:
A internal/adapters/outputkit/capture/hint_capture_test.go +309 -0
@@ -0,0 +1,309 @@
1+package capture
2+
3+import (
4+ "context"
5+ "image"
6+ "os"
7+ "strings"
8+ "sync"
9+ "testing"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+const internalID = ports.CaptureIDInternal | 1
18+
19+// hintFrame describes a scene for hint.capture tests: what it draws and
20+// whether its exclusion is active.
21+type hintFrame struct {
22+ capture bool // hint.capture
23+ labels bool // hint mode is shown on this output
24+ indicator bool
25+ posHint bool
26+ exclusion bool // the scene carries an exclusion session (7) hiding drawn layers
27+ unshown bool // ... hiding a surface the scene does not draw
28+}
29+
30+func (f hintFrame) scene() ports.Scene {
31+ s := excludedTestScene()
32+ s.Capture = nil
33+ if f.exclusion {
34+ s.Capture = &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{10, 20}}
35+ }
36+ if f.unshown {
37+ s.Capture = &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{99}}
38+ }
39+ s.HintStyle.Capture = f.capture
40+ if f.labels {
41+ s.HintLabels = []ports.HintLabel{{Text: "f"}}
42+ }
43+ if f.indicator {
44+ s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 10, H: 10}}}
45+ }
46+ if f.posHint {
47+ s.PositionHint = ports.PositionHint{Opacity: 1, Columns: 1}
48+ }
49+ return s
50+}
51+
52+// drawn names what a rendered scene shows: its labels, indicator and
53+// position hint, and "excluded" when the excluded layers are gone.
54+func drawn(s ports.Scene) string {
55+ var parts []string
56+ if len(s.HintLabels) > 0 {
57+ parts = append(parts, "labels")
58+ }
59+ if len(s.CaptureIndicators) > 0 {
60+ parts = append(parts, "indicator")
61+ }
62+ if s.PositionHint.Shown() {
63+ parts = append(parts, "poshint")
64+ }
65+ if len(s.Layers) == 2 {
66+ parts = append(parts, "excluded")
67+ }
68+ if len(parts) == 0 {
69+ return "bare"
70+ }
71+ return strings.Join(parts, "+")
72+}
73+
74+// frameRun is what one output frame did, as the renderer saw it.
75+type frameRun struct {
76+ renders []string // drawn() of every Render, in call order
77+ batches []int // requests copied by each BeginCapture, in call order
78+ results map[uint64]error // reply of every request
79+}
80+
81+// runFrame serves the requests the way the outputs do: Split, SubmitPlain,
82+// the excluded frame, then the displayed frame and what still reads it.
83+func runFrame(t *testing.T, s ports.Scene, reqs ...ports.CaptureRequest) frameRun {
84+ t.Helper()
85+ replies := make(chan ports.CaptureDone, len(reqs))
86+ p := NewPipeline(context.Background(), replies)
87+ r := portsmocks.NewMockRenderer(t)
88+ var mu sync.Mutex
89+ var run frameRun
90+ var frames []*portsmocks.MockCaptureFrame
91+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
92+ mu.Lock()
93+ defer mu.Unlock()
94+ run.renders = append(run.renders, drawn(s))
95+ return nil, nil
96+ }).Maybe()
97+ r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
98+ mu.Lock()
99+ defer mu.Unlock()
100+ n := len(run.batches)
101+ run.batches = append(run.batches, 0)
102+ f := portsmocks.NewMockCaptureFrame(t)
103+ f.EXPECT().Done().Return((*os.File)(nil)).Maybe()
104+ f.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
105+ mu.Lock()
106+ defer mu.Unlock()
107+ run.batches[n]++
108+ return nil
109+ }).Maybe()
110+ frames = append(frames, f)
111+ return f, nil
112+ }).Maybe()
113+ r.EXPECT().EndCapture(mock.Anything).Return().Maybe()
114+
115+ normal, excluded, hidden := p.Split(s, reqs)
116+ require.Empty(t, hidden)
117+ took, done, err := p.SubmitPlain(r, s, nil, normal)
118+ require.NoError(t, err)
119+ require.Nil(t, done)
120+ if took {
121+ normal = nil
122+ }
123+ if len(excluded) > 0 {
124+ _, _ = r.Render(p.ExcludedScene(s), nil)
125+ p.SubmitScoped(s.Security, r, excluded)
126+ }
127+ _, _ = r.Render(s, nil)
128+ p.SubmitScoped(s.Security, r, normal)
129+
130+ run.results = map[uint64]error{}
131+ for range reqs {
132+ d := awaitCapture(t, replies)
133+ run.results[d.ID] = d.Err
134+ }
135+ p.Close(r)
136+ for _, f := range frames {
137+ f.Calls = nil // Read's arguments point into unmapped destinations
138+ }
139+ return run
140+}
141+
142+func client(t *testing.T, id uint64) ports.CaptureRequest { return pipelineRequest(t, id) }
143+
144+func clientExclude(t *testing.T, id uint64) ports.CaptureRequest {
145+ q := pipelineRequest(t, id)
146+ q.Exclude, q.Session = true, 7
147+ return q
148+}
149+
150+func internalReq(t *testing.T, exclude bool) ports.CaptureRequest {
151+ q := pipelineRequest(t, internalID)
152+ if exclude {
153+ q.Exclude, q.Session = true, 7
154+ }
155+ return q
156+}
157+
158+// Every frame shape hint.capture can produce: what is rendered, how many
159+// captures each batch copies, which requests fail. The displayed frame is
160+// always the last render. No frame needs more than two batches.
161+func TestHintCaptureFrames(t *testing.T) {
162+ const (
163+ displayed = "labels"
164+ bare = "bare"
165+ )
166+ tests := []struct {
167+ name string
168+ frame hintFrame
169+ reqs func(t *testing.T) []ports.CaptureRequest
170+ renders []string
171+ batches []int
172+ failed map[uint64]error // want errors (ErrorIs); every other request succeeds
173+ }{
174+ {
175+ name: "off: labels leave every capture, one plain frame serves both",
176+ frame: hintFrame{labels: true},
177+ reqs: func(t *testing.T) []ports.CaptureRequest {
178+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
179+ },
180+ renders: []string{bare, displayed},
181+ batches: []int{2},
182+ },
183+ {
184+ name: "on: a client capture reads the displayed frame, labels included",
185+ frame: hintFrame{capture: true, labels: true},
186+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{client(t, 1)} },
187+ renders: []string{displayed},
188+ batches: []int{1},
189+ },
190+ {
191+ name: "on: the indicator is dropped, the labels are kept",
192+ frame: hintFrame{capture: true, labels: true, indicator: true},
193+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{client(t, 1)} },
194+ renders: []string{"labels", "labels+indicator"},
195+ batches: []int{1},
196+ },
197+ {
198+ name: "on: the position hint is dropped, the labels are kept",
199+ frame: hintFrame{capture: true, labels: true, posHint: true},
200+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{client(t, 1)} },
201+ renders: []string{"labels", "labels+poshint"},
202+ batches: []int{1},
203+ },
204+ {
205+ name: "on: an internal capture of a labelled frame fails, the client is served",
206+ frame: hintFrame{capture: true, labels: true, indicator: true},
207+ reqs: func(t *testing.T) []ports.CaptureRequest {
208+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
209+ },
210+ renders: []string{"labels", "labels+indicator"},
211+ batches: []int{1},
212+ failed: map[uint64]error{internalID: ErrInternalLabelled},
213+ },
214+ {
215+ name: "on: an internal capture alone fails and renders nothing for it",
216+ frame: hintFrame{capture: true, labels: true},
217+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{internalReq(t, false)} },
218+ // the displayed frame is still drawn by the output, with no batch
219+ renders: []string{displayed},
220+ failed: map[uint64]error{internalID: ErrInternalLabelled},
221+ },
222+ {
223+ name: "on: an internal Exclude capture fails, its excluded frame has labels",
224+ frame: hintFrame{capture: true, labels: true, exclusion: true},
225+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{internalReq(t, true)} },
226+ // no excluded frame is rendered for a failed request
227+ renders: []string{displayed},
228+ failed: map[uint64]error{internalID: ErrInternalExclude},
229+ },
230+ {
231+ name: "on: client, internal and Exclude requests over an indicator fit two batches",
232+ frame: hintFrame{capture: true, labels: true, indicator: true, posHint: true, exclusion: true},
233+ reqs: func(t *testing.T) []ports.CaptureRequest {
234+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false), clientExclude(t, 3)}
235+ },
236+ // plain frame for the client, excluded frame (labels kept, no
237+ // indicator, no position hint) for Exclude, then the displayed one
238+ renders: []string{"labels", "labels+excluded", "labels+indicator+poshint"},
239+ batches: []int{1, 1},
240+ failed: map[uint64]error{internalID: ErrInternalLabelled},
241+ },
242+ {
243+ name: "on: labels alone do not force the excluded frame",
244+ frame: hintFrame{capture: true, labels: true, unshown: true},
245+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{clientExclude(t, 1)} },
246+ renders: []string{displayed},
247+ batches: []int{1},
248+ },
249+ {
250+ name: "off: labels force the excluded frame, without labels",
251+ frame: hintFrame{labels: true, exclusion: true},
252+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{clientExclude(t, 1)} },
253+ renders: []string{"excluded", displayed},
254+ batches: []int{1},
255+ },
256+ {
257+ name: "on without labels shown: internal and client share the plain frame",
258+ frame: hintFrame{capture: true, indicator: true},
259+ reqs: func(t *testing.T) []ports.CaptureRequest {
260+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
261+ },
262+ renders: []string{bare, "indicator"},
263+ batches: []int{2},
264+ },
265+ {
266+ name: "nothing to hide: the displayed frame serves everyone",
267+ frame: hintFrame{capture: true},
268+ reqs: func(t *testing.T) []ports.CaptureRequest {
269+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
270+ },
271+ renders: []string{bare},
272+ batches: []int{2},
273+ },
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ run := runFrame(t, tt.frame.scene(), tt.reqs(t)...)
278+ require.Equal(t, tt.renders, run.renders)
279+ require.Equal(t, tt.batches, run.batches)
280+ require.LessOrEqual(t, len(run.batches), pipelineSlots)
281+ for id, err := range run.results {
282+ if want, ok := tt.failed[id]; ok {
283+ require.ErrorIs(t, err, want, "request %#x", id)
284+ require.ErrorIs(t, err, ports.ErrCaptureTransient)
285+ } else {
286+ require.NoError(t, err, "request %#x", id)
287+ }
288+ }
289+ })
290+ }
291+}
292+
293+// Splitting and building the excluded scene of a frame that keeps labels
294+// allocates nothing.
295+func TestSplitNoAllocHintCapture(t *testing.T) {
296+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 8))
297+ defer p.Close(nil)
298+ s := hintFrame{capture: true, labels: true, indicator: true, exclusion: true}.scene()
299+ reqs := make([]ports.CaptureRequest, 3)
300+ if allocs := testing.AllocsPerRun(100, func() {
301+ reqs[0] = ports.CaptureRequest{ID: 1}
302+ reqs[1] = ports.CaptureRequest{ID: 2, Exclude: true, Session: 7}
303+ reqs[2] = ports.CaptureRequest{ID: 3, Exclude: true, Session: 7}
304+ _ = p.ExcludedScene(s)
305+ p.Split(s, reqs)
306+ }); allocs != 0 {
307+ t.Fatalf("%.1f allocs, want 0", allocs)
308+ }
309+}
@@ -0,0 +1,309 @@
1+package capture
2+
3+import (
4+ "context"
5+ "image"
6+ "os"
7+ "strings"
8+ "sync"
9+ "testing"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+const internalID = ports.CaptureIDInternal | 1
18+
19+// hintFrame describes a scene for hint.capture tests: what it draws and
20+// whether its exclusion is active.
21+type hintFrame struct {
22+ capture bool // hint.capture
23+ labels bool // hint mode is shown on this output
24+ indicator bool
25+ posHint bool
26+ exclusion bool // the scene carries an exclusion session (7) hiding drawn layers
27+ unshown bool // ... hiding a surface the scene does not draw
28+}
29+
30+func (f hintFrame) scene() ports.Scene {
31+ s := excludedTestScene()
32+ s.Capture = nil
33+ if f.exclusion {
34+ s.Capture = &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{10, 20}}
35+ }
36+ if f.unshown {
37+ s.Capture = &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{99}}
38+ }
39+ s.HintStyle.Capture = f.capture
40+ if f.labels {
41+ s.HintLabels = []ports.HintLabel{{Text: "f"}}
42+ }
43+ if f.indicator {
44+ s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 10, H: 10}}}
45+ }
46+ if f.posHint {
47+ s.PositionHint = ports.PositionHint{Opacity: 1, Columns: 1}
48+ }
49+ return s
50+}
51+
52+// drawn names what a rendered scene shows: its labels, indicator and
53+// position hint, and "excluded" when the excluded layers are gone.
54+func drawn(s ports.Scene) string {
55+ var parts []string
56+ if len(s.HintLabels) > 0 {
57+ parts = append(parts, "labels")
58+ }
59+ if len(s.CaptureIndicators) > 0 {
60+ parts = append(parts, "indicator")
61+ }
62+ if s.PositionHint.Shown() {
63+ parts = append(parts, "poshint")
64+ }
65+ if len(s.Layers) == 2 {
66+ parts = append(parts, "excluded")
67+ }
68+ if len(parts) == 0 {
69+ return "bare"
70+ }
71+ return strings.Join(parts, "+")
72+}
73+
74+// frameRun is what one output frame did, as the renderer saw it.
75+type frameRun struct {
76+ renders []string // drawn() of every Render, in call order
77+ batches []int // requests copied by each BeginCapture, in call order
78+ results map[uint64]error // reply of every request
79+}
80+
81+// runFrame serves the requests the way the outputs do: Split, SubmitPlain,
82+// the excluded frame, then the displayed frame and what still reads it.
83+func runFrame(t *testing.T, s ports.Scene, reqs ...ports.CaptureRequest) frameRun {
84+ t.Helper()
85+ replies := make(chan ports.CaptureDone, len(reqs))
86+ p := NewPipeline(context.Background(), replies)
87+ r := portsmocks.NewMockRenderer(t)
88+ var mu sync.Mutex
89+ var run frameRun
90+ var frames []*portsmocks.MockCaptureFrame
91+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
92+ mu.Lock()
93+ defer mu.Unlock()
94+ run.renders = append(run.renders, drawn(s))
95+ return nil, nil
96+ }).Maybe()
97+ r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
98+ mu.Lock()
99+ defer mu.Unlock()
100+ n := len(run.batches)
101+ run.batches = append(run.batches, 0)
102+ f := portsmocks.NewMockCaptureFrame(t)
103+ f.EXPECT().Done().Return((*os.File)(nil)).Maybe()
104+ f.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
105+ mu.Lock()
106+ defer mu.Unlock()
107+ run.batches[n]++
108+ return nil
109+ }).Maybe()
110+ frames = append(frames, f)
111+ return f, nil
112+ }).Maybe()
113+ r.EXPECT().EndCapture(mock.Anything).Return().Maybe()
114+
115+ normal, excluded, hidden := p.Split(s, reqs)
116+ require.Empty(t, hidden)
117+ took, done, err := p.SubmitPlain(r, s, nil, normal)
118+ require.NoError(t, err)
119+ require.Nil(t, done)
120+ if took {
121+ normal = nil
122+ }
123+ if len(excluded) > 0 {
124+ _, _ = r.Render(p.ExcludedScene(s), nil)
125+ p.SubmitScoped(s.Security, r, excluded)
126+ }
127+ _, _ = r.Render(s, nil)
128+ p.SubmitScoped(s.Security, r, normal)
129+
130+ run.results = map[uint64]error{}
131+ for range reqs {
132+ d := awaitCapture(t, replies)
133+ run.results[d.ID] = d.Err
134+ }
135+ p.Close(r)
136+ for _, f := range frames {
137+ f.Calls = nil // Read's arguments point into unmapped destinations
138+ }
139+ return run
140+}
141+
142+func client(t *testing.T, id uint64) ports.CaptureRequest { return pipelineRequest(t, id) }
143+
144+func clientExclude(t *testing.T, id uint64) ports.CaptureRequest {
145+ q := pipelineRequest(t, id)
146+ q.Exclude, q.Session = true, 7
147+ return q
148+}
149+
150+func internalReq(t *testing.T, exclude bool) ports.CaptureRequest {
151+ q := pipelineRequest(t, internalID)
152+ if exclude {
153+ q.Exclude, q.Session = true, 7
154+ }
155+ return q
156+}
157+
158+// Every frame shape hint.capture can produce: what is rendered, how many
159+// captures each batch copies, which requests fail. The displayed frame is
160+// always the last render. No frame needs more than two batches.
161+func TestHintCaptureFrames(t *testing.T) {
162+ const (
163+ displayed = "labels"
164+ bare = "bare"
165+ )
166+ tests := []struct {
167+ name string
168+ frame hintFrame
169+ reqs func(t *testing.T) []ports.CaptureRequest
170+ renders []string
171+ batches []int
172+ failed map[uint64]error // want errors (ErrorIs); every other request succeeds
173+ }{
174+ {
175+ name: "off: labels leave every capture, one plain frame serves both",
176+ frame: hintFrame{labels: true},
177+ reqs: func(t *testing.T) []ports.CaptureRequest {
178+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
179+ },
180+ renders: []string{bare, displayed},
181+ batches: []int{2},
182+ },
183+ {
184+ name: "on: a client capture reads the displayed frame, labels included",
185+ frame: hintFrame{capture: true, labels: true},
186+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{client(t, 1)} },
187+ renders: []string{displayed},
188+ batches: []int{1},
189+ },
190+ {
191+ name: "on: the indicator is dropped, the labels are kept",
192+ frame: hintFrame{capture: true, labels: true, indicator: true},
193+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{client(t, 1)} },
194+ renders: []string{"labels", "labels+indicator"},
195+ batches: []int{1},
196+ },
197+ {
198+ name: "on: the position hint is dropped, the labels are kept",
199+ frame: hintFrame{capture: true, labels: true, posHint: true},
200+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{client(t, 1)} },
201+ renders: []string{"labels", "labels+poshint"},
202+ batches: []int{1},
203+ },
204+ {
205+ name: "on: an internal capture of a labelled frame fails, the client is served",
206+ frame: hintFrame{capture: true, labels: true, indicator: true},
207+ reqs: func(t *testing.T) []ports.CaptureRequest {
208+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
209+ },
210+ renders: []string{"labels", "labels+indicator"},
211+ batches: []int{1},
212+ failed: map[uint64]error{internalID: ErrInternalLabelled},
213+ },
214+ {
215+ name: "on: an internal capture alone fails and renders nothing for it",
216+ frame: hintFrame{capture: true, labels: true},
217+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{internalReq(t, false)} },
218+ // the displayed frame is still drawn by the output, with no batch
219+ renders: []string{displayed},
220+ failed: map[uint64]error{internalID: ErrInternalLabelled},
221+ },
222+ {
223+ name: "on: an internal Exclude capture fails, its excluded frame has labels",
224+ frame: hintFrame{capture: true, labels: true, exclusion: true},
225+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{internalReq(t, true)} },
226+ // no excluded frame is rendered for a failed request
227+ renders: []string{displayed},
228+ failed: map[uint64]error{internalID: ErrInternalExclude},
229+ },
230+ {
231+ name: "on: client, internal and Exclude requests over an indicator fit two batches",
232+ frame: hintFrame{capture: true, labels: true, indicator: true, posHint: true, exclusion: true},
233+ reqs: func(t *testing.T) []ports.CaptureRequest {
234+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false), clientExclude(t, 3)}
235+ },
236+ // plain frame for the client, excluded frame (labels kept, no
237+ // indicator, no position hint) for Exclude, then the displayed one
238+ renders: []string{"labels", "labels+excluded", "labels+indicator+poshint"},
239+ batches: []int{1, 1},
240+ failed: map[uint64]error{internalID: ErrInternalLabelled},
241+ },
242+ {
243+ name: "on: labels alone do not force the excluded frame",
244+ frame: hintFrame{capture: true, labels: true, unshown: true},
245+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{clientExclude(t, 1)} },
246+ renders: []string{displayed},
247+ batches: []int{1},
248+ },
249+ {
250+ name: "off: labels force the excluded frame, without labels",
251+ frame: hintFrame{labels: true, exclusion: true},
252+ reqs: func(t *testing.T) []ports.CaptureRequest { return []ports.CaptureRequest{clientExclude(t, 1)} },
253+ renders: []string{"excluded", displayed},
254+ batches: []int{1},
255+ },
256+ {
257+ name: "on without labels shown: internal and client share the plain frame",
258+ frame: hintFrame{capture: true, indicator: true},
259+ reqs: func(t *testing.T) []ports.CaptureRequest {
260+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
261+ },
262+ renders: []string{bare, "indicator"},
263+ batches: []int{2},
264+ },
265+ {
266+ name: "nothing to hide: the displayed frame serves everyone",
267+ frame: hintFrame{capture: true},
268+ reqs: func(t *testing.T) []ports.CaptureRequest {
269+ return []ports.CaptureRequest{client(t, 1), internalReq(t, false)}
270+ },
271+ renders: []string{bare},
272+ batches: []int{2},
273+ },
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ run := runFrame(t, tt.frame.scene(), tt.reqs(t)...)
278+ require.Equal(t, tt.renders, run.renders)
279+ require.Equal(t, tt.batches, run.batches)
280+ require.LessOrEqual(t, len(run.batches), pipelineSlots)
281+ for id, err := range run.results {
282+ if want, ok := tt.failed[id]; ok {
283+ require.ErrorIs(t, err, want, "request %#x", id)
284+ require.ErrorIs(t, err, ports.ErrCaptureTransient)
285+ } else {
286+ require.NoError(t, err, "request %#x", id)
287+ }
288+ }
289+ })
290+ }
291+}
292+
293+// Splitting and building the excluded scene of a frame that keeps labels
294+// allocates nothing.
295+func TestSplitNoAllocHintCapture(t *testing.T) {
296+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 8))
297+ defer p.Close(nil)
298+ s := hintFrame{capture: true, labels: true, indicator: true, exclusion: true}.scene()
299+ reqs := make([]ports.CaptureRequest, 3)
300+ if allocs := testing.AllocsPerRun(100, func() {
301+ reqs[0] = ports.CaptureRequest{ID: 1}
302+ reqs[1] = ports.CaptureRequest{ID: 2, Exclude: true, Session: 7}
303+ reqs[2] = ports.CaptureRequest{ID: 3, Exclude: true, Session: 7}
304+ _ = p.ExcludedScene(s)
305+ p.Split(s, reqs)
306+ }); allocs != 0 {
307+ t.Fatalf("%.1f allocs, want 0", allocs)
308+ }
309+}
M internal/adapters/vulkan/capture_test.go +19 -11
@@ -98,7 +98,7 @@ func TestCaptureLastFrameRegion(t *testing.T) {
9898 r.EndCapture(cf) // idempotent
9999 }
100100
101-// Both slots leased: the third capture fails at once; returning one
101+// Every slot leased: the next capture fails at once; returning one
102102 // frees it again once its copy finished.
103103 func TestCaptureSlotsBounded(t *testing.T) {
104104 r, err := New(8, 8)
@@ -109,13 +109,18 @@ func TestCaptureSlotsBounded(t *testing.T) {
109109 if err := render(r, ports.Scene{Background: "#ff0000"}, nil); err != nil {
110110 t.Fatal(err)
111111 }
112- a := captureWait(t, r)
113- b := captureWait(t, r)
112+ leaseAll := func() (leased []ports.CaptureFrame) {
113+ for range captureSlots {
114+ leased = append(leased, captureWait(t, r))
115+ }
116+ return leased
117+ }
118+ leased := leaseAll()
114119 if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
115- t.Fatalf("third capture: %v", err)
120+ t.Fatalf("capture past the last slot: %v", err)
116121 }
117- r.EndCapture(a)
118- // a's copy finished (waited above): its fence polls signalled.
122+ r.EndCapture(leased[0])
123+ // The first copy finished (waited above): its fence polls signalled.
119124 c := captureWait(t, r)
120125 dst := make([]byte, 4)
121126 if err := c.Read(image.Rect(0, 0, 1, 1), dst, 4); err != nil {
@@ -124,15 +129,18 @@ func TestCaptureSlotsBounded(t *testing.T) {
124129 if dst[2] != 255 || dst[1] != 0 || dst[0] != 0 {
125130 t.Fatalf("pixel %v", dst)
126131 }
127- r.EndCapture(b)
132+ for _, f := range leased[1:] {
133+ r.EndCapture(f)
134+ }
128135 r.EndCapture(c)
129- // Pixels never takes a leased slot: with both out it reports nil.
130- a, b = captureWait(t, r), captureWait(t, r)
136+ // Pixels never takes a leased slot: with all out it reports nil.
137+ leased = leaseAll()
131138 if img := r.Pixels(); img != nil {
132139 t.Fatal("Pixels stole a leased slot")
133140 }
134- r.EndCapture(a)
135- r.EndCapture(b)
141+ for _, f := range leased {
142+ r.EndCapture(f)
143+ }
136144 if img := r.Pixels(); img == nil || img.RGBAAt(0, 0) != (color.RGBA{255, 0, 0, 255}) {
137145 t.Fatalf("Pixels after release: %v", img)
138146 }
@@ -98,7 +98,7 @@ func TestCaptureLastFrameRegion(t *testing.T) {
98 r.EndCapture(cf) // idempotent98 r.EndCapture(cf) // idempotent
99 }99 }
100 100
101-// Both slots leased: the third capture fails at once; returning one101+// Every slot leased: the next capture fails at once; returning one
102 // frees it again once its copy finished.102 // frees it again once its copy finished.
103 func TestCaptureSlotsBounded(t *testing.T) {103 func TestCaptureSlotsBounded(t *testing.T) {
104 r, err := New(8, 8)104 r, err := New(8, 8)
@@ -109,13 +109,18 @@ func TestCaptureSlotsBounded(t *testing.T) {
109 if err := render(r, ports.Scene{Background: "#ff0000"}, nil); err != nil {109 if err := render(r, ports.Scene{Background: "#ff0000"}, nil); err != nil {
110 t.Fatal(err)110 t.Fatal(err)
111 }111 }
112- a := captureWait(t, r)112+ leaseAll := func() (leased []ports.CaptureFrame) {
113- b := captureWait(t, r)113+ for range captureSlots {
114+ leased = append(leased, captureWait(t, r))
115+ }
116+ return leased
117+ }
118+ leased := leaseAll()
114 if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {119 if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
115- t.Fatalf("third capture: %v", err)120+ t.Fatalf("capture past the last slot: %v", err)
116 }121 }
117- r.EndCapture(a)122+ r.EndCapture(leased[0])
118- // a's copy finished (waited above): its fence polls signalled.123+ // The first copy finished (waited above): its fence polls signalled.
119 c := captureWait(t, r)124 c := captureWait(t, r)
120 dst := make([]byte, 4)125 dst := make([]byte, 4)
121 if err := c.Read(image.Rect(0, 0, 1, 1), dst, 4); err != nil {126 if err := c.Read(image.Rect(0, 0, 1, 1), dst, 4); err != nil {
@@ -124,15 +129,18 @@ func TestCaptureSlotsBounded(t *testing.T) {
124 if dst[2] != 255 || dst[1] != 0 || dst[0] != 0 {129 if dst[2] != 255 || dst[1] != 0 || dst[0] != 0 {
125 t.Fatalf("pixel %v", dst)130 t.Fatalf("pixel %v", dst)
126 }131 }
127- r.EndCapture(b)132+ for _, f := range leased[1:] {
133+ r.EndCapture(f)
134+ }
128 r.EndCapture(c)135 r.EndCapture(c)
129- // Pixels never takes a leased slot: with both out it reports nil.136+ // Pixels never takes a leased slot: with all out it reports nil.
130- a, b = captureWait(t, r), captureWait(t, r)137+ leased = leaseAll()
131 if img := r.Pixels(); img != nil {138 if img := r.Pixels(); img != nil {
132 t.Fatal("Pixels stole a leased slot")139 t.Fatal("Pixels stole a leased slot")
133 }140 }
134- r.EndCapture(a)141+ for _, f := range leased {
135- r.EndCapture(b)142+ r.EndCapture(f)
143+ }
136 if img := r.Pixels(); img == nil || img.RGBAAt(0, 0) != (color.RGBA{255, 0, 0, 255}) {144 if img := r.Pixels(); img == nil || img.RGBAAt(0, 0) != (color.RGBA{255, 0, 0, 255}) {
137 t.Fatalf("Pixels after release: %v", img)145 t.Fatalf("Pixels after release: %v", img)
138 }146 }
M internal/core/hint_test.go +18 -1
@@ -35,9 +35,15 @@ func newHint(t *testing.T, opts core.Options) *hintHarness {
3535
3636 // newHintMods is newHint with hint.text-click set to textClick.
3737 func newHintMods(t *testing.T, opts core.Options, textClick ports.Mods) *hintHarness {
38+ t.Helper()
39+ return newHintConfig(t, opts, func(cfg *ports.Config) { cfg.Hint.TextClick = textClick })
40+}
41+
42+// newHintConfig is newHint with the config edited by edit.
43+func newHintConfig(t *testing.T, opts core.Options, edit func(*ports.Config)) *hintHarness {
3844 t.Helper()
3945 cfg := scrollDefaults()
40- cfg.Hint.TextClick = textClick
46+ edit(&cfg)
4147 cfg.Border.Width = 0
4248 cfg.Layout.Gaps = 20
4349 cfg.Binds["Cmd+g"] = "hint"
@@ -878,3 +884,14 @@ func TestHintRejectsResizedSource(t *testing.T) {
878884 }
879885 }
880886 }
887+
888+// hint.capture reaches the scene next to the labels, off by default.
889+func TestHintCaptureReachesScene(t *testing.T) {
890+ for _, on := range []bool{false, true} {
891+ h := newHintConfig(t, core.Options{}, func(cfg *ports.Config) { cfg.Hint.Capture = on })
892+ s := h.show(h.inside(0))
893+ if s.HintStyle.Capture != on {
894+ t.Errorf("hint.capture %v: scene has %v", on, s.HintStyle.Capture)
895+ }
896+ }
897+}
@@ -35,9 +35,15 @@ func newHint(t *testing.T, opts core.Options) *hintHarness {
35 35
36 // newHintMods is newHint with hint.text-click set to textClick.36 // newHintMods is newHint with hint.text-click set to textClick.
37 func newHintMods(t *testing.T, opts core.Options, textClick ports.Mods) *hintHarness {37 func newHintMods(t *testing.T, opts core.Options, textClick ports.Mods) *hintHarness {
38+ t.Helper()
39+ return newHintConfig(t, opts, func(cfg *ports.Config) { cfg.Hint.TextClick = textClick })
40+}
41+
42+// newHintConfig is newHint with the config edited by edit.
43+func newHintConfig(t *testing.T, opts core.Options, edit func(*ports.Config)) *hintHarness {
38 t.Helper()44 t.Helper()
39 cfg := scrollDefaults()45 cfg := scrollDefaults()
40- cfg.Hint.TextClick = textClick46+ edit(&cfg)
41 cfg.Border.Width = 047 cfg.Border.Width = 0
42 cfg.Layout.Gaps = 2048 cfg.Layout.Gaps = 20
43 cfg.Binds["Cmd+g"] = "hint"49 cfg.Binds["Cmd+g"] = "hint"
@@ -878,3 +884,14 @@ func TestHintRejectsResizedSource(t *testing.T) {
878 }884 }
879 }885 }
880 }886 }
887+
888+// hint.capture reaches the scene next to the labels, off by default.
889+func TestHintCaptureReachesScene(t *testing.T) {
890+ for _, on := range []bool{false, true} {
891+ h := newHintConfig(t, core.Options{}, func(cfg *ports.Config) { cfg.Hint.Capture = on })
892+ s := h.show(h.inside(0))
893+ if s.HintStyle.Capture != on {
894+ t.Errorf("hint.capture %v: scene has %v", on, s.HintStyle.Capture)
895+ }
896+ }
897+}
M internal/core/scene.go +1 -1
@@ -126,7 +126,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
126126 scene.Flash = c.flashFor(sc)
127127 scene.CaptureIndicators = c.captureIndicators(sc)
128128 scene.HintLabels = c.hintLabels(sc)
129- scene.HintStyle = ports.HintStyle{Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH}
129+ scene.HintStyle = ports.HintStyle{Capture: c.cfg.Hint.Capture, Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH}
130130 scene.Capture = c.captureSceneFor(sc, capture)
131131 // The springs of this frame are advanced: a scene shown while one still
132132 // runs wakes core when it flips; the one a spring settles on does not.
@@ -126,7 +126,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
126 scene.Flash = c.flashFor(sc)126 scene.Flash = c.flashFor(sc)
127 scene.CaptureIndicators = c.captureIndicators(sc)127 scene.CaptureIndicators = c.captureIndicators(sc)
128 scene.HintLabels = c.hintLabels(sc)128 scene.HintLabels = c.hintLabels(sc)
129- scene.HintStyle = ports.HintStyle{Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH}129+ scene.HintStyle = ports.HintStyle{Capture: c.cfg.Hint.Capture, Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH}
130 scene.Capture = c.captureSceneFor(sc, capture)130 scene.Capture = c.captureSceneFor(sc, capture)
131 // The springs of this frame are advanced: a scene shown while one still131 // The springs of this frame are advanced: a scene shown while one still
132 // runs wakes core when it flips; the one a spring settles on does not.132 // runs wakes core when it flips; the one a spring settles on does not.
M internal/ports/config.go +4 -1
@@ -175,7 +175,10 @@ type Config struct {
175175 LinksOnly bool
176176 // TextClick are the modifiers held for a click on a link found by
177177 // OCR (hint.text-click); zero sends a plain click.
178- TextClick Mods
178+ TextClick Mods
179+ // Capture shows the labels in the captures requested by Wayland
180+ // clients (hint.capture); the compositor's own scans never see them.
181+ Capture bool
179182 Size float64 // logical pixels; zero selects automatic density sizing
180183 Background, Foreground string // empty derives from Border.Active
181184 }
@@ -175,7 +175,10 @@ type Config struct {
175 LinksOnly bool175 LinksOnly bool
176 // TextClick are the modifiers held for a click on a link found by176 // TextClick are the modifiers held for a click on a link found by
177 // OCR (hint.text-click); zero sends a plain click.177 // OCR (hint.text-click); zero sends a plain click.
178- TextClick Mods178+ TextClick Mods
179+ // Capture shows the labels in the captures requested by Wayland
180+ // clients (hint.capture); the compositor's own scans never see them.
181+ Capture bool
179 Size float64 // logical pixels; zero selects automatic density sizing182 Size float64 // logical pixels; zero selects automatic density sizing
180 Background, Foreground string // empty derives from Border.Active183 Background, Foreground string // empty derives from Border.Active
181 }184 }
M internal/ports/scene.go +6 -1
@@ -71,7 +71,8 @@ type Scene struct {
7171 CaptureIndicators []CaptureIndicator
7272 // HintLabels are the keyboard hint labels of this output, drawn over
7373 // everything, after CaptureIndicators; nil when hint mode is not shown
74- // here. Captures never contain them.
74+ // here. Captures never contain them unless HintStyle.Capture is set, and
75+ // then only those of Wayland clients.
7576 HintLabels []HintLabel
7677 HintStyle HintStyle
7778 // Flash is the focus flash drawn over everything of this output; nil
@@ -254,6 +255,10 @@ func (w SceneWindow) Tile() bool {
254255
255256 // HintStyle carries reloadable label settings and untransformed output density.
256257 type HintStyle struct {
258+ // Capture (hint.capture) shows the labels in the captures requested by
259+ // Wayland clients. Captures requested by the compositor itself
260+ // (CaptureIDInternal) never contain them.
261+ Capture bool
257262 Size float64
258263 Background, Foreground string
259264 Width, Height, PhysicalW, PhysicalH int
@@ -71,7 +71,8 @@ type Scene struct {
71 CaptureIndicators []CaptureIndicator71 CaptureIndicators []CaptureIndicator
72 // HintLabels are the keyboard hint labels of this output, drawn over72 // HintLabels are the keyboard hint labels of this output, drawn over
73 // everything, after CaptureIndicators; nil when hint mode is not shown73 // everything, after CaptureIndicators; nil when hint mode is not shown
74- // here. Captures never contain them.74+ // here. Captures never contain them unless HintStyle.Capture is set, and
75+ // then only those of Wayland clients.
75 HintLabels []HintLabel76 HintLabels []HintLabel
76 HintStyle HintStyle77 HintStyle HintStyle
77 // Flash is the focus flash drawn over everything of this output; nil78 // Flash is the focus flash drawn over everything of this output; nil
@@ -254,6 +255,10 @@ func (w SceneWindow) Tile() bool {
254 255
255 // HintStyle carries reloadable label settings and untransformed output density.256 // HintStyle carries reloadable label settings and untransformed output density.
256 type HintStyle struct {257 type HintStyle struct {
258+ // Capture (hint.capture) shows the labels in the captures requested by
259+ // Wayland clients. Captures requested by the compositor itself
260+ // (CaptureIDInternal) never contain them.
261+ Capture bool
257 Size float64262 Size float64
258 Background, Foreground string263 Background, Foreground string
259 Width, Height, PhysicalW, PhysicalH int264 Width, Height, PhysicalW, PhysicalH int
M internal/ports/scene_same_test.go +1 -0
@@ -64,6 +64,7 @@ func TestSceneSameAs(t *testing.T) {
6464 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
6565 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
6666 "hint style": func(s *Scene) { s.HintStyle.Size = 24 },
67+ "hint capture": func(s *Scene) { s.HintStyle.Capture = true },
6768 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
6869 "transform": func(s *Scene) { s.Transform = 1 },
6970 "capture shown": func(s *Scene) { s.Capture.Shown++ },
@@ -64,6 +64,7 @@ func TestSceneSameAs(t *testing.T) {
64 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },64 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
65 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },65 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
66 "hint style": func(s *Scene) { s.HintStyle.Size = 24 },66 "hint style": func(s *Scene) { s.HintStyle.Size = 24 },
67+ "hint capture": func(s *Scene) { s.HintStyle.Capture = true },
67 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },68 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
68 "transform": func(s *Scene) { s.Transform = 1 },69 "transform": func(s *Scene) { s.Transform = 1 },
69 "capture shown": func(s *Scene) { s.Capture.Shown++ },70 "capture shown": func(s *Scene) { s.Capture.Shown++ },

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 25

Tags 10

↑↓ moveenter openesc close