feat(vulkan): draw hint labels
- commit
- 9ad305426fce03a8cce48be32ba7ce23c688a669
- author
- brice · · 2026-10-19 09:58 UTC
- parent
- a329128
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +365 -7
M Makefile +1 -1
| @@ -61,7 +61,7 @@ arch: adapter-imports | |||
| 61 | 61 | # Adapters stay independent: production code may import only the shared | |
| 62 | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | |
| 63 | 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|xkb | |
| 64 | + | ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb | |
| 65 | 65 | adapter-imports: | |
| 66 | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | |
| 67 | 67 | 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 shared | 61 | # 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|xkb | 64 | + | 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 ( | |||
| 15 | 15 | github.com/godbus/dbus/v5 v5.2.2 | |
| 16 | 16 | github.com/rs/zerolog v1.35.1 | |
| 17 | 17 | github.com/stretchr/testify v1.12.1 | |
| 18 | + | golang.org/x/image v0.46.0 | |
| 18 | 19 | golang.org/x/sys v0.48.0 | |
| 19 | 20 | golang.org/x/term v0.46.0 | |
| 20 | 21 | ) | |
| @@ -24,5 +25,6 @@ require ( | |||
| 24 | 25 | github.com/mattn/go-isatty v0.0.22 // indirect | |
| 25 | 26 | github.com/stretchr/objx v0.5.3 // indirect | |
| 26 | 27 | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 28 | + | golang.org/x/text v0.42.0 // indirect | |
| 27 | 29 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| 28 | 30 | ) | |
| @@ -15,6 +15,7 @@ require ( | |||||
| 15 | github.com/godbus/dbus/v5 v5.2.2 | 15 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 16 | github.com/rs/zerolog v1.35.1 | 16 | github.com/rs/zerolog v1.35.1 | ||
| 17 | github.com/stretchr/testify v1.12.1 | 17 | github.com/stretchr/testify v1.12.1 | ||
| 18 | + | golang.org/x/image v0.46.0 | |||
| 18 | golang.org/x/sys v0.48.0 | 19 | golang.org/x/sys v0.48.0 | ||
| 19 | golang.org/x/term v0.46.0 | 20 | golang.org/x/term v0.46.0 | ||
| 20 | ) | 21 | ) | ||
| @@ -24,5 +25,6 @@ require ( | |||||
| 24 | github.com/mattn/go-isatty v0.0.22 // indirect | 25 | github.com/mattn/go-isatty v0.0.22 // indirect | ||
| 25 | github.com/stretchr/objx v0.5.3 // indirect | 26 | github.com/stretchr/objx v0.5.3 // indirect | ||
| 26 | go.yaml.in/yaml/v3 v3.0.5 // indirect | 27 | 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 // indirect | 29 | 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 | |||
| 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" | ||
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) { | |||
| 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 | |
| @@ -275,7 +277,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||
| 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 | ||
| @@ -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{}, 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 | + | // 24 with Animating, which sceneDelta intentionally ignores: it only | |
| 21 | 21 | // schedules the next frame, it draws nothing. | |
| 22 | - | const fields = 23 | |
| 22 | + | const fields = 24 | |
| 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 | + | // 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 = 23 | 22 | + | 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 ( | |||
| 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 +6 -0
| @@ -154,6 +154,12 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) { | |||
| 154 | 154 | type shmKey struct { | |
| 155 | 155 | win ports.WindowID | |
| 156 | 156 | 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 | 165 | // 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.WindowID | 155 | win ports.WindowID | ||
| 156 | surface uint64 | 156 | 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 content | 165 | // 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 | ||||