feat(vulkan): draw hint labels and keep them out of captures
- commit
- 6319bfa599f3438cd336420e08c26172b1fdb85f
- author
- brice · · 2026-10-19 16:03 UTC
- committer
- brice · · 2026-10-19 17:36 UTC
- parent
- 4b849d0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +263 -12
M internal/adapters/drm/overlay.go +3 -0
| @@ -70,6 +70,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 70 | 70 | if s.Flash != nil { | |
| 71 | 71 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | |
| 72 | 72 | } | |
| 73 | + | if len(s.HintLabels) > 0 { | |
| 74 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "hint_labels" | |
| 75 | + | } | |
| 73 | 76 | if s.Transform != 0 { | |
| 74 | 77 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | |
| 75 | 78 | } | |
| @@ -70,6 +70,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 70 | if s.Flash != nil { | 70 | if s.Flash != nil { | ||
| 71 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | 71 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | ||
| 72 | } | 72 | } | ||
| 73 | + | if len(s.HintLabels) > 0 { | |||
| 74 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "hint_labels" | |||
| 75 | + | } | |||
| 73 | if s.Transform != 0 { | 76 | if s.Transform != 0 { | ||
| 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | 77 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | ||
| 75 | } | 78 | } | ||
M internal/adapters/drm/scanout.go +4 -0
| @@ -94,6 +94,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 94 | 94 | if s.Flash != nil { | |
| 95 | 95 | return ports.SurfaceContent{}, "focus_flash" | |
| 96 | 96 | } | |
| 97 | + | // Hint labels are composed over the window. | |
| 98 | + | if len(s.HintLabels) > 0 { | |
| 99 | + | return ports.SurfaceContent{}, "hint_labels" | |
| 100 | + | } | |
| 97 | 101 | if s.Transform != 0 { | |
| 98 | 102 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 99 | 103 | return ports.SurfaceContent{}, "output_transform" | |
| @@ -94,6 +94,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 94 | if s.Flash != nil { | 94 | if s.Flash != nil { | ||
| 95 | return ports.SurfaceContent{}, "focus_flash" | 95 | return ports.SurfaceContent{}, "focus_flash" | ||
| 96 | } | 96 | } | ||
| 97 | + | // Hint labels are composed over the window. | |||
| 98 | + | if len(s.HintLabels) > 0 { | |||
| 99 | + | return ports.SurfaceContent{}, "hint_labels" | |||
| 100 | + | } | |||
| 97 | if s.Transform != 0 { | 101 | if s.Transform != 0 { | ||
| 98 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | 102 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | ||
| 99 | return ports.SurfaceContent{}, "output_transform" | 103 | return ports.SurfaceContent{}, "output_transform" | ||
M internal/adapters/outputkit/capture/excluded.go +5 -5
| @@ -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 | ||
| @@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool { | |||
| 64 | 64 | return false | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | - | // plain returns s without its capture state and indicators, Seq 0 so the | |
| 67 | + | // plain returns s without its capture state, indicators and hint labels, Seq 0 so the | |
| 68 | 68 | // renderer redraws the whole target instead of reusing a displayed frame. | |
| 69 | 69 | func plain(s ports.Scene) ports.Scene { | |
| 70 | - | s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 0 | |
| 70 | + | s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0 | |
| 71 | 71 | return s | |
| 72 | 72 | } | |
| 73 | 73 | ||
| @@ -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 | ||||
| @@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool { | |||||
| 64 | return false | 64 | return false | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | - | // plain returns s without its capture state and indicators, Seq 0 so the | 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. | 68 | // renderer redraws the whole target instead of reusing a displayed frame. | ||
| 69 | func plain(s ports.Scene) ports.Scene { | 69 | func plain(s ports.Scene) ports.Scene { | ||
| 70 | - | s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 0 | 70 | + | s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0 |
| 71 | return s | 71 | return s | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| @@ -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 | + | } | |||
M internal/adapters/vulkan/damage.go +4 -2
| @@ -238,9 +238,11 @@ func (tg *target) hold(s ports.Scene, d *damageRegion) { | |||
| 238 | 238 | tg.heldLayers = append(tg.heldLayers[:0], s.Layers...) | |
| 239 | 239 | tg.heldHints = append(tg.heldHints[:0], s.DropHints...) | |
| 240 | 240 | tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...) | |
| 241 | + | tg.heldLabels = append(tg.heldLabels[:0], s.HintLabels...) | |
| 241 | 242 | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | |
| 242 | 243 | tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers | |
| 243 | 244 | tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators | |
| 245 | + | tg.scene.HintLabels = tg.heldLabels | |
| 244 | 246 | // Only whether they are set matters (any capture redraws everything): | |
| 245 | 247 | // sentinels, so no pointer into the caller's capture state is kept. | |
| 246 | 248 | tg.scene.Capture, tg.scene.CaptureScene = nil, nil | |
| @@ -271,11 +273,11 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||
| 271 | 273 | if old.Security != cur.Security || old.Output != cur.Output || | |
| 272 | 274 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |
| 273 | 275 | old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off || | |
| 274 | - | old.Background != cur.Background || old.Border != cur.Border || | |
| 276 | + | old.HintStyle != cur.HintStyle || old.Background != cur.Background || old.Border != cur.Border || | |
| 275 | 277 | old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | |
| 276 | 278 | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | |
| 277 | 279 | !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) || | |
| 278 | - | !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || | |
| 280 | + | !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || !slices.Equal(old.HintLabels, cur.HintLabels) || | |
| 279 | 281 | len(old.Windows) != len(cur.Windows) { | |
| 280 | 282 | return image.Rectangle{}, false | |
| 281 | 283 | } | |
| @@ -238,9 +238,11 @@ func (tg *target) hold(s ports.Scene, d *damageRegion) { | |||||
| 238 | tg.heldLayers = append(tg.heldLayers[:0], s.Layers...) | 238 | tg.heldLayers = append(tg.heldLayers[:0], s.Layers...) | ||
| 239 | tg.heldHints = append(tg.heldHints[:0], s.DropHints...) | 239 | tg.heldHints = append(tg.heldHints[:0], s.DropHints...) | ||
| 240 | tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...) | 240 | tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...) | ||
| 241 | + | tg.heldLabels = append(tg.heldLabels[:0], s.HintLabels...) | |||
| 241 | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | 242 | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | ||
| 242 | tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers | 243 | tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers | ||
| 243 | tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators | 244 | tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators | ||
| 245 | + | tg.scene.HintLabels = tg.heldLabels | |||
| 244 | // Only whether they are set matters (any capture redraws everything): | 246 | // Only whether they are set matters (any capture redraws everything): | ||
| 245 | // sentinels, so no pointer into the caller's capture state is kept. | 247 | // sentinels, so no pointer into the caller's capture state is kept. | ||
| 246 | tg.scene.Capture, tg.scene.CaptureScene = nil, nil | 248 | tg.scene.Capture, tg.scene.CaptureScene = nil, nil | ||
| @@ -271,11 +273,11 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||||
| 271 | if old.Security != cur.Security || old.Output != cur.Output || | 273 | if old.Security != cur.Security || old.Output != cur.Output || | ||
| 272 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | 274 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | ||
| 273 | old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off || | 275 | old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off || | ||
| 274 | - | old.Background != cur.Background || old.Border != cur.Border || | 276 | + | old.HintStyle != cur.HintStyle || old.Background != cur.Background || old.Border != cur.Border || |
| 275 | old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | 277 | old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | ||
| 276 | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | 278 | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | ||
| 277 | !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) || | 279 | !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) || | ||
| 278 | - | !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || | 280 | + | !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || !slices.Equal(old.HintLabels, cur.HintLabels) || |
| 279 | len(old.Windows) != len(cur.Windows) { | 281 | len(old.Windows) != len(cur.Windows) { | ||
| 280 | return image.Rectangle{}, false | 282 | return image.Rectangle{}, false | ||
| 281 | } | 283 | } | ||
M internal/adapters/vulkan/damage_delta_test.go +2 -2
| @@ -17,9 +17,9 @@ import ( | |||
| 17 | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | 18 | // lists the fields that force a full redraw. | |
| 19 | 19 | func TestSceneFieldCount(t *testing.T) { | |
| 20 | - | // 23 with Animating, which sceneDelta intentionally ignores: it only | |
| 20 | + | // 25 with Animating, which sceneDelta intentionally ignores: it only | |
| 21 | 21 | // schedules the next frame, it draws nothing. | |
| 22 | - | const fields = 23 | |
| 22 | + | const fields = 25 | |
| 23 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 24 | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | |
| 25 | 25 | } | |
| @@ -17,9 +17,9 @@ import ( | |||||
| 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | ||
| 18 | // lists the fields that force a full redraw. | 18 | // lists the fields that force a full redraw. | ||
| 19 | func TestSceneFieldCount(t *testing.T) { | 19 | func TestSceneFieldCount(t *testing.T) { | ||
| 20 | - | // 23 with Animating, which sceneDelta intentionally ignores: it only | 20 | + | // 25 with Animating, which sceneDelta intentionally ignores: it only |
| 21 | // schedules the next frame, it draws nothing. | 21 | // schedules the next frame, it draws nothing. | ||
| 22 | - | const fields = 23 | 22 | + | const fields = 25 |
| 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | ||
| 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | ||
| 25 | } | 25 | } | ||
A internal/adapters/vulkan/hint_labels.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over | |
| 11 | + | // everything, after the capture indicators. Each label is rasterized on the CPU | |
| 12 | + | // (hintlabel) at the scene's scale and drawn from the SHM copy path: the GPU | |
| 13 | + | // buffer is cached under a key naming the label, so a label that stays the | |
| 14 | + | // same uploads once. Captures are rendered from scenes without labels. | |
| 15 | + | func (w *sceneWalk) hintLabels() { | |
| 16 | + | if len(w.s.HintLabels) == 0 { | |
| 17 | + | return | |
| 18 | + | } | |
| 19 | + | r := w.r | |
| 20 | + | hs := w.s.HintStyle | |
| 21 | + | bg, fg := hintlabel.Colors(w.s.Border.Active, hs.Background, hs.Foreground) | |
| 22 | + | style := hintlabel.Style{FontPx: hintlabel.FontPixels(hs.Width, hs.Height, hs.PhysicalW, hs.PhysicalH, w.scale, hs.Size), Background: bg, Foreground: fg} | |
| 23 | + | for _, l := range w.s.HintLabels { | |
| 24 | + | pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale, style) | |
| 25 | + | if lw <= 0 || lh <= 0 { | |
| 26 | + | continue | |
| 27 | + | } | |
| 28 | + | // Anchored outside the target's top-left corner, overlapping it by a | |
| 29 | + | // quarter of the label height, then kept inside the output. | |
| 30 | + | inset := int(math.Round(0.25 * float64(lh))) | |
| 31 | + | x := min(max(w.phys(l.Rect.X)-lw+inset, w.bounds.Min.X), max(w.bounds.Max.X-lw, w.bounds.Min.X)) | |
| 32 | + | y := min(max(w.phys(l.Rect.Y)-lh+inset, w.bounds.Min.Y), max(w.bounds.Max.Y-lh, w.bounds.Min.Y)) | |
| 33 | + | rect := image.Rect(x, y, x+lw, y+lh) | |
| 34 | + | if rect.Intersect(w.bounds).Empty() { | |
| 35 | + | continue | |
| 36 | + | } | |
| 37 | + | key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale, style: style} | |
| 38 | + | st := shmState{w: lw, h: lh, seq: 1} | |
| 39 | + | c := r.shmCached(key, st) | |
| 40 | + | if c == nil { | |
| 41 | + | var err error | |
| 42 | + | if c, err = r.shmCopyFor(key, st, pix, 0, lw*4, nil); err != nil { | |
| 43 | + | continue | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | dr := r.contentDraw(rect, rect, lw, lh, [4]float32{}, 0, modeBuffer, true) | |
| 47 | + | dr.pc.color[3] = 1 | |
| 48 | + | dr.shm = c | |
| 49 | + | dr.pc.buf = [4]uint32{0, uint32(lw), uint32(lh), 0} | |
| 50 | + | w.draws = append(w.draws, dr) | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over | |||
| 11 | + | // everything, after the capture indicators. Each label is rasterized on the CPU | |||
| 12 | + | // (hintlabel) at the scene's scale and drawn from the SHM copy path: the GPU | |||
| 13 | + | // buffer is cached under a key naming the label, so a label that stays the | |||
| 14 | + | // same uploads once. Captures are rendered from scenes without labels. | |||
| 15 | + | func (w *sceneWalk) hintLabels() { | |||
| 16 | + | if len(w.s.HintLabels) == 0 { | |||
| 17 | + | return | |||
| 18 | + | } | |||
| 19 | + | r := w.r | |||
| 20 | + | hs := w.s.HintStyle | |||
| 21 | + | bg, fg := hintlabel.Colors(w.s.Border.Active, hs.Background, hs.Foreground) | |||
| 22 | + | style := hintlabel.Style{FontPx: hintlabel.FontPixels(hs.Width, hs.Height, hs.PhysicalW, hs.PhysicalH, w.scale, hs.Size), Background: bg, Foreground: fg} | |||
| 23 | + | for _, l := range w.s.HintLabels { | |||
| 24 | + | pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale, style) | |||
| 25 | + | if lw <= 0 || lh <= 0 { | |||
| 26 | + | continue | |||
| 27 | + | } | |||
| 28 | + | // Anchored outside the target's top-left corner, overlapping it by a | |||
| 29 | + | // quarter of the label height, then kept inside the output. | |||
| 30 | + | inset := int(math.Round(0.25 * float64(lh))) | |||
| 31 | + | x := min(max(w.phys(l.Rect.X)-lw+inset, w.bounds.Min.X), max(w.bounds.Max.X-lw, w.bounds.Min.X)) | |||
| 32 | + | y := min(max(w.phys(l.Rect.Y)-lh+inset, w.bounds.Min.Y), max(w.bounds.Max.Y-lh, w.bounds.Min.Y)) | |||
| 33 | + | rect := image.Rect(x, y, x+lw, y+lh) | |||
| 34 | + | if rect.Intersect(w.bounds).Empty() { | |||
| 35 | + | continue | |||
| 36 | + | } | |||
| 37 | + | key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale, style: style} | |||
| 38 | + | st := shmState{w: lw, h: lh, seq: 1} | |||
| 39 | + | c := r.shmCached(key, st) | |||
| 40 | + | if c == nil { | |||
| 41 | + | var err error | |||
| 42 | + | if c, err = r.shmCopyFor(key, st, pix, 0, lw*4, nil); err != nil { | |||
| 43 | + | continue | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | dr := r.contentDraw(rect, rect, lw, lh, [4]float32{}, 0, modeBuffer, true) | |||
| 47 | + | dr.pc.color[3] = 1 | |||
| 48 | + | dr.shm = c | |||
| 49 | + | dr.pc.buf = [4]uint32{0, uint32(lw), uint32(lh), 0} | |||
| 50 | + | w.draws = append(w.draws, dr) | |||
| 51 | + | } | |||
| 52 | + | } | |||
A internal/adapters/vulkan/hint_labels_test.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestHintLabelsDrawnAfterIndicators(t *testing.T) { | |
| 14 | + | r, err := New(256, 256) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 17 | + | } | |
| 18 | + | defer r.Close() | |
| 19 | + | for _, scale := range []int{1, 2} { | |
| 20 | + | s := ports.Scene{Scale: float64(scale), | |
| 21 | + | CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}, | |
| 22 | + | HintLabels: []ports.HintLabel{ | |
| 23 | + | {Rect: ports.Rect{X: 80, Y: 90}, Text: "f"}, | |
| 24 | + | {Rect: ports.Rect{X: 100, Y: 110}, Text: "jk", Typed: 1}, | |
| 25 | + | }} | |
| 26 | + | bare := s | |
| 27 | + | bare.HintLabels = nil | |
| 28 | + | before := len(r.draws(bare, nil, newDamage(&target{}, bare, image.Rect(0, 0, 256, 256)))) | |
| 29 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 256, 256))) | |
| 30 | + | require.Len(t, ds, before+2, "one draw per label, after the indicator's") | |
| 31 | + | for i, tl := range []image.Point{{80 * scale, 90 * scale}, {100 * scale, 110 * scale}} { | |
| 32 | + | d := ds[before+i] | |
| 33 | + | require.NotNil(t, d.shm) | |
| 34 | + | lw, lh := int(d.pc.rect[2]-d.pc.rect[0]), int(d.pc.rect[3]-d.pc.rect[1]) | |
| 35 | + | inset := int(math.Round(0.25 * float64(lh))) | |
| 36 | + | require.Equal(t, int32(tl.X-lw+inset), d.pc.rect[0]) | |
| 37 | + | require.Equal(t, int32(tl.Y-lh+inset), d.pc.rect[1]) | |
| 38 | + | require.Greater(t, d.pc.rect[2], d.pc.rect[0]) | |
| 39 | + | require.Greater(t, d.pc.rect[3], d.pc.rect[1]) | |
| 40 | + | require.Equal(t, float32(1), d.pc.color[3]) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestHintLabelsAnchoredAndClampedAtTopLeft(t *testing.T) { | |
| 46 | + | r, err := New(128, 128) | |
| 47 | + | if err != nil { | |
| 48 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 49 | + | } | |
| 50 | + | defer r.Close() | |
| 51 | + | // A target at the origin puts the anchor off-screen: clamped to 0,0. | |
| 52 | + | s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 0, Y: 0}, Text: "f"}, {Rect: ports.Rect{X: 60, Y: 60}, Text: "f"}}} | |
| 53 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |
| 54 | + | require.Len(t, ds, 2) | |
| 55 | + | require.Equal(t, int32(0), ds[0].pc.rect[0]) | |
| 56 | + | require.Equal(t, int32(0), ds[0].pc.rect[1]) | |
| 57 | + | lw, lh := int(ds[1].pc.rect[2]-ds[1].pc.rect[0]), int(ds[1].pc.rect[3]-ds[1].pc.rect[1]) | |
| 58 | + | inset := int(math.Round(0.25 * float64(lh))) | |
| 59 | + | require.Equal(t, int32(60-lw+inset), ds[1].pc.rect[0]) | |
| 60 | + | require.Equal(t, int32(60-lh+inset), ds[1].pc.rect[1]) | |
| 61 | + | require.Less(t, int(ds[1].pc.rect[2]), 60+lw, "label sits left of the target, mostly outside it") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestHintLabelsAbsentDrawNothing(t *testing.T) { | |
| 65 | + | r := &Renderer{width: 64, height: 64} | |
| 66 | + | s := ports.Scene{Scale: 1} | |
| 67 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestHintLabelsClampedInsideOutput(t *testing.T) { | |
| 71 | + | r, err := New(128, 128) | |
| 72 | + | if err != nil { | |
| 73 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 74 | + | } | |
| 75 | + | defer r.Close() | |
| 76 | + | s := ports.Scene{Scale: 1, HintStyle: ports.HintStyle{Size: 40}, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}} | |
| 77 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |
| 78 | + | require.Len(t, ds, 1) | |
| 79 | + | require.LessOrEqual(t, ds[0].pc.rect[2], int32(128)) | |
| 80 | + | require.LessOrEqual(t, ds[0].pc.rect[3], int32(128)) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestHintLabelsRenderAndUploadOnce(t *testing.T) { | |
| 84 | + | r, err := New(128, 128) | |
| 85 | + | if err != nil { | |
| 86 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 87 | + | } | |
| 88 | + | defer r.Close() | |
| 89 | + | s := ports.Scene{Scale: 1, Seq: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 4, Y: 4}, Text: "fj"}}} | |
| 90 | + | require.NoError(t, render(r, s, nil)) | |
| 91 | + | first := r.copied | |
| 92 | + | require.Positive(t, first) | |
| 93 | + | s.Seq = 2 | |
| 94 | + | require.NoError(t, render(r, s, nil)) | |
| 95 | + | require.Equal(t, first, r.copied, "an unchanged label is not uploaded again") | |
| 96 | + | s.HintStyle.Size = 26 | |
| 97 | + | s.Seq++ | |
| 98 | + | require.NoError(t, render(r, s, nil)) | |
| 99 | + | require.Greater(t, r.copied, first, "size reload uploads a new raster") | |
| 100 | + | sized := r.copied | |
| 101 | + | s.HintStyle.Background = "#89b4fa" | |
| 102 | + | s.Seq++ | |
| 103 | + | require.NoError(t, render(r, s, nil)) | |
| 104 | + | require.Greater(t, r.copied, sized, "colour reload uploads a new raster") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) { | |
| 108 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |
| 109 | + | base := ports.Scene{Scale: 1, OutputWidth: 100, OutputHeight: 100} | |
| 110 | + | with := base | |
| 111 | + | with.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f"}} | |
| 112 | + | _, ok := sceneDelta(base, with, phys, 1<<30) | |
| 113 | + | require.False(t, ok, "labels appearing redraw the output") | |
| 114 | + | typed := with | |
| 115 | + | typed.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f", Typed: 1}} | |
| 116 | + | _, ok = sceneDelta(with, typed, phys, 1<<30) | |
| 117 | + | require.False(t, ok) | |
| 118 | + | _, ok = sceneDelta(with, with, phys, 1<<30) | |
| 119 | + | require.True(t, ok, "same labels, no damage") | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestHintLabelsHeldByTarget(t *testing.T) { | |
| 123 | + | s := ports.Scene{Seq: 1, HintLabels: []ports.HintLabel{{Text: "f"}}} | |
| 124 | + | tg := &target{} | |
| 125 | + | tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}}) | |
| 126 | + | s.HintLabels[0].Text = "j" | |
| 127 | + | require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels") | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestHintStyleChangesSceneDamage(t *testing.T) { | |
| 131 | + | old := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Text: "f"}}} | |
| 132 | + | changed := old | |
| 133 | + | changed.HintStyle.Size = 24 | |
| 134 | + | require.False(t, old.SameAs(changed)) | |
| 135 | + | _, partial := sceneDelta(old, changed, func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }, 10) | |
| 136 | + | require.False(t, partial) | |
| 137 | + | } | |
| @@ -0,0 +1,137 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestHintLabelsDrawnAfterIndicators(t *testing.T) { | |||
| 14 | + | r, err := New(256, 256) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 17 | + | } | |||
| 18 | + | defer r.Close() | |||
| 19 | + | for _, scale := range []int{1, 2} { | |||
| 20 | + | s := ports.Scene{Scale: float64(scale), | |||
| 21 | + | CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}, | |||
| 22 | + | HintLabels: []ports.HintLabel{ | |||
| 23 | + | {Rect: ports.Rect{X: 80, Y: 90}, Text: "f"}, | |||
| 24 | + | {Rect: ports.Rect{X: 100, Y: 110}, Text: "jk", Typed: 1}, | |||
| 25 | + | }} | |||
| 26 | + | bare := s | |||
| 27 | + | bare.HintLabels = nil | |||
| 28 | + | before := len(r.draws(bare, nil, newDamage(&target{}, bare, image.Rect(0, 0, 256, 256)))) | |||
| 29 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 256, 256))) | |||
| 30 | + | require.Len(t, ds, before+2, "one draw per label, after the indicator's") | |||
| 31 | + | for i, tl := range []image.Point{{80 * scale, 90 * scale}, {100 * scale, 110 * scale}} { | |||
| 32 | + | d := ds[before+i] | |||
| 33 | + | require.NotNil(t, d.shm) | |||
| 34 | + | lw, lh := int(d.pc.rect[2]-d.pc.rect[0]), int(d.pc.rect[3]-d.pc.rect[1]) | |||
| 35 | + | inset := int(math.Round(0.25 * float64(lh))) | |||
| 36 | + | require.Equal(t, int32(tl.X-lw+inset), d.pc.rect[0]) | |||
| 37 | + | require.Equal(t, int32(tl.Y-lh+inset), d.pc.rect[1]) | |||
| 38 | + | require.Greater(t, d.pc.rect[2], d.pc.rect[0]) | |||
| 39 | + | require.Greater(t, d.pc.rect[3], d.pc.rect[1]) | |||
| 40 | + | require.Equal(t, float32(1), d.pc.color[3]) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestHintLabelsAnchoredAndClampedAtTopLeft(t *testing.T) { | |||
| 46 | + | r, err := New(128, 128) | |||
| 47 | + | if err != nil { | |||
| 48 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 49 | + | } | |||
| 50 | + | defer r.Close() | |||
| 51 | + | // A target at the origin puts the anchor off-screen: clamped to 0,0. | |||
| 52 | + | s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 0, Y: 0}, Text: "f"}, {Rect: ports.Rect{X: 60, Y: 60}, Text: "f"}}} | |||
| 53 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |||
| 54 | + | require.Len(t, ds, 2) | |||
| 55 | + | require.Equal(t, int32(0), ds[0].pc.rect[0]) | |||
| 56 | + | require.Equal(t, int32(0), ds[0].pc.rect[1]) | |||
| 57 | + | lw, lh := int(ds[1].pc.rect[2]-ds[1].pc.rect[0]), int(ds[1].pc.rect[3]-ds[1].pc.rect[1]) | |||
| 58 | + | inset := int(math.Round(0.25 * float64(lh))) | |||
| 59 | + | require.Equal(t, int32(60-lw+inset), ds[1].pc.rect[0]) | |||
| 60 | + | require.Equal(t, int32(60-lh+inset), ds[1].pc.rect[1]) | |||
| 61 | + | require.Less(t, int(ds[1].pc.rect[2]), 60+lw, "label sits left of the target, mostly outside it") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestHintLabelsAbsentDrawNothing(t *testing.T) { | |||
| 65 | + | r := &Renderer{width: 64, height: 64} | |||
| 66 | + | s := ports.Scene{Scale: 1} | |||
| 67 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestHintLabelsClampedInsideOutput(t *testing.T) { | |||
| 71 | + | r, err := New(128, 128) | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 74 | + | } | |||
| 75 | + | defer r.Close() | |||
| 76 | + | s := ports.Scene{Scale: 1, HintStyle: ports.HintStyle{Size: 40}, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}} | |||
| 77 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |||
| 78 | + | require.Len(t, ds, 1) | |||
| 79 | + | require.LessOrEqual(t, ds[0].pc.rect[2], int32(128)) | |||
| 80 | + | require.LessOrEqual(t, ds[0].pc.rect[3], int32(128)) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestHintLabelsRenderAndUploadOnce(t *testing.T) { | |||
| 84 | + | r, err := New(128, 128) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 87 | + | } | |||
| 88 | + | defer r.Close() | |||
| 89 | + | s := ports.Scene{Scale: 1, Seq: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 4, Y: 4}, Text: "fj"}}} | |||
| 90 | + | require.NoError(t, render(r, s, nil)) | |||
| 91 | + | first := r.copied | |||
| 92 | + | require.Positive(t, first) | |||
| 93 | + | s.Seq = 2 | |||
| 94 | + | require.NoError(t, render(r, s, nil)) | |||
| 95 | + | require.Equal(t, first, r.copied, "an unchanged label is not uploaded again") | |||
| 96 | + | s.HintStyle.Size = 26 | |||
| 97 | + | s.Seq++ | |||
| 98 | + | require.NoError(t, render(r, s, nil)) | |||
| 99 | + | require.Greater(t, r.copied, first, "size reload uploads a new raster") | |||
| 100 | + | sized := r.copied | |||
| 101 | + | s.HintStyle.Background = "#89b4fa" | |||
| 102 | + | s.Seq++ | |||
| 103 | + | require.NoError(t, render(r, s, nil)) | |||
| 104 | + | require.Greater(t, r.copied, sized, "colour reload uploads a new raster") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) { | |||
| 108 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |||
| 109 | + | base := ports.Scene{Scale: 1, OutputWidth: 100, OutputHeight: 100} | |||
| 110 | + | with := base | |||
| 111 | + | with.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f"}} | |||
| 112 | + | _, ok := sceneDelta(base, with, phys, 1<<30) | |||
| 113 | + | require.False(t, ok, "labels appearing redraw the output") | |||
| 114 | + | typed := with | |||
| 115 | + | typed.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f", Typed: 1}} | |||
| 116 | + | _, ok = sceneDelta(with, typed, phys, 1<<30) | |||
| 117 | + | require.False(t, ok) | |||
| 118 | + | _, ok = sceneDelta(with, with, phys, 1<<30) | |||
| 119 | + | require.True(t, ok, "same labels, no damage") | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestHintLabelsHeldByTarget(t *testing.T) { | |||
| 123 | + | s := ports.Scene{Seq: 1, HintLabels: []ports.HintLabel{{Text: "f"}}} | |||
| 124 | + | tg := &target{} | |||
| 125 | + | tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}}) | |||
| 126 | + | s.HintLabels[0].Text = "j" | |||
| 127 | + | require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels") | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestHintStyleChangesSceneDamage(t *testing.T) { | |||
| 131 | + | old := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Text: "f"}}} | |||
| 132 | + | changed := old | |||
| 133 | + | changed.HintStyle.Size = 24 | |||
| 134 | + | require.False(t, old.SameAs(changed)) | |||
| 135 | + | _, partial := sceneDelta(old, changed, func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }, 10) | |||
| 136 | + | require.False(t, partial) | |||
| 137 | + | } | |||
M internal/adapters/vulkan/renderer.go +3 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "strconv" | |
| 10 | 10 | "unsafe" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |
| 12 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 14 | "github.com/bnema/zerowrap" | |
| 14 | 15 | ||
| @@ -85,6 +86,8 @@ type Renderer struct { | |||
| 85 | 86 | // the bytes copied into them, for tests. | |
| 86 | 87 | shm map[shmKey]*shmSurface | |
| 87 | 88 | copied int | |
| 89 | + | // labels rasterizes the hint labels (hint_labels.go); the zero value works. | |
| 90 | + | labels hintlabel.Raster | |
| 88 | 91 | // shmRects is scratch for shmCopyFor. | |
| 89 | 92 | shmRects []image.Rectangle | |
| 90 | 93 | // marks date the frames for Trim's wall-clock eviction (trim.go). | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "strconv" | 9 | "strconv" | ||
| 10 | "unsafe" | 10 | "unsafe" | ||
| 11 | 11 | ||||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |||
| 12 | "github.com/bnema/neferwl/internal/ports" | 13 | "github.com/bnema/neferwl/internal/ports" | ||
| 13 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 14 | 15 | ||||
| @@ -85,6 +86,8 @@ type Renderer struct { | |||||
| 85 | // the bytes copied into them, for tests. | 86 | // the bytes copied into them, for tests. | ||
| 86 | shm map[shmKey]*shmSurface | 87 | shm map[shmKey]*shmSurface | ||
| 87 | copied int | 88 | copied int | ||
| 89 | + | // labels rasterizes the hint labels (hint_labels.go); the zero value works. | |||
| 90 | + | labels hintlabel.Raster | |||
| 88 | // shmRects is scratch for shmCopyFor. | 91 | // shmRects is scratch for shmCopyFor. | ||
| 89 | shmRects []image.Rectangle | 92 | shmRects []image.Rectangle | ||
| 90 | // marks date the frames for Trim's wall-clock eviction (trim.go). | 93 | // marks date the frames for Trim's wall-clock eviction (trim.go). | ||
M internal/adapters/vulkan/scene.go +4 -3
| @@ -60,6 +60,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 60 | 60 | w.popups(true) | |
| 61 | 61 | w.focusFlash() | |
| 62 | 62 | w.captureIndicators() | |
| 63 | + | w.hintLabels() | |
| 63 | 64 | r.scratchDraws = w.draws | |
| 64 | 65 | return w.draws | |
| 65 | 66 | } | |
| @@ -331,14 +332,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||
| 331 | 332 | for i := range content.Children { | |
| 332 | 333 | if ch := &content.Children[i]; ch.Below { | |
| 333 | 334 | cx, cy := at(ch) | |
| 334 | - | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, covers, false) | |
| 335 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, covers, false) | |
| 335 | 336 | } | |
| 336 | 337 | } | |
| 337 | - | w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true) | |
| 338 | + | w.surface(content, ox, oy, clip, shmKey{win: id, surface: content.Surface}, content.Version, covers, true) | |
| 338 | 339 | for i := range content.Children { | |
| 339 | 340 | if ch := &content.Children[i]; !ch.Below { | |
| 340 | 341 | cx, cy := at(ch) | |
| 341 | - | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, nil, false) | |
| 342 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, nil, false) | |
| 342 | 343 | } | |
| 343 | 344 | } | |
| 344 | 345 | } | |
| @@ -60,6 +60,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 60 | w.popups(true) | 60 | w.popups(true) | ||
| 61 | w.focusFlash() | 61 | w.focusFlash() | ||
| 62 | w.captureIndicators() | 62 | w.captureIndicators() | ||
| 63 | + | w.hintLabels() | |||
| 63 | r.scratchDraws = w.draws | 64 | r.scratchDraws = w.draws | ||
| 64 | return w.draws | 65 | return w.draws | ||
| 65 | } | 66 | } | ||
| @@ -331,14 +332,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||||
| 331 | for i := range content.Children { | 332 | for i := range content.Children { | ||
| 332 | if ch := &content.Children[i]; ch.Below { | 333 | if ch := &content.Children[i]; ch.Below { | ||
| 333 | cx, cy := at(ch) | 334 | cx, cy := at(ch) | ||
| 334 | - | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, covers, false) | 335 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, covers, false) |
| 335 | } | 336 | } | ||
| 336 | } | 337 | } | ||
| 337 | - | w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true) | 338 | + | w.surface(content, ox, oy, clip, shmKey{win: id, surface: content.Surface}, content.Version, covers, true) |
| 338 | for i := range content.Children { | 339 | for i := range content.Children { | ||
| 339 | if ch := &content.Children[i]; !ch.Below { | 340 | if ch := &content.Children[i]; !ch.Below { | ||
| 340 | cx, cy := at(ch) | 341 | cx, cy := at(ch) | ||
| 341 | - | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, nil, false) | 342 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, nil, false) |
| 342 | } | 343 | } | ||
| 343 | } | 344 | } | ||
| 344 | } | 345 | } | ||
M internal/adapters/vulkan/shm.go +8 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "runtime/debug" | |
| 7 | 7 | "unsafe" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |
| 9 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 11 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 11 | 12 | "golang.org/x/sys/unix" | |
| @@ -154,6 +155,13 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) { | |||
| 154 | 155 | type shmKey struct { | |
| 155 | 156 | win ports.WindowID | |
| 156 | 157 | surface uint64 | |
| 158 | + | // label, typed and scale name a compositor-drawn hint label (hint_labels.go) | |
| 159 | + | // instead of a client surface: win and surface are 0 and label is never | |
| 160 | + | // empty, so such a key cannot collide with a client's. | |
| 161 | + | label string | |
| 162 | + | typed int | |
| 163 | + | scale float64 | |
| 164 | + | style hintlabel.Style | |
| 157 | 165 | } | |
| 158 | 166 | ||
| 159 | 167 | // shmState is what a GPU buffer holds: a w×h client buffer at a content | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "runtime/debug" | 6 | "runtime/debug" | ||
| 7 | "unsafe" | 7 | "unsafe" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |||
| 9 | "github.com/bnema/neferwl/internal/ports" | 10 | "github.com/bnema/neferwl/internal/ports" | ||
| 10 | vk "github.com/bnema/purego-vulkan/vulkan" | 11 | vk "github.com/bnema/purego-vulkan/vulkan" | ||
| 11 | "golang.org/x/sys/unix" | 12 | "golang.org/x/sys/unix" | ||
| @@ -154,6 +155,13 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) { | |||||
| 154 | type shmKey struct { | 155 | type shmKey struct { | ||
| 155 | win ports.WindowID | 156 | win ports.WindowID | ||
| 156 | surface uint64 | 157 | surface uint64 | ||
| 158 | + | // label, typed and scale name a compositor-drawn hint label (hint_labels.go) | |||
| 159 | + | // instead of a client surface: win and surface are 0 and label is never | |||
| 160 | + | // empty, so such a key cannot collide with a client's. | |||
| 161 | + | label string | |||
| 162 | + | typed int | |||
| 163 | + | scale float64 | |||
| 164 | + | style hintlabel.Style | |||
| 157 | } | 165 | } | ||
| 158 | 166 | ||||
| 159 | // shmState is what a GPU buffer holds: a w×h client buffer at a content | 167 | // shmState is what a GPU buffer holds: a w×h client buffer at a content | ||
M internal/adapters/vulkan/targets.go +1 -0
| @@ -43,6 +43,7 @@ type target struct { | |||
| 43 | 43 | heldLayers []ports.SceneLayer | |
| 44 | 44 | heldHints []ports.Rect | |
| 45 | 45 | heldIndicators []ports.CaptureIndicator | |
| 46 | + | heldLabels []ports.HintLabel | |
| 46 | 47 | windows map[ports.WindowID]heldWindow | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -43,6 +43,7 @@ type target struct { | |||||
| 43 | heldLayers []ports.SceneLayer | 43 | heldLayers []ports.SceneLayer | ||
| 44 | heldHints []ports.Rect | 44 | heldHints []ports.Rect | ||
| 45 | heldIndicators []ports.CaptureIndicator | 45 | heldIndicators []ports.CaptureIndicator | ||
| 46 | + | heldLabels []ports.HintLabel | |||
| 46 | windows map[ports.WindowID]heldWindow | 47 | windows map[ports.WindowID]heldWindow | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||