detached 6319bfa

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
7070 if s.Flash != nil {
7171 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
7272 }
73+ if len(s.HintLabels) > 0 {
74+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "hint_labels"
75+ }
7376 if s.Transform != 0 {
7477 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
7578 }
@@ -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
9494 if s.Flash != nil {
9595 return ports.SurfaceContent{}, "focus_flash"
9696 }
97+ // Hint labels are composed over the window.
98+ if len(s.HintLabels) > 0 {
99+ return ports.SurfaceContent{}, "hint_labels"
100+ }
97101 if s.Transform != 0 {
98102 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
99103 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 (
1010
1111 // A request with Exclude leaves out the exclusion's HUD layers and popups;
1212 // every other capture shows them. No capture shows the capture indicator
13-// (Scene.CaptureIndicators): a scene that carries it is never the source of a
13+// (Scene.CaptureIndicators): a scene that carries it, or hint labels, is never the source of a
1414 // capture image. Requests of the displayed frame are then served from the
1515 // frame rendered without it first (SubmitPlain, ExcludedScene).
1616
@@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool {
6464 return false
6565 }
6666
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
6868 // renderer redraws the whole target instead of reusing a displayed frame.
6969 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
7171 return s
7272 }
7373
@@ -85,7 +85,7 @@ func plain(s ports.Scene) ports.Scene {
8585 // the plain render reads client buffers even when the requests are rejected,
8686 // so the caller owns it (hold or wait it) whatever happened, error included.
8787 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 {
8989 return false, nil, nil
9090 }
9191 done, err = r.Render(plain(s), surfaces)
@@ -164,7 +164,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
164164 }
165165 clear(reqs[n:])
166166 reqs = reqs[:n]
167- excl := excludedShown(s) || len(s.CaptureIndicators) > 0
167+ excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0
168168 group := func(q ports.CaptureRequest) int {
169169 switch {
170170 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 indicator12 // every other capture shows them. No capture shows the capture indicator
13-// (Scene.CaptureIndicators): a scene that carries it is never the source of a13+// (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 the14 // 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 false64 return false
65 }65 }
66 66
67-// plain returns s without its capture state and indicators, Seq 0 so the67+// 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, 070+ s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
71 return s71 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, nil89 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) > 0167+ 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) {
112112 _, statErr := file.Stat()
113113 require.ErrorIs(t, statErr, os.ErrClosed)
114114 }
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) {
238238 tg.heldLayers = append(tg.heldLayers[:0], s.Layers...)
239239 tg.heldHints = append(tg.heldHints[:0], s.DropHints...)
240240 tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...)
241+ tg.heldLabels = append(tg.heldLabels[:0], s.HintLabels...)
241242 tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn
242243 tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers
243244 tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators
245+ tg.scene.HintLabels = tg.heldLabels
244246 // Only whether they are set matters (any capture redraws everything):
245247 // sentinels, so no pointer into the caller's capture state is kept.
246248 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
271273 if old.Security != cur.Security || old.Output != cur.Output ||
272274 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
273275 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 ||
275277 old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
276278 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
277279 !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) ||
279281 len(old.Windows) != len(cur.Windows) {
280282 return image.Rectangle{}, false
281283 }
@@ -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.drawn242 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.heldLayers243 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.heldIndicators244 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, nil248 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{}, false282 return image.Rectangle{}, false
281 }283 }
M internal/adapters/vulkan/damage_delta_test.go +2 -2
@@ -17,9 +17,9 @@ import (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 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
2121 // schedules the next frame, it draws nothing.
22- const fields = 23
22+ const fields = 25
2323 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2424 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2525 }
@@ -17,9 +17,9 @@ import (
17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta17 // 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 only20+ // 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 = 2322+ 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 (
99 "strconv"
1010 "unsafe"
1111
12+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
1213 "github.com/bnema/neferwl/internal/ports"
1314 "github.com/bnema/zerowrap"
1415
@@ -85,6 +86,8 @@ type Renderer struct {
8586 // the bytes copied into them, for tests.
8687 shm map[shmKey]*shmSurface
8788 copied int
89+ // labels rasterizes the hint labels (hint_labels.go); the zero value works.
90+ labels hintlabel.Raster
8891 // shmRects is scratch for shmCopyFor.
8992 shmRects []image.Rectangle
9093 // 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]*shmSurface87 shm map[shmKey]*shmSurface
87 copied int88 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.Rectangle92 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
6060 w.popups(true)
6161 w.focusFlash()
6262 w.captureIndicators()
63+ w.hintLabels()
6364 r.scratchDraws = w.draws
6465 return w.draws
6566 }
@@ -331,14 +332,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
331332 for i := range content.Children {
332333 if ch := &content.Children[i]; ch.Below {
333334 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)
335336 }
336337 }
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)
338339 for i := range content.Children {
339340 if ch := &content.Children[i]; !ch.Below {
340341 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)
342343 }
343344 }
344345 }
@@ -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.draws64 r.scratchDraws = w.draws
64 return w.draws65 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 (
66 "runtime/debug"
77 "unsafe"
88
9+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
910 "github.com/bnema/neferwl/internal/ports"
1011 vk "github.com/bnema/purego-vulkan/vulkan"
1112 "golang.org/x/sys/unix"
@@ -154,6 +155,13 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) {
154155 type shmKey struct {
155156 win ports.WindowID
156157 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
157165 }
158166
159167 // 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.WindowID156 win ports.WindowID
156 surface uint64157 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 content167 // 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 {
4343 heldLayers []ports.SceneLayer
4444 heldHints []ports.Rect
4545 heldIndicators []ports.CaptureIndicator
46+ heldLabels []ports.HintLabel
4647 windows map[ports.WindowID]heldWindow
4748 }
4849
@@ -43,6 +43,7 @@ type target struct {
43 heldLayers []ports.SceneLayer43 heldLayers []ports.SceneLayer
44 heldHints []ports.Rect44 heldHints []ports.Rect
45 heldIndicators []ports.CaptureIndicator45 heldIndicators []ports.CaptureIndicator
46+ heldLabels []ports.HintLabel
46 windows map[ports.WindowID]heldWindow47 windows map[ports.WindowID]heldWindow
47 }48 }
48 49

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close