detached 9ad3054

feat(vulkan): draw hint labels

commit
9ad305426fce03a8cce48be32ba7ce23c688a669
author
brice · · 2026-10-19 09:58 UTC
parent
a329128
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +365 -7
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
M go.mod +2 -0
@@ -15,6 +15,7 @@ require (
1515 github.com/godbus/dbus/v5 v5.2.2
1616 github.com/rs/zerolog v1.35.1
1717 github.com/stretchr/testify v1.12.1
18+ golang.org/x/image v0.46.0
1819 golang.org/x/sys v0.48.0
1920 golang.org/x/term v0.46.0
2021 )
@@ -24,5 +25,6 @@ require (
2425 github.com/mattn/go-isatty v0.0.22 // indirect
2526 github.com/stretchr/objx v0.5.3 // indirect
2627 go.yaml.in/yaml/v3 v3.0.5 // indirect
28+ golang.org/x/text v0.42.0 // indirect
2729 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
2830 )
@@ -15,6 +15,7 @@ require (
15 github.com/godbus/dbus/v5 v5.2.215 github.com/godbus/dbus/v5 v5.2.2
16 github.com/rs/zerolog v1.35.116 github.com/rs/zerolog v1.35.1
17 github.com/stretchr/testify v1.12.117 github.com/stretchr/testify v1.12.1
18+ golang.org/x/image v0.46.0
18 golang.org/x/sys v0.48.019 golang.org/x/sys v0.48.0
19 golang.org/x/term v0.46.020 golang.org/x/term v0.46.0
20 )21 )
@@ -24,5 +25,6 @@ require (
24 github.com/mattn/go-isatty v0.0.22 // indirect25 github.com/mattn/go-isatty v0.0.22 // indirect
25 github.com/stretchr/objx v0.5.3 // indirect26 github.com/stretchr/objx v0.5.3 // indirect
26 go.yaml.in/yaml/v3 v3.0.5 // indirect27 go.yaml.in/yaml/v3 v3.0.5 // indirect
28+ golang.org/x/text v0.42.0 // indirect
27 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect29 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
28 )30 )
M go.sum +4 -0 generated file: collapsed
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"
A internal/adapters/hintlabel/hintlabel.go +131 -0
@@ -0,0 +1,131 @@
1+// Package hintlabel rasterizes the keyboard hint labels on the CPU: a small
2+// box with the label text in Go Mono, as premultiplied BGRA pixels for the
3+// renderer to upload and draw.
4+package hintlabel
5+
6+import (
7+ "image"
8+ "image/color"
9+ "image/draw"
10+ "math"
11+ "sync"
12+
13+ "golang.org/x/image/font"
14+ "golang.org/x/image/font/gofont/gomono"
15+ "golang.org/x/image/font/opentype"
16+ "golang.org/x/image/math/fixed"
17+)
18+
19+const (
20+ // fontSize is the text size in logical pixels.
21+ fontSize = 13
22+ // padX and padY are the padding around the text in logical pixels.
23+ padX, padY = 3, 1
24+ // maxEntries bounds the cache: it is cleared when it grows past it.
25+ maxEntries = 1024
26+)
27+
28+var (
29+ background = color.RGBA{0xf9, 0xe2, 0xaf, 0xff}
30+ ink = color.RGBA{0x1e, 0x1e, 0x2e, 0xff}
31+ typedInk = color.RGBA{0x7f, 0x84, 0x9c, 0xff}
32+
33+ parseOnce sync.Once
34+ parsed *opentype.Font
35+ parseErr error
36+)
37+
38+type key struct {
39+ text string
40+ typed int
41+ scale float64
42+}
43+
44+type entry struct {
45+ pix []byte
46+ w, h int
47+}
48+
49+// Raster draws and caches labels. It is not safe for concurrent use: the
50+// renderer owns it.
51+type Raster struct {
52+ faces map[float64]font.Face
53+ cache map[key]entry
54+}
55+
56+// Label returns the label box for text, whose first typed characters are
57+// drawn dimmed, as premultiplied BGRA at scale physical pixels per logical
58+// pixel. The slice is owned by the cache and stays valid until the cache is
59+// cleared by a later call: do not modify it. Empty text, or a font that does
60+// not load, yields a nil slice.
61+func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h int) {
62+ if text == "" {
63+ return nil, 0, 0
64+ }
65+ if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
66+ scale = 1
67+ }
68+ typed = max(0, min(typed, len(text)))
69+ k := key{text, typed, scale}
70+ if e, ok := r.cache[k]; ok {
71+ return e.pix, e.w, e.h
72+ }
73+ face := r.face(scale)
74+ if face == nil {
75+ return nil, 0, 0
76+ }
77+ if r.cache == nil || len(r.cache) >= maxEntries {
78+ r.cache = map[key]entry{}
79+ }
80+ e := render(face, text, typed, scale)
81+ r.cache[k] = e
82+ return e.pix, e.w, e.h
83+}
84+
85+func (r *Raster) face(scale float64) font.Face {
86+ if f, ok := r.faces[scale]; ok {
87+ return f
88+ }
89+ parseOnce.Do(func() { parsed, parseErr = opentype.Parse(gomono.TTF) })
90+ if parseErr != nil {
91+ return nil
92+ }
93+ f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: fontSize * scale, DPI: 72, Hinting: font.HintingFull})
94+ if err != nil {
95+ return nil
96+ }
97+ if r.faces == nil || len(r.faces) >= 16 {
98+ for _, old := range r.faces {
99+ _ = old.Close()
100+ }
101+ r.faces = map[float64]font.Face{}
102+ }
103+ r.faces[scale] = f
104+ return f
105+}
106+
107+// render draws the box. Colours are opaque, so the result is premultiplied
108+// by construction.
109+func render(face font.Face, text string, typed int, scale float64) entry {
110+ m := face.Metrics()
111+ px := int(math.Round(padX * scale))
112+ py := int(math.Round(padY * scale))
113+ border := 1
114+ adv := font.MeasureString(face, text).Ceil()
115+ w := adv + 2*px + 2*border
116+ h := m.Height.Ceil() + 2*py + 2*border
117+ img := image.NewRGBA(image.Rect(0, 0, w, h))
118+ draw.Draw(img, img.Bounds(), image.NewUniform(ink), image.Point{}, draw.Src)
119+ draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src)
120+ d := font.Drawer{Dst: img, Face: face, Dot: fixed.Point26_6{X: fixed.I(border + px), Y: fixed.I(border+py) + m.Ascent}}
121+ d.Src = image.NewUniform(typedInk)
122+ d.DrawString(text[:typed])
123+ d.Src = image.NewUniform(ink)
124+ d.DrawString(text[typed:])
125+ // RGBA to BGRA in place; alpha is 0xff everywhere.
126+ pix := img.Pix
127+ for i := 0; i+3 < len(pix); i += 4 {
128+ pix[i], pix[i+2] = pix[i+2], pix[i]
129+ }
130+ return entry{pix: pix, w: w, h: h}
131+}
@@ -0,0 +1,131 @@
1+// Package hintlabel rasterizes the keyboard hint labels on the CPU: a small
2+// box with the label text in Go Mono, as premultiplied BGRA pixels for the
3+// renderer to upload and draw.
4+package hintlabel
5+
6+import (
7+ "image"
8+ "image/color"
9+ "image/draw"
10+ "math"
11+ "sync"
12+
13+ "golang.org/x/image/font"
14+ "golang.org/x/image/font/gofont/gomono"
15+ "golang.org/x/image/font/opentype"
16+ "golang.org/x/image/math/fixed"
17+)
18+
19+const (
20+ // fontSize is the text size in logical pixels.
21+ fontSize = 13
22+ // padX and padY are the padding around the text in logical pixels.
23+ padX, padY = 3, 1
24+ // maxEntries bounds the cache: it is cleared when it grows past it.
25+ maxEntries = 1024
26+)
27+
28+var (
29+ background = color.RGBA{0xf9, 0xe2, 0xaf, 0xff}
30+ ink = color.RGBA{0x1e, 0x1e, 0x2e, 0xff}
31+ typedInk = color.RGBA{0x7f, 0x84, 0x9c, 0xff}
32+
33+ parseOnce sync.Once
34+ parsed *opentype.Font
35+ parseErr error
36+)
37+
38+type key struct {
39+ text string
40+ typed int
41+ scale float64
42+}
43+
44+type entry struct {
45+ pix []byte
46+ w, h int
47+}
48+
49+// Raster draws and caches labels. It is not safe for concurrent use: the
50+// renderer owns it.
51+type Raster struct {
52+ faces map[float64]font.Face
53+ cache map[key]entry
54+}
55+
56+// Label returns the label box for text, whose first typed characters are
57+// drawn dimmed, as premultiplied BGRA at scale physical pixels per logical
58+// pixel. The slice is owned by the cache and stays valid until the cache is
59+// cleared by a later call: do not modify it. Empty text, or a font that does
60+// not load, yields a nil slice.
61+func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h int) {
62+ if text == "" {
63+ return nil, 0, 0
64+ }
65+ if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
66+ scale = 1
67+ }
68+ typed = max(0, min(typed, len(text)))
69+ k := key{text, typed, scale}
70+ if e, ok := r.cache[k]; ok {
71+ return e.pix, e.w, e.h
72+ }
73+ face := r.face(scale)
74+ if face == nil {
75+ return nil, 0, 0
76+ }
77+ if r.cache == nil || len(r.cache) >= maxEntries {
78+ r.cache = map[key]entry{}
79+ }
80+ e := render(face, text, typed, scale)
81+ r.cache[k] = e
82+ return e.pix, e.w, e.h
83+}
84+
85+func (r *Raster) face(scale float64) font.Face {
86+ if f, ok := r.faces[scale]; ok {
87+ return f
88+ }
89+ parseOnce.Do(func() { parsed, parseErr = opentype.Parse(gomono.TTF) })
90+ if parseErr != nil {
91+ return nil
92+ }
93+ f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: fontSize * scale, DPI: 72, Hinting: font.HintingFull})
94+ if err != nil {
95+ return nil
96+ }
97+ if r.faces == nil || len(r.faces) >= 16 {
98+ for _, old := range r.faces {
99+ _ = old.Close()
100+ }
101+ r.faces = map[float64]font.Face{}
102+ }
103+ r.faces[scale] = f
104+ return f
105+}
106+
107+// render draws the box. Colours are opaque, so the result is premultiplied
108+// by construction.
109+func render(face font.Face, text string, typed int, scale float64) entry {
110+ m := face.Metrics()
111+ px := int(math.Round(padX * scale))
112+ py := int(math.Round(padY * scale))
113+ border := 1
114+ adv := font.MeasureString(face, text).Ceil()
115+ w := adv + 2*px + 2*border
116+ h := m.Height.Ceil() + 2*py + 2*border
117+ img := image.NewRGBA(image.Rect(0, 0, w, h))
118+ draw.Draw(img, img.Bounds(), image.NewUniform(ink), image.Point{}, draw.Src)
119+ draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src)
120+ d := font.Drawer{Dst: img, Face: face, Dot: fixed.Point26_6{X: fixed.I(border + px), Y: fixed.I(border+py) + m.Ascent}}
121+ d.Src = image.NewUniform(typedInk)
122+ d.DrawString(text[:typed])
123+ d.Src = image.NewUniform(ink)
124+ d.DrawString(text[typed:])
125+ // RGBA to BGRA in place; alpha is 0xff everywhere.
126+ pix := img.Pix
127+ for i := 0; i+3 < len(pix); i += 4 {
128+ pix[i], pix[i+2] = pix[i+2], pix[i]
129+ }
130+ return entry{pix: pix, w: w, h: h}
131+}
A internal/adapters/hintlabel/hintlabel_test.go +60 -0
@@ -0,0 +1,60 @@
1+package hintlabel
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestHintLabelSize(t *testing.T) {
10+ var r Raster
11+ _, w1, h1 := r.Label("f", 0, 1)
12+ _, w2, h2 := r.Label("fj", 0, 1)
13+ require.Greater(t, w1, 0)
14+ require.Greater(t, w2, w1, "a longer label is wider")
15+ require.Equal(t, h1, h2)
16+ _, w3, h3 := r.Label("fj", 0, 2)
17+ // The 1 px border does not scale: compare the boxes without it.
18+ require.InDelta(t, 2*(w2-2), w3-2, 1)
19+ require.InDelta(t, 2*(h2-2), h3-2, 1)
20+}
21+
22+func TestHintLabelCache(t *testing.T) {
23+ var r Raster
24+ a, w, h := r.Label("fj", 1, 1)
25+ b, _, _ := r.Label("fj", 1, 1)
26+ require.Len(t, a, w*h*4)
27+ require.Same(t, &a[0], &b[0], "same key returns the cached slice")
28+ c, _, _ := r.Label("fj", 0, 1)
29+ require.NotSame(t, &a[0], &c[0], "typed count is part of the key")
30+}
31+
32+func TestHintLabelPremultiplied(t *testing.T) {
33+ var r Raster
34+ pix, w, h := r.Label("ab", 1, 1.5)
35+ require.Len(t, pix, w*h*4)
36+ for i := 0; i < len(pix); i += 4 {
37+ a := pix[i+3]
38+ require.GreaterOrEqual(t, a, pix[i])
39+ require.GreaterOrEqual(t, a, pix[i+1])
40+ require.GreaterOrEqual(t, a, pix[i+2])
41+ }
42+}
43+
44+func TestHintLabelTypedIsDimmed(t *testing.T) {
45+ var r Raster
46+ plain, _, _ := r.Label("fj", 0, 1)
47+ dim, _, _ := r.Label("fj", 1, 1)
48+ require.NotEqual(t, plain, dim)
49+}
50+
51+func TestHintLabelEmptyAndCacheBound(t *testing.T) {
52+ var r Raster
53+ pix, w, h := r.Label("", 0, 1)
54+ require.Nil(t, pix)
55+ require.Zero(t, w+h)
56+ for i := 0; i < maxEntries+10; i++ {
57+ r.Label("a", 0, 1+float64(i)/100000)
58+ }
59+ require.LessOrEqual(t, len(r.cache), maxEntries)
60+}
@@ -0,0 +1,60 @@
1+package hintlabel
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestHintLabelSize(t *testing.T) {
10+ var r Raster
11+ _, w1, h1 := r.Label("f", 0, 1)
12+ _, w2, h2 := r.Label("fj", 0, 1)
13+ require.Greater(t, w1, 0)
14+ require.Greater(t, w2, w1, "a longer label is wider")
15+ require.Equal(t, h1, h2)
16+ _, w3, h3 := r.Label("fj", 0, 2)
17+ // The 1 px border does not scale: compare the boxes without it.
18+ require.InDelta(t, 2*(w2-2), w3-2, 1)
19+ require.InDelta(t, 2*(h2-2), h3-2, 1)
20+}
21+
22+func TestHintLabelCache(t *testing.T) {
23+ var r Raster
24+ a, w, h := r.Label("fj", 1, 1)
25+ b, _, _ := r.Label("fj", 1, 1)
26+ require.Len(t, a, w*h*4)
27+ require.Same(t, &a[0], &b[0], "same key returns the cached slice")
28+ c, _, _ := r.Label("fj", 0, 1)
29+ require.NotSame(t, &a[0], &c[0], "typed count is part of the key")
30+}
31+
32+func TestHintLabelPremultiplied(t *testing.T) {
33+ var r Raster
34+ pix, w, h := r.Label("ab", 1, 1.5)
35+ require.Len(t, pix, w*h*4)
36+ for i := 0; i < len(pix); i += 4 {
37+ a := pix[i+3]
38+ require.GreaterOrEqual(t, a, pix[i])
39+ require.GreaterOrEqual(t, a, pix[i+1])
40+ require.GreaterOrEqual(t, a, pix[i+2])
41+ }
42+}
43+
44+func TestHintLabelTypedIsDimmed(t *testing.T) {
45+ var r Raster
46+ plain, _, _ := r.Label("fj", 0, 1)
47+ dim, _, _ := r.Label("fj", 1, 1)
48+ require.NotEqual(t, plain, dim)
49+}
50+
51+func TestHintLabelEmptyAndCacheBound(t *testing.T) {
52+ var r Raster
53+ pix, w, h := r.Label("", 0, 1)
54+ require.Nil(t, pix)
55+ require.Zero(t, w+h)
56+ for i := 0; i < maxEntries+10; i++ {
57+ r.Label("a", 0, 1+float64(i)/100000)
58+ }
59+ require.LessOrEqual(t, len(r.cache), maxEntries)
60+}
M internal/adapters/vulkan/damage.go +3 -1
@@ -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
@@ -275,7 +277,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
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
@@ -275,7 +277,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
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+ // 24 with Animating, which sceneDelta intentionally ignores: it only
2121 // schedules the next frame, it draws nothing.
22- const fields = 23
22+ const fields = 24
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+ // 24 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 = 24
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 +44 -0
@@ -0,0 +1,44 @@
1+package vulkan
2+
3+import (
4+ "image"
5+)
6+
7+// hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over
8+// everything, after the capture indicators. Each label is rasterized on the CPU
9+// (hintlabel) at the scene's scale and drawn from the SHM copy path: the GPU
10+// buffer is cached under a key naming the label, so a label that stays the
11+// same uploads once. Captures are rendered from scenes without labels.
12+func (w *sceneWalk) hintLabels() {
13+ if len(w.s.HintLabels) == 0 {
14+ return
15+ }
16+ r := w.r
17+ for _, l := range w.s.HintLabels {
18+ pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale)
19+ if lw <= 0 || lh <= 0 {
20+ continue
21+ }
22+ // Inside the output, whatever the target's position.
23+ x := min(max(w.phys(l.Rect.X), w.bounds.Min.X), max(w.bounds.Max.X-lw, w.bounds.Min.X))
24+ y := min(max(w.phys(l.Rect.Y), w.bounds.Min.Y), max(w.bounds.Max.Y-lh, w.bounds.Min.Y))
25+ rect := image.Rect(x, y, x+lw, y+lh)
26+ if rect.Intersect(w.bounds).Empty() {
27+ continue
28+ }
29+ key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale}
30+ st := shmState{w: lw, h: lh, seq: 1}
31+ c := r.shmCached(key, st)
32+ if c == nil {
33+ var err error
34+ if c, err = r.shmCopyFor(key, st, pix, 0, lw*4, nil); err != nil {
35+ continue
36+ }
37+ }
38+ dr := r.contentDraw(rect, rect, lw, lh, [4]float32{}, 0, modeBuffer, true)
39+ dr.pc.color[3] = 1
40+ dr.shm = c
41+ dr.pc.buf = [4]uint32{0, uint32(lw), uint32(lh), 0}
42+ w.draws = append(w.draws, dr)
43+ }
44+}
@@ -0,0 +1,44 @@
1+package vulkan
2+
3+import (
4+ "image"
5+)
6+
7+// hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over
8+// everything, after the capture indicators. Each label is rasterized on the CPU
9+// (hintlabel) at the scene's scale and drawn from the SHM copy path: the GPU
10+// buffer is cached under a key naming the label, so a label that stays the
11+// same uploads once. Captures are rendered from scenes without labels.
12+func (w *sceneWalk) hintLabels() {
13+ if len(w.s.HintLabels) == 0 {
14+ return
15+ }
16+ r := w.r
17+ for _, l := range w.s.HintLabels {
18+ pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale)
19+ if lw <= 0 || lh <= 0 {
20+ continue
21+ }
22+ // Inside the output, whatever the target's position.
23+ x := min(max(w.phys(l.Rect.X), w.bounds.Min.X), max(w.bounds.Max.X-lw, w.bounds.Min.X))
24+ y := min(max(w.phys(l.Rect.Y), w.bounds.Min.Y), max(w.bounds.Max.Y-lh, w.bounds.Min.Y))
25+ rect := image.Rect(x, y, x+lw, y+lh)
26+ if rect.Intersect(w.bounds).Empty() {
27+ continue
28+ }
29+ key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale}
30+ st := shmState{w: lw, h: lh, seq: 1}
31+ c := r.shmCached(key, st)
32+ if c == nil {
33+ var err error
34+ if c, err = r.shmCopyFor(key, st, pix, 0, lw*4, nil); err != nil {
35+ continue
36+ }
37+ }
38+ dr := r.contentDraw(rect, rect, lw, lh, [4]float32{}, 0, modeBuffer, true)
39+ dr.pc.color[3] = 1
40+ dr.shm = c
41+ dr.pc.buf = [4]uint32{0, uint32(lw), uint32(lh), 0}
42+ w.draws = append(w.draws, dr)
43+ }
44+}
A internal/adapters/vulkan/hint_labels_test.go +97 -0
@@ -0,0 +1,97 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestHintLabelsDrawnAfterIndicators(t *testing.T) {
13+ r, err := New(256, 256)
14+ if err != nil {
15+ t.Skipf("Vulkan unavailable: %v", err)
16+ }
17+ defer r.Close()
18+ for _, scale := range []int{1, 2} {
19+ s := ports.Scene{Scale: float64(scale),
20+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}},
21+ HintLabels: []ports.HintLabel{
22+ {Rect: ports.Rect{X: 10, Y: 12}, Text: "f"},
23+ {Rect: ports.Rect{X: 30, Y: 40}, Text: "jk", Typed: 1},
24+ }}
25+ bare := s
26+ bare.HintLabels = nil
27+ before := len(r.draws(bare, nil, newDamage(&target{}, bare, image.Rect(0, 0, 256, 256))))
28+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 256, 256)))
29+ require.Len(t, ds, before+2, "one draw per label, after the indicator's")
30+ for i, want := range []image.Point{{10 * scale, 12 * scale}, {30 * scale, 40 * scale}} {
31+ d := ds[before+i]
32+ require.NotNil(t, d.shm)
33+ require.Equal(t, int32(want.X), d.pc.rect[0])
34+ require.Equal(t, int32(want.Y), d.pc.rect[1])
35+ require.Greater(t, d.pc.rect[2], d.pc.rect[0])
36+ require.Greater(t, d.pc.rect[3], d.pc.rect[1])
37+ require.Equal(t, float32(1), d.pc.color[3])
38+ }
39+ }
40+}
41+
42+func TestHintLabelsAbsentDrawNothing(t *testing.T) {
43+ r := &Renderer{width: 64, height: 64}
44+ s := ports.Scene{Scale: 1}
45+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
46+}
47+
48+func TestHintLabelsClampedInsideOutput(t *testing.T) {
49+ r, err := New(128, 128)
50+ if err != nil {
51+ t.Skipf("Vulkan unavailable: %v", err)
52+ }
53+ defer r.Close()
54+ s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}}
55+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
56+ require.Len(t, ds, 1)
57+ require.LessOrEqual(t, ds[0].pc.rect[2], int32(128))
58+ require.LessOrEqual(t, ds[0].pc.rect[3], int32(128))
59+}
60+
61+func TestHintLabelsRenderAndUploadOnce(t *testing.T) {
62+ r, err := New(128, 128)
63+ if err != nil {
64+ t.Skipf("Vulkan unavailable: %v", err)
65+ }
66+ defer r.Close()
67+ s := ports.Scene{Scale: 1, Seq: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 4, Y: 4}, Text: "fj"}}}
68+ require.NoError(t, render(r, s, nil))
69+ first := r.copied
70+ require.Positive(t, first)
71+ s.Seq = 2
72+ require.NoError(t, render(r, s, nil))
73+ require.Equal(t, first, r.copied, "an unchanged label is not uploaded again")
74+}
75+
76+func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) {
77+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
78+ base := ports.Scene{Scale: 1, OutputWidth: 100, OutputHeight: 100}
79+ with := base
80+ with.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f"}}
81+ _, ok := sceneDelta(base, with, phys, 1<<30)
82+ require.False(t, ok, "labels appearing redraw the output")
83+ typed := with
84+ typed.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f", Typed: 1}}
85+ _, ok = sceneDelta(with, typed, phys, 1<<30)
86+ require.False(t, ok)
87+ _, ok = sceneDelta(with, with, phys, 1<<30)
88+ require.True(t, ok, "same labels, no damage")
89+}
90+
91+func TestHintLabelsHeldByTarget(t *testing.T) {
92+ s := ports.Scene{Seq: 1, HintLabels: []ports.HintLabel{{Text: "f"}}}
93+ tg := &target{}
94+ tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
95+ s.HintLabels[0].Text = "j"
96+ require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels")
97+}
@@ -0,0 +1,97 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestHintLabelsDrawnAfterIndicators(t *testing.T) {
13+ r, err := New(256, 256)
14+ if err != nil {
15+ t.Skipf("Vulkan unavailable: %v", err)
16+ }
17+ defer r.Close()
18+ for _, scale := range []int{1, 2} {
19+ s := ports.Scene{Scale: float64(scale),
20+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}},
21+ HintLabels: []ports.HintLabel{
22+ {Rect: ports.Rect{X: 10, Y: 12}, Text: "f"},
23+ {Rect: ports.Rect{X: 30, Y: 40}, Text: "jk", Typed: 1},
24+ }}
25+ bare := s
26+ bare.HintLabels = nil
27+ before := len(r.draws(bare, nil, newDamage(&target{}, bare, image.Rect(0, 0, 256, 256))))
28+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 256, 256)))
29+ require.Len(t, ds, before+2, "one draw per label, after the indicator's")
30+ for i, want := range []image.Point{{10 * scale, 12 * scale}, {30 * scale, 40 * scale}} {
31+ d := ds[before+i]
32+ require.NotNil(t, d.shm)
33+ require.Equal(t, int32(want.X), d.pc.rect[0])
34+ require.Equal(t, int32(want.Y), d.pc.rect[1])
35+ require.Greater(t, d.pc.rect[2], d.pc.rect[0])
36+ require.Greater(t, d.pc.rect[3], d.pc.rect[1])
37+ require.Equal(t, float32(1), d.pc.color[3])
38+ }
39+ }
40+}
41+
42+func TestHintLabelsAbsentDrawNothing(t *testing.T) {
43+ r := &Renderer{width: 64, height: 64}
44+ s := ports.Scene{Scale: 1}
45+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
46+}
47+
48+func TestHintLabelsClampedInsideOutput(t *testing.T) {
49+ r, err := New(128, 128)
50+ if err != nil {
51+ t.Skipf("Vulkan unavailable: %v", err)
52+ }
53+ defer r.Close()
54+ s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}}
55+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
56+ require.Len(t, ds, 1)
57+ require.LessOrEqual(t, ds[0].pc.rect[2], int32(128))
58+ require.LessOrEqual(t, ds[0].pc.rect[3], int32(128))
59+}
60+
61+func TestHintLabelsRenderAndUploadOnce(t *testing.T) {
62+ r, err := New(128, 128)
63+ if err != nil {
64+ t.Skipf("Vulkan unavailable: %v", err)
65+ }
66+ defer r.Close()
67+ s := ports.Scene{Scale: 1, Seq: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 4, Y: 4}, Text: "fj"}}}
68+ require.NoError(t, render(r, s, nil))
69+ first := r.copied
70+ require.Positive(t, first)
71+ s.Seq = 2
72+ require.NoError(t, render(r, s, nil))
73+ require.Equal(t, first, r.copied, "an unchanged label is not uploaded again")
74+}
75+
76+func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) {
77+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
78+ base := ports.Scene{Scale: 1, OutputWidth: 100, OutputHeight: 100}
79+ with := base
80+ with.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f"}}
81+ _, ok := sceneDelta(base, with, phys, 1<<30)
82+ require.False(t, ok, "labels appearing redraw the output")
83+ typed := with
84+ typed.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f", Typed: 1}}
85+ _, ok = sceneDelta(with, typed, phys, 1<<30)
86+ require.False(t, ok)
87+ _, ok = sceneDelta(with, with, phys, 1<<30)
88+ require.True(t, ok, "same labels, no damage")
89+}
90+
91+func TestHintLabelsHeldByTarget(t *testing.T) {
92+ s := ports.Scene{Seq: 1, HintLabels: []ports.HintLabel{{Text: "f"}}}
93+ tg := &target{}
94+ tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
95+ s.HintLabels[0].Text = "j"
96+ require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels")
97+}
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 +6 -0
@@ -154,6 +154,12 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) {
154154 type shmKey struct {
155155 win ports.WindowID
156156 surface uint64
157+ // label, typed and scale name a compositor-drawn hint label (hint_labels.go)
158+ // instead of a client surface: win and surface are 0 and label is never
159+ // empty, so such a key cannot collide with a client's.
160+ label string
161+ typed int
162+ scale float64
157163 }
158164
159165 // shmState is what a GPU buffer holds: a w×h client buffer at a content
@@ -154,6 +154,12 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) {
154 type shmKey struct {154 type shmKey struct {
155 win ports.WindowID155 win ports.WindowID
156 surface uint64156 surface uint64
157+ // label, typed and scale name a compositor-drawn hint label (hint_labels.go)
158+ // instead of a client surface: win and surface are 0 and label is never
159+ // empty, so such a key cannot collide with a client's.
160+ label string
161+ typed int
162+ scale float64
157 }163 }
158 164
159 // shmState is what a GPU buffer holds: a w×h client buffer at a content165 // 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