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.
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. | |||
| 62 | 62 | | `hint.enable` | `off` | Turn on the experimental [hint mode](#hint-mode) (restart required) | | |
| 63 | 63 | | `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) | | |
| 64 | 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 | 66 | | `hint.size` | `auto` | Label text size in logical pixels (6 to 72), or `auto` for screen pixel density | | |
| 66 | 67 | | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` | | |
| 67 | 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 | 363 | clicked with the `hint.text-click` modifiers held, which is how terminals open | |
| 363 | 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 | 372 | ### Where targets come from | |
| 366 | 373 | ||
| 367 | 374 | | 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 open | 363 | 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 from | 372 | ### 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 = | |||
| 101 | 101 | # Hint mode labels the links, URLs and buttons on the focused screen: | |
| 102 | 102 | # clickable (links, URLs and buttons) or links (links and URLs only). | |
| 103 | 103 | # hint.targets = clickable | |
| 104 | + | # Show the labels in screenshots and screencasts taken by clients. | |
| 105 | + | # hint.capture = off | |
| 104 | 106 | ||
| 105 | 107 | # Named workspaces are outside the numbered list and require a bind. The bind | |
| 106 | 108 | # 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 = clickable | 103 | # 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 bind | 107 | # 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 { | |||
| 870 | 870 | return err | |
| 871 | 871 | } | |
| 872 | 872 | 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 | 879 | case "hint.size": | |
| 874 | 880 | if v == "auto" { | |
| 875 | 881 | c.Hint.Size = 0 | |
| @@ -870,6 +870,12 @@ func set(c *ports.Config, key, v string) error { | |||||
| 870 | return err | 870 | return err | ||
| 871 | } | 871 | } | ||
| 872 | c.Hint.Enable = b | 872 | 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 = 0 | 881 | c.Hint.Size = 0 | ||
M internal/adapters/config/config_test.go +18 -0
| @@ -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 | 1015 | func TestHintSizeBounds(t *testing.T) { | |
| 998 | 1016 | for _, v := range []string{"6", "72", "13"} { | |
| 999 | 1017 | 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. | |||
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | 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 | 111 | took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal) | |
| 111 | 112 | if perr != nil { | |
| 112 | 113 | o.holdRead(pdone) | |
| @@ -136,6 +137,8 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 136 | 137 | // The excluded frame goes first to the same target and is copied by | |
| 137 | 138 | // the capture; the displayed frame is drawn over it in queue order. | |
| 138 | 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 | 142 | xdone, xerr := r.Render(pipeline.ExcludedScene(composed), surfaces) | |
| 140 | 143 | if xerr != nil { | |
| 141 | 144 | 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 by | 137 | // 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 | |||
| 466 | 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 | 472 | took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal) | |
| 471 | 473 | if pdone != nil { | |
| 472 | 474 | // 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 | 492 | if len(excluded) > 0 { | |
| 491 | 493 | // Exclude requests: the frame without the excluded surfaces is | |
| 492 | 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 | 497 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 494 | 498 | failRequests(capture.ErrEpochChanged) | |
| 495 | 499 | 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 requests | 474 | // 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 is | 493 | // 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 | continue | 499 | continue | ||
M internal/adapters/outputkit/capture/excluded.go +73 -23
| @@ -10,9 +10,31 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // A request with Exclude leaves out the exclusion's HUD layers and popups; | |
| 12 | 12 | // 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) | |
| 16 | 38 | ||
| 17 | 39 | // ErrSessionInactive fails an Exclude request whose session is not the one | |
| 18 | 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 | 86 | return false | |
| 65 | 87 | } | |
| 66 | 88 | ||
| 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. | |
| 69 | 100 | 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 | + | } | |
| 71 | 105 | s.PositionHint = ports.PositionHint{} | |
| 72 | 106 | return s | |
| 73 | 107 | } | |
| 74 | 108 | ||
| 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. | |
| 82 | 120 | // | |
| 83 | 121 | // Admission is the session gate's: the requests are handed over with | |
| 84 | 122 | // 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 | 124 | // the plain render reads client buffers even when the requests are rejected, | |
| 87 | 125 | // so the caller owns it (hold or wait it) whatever happened, error included. | |
| 88 | 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 | 128 | return false, nil, nil | |
| 91 | 129 | } | |
| 92 | 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 | 169 | // Split answers or orders the requests of one output frame, in place. No | |
| 132 | 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 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; | |
| 141 | 186 | // - hidden: the requests of the workspace rendered off screen | |
| 142 | 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 | 192 | for _, q := range reqs { | |
| 148 | 193 | var err error | |
| 149 | 194 | switch { | |
| 195 | + | case internal(q) && keepsLabels(s): | |
| 196 | + | err = ErrInternalLabelled | |
| 197 | + | if q.Exclude { | |
| 198 | + | err = ErrInternalExclude | |
| 199 | + | } | |
| 150 | 200 | case q.Exclude && (s.Capture == nil || s.Capture.Session == 0 || q.Session != s.Capture.Session): | |
| 151 | 201 | err = ErrSessionInactive | |
| 152 | 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 | 216 | clear(reqs[n:]) | |
| 167 | 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 | 219 | group := func(q ports.CaptureRequest) int { | |
| 170 | 220 | switch { | |
| 171 | 221 | 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 indicator | 12 | // 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 | 13 | + | // (Scene.CaptureIndicators) or the position hint. Hint labels are shown to |
| 14 | - | // capture image. Requests of the displayed frame are then served from the | 14 | + | // 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 one | 39 | // 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 false | 86 | return false | ||
| 65 | } | 87 | } | ||
| 66 | 88 | ||||
| 67 | - | // plain returns s without its capture state, indicators and hint labels, Seq 0 so the | 89 | + | // 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, 0 | 101 | + | 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 s | 106 | return s | ||
| 73 | } | 107 | } | ||
| 74 | 108 | ||||
| 75 | - | // SubmitPlain serves the displayed-frame requests of a scene that carries the | 109 | + | // 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 is | 111 | + | // 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); false | 112 | + | // without it, first (same renderer, queue order, like ExcludedScene), and |
| 79 | - | // when the scene has no indicator or there is nothing to serve, the caller | 113 | + | // 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 the | 114 | + | // 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 with | 121 | // 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 fails | 122 | // 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, nil | 128 | 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. No | 169 | // 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 exclusion | 171 | + | // - internal requests of a frame that keeps hint labels for clients |
| 134 | - | // in it (ErrSessionInactive), and those newer than its Revision | 172 | + | // (ErrInternalLabelled, ErrInternalExclude); |
| 135 | - | // (ErrSessionStale); workspace requests whose workspace is not where they | 173 | + | // - 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 no | 177 | + | // (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 screen | 186 | // - 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 error | 193 | 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 = ErrSessionInactive | 201 | 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) { | |||
| 98 | 98 | r.EndCapture(cf) // idempotent | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | - | // Both slots leased: the third capture fails at once; returning one | |
| 101 | + | // Every slot leased: the next capture fails at once; returning one | |
| 102 | 102 | // frees it again once its copy finished. | |
| 103 | 103 | func TestCaptureSlotsBounded(t *testing.T) { | |
| 104 | 104 | r, err := New(8, 8) | |
| @@ -109,13 +109,18 @@ func TestCaptureSlotsBounded(t *testing.T) { | |||
| 109 | 109 | if err := render(r, ports.Scene{Background: "#ff0000"}, nil); err != nil { | |
| 110 | 110 | t.Fatal(err) | |
| 111 | 111 | } | |
| 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() | |
| 114 | 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) | |
| 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. | |
| 119 | 124 | c := captureWait(t, r) | |
| 120 | 125 | dst := make([]byte, 4) | |
| 121 | 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 | 129 | if dst[2] != 255 || dst[1] != 0 || dst[0] != 0 { | |
| 125 | 130 | t.Fatalf("pixel %v", dst) | |
| 126 | 131 | } | |
| 127 | - | r.EndCapture(b) | |
| 132 | + | for _, f := range leased[1:] { | |
| 133 | + | r.EndCapture(f) | |
| 134 | + | } | |
| 128 | 135 | 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() | |
| 131 | 138 | if img := r.Pixels(); img != nil { | |
| 132 | 139 | t.Fatal("Pixels stole a leased slot") | |
| 133 | 140 | } | |
| 134 | - | r.EndCapture(a) | |
| 135 | - | r.EndCapture(b) | |
| 141 | + | for _, f := range leased { | |
| 142 | + | r.EndCapture(f) | |
| 143 | + | } | |
| 136 | 144 | if img := r.Pixels(); img == nil || img.RGBAAt(0, 0) != (color.RGBA{255, 0, 0, 255}) { | |
| 137 | 145 | t.Fatalf("Pixels after release: %v", img) | |
| 138 | 146 | } | |
| @@ -98,7 +98,7 @@ func TestCaptureLastFrameRegion(t *testing.T) { | |||||
| 98 | r.EndCapture(cf) // idempotent | 98 | r.EndCapture(cf) // idempotent | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | - | // Both slots leased: the third capture fails at once; returning one | 101 | + | // 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 { | |||
| 35 | 35 | ||
| 36 | 36 | // newHintMods is newHint with hint.text-click set to textClick. | |
| 37 | 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 | 44 | t.Helper() | |
| 39 | 45 | cfg := scrollDefaults() | |
| 40 | - | cfg.Hint.TextClick = textClick | |
| 46 | + | edit(&cfg) | |
| 41 | 47 | cfg.Border.Width = 0 | |
| 42 | 48 | cfg.Layout.Gaps = 20 | |
| 43 | 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 | + | } | |
| @@ -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 = textClick | 46 | + | edit(&cfg) |
| 41 | cfg.Border.Width = 0 | 47 | cfg.Border.Width = 0 | ||
| 42 | cfg.Layout.Gaps = 20 | 48 | 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 | |||
| 126 | 126 | scene.Flash = c.flashFor(sc) | |
| 127 | 127 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 128 | 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 | 130 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 131 | 131 | // The springs of this frame are advanced: a scene shown while one still | |
| 132 | 132 | // 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 still | 131 | // 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 { | |||
| 175 | 175 | LinksOnly bool | |
| 176 | 176 | // TextClick are the modifiers held for a click on a link found by | |
| 177 | 177 | // 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 | |
| 179 | 182 | Size float64 // logical pixels; zero selects automatic density sizing | |
| 180 | 183 | Background, Foreground string // empty derives from Border.Active | |
| 181 | 184 | } | |
| @@ -175,7 +175,10 @@ type Config struct { | |||||
| 175 | LinksOnly bool | 175 | LinksOnly bool | ||
| 176 | // TextClick are the modifiers held for a click on a link found by | 176 | // 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 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 | |||
| 179 | Size float64 // logical pixels; zero selects automatic density sizing | 182 | Size float64 // logical pixels; zero selects automatic density sizing | ||
| 180 | Background, Foreground string // empty derives from Border.Active | 183 | Background, Foreground string // empty derives from Border.Active | ||
| 181 | } | 184 | } | ||
M internal/ports/scene.go +6 -1
| @@ -71,7 +71,8 @@ type Scene struct { | |||
| 71 | 71 | CaptureIndicators []CaptureIndicator | |
| 72 | 72 | // HintLabels are the keyboard hint labels of this output, drawn over | |
| 73 | 73 | // 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 | 76 | HintLabels []HintLabel | |
| 76 | 77 | HintStyle HintStyle | |
| 77 | 78 | // Flash is the focus flash drawn over everything of this output; nil | |
| @@ -254,6 +255,10 @@ func (w SceneWindow) Tile() bool { | |||
| 254 | 255 | ||
| 255 | 256 | // HintStyle carries reloadable label settings and untransformed output density. | |
| 256 | 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 | 262 | Size float64 | |
| 258 | 263 | Background, Foreground string | |
| 259 | 264 | Width, Height, PhysicalW, PhysicalH int | |
| @@ -71,7 +71,8 @@ type Scene struct { | |||||
| 71 | CaptureIndicators []CaptureIndicator | 71 | CaptureIndicators []CaptureIndicator | ||
| 72 | // HintLabels are the keyboard hint labels of this output, drawn over | 72 | // HintLabels are the keyboard hint labels of this output, drawn over | ||
| 73 | // everything, after CaptureIndicators; nil when hint mode is not shown | 73 | // 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 []HintLabel | 76 | HintLabels []HintLabel | ||
| 76 | HintStyle HintStyle | 77 | HintStyle HintStyle | ||
| 77 | // Flash is the focus flash drawn over everything of this output; nil | 78 | // 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 float64 | 262 | Size float64 | ||
| 258 | Background, Foreground string | 263 | Background, Foreground string | ||
| 259 | Width, Height, PhysicalW, PhysicalH int | 264 | Width, Height, PhysicalW, PhysicalH int | ||
M internal/ports/scene_same_test.go +1 -0
| @@ -64,6 +64,7 @@ func TestSceneSameAs(t *testing.T) { | |||
| 64 | 64 | "layer": func(s *Scene) { s.Layers[0].Rect.H++ }, | |
| 65 | 65 | "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true }, | |
| 66 | 66 | "hint style": func(s *Scene) { s.HintStyle.Size = 24 }, | |
| 67 | + | "hint capture": func(s *Scene) { s.HintStyle.Capture = true }, | |
| 67 | 68 | "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 }, | |
| 68 | 69 | "transform": func(s *Scene) { s.Transform = 1 }, | |
| 69 | 70 | "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++ }, | ||