fix(capture): keep hint labels out of captured frames
- commit
- 90d23d415f9a451a64d3d51f9c2796c219cfe2a6
- author
- brice · · 2026-10-19 10:05 UTC
- parent
- 9ad3054
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +43 -3
M internal/adapters/outputkit/capture/excluded.go +3 -3
| @@ -10,7 +10,7 @@ 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 is never the source of a | |
| 13 | + | // (Scene.CaptureIndicators): a scene that carries it, or hint labels, is never the source of a | |
| 14 | 14 | // capture image. Requests of the displayed frame are then served from the | |
| 15 | 15 | // frame rendered without it first (SubmitPlain, ExcludedScene). | |
| 16 | 16 | ||
| @@ -85,7 +85,7 @@ func plain(s ports.Scene) ports.Scene { | |||
| 85 | 85 | // the plain render reads client buffers even when the requests are rejected, | |
| 86 | 86 | // so the caller owns it (hold or wait it) whatever happened, error included. | |
| 87 | 87 | 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) { | |
| 88 | - | if len(normal) == 0 || len(s.CaptureIndicators) == 0 { | |
| 88 | + | if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 { | |
| 89 | 89 | return false, nil, nil | |
| 90 | 90 | } | |
| 91 | 91 | done, err = r.Render(plain(s), surfaces) | |
| @@ -164,7 +164,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex | |||
| 164 | 164 | } | |
| 165 | 165 | clear(reqs[n:]) | |
| 166 | 166 | reqs = reqs[:n] | |
| 167 | - | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 | |
| 167 | + | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 | |
| 168 | 168 | group := func(q ports.CaptureRequest) int { | |
| 169 | 169 | switch { | |
| 170 | 170 | case q.OffScreen: | |
| @@ -10,7 +10,7 @@ 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 is never the source of a | 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 | 14 | // capture image. Requests of the displayed frame are then served from the | ||
| 15 | // frame rendered without it first (SubmitPlain, ExcludedScene). | 15 | // frame rendered without it first (SubmitPlain, ExcludedScene). | ||
| 16 | 16 | ||||
| @@ -85,7 +85,7 @@ func plain(s ports.Scene) ports.Scene { | |||||
| 85 | // the plain render reads client buffers even when the requests are rejected, | 85 | // the plain render reads client buffers even when the requests are rejected, | ||
| 86 | // so the caller owns it (hold or wait it) whatever happened, error included. | 86 | // so the caller owns it (hold or wait it) whatever happened, error included. | ||
| 87 | 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) { | 87 | 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) { | ||
| 88 | - | if len(normal) == 0 || len(s.CaptureIndicators) == 0 { | 88 | + | if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 { |
| 89 | return false, nil, nil | 89 | return false, nil, nil | ||
| 90 | } | 90 | } | ||
| 91 | done, err = r.Render(plain(s), surfaces) | 91 | done, err = r.Render(plain(s), surfaces) | ||
| @@ -164,7 +164,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex | |||||
| 164 | } | 164 | } | ||
| 165 | clear(reqs[n:]) | 165 | clear(reqs[n:]) | ||
| 166 | reqs = reqs[:n] | 166 | reqs = reqs[:n] | ||
| 167 | - | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 | 167 | + | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 |
| 168 | group := func(q ports.CaptureRequest) int { | 168 | group := func(q ports.CaptureRequest) int { | ||
| 169 | switch { | 169 | switch { | ||
| 170 | case q.OffScreen: | 170 | case q.OffScreen: | ||
M internal/adapters/outputkit/capture/indicator_test.go +40 -0
| @@ -112,3 +112,43 @@ func TestSubmitPlainRejectsProtectedEpoch(t *testing.T) { | |||
| 112 | 112 | _, statErr := file.Stat() | |
| 113 | 113 | require.ErrorIs(t, statErr, os.ErrClosed) | |
| 114 | 114 | } | |
| 115 | + | ||
| 116 | + | // Hint labels are never captured either: a scene with only labels is served | |
| 117 | + | // from the plain frame, and an Exclude request is ordered to the excluded scene. | |
| 118 | + | func TestSubmitPlainServesHintLabelScenesWithoutLabels(t *testing.T) { | |
| 119 | + | replies := make(chan ports.CaptureDone, 1) | |
| 120 | + | p := NewPipeline(context.Background(), replies) | |
| 121 | + | r := portsmocks.NewMockRenderer(t) | |
| 122 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 123 | + | frame.EXPECT().Done().Return((*os.File)(nil)) | |
| 124 | + | frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).Return(nil).Once() | |
| 125 | + | var rendered []ports.Scene | |
| 126 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 127 | + | rendered = append(rendered, s) | |
| 128 | + | return nil, nil | |
| 129 | + | }).Once() | |
| 130 | + | r.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 131 | + | r.EXPECT().EndCapture(frame).Return().Once() | |
| 132 | + | s := excludedTestScene() | |
| 133 | + | s.Capture = nil | |
| 134 | + | s.HintLabels = []ports.HintLabel{{Text: "f"}} | |
| 135 | + | reqs := []ports.CaptureRequest{pipelineRequest(t, 1)} | |
| 136 | + | require.False(t, reqs[0].Indicate) | |
| 137 | + | took, done, err := p.SubmitPlain(r, s, nil, reqs) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | require.Nil(t, done) | |
| 140 | + | require.True(t, took) | |
| 141 | + | require.Len(t, rendered, 1) | |
| 142 | + | require.Nil(t, rendered[0].HintLabels) | |
| 143 | + | require.Zero(t, rendered[0].Seq) | |
| 144 | + | require.Equal(t, uint64(1), awaitCapture(t, replies).ID) | |
| 145 | + | p.Close(r) | |
| 146 | + | frame.Calls = nil | |
| 147 | + | ||
| 148 | + | withSession := s | |
| 149 | + | withSession.Capture = &ports.SceneCapture{Session: 7} | |
| 150 | + | normal, excluded, _ := p.Split(withSession, []ports.CaptureRequest{{ID: 2, Exclude: true, Session: 7}}) | |
| 151 | + | require.Empty(t, normal) | |
| 152 | + | require.Equal(t, []uint64{2}, ids(excluded), "labels force the Exclude request to the excluded scene") | |
| 153 | + | require.Nil(t, p.ExcludedScene(s).HintLabels) | |
| 154 | + | } | |
| @@ -112,3 +112,43 @@ func TestSubmitPlainRejectsProtectedEpoch(t *testing.T) { | |||||
| 112 | _, statErr := file.Stat() | 112 | _, statErr := file.Stat() | ||
| 113 | require.ErrorIs(t, statErr, os.ErrClosed) | 113 | require.ErrorIs(t, statErr, os.ErrClosed) | ||
| 114 | } | 114 | } | ||
| 115 | + | ||||
| 116 | + | // Hint labels are never captured either: a scene with only labels is served | |||
| 117 | + | // from the plain frame, and an Exclude request is ordered to the excluded scene. | |||
| 118 | + | func TestSubmitPlainServesHintLabelScenesWithoutLabels(t *testing.T) { | |||
| 119 | + | replies := make(chan ports.CaptureDone, 1) | |||
| 120 | + | p := NewPipeline(context.Background(), replies) | |||
| 121 | + | r := portsmocks.NewMockRenderer(t) | |||
| 122 | + | frame := portsmocks.NewMockCaptureFrame(t) | |||
| 123 | + | frame.EXPECT().Done().Return((*os.File)(nil)) | |||
| 124 | + | frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).Return(nil).Once() | |||
| 125 | + | var rendered []ports.Scene | |||
| 126 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 127 | + | rendered = append(rendered, s) | |||
| 128 | + | return nil, nil | |||
| 129 | + | }).Once() | |||
| 130 | + | r.EXPECT().BeginCapture().Return(frame, nil).Once() | |||
| 131 | + | r.EXPECT().EndCapture(frame).Return().Once() | |||
| 132 | + | s := excludedTestScene() | |||
| 133 | + | s.Capture = nil | |||
| 134 | + | s.HintLabels = []ports.HintLabel{{Text: "f"}} | |||
| 135 | + | reqs := []ports.CaptureRequest{pipelineRequest(t, 1)} | |||
| 136 | + | require.False(t, reqs[0].Indicate) | |||
| 137 | + | took, done, err := p.SubmitPlain(r, s, nil, reqs) | |||
| 138 | + | require.NoError(t, err) | |||
| 139 | + | require.Nil(t, done) | |||
| 140 | + | require.True(t, took) | |||
| 141 | + | require.Len(t, rendered, 1) | |||
| 142 | + | require.Nil(t, rendered[0].HintLabels) | |||
| 143 | + | require.Zero(t, rendered[0].Seq) | |||
| 144 | + | require.Equal(t, uint64(1), awaitCapture(t, replies).ID) | |||
| 145 | + | p.Close(r) | |||
| 146 | + | frame.Calls = nil | |||
| 147 | + | ||||
| 148 | + | withSession := s | |||
| 149 | + | withSession.Capture = &ports.SceneCapture{Session: 7} | |||
| 150 | + | normal, excluded, _ := p.Split(withSession, []ports.CaptureRequest{{ID: 2, Exclude: true, Session: 7}}) | |||
| 151 | + | require.Empty(t, normal) | |||
| 152 | + | require.Equal(t, []uint64{2}, ids(excluded), "labels force the Exclude request to the excluded scene") | |||
| 153 | + | require.Nil(t, p.ExcludedScene(s).HintLabels) | |||
| 154 | + | } | |||