feat(hintlabel): rasterize hint labels sized and coloured for the output
- commit
- 4b849d0f951d39cd7293881af013f8b1b669f37f
- author
- brice · · 2026-10-19 16:03 UTC
- committer
- brice · · 2026-10-19 17:36 UTC
- parent
- 40c7803
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +385 -0
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
A internal/adapters/hintlabel/hintlabel.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 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 | + | parseOnce sync.Once | |
| 30 | + | parsed *opentype.Font | |
| 31 | + | parseErr error | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | type key struct { | |
| 35 | + | text string | |
| 36 | + | typed int | |
| 37 | + | scale float64 | |
| 38 | + | style Style | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type entry struct { | |
| 42 | + | pix []byte | |
| 43 | + | w, h int | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Raster draws and caches labels. It is not safe for concurrent use: the | |
| 47 | + | // renderer owns it. | |
| 48 | + | type Raster struct { | |
| 49 | + | faces map[float64]font.Face | |
| 50 | + | cache map[key]entry | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Label returns the label box for text, whose first typed characters are | |
| 54 | + | // drawn dimmed, as premultiplied BGRA at scale physical pixels per logical | |
| 55 | + | // pixel. The slice is owned by the cache and stays valid until the cache is | |
| 56 | + | // cleared by a later call: do not modify it. Empty text, or a font that does | |
| 57 | + | // not load, yields a nil slice. | |
| 58 | + | func (r *Raster) Label(text string, typed int, scale float64, styles ...Style) (pix []byte, w, h int) { | |
| 59 | + | if text == "" { | |
| 60 | + | return nil, 0, 0 | |
| 61 | + | } | |
| 62 | + | if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { | |
| 63 | + | scale = 1 | |
| 64 | + | } | |
| 65 | + | typed = max(0, min(typed, len(text))) | |
| 66 | + | bg, fg := Colors("", "", "") | |
| 67 | + | style := Style{fontSize * scale, bg, fg} | |
| 68 | + | if len(styles) > 0 { | |
| 69 | + | style = styles[0] | |
| 70 | + | } | |
| 71 | + | k := key{text, typed, scale, style} | |
| 72 | + | if e, ok := r.cache[k]; ok { | |
| 73 | + | return e.pix, e.w, e.h | |
| 74 | + | } | |
| 75 | + | face := r.face(style.FontPx) | |
| 76 | + | if face == nil { | |
| 77 | + | return nil, 0, 0 | |
| 78 | + | } | |
| 79 | + | if r.cache == nil || len(r.cache) >= maxEntries { | |
| 80 | + | r.cache = map[key]entry{} | |
| 81 | + | } | |
| 82 | + | e := render(face, text, typed, style) | |
| 83 | + | r.cache[k] = e | |
| 84 | + | return e.pix, e.w, e.h | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (r *Raster) face(scale float64) font.Face { | |
| 88 | + | if f, ok := r.faces[scale]; ok { | |
| 89 | + | return f | |
| 90 | + | } | |
| 91 | + | parseOnce.Do(func() { parsed, parseErr = opentype.Parse(gomono.TTF) }) | |
| 92 | + | if parseErr != nil { | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: scale, DPI: 72, Hinting: font.HintingFull}) | |
| 96 | + | if err != nil { | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | if r.faces == nil || len(r.faces) >= 16 { | |
| 100 | + | for _, old := range r.faces { | |
| 101 | + | _ = old.Close() | |
| 102 | + | } | |
| 103 | + | r.faces = map[float64]font.Face{} | |
| 104 | + | } | |
| 105 | + | r.faces[scale] = f | |
| 106 | + | return f | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // render draws an opaque box inside a one-physical-pixel translucent outline. | |
| 110 | + | // All colours are premultiplied. | |
| 111 | + | func render(face font.Face, text string, typed int, style Style) entry { | |
| 112 | + | scale := style.FontPx / fontSize | |
| 113 | + | background, ink := style.Background, style.Foreground | |
| 114 | + | typedInk := mix(ink, background) | |
| 115 | + | outline := color.RGBA{uint8(math.Round(float64(ink.R) * .35)), uint8(math.Round(float64(ink.G) * .35)), uint8(math.Round(float64(ink.B) * .35)), 89} | |
| 116 | + | m := face.Metrics() | |
| 117 | + | px := int(math.Round(padX * scale)) | |
| 118 | + | py := int(math.Round(padY * scale)) | |
| 119 | + | border := 1 | |
| 120 | + | adv := font.MeasureString(face, text).Ceil() | |
| 121 | + | w := adv + 2*px + 2*border | |
| 122 | + | h := m.Height.Ceil() + 2*py + 2*border | |
| 123 | + | img := image.NewRGBA(image.Rect(0, 0, w, h)) | |
| 124 | + | draw.Draw(img, img.Bounds(), image.NewUniform(outline), image.Point{}, draw.Src) | |
| 125 | + | draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src) | |
| 126 | + | d := font.Drawer{Dst: img, Face: face, Dot: fixed.Point26_6{X: fixed.I(border + px), Y: fixed.I(border+py) + m.Ascent}} | |
| 127 | + | d.Src = image.NewUniform(typedInk) | |
| 128 | + | d.DrawString(text[:typed]) | |
| 129 | + | d.Src = image.NewUniform(ink) | |
| 130 | + | d.DrawString(text[typed:]) | |
| 131 | + | // Premultiplied RGBA to BGRA in place. | |
| 132 | + | pix := img.Pix | |
| 133 | + | for i := 0; i+3 < len(pix); i += 4 { | |
| 134 | + | pix[i], pix[i+2] = pix[i+2], pix[i] | |
| 135 | + | } | |
| 136 | + | return entry{pix: pix, w: w, h: h} | |
| 137 | + | } | |
| @@ -0,0 +1,137 @@ | |||||
| 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 | + | parseOnce sync.Once | |||
| 30 | + | parsed *opentype.Font | |||
| 31 | + | parseErr error | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | type key struct { | |||
| 35 | + | text string | |||
| 36 | + | typed int | |||
| 37 | + | scale float64 | |||
| 38 | + | style Style | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type entry struct { | |||
| 42 | + | pix []byte | |||
| 43 | + | w, h int | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Raster draws and caches labels. It is not safe for concurrent use: the | |||
| 47 | + | // renderer owns it. | |||
| 48 | + | type Raster struct { | |||
| 49 | + | faces map[float64]font.Face | |||
| 50 | + | cache map[key]entry | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Label returns the label box for text, whose first typed characters are | |||
| 54 | + | // drawn dimmed, as premultiplied BGRA at scale physical pixels per logical | |||
| 55 | + | // pixel. The slice is owned by the cache and stays valid until the cache is | |||
| 56 | + | // cleared by a later call: do not modify it. Empty text, or a font that does | |||
| 57 | + | // not load, yields a nil slice. | |||
| 58 | + | func (r *Raster) Label(text string, typed int, scale float64, styles ...Style) (pix []byte, w, h int) { | |||
| 59 | + | if text == "" { | |||
| 60 | + | return nil, 0, 0 | |||
| 61 | + | } | |||
| 62 | + | if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { | |||
| 63 | + | scale = 1 | |||
| 64 | + | } | |||
| 65 | + | typed = max(0, min(typed, len(text))) | |||
| 66 | + | bg, fg := Colors("", "", "") | |||
| 67 | + | style := Style{fontSize * scale, bg, fg} | |||
| 68 | + | if len(styles) > 0 { | |||
| 69 | + | style = styles[0] | |||
| 70 | + | } | |||
| 71 | + | k := key{text, typed, scale, style} | |||
| 72 | + | if e, ok := r.cache[k]; ok { | |||
| 73 | + | return e.pix, e.w, e.h | |||
| 74 | + | } | |||
| 75 | + | face := r.face(style.FontPx) | |||
| 76 | + | if face == nil { | |||
| 77 | + | return nil, 0, 0 | |||
| 78 | + | } | |||
| 79 | + | if r.cache == nil || len(r.cache) >= maxEntries { | |||
| 80 | + | r.cache = map[key]entry{} | |||
| 81 | + | } | |||
| 82 | + | e := render(face, text, typed, style) | |||
| 83 | + | r.cache[k] = e | |||
| 84 | + | return e.pix, e.w, e.h | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (r *Raster) face(scale float64) font.Face { | |||
| 88 | + | if f, ok := r.faces[scale]; ok { | |||
| 89 | + | return f | |||
| 90 | + | } | |||
| 91 | + | parseOnce.Do(func() { parsed, parseErr = opentype.Parse(gomono.TTF) }) | |||
| 92 | + | if parseErr != nil { | |||
| 93 | + | return nil | |||
| 94 | + | } | |||
| 95 | + | f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: scale, DPI: 72, Hinting: font.HintingFull}) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return nil | |||
| 98 | + | } | |||
| 99 | + | if r.faces == nil || len(r.faces) >= 16 { | |||
| 100 | + | for _, old := range r.faces { | |||
| 101 | + | _ = old.Close() | |||
| 102 | + | } | |||
| 103 | + | r.faces = map[float64]font.Face{} | |||
| 104 | + | } | |||
| 105 | + | r.faces[scale] = f | |||
| 106 | + | return f | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // render draws an opaque box inside a one-physical-pixel translucent outline. | |||
| 110 | + | // All colours are premultiplied. | |||
| 111 | + | func render(face font.Face, text string, typed int, style Style) entry { | |||
| 112 | + | scale := style.FontPx / fontSize | |||
| 113 | + | background, ink := style.Background, style.Foreground | |||
| 114 | + | typedInk := mix(ink, background) | |||
| 115 | + | outline := color.RGBA{uint8(math.Round(float64(ink.R) * .35)), uint8(math.Round(float64(ink.G) * .35)), uint8(math.Round(float64(ink.B) * .35)), 89} | |||
| 116 | + | m := face.Metrics() | |||
| 117 | + | px := int(math.Round(padX * scale)) | |||
| 118 | + | py := int(math.Round(padY * scale)) | |||
| 119 | + | border := 1 | |||
| 120 | + | adv := font.MeasureString(face, text).Ceil() | |||
| 121 | + | w := adv + 2*px + 2*border | |||
| 122 | + | h := m.Height.Ceil() + 2*py + 2*border | |||
| 123 | + | img := image.NewRGBA(image.Rect(0, 0, w, h)) | |||
| 124 | + | draw.Draw(img, img.Bounds(), image.NewUniform(outline), image.Point{}, draw.Src) | |||
| 125 | + | draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src) | |||
| 126 | + | d := font.Drawer{Dst: img, Face: face, Dot: fixed.Point26_6{X: fixed.I(border + px), Y: fixed.I(border+py) + m.Ascent}} | |||
| 127 | + | d.Src = image.NewUniform(typedInk) | |||
| 128 | + | d.DrawString(text[:typed]) | |||
| 129 | + | d.Src = image.NewUniform(ink) | |||
| 130 | + | d.DrawString(text[typed:]) | |||
| 131 | + | // Premultiplied RGBA to BGRA in place. | |||
| 132 | + | pix := img.Pix | |||
| 133 | + | for i := 0; i+3 < len(pix); i += 4 { | |||
| 134 | + | pix[i], pix[i+2] = pix[i+2], pix[i] | |||
| 135 | + | } | |||
| 136 | + | return entry{pix: pix, w: w, h: h} | |||
| 137 | + | } | |||
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 | + | } | |||
A internal/adapters/hintlabel/style.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | package hintlabel | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image/color" | |
| 5 | + | "math" | |
| 6 | + | "strconv" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Style is the resolved, physical-pixel label style and is also a cache key. | |
| 10 | + | type Style struct { | |
| 11 | + | FontPx float64 | |
| 12 | + | Background, Foreground color.RGBA | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // FontPixels resolves an explicit logical size or the output's pixel density. | |
| 16 | + | // Both millimetre dimensions must be plausible for the untransformed mode. | |
| 17 | + | func FontPixels(width, height, mmWidth, mmHeight int, scale, logical float64) float64 { | |
| 18 | + | return min(maxFontPx, fontPixels(width, height, mmWidth, mmHeight, scale, logical)) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // maxFontPx bounds the physical font size, whatever the configuration or the | |
| 22 | + | // scale ask for. | |
| 23 | + | const maxFontPx = 256 | |
| 24 | + | ||
| 25 | + | func fontPixels(width, height, mmWidth, mmHeight int, scale, logical float64) float64 { | |
| 26 | + | if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { | |
| 27 | + | scale = 1 | |
| 28 | + | } | |
| 29 | + | if logical > 0 && !math.IsInf(logical, 0) { | |
| 30 | + | return logical * scale | |
| 31 | + | } | |
| 32 | + | if width > 0 && height > 0 && mmWidth > 0 && mmHeight > 0 { | |
| 33 | + | ratio := (float64(width) / float64(height)) / (float64(mmWidth) / float64(mmHeight)) | |
| 34 | + | if math.Abs(ratio-1) <= .2 { | |
| 35 | + | return min(math.Round(40*scale), max(math.Round(13*scale), math.Round(3.2*float64(width)/float64(mmWidth)))) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return 13 * scale * math.Sqrt(max(1, float64(height)/(1080*scale))) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func parseColor(s string, fallback color.RGBA) color.RGBA { | |
| 42 | + | if len(s) != 7 || s[0] != '#' { | |
| 43 | + | return fallback | |
| 44 | + | } | |
| 45 | + | n, err := strconv.ParseUint(s[1:], 16, 24) | |
| 46 | + | if err != nil { | |
| 47 | + | return fallback | |
| 48 | + | } | |
| 49 | + | return color.RGBA{uint8(n >> 16), uint8(n >> 8), uint8(n), 255} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func luminance(c color.RGBA) float64 { | |
| 53 | + | linear := func(v uint8) float64 { | |
| 54 | + | x := float64(v) / 255 | |
| 55 | + | if x <= .04045 { | |
| 56 | + | return x / 12.92 | |
| 57 | + | } | |
| 58 | + | return math.Pow((x+.055)/1.055, 2.4) | |
| 59 | + | } | |
| 60 | + | return .2126*linear(c.R) + .7152*linear(c.G) + .0722*linear(c.B) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func contrast(a, b color.RGBA) float64 { | |
| 64 | + | x, y := luminance(a), luminance(b) | |
| 65 | + | return (max(x, y) + .05) / (min(x, y) + .05) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Colors derives ink from the background. Near-black and near-white have a | |
| 69 | + | // narrow luminance gap where neither meets AA; in that gap move HSL | |
| 70 | + | // lightness away from .5, preserving hue and saturation. | |
| 71 | + | // Explicit foreground overrides are honoured as supplied. | |
| 72 | + | func Colors(active, background, foreground string) (color.RGBA, color.RGBA) { | |
| 73 | + | bg := parseColor(background, parseColor(active, color.RGBA{128, 128, 128, 255})) | |
| 74 | + | dark, light := color.RGBA{17, 17, 17, 255}, color.RGBA{245, 245, 245, 255} | |
| 75 | + | ink := dark | |
| 76 | + | if contrast(bg, light) > contrast(bg, dark) { | |
| 77 | + | ink = light | |
| 78 | + | } | |
| 79 | + | for contrast(bg, ink) < 4.5 { | |
| 80 | + | // Move HSL lightness toward zero while preserving hue and saturation. | |
| 81 | + | hi, lo := max(bg.R, bg.G, bg.B), min(bg.R, bg.G, bg.B) | |
| 82 | + | l := (float64(hi) + float64(lo)) / 510 | |
| 83 | + | if l > .5 { | |
| 84 | + | // Above .5, moving toward white increases contrast with dark ink. | |
| 85 | + | bg.R += uint8(math.Ceil(float64(255-bg.R) * .01)) | |
| 86 | + | bg.G += uint8(math.Ceil(float64(255-bg.G) * .01)) | |
| 87 | + | bg.B += uint8(math.Ceil(float64(255-bg.B) * .01)) | |
| 88 | + | ink = dark | |
| 89 | + | } else { | |
| 90 | + | bg.R = uint8(float64(bg.R) * .99) | |
| 91 | + | bg.G = uint8(float64(bg.G) * .99) | |
| 92 | + | bg.B = uint8(float64(bg.B) * .99) | |
| 93 | + | ink = light | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return bg, parseColor(foreground, ink) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func mix(ink, bg color.RGBA) color.RGBA { | |
| 100 | + | channel := func(a, b uint8) uint8 { return uint8(math.Round(.45*float64(a) + .55*float64(b))) } | |
| 101 | + | return color.RGBA{channel(ink.R, bg.R), channel(ink.G, bg.G), channel(ink.B, bg.B), 255} | |
| 102 | + | } | |
| @@ -0,0 +1,102 @@ | |||||
| 1 | + | package hintlabel | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image/color" | |||
| 5 | + | "math" | |||
| 6 | + | "strconv" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Style is the resolved, physical-pixel label style and is also a cache key. | |||
| 10 | + | type Style struct { | |||
| 11 | + | FontPx float64 | |||
| 12 | + | Background, Foreground color.RGBA | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // FontPixels resolves an explicit logical size or the output's pixel density. | |||
| 16 | + | // Both millimetre dimensions must be plausible for the untransformed mode. | |||
| 17 | + | func FontPixels(width, height, mmWidth, mmHeight int, scale, logical float64) float64 { | |||
| 18 | + | return min(maxFontPx, fontPixels(width, height, mmWidth, mmHeight, scale, logical)) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // maxFontPx bounds the physical font size, whatever the configuration or the | |||
| 22 | + | // scale ask for. | |||
| 23 | + | const maxFontPx = 256 | |||
| 24 | + | ||||
| 25 | + | func fontPixels(width, height, mmWidth, mmHeight int, scale, logical float64) float64 { | |||
| 26 | + | if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { | |||
| 27 | + | scale = 1 | |||
| 28 | + | } | |||
| 29 | + | if logical > 0 && !math.IsInf(logical, 0) { | |||
| 30 | + | return logical * scale | |||
| 31 | + | } | |||
| 32 | + | if width > 0 && height > 0 && mmWidth > 0 && mmHeight > 0 { | |||
| 33 | + | ratio := (float64(width) / float64(height)) / (float64(mmWidth) / float64(mmHeight)) | |||
| 34 | + | if math.Abs(ratio-1) <= .2 { | |||
| 35 | + | return min(math.Round(40*scale), max(math.Round(13*scale), math.Round(3.2*float64(width)/float64(mmWidth)))) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | return 13 * scale * math.Sqrt(max(1, float64(height)/(1080*scale))) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func parseColor(s string, fallback color.RGBA) color.RGBA { | |||
| 42 | + | if len(s) != 7 || s[0] != '#' { | |||
| 43 | + | return fallback | |||
| 44 | + | } | |||
| 45 | + | n, err := strconv.ParseUint(s[1:], 16, 24) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return fallback | |||
| 48 | + | } | |||
| 49 | + | return color.RGBA{uint8(n >> 16), uint8(n >> 8), uint8(n), 255} | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func luminance(c color.RGBA) float64 { | |||
| 53 | + | linear := func(v uint8) float64 { | |||
| 54 | + | x := float64(v) / 255 | |||
| 55 | + | if x <= .04045 { | |||
| 56 | + | return x / 12.92 | |||
| 57 | + | } | |||
| 58 | + | return math.Pow((x+.055)/1.055, 2.4) | |||
| 59 | + | } | |||
| 60 | + | return .2126*linear(c.R) + .7152*linear(c.G) + .0722*linear(c.B) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func contrast(a, b color.RGBA) float64 { | |||
| 64 | + | x, y := luminance(a), luminance(b) | |||
| 65 | + | return (max(x, y) + .05) / (min(x, y) + .05) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Colors derives ink from the background. Near-black and near-white have a | |||
| 69 | + | // narrow luminance gap where neither meets AA; in that gap move HSL | |||
| 70 | + | // lightness away from .5, preserving hue and saturation. | |||
| 71 | + | // Explicit foreground overrides are honoured as supplied. | |||
| 72 | + | func Colors(active, background, foreground string) (color.RGBA, color.RGBA) { | |||
| 73 | + | bg := parseColor(background, parseColor(active, color.RGBA{128, 128, 128, 255})) | |||
| 74 | + | dark, light := color.RGBA{17, 17, 17, 255}, color.RGBA{245, 245, 245, 255} | |||
| 75 | + | ink := dark | |||
| 76 | + | if contrast(bg, light) > contrast(bg, dark) { | |||
| 77 | + | ink = light | |||
| 78 | + | } | |||
| 79 | + | for contrast(bg, ink) < 4.5 { | |||
| 80 | + | // Move HSL lightness toward zero while preserving hue and saturation. | |||
| 81 | + | hi, lo := max(bg.R, bg.G, bg.B), min(bg.R, bg.G, bg.B) | |||
| 82 | + | l := (float64(hi) + float64(lo)) / 510 | |||
| 83 | + | if l > .5 { | |||
| 84 | + | // Above .5, moving toward white increases contrast with dark ink. | |||
| 85 | + | bg.R += uint8(math.Ceil(float64(255-bg.R) * .01)) | |||
| 86 | + | bg.G += uint8(math.Ceil(float64(255-bg.G) * .01)) | |||
| 87 | + | bg.B += uint8(math.Ceil(float64(255-bg.B) * .01)) | |||
| 88 | + | ink = dark | |||
| 89 | + | } else { | |||
| 90 | + | bg.R = uint8(float64(bg.R) * .99) | |||
| 91 | + | bg.G = uint8(float64(bg.G) * .99) | |||
| 92 | + | bg.B = uint8(float64(bg.B) * .99) | |||
| 93 | + | ink = light | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return bg, parseColor(foreground, ink) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func mix(ink, bg color.RGBA) color.RGBA { | |||
| 100 | + | channel := func(a, b uint8) uint8 { return uint8(math.Round(.45*float64(a) + .55*float64(b))) } | |||
| 101 | + | return color.RGBA{channel(ink.R, bg.R), channel(ink.G, bg.G), channel(ink.B, bg.B), 255} | |||
| 102 | + | } | |||
A internal/adapters/hintlabel/style_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package hintlabel | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image/color" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDensitySize(t *testing.T) { | |
| 11 | + | for _, tt := range []struct { | |
| 12 | + | name string | |
| 13 | + | w, h, mw, mh int | |
| 14 | + | scale, logical, want float64 | |
| 15 | + | }{ | |
| 16 | + | {"ultrawide", 5120, 2160, 990, 420, 1, 0, 17}, | |
| 17 | + | {"4k", 3840, 2160, 600, 340, 1.2, 0, 20}, | |
| 18 | + | {"unknown", 1920, 1080, 0, 0, 1, 0, 13}, | |
| 19 | + | {"bogus aspect", 1920, 1080, 100, 100, 1, 0, 13}, | |
| 20 | + | {"fallback high", 5120, 2160, 0, 0, 1, 0, 18.38477631085024}, | |
| 21 | + | {"minimum", 1920, 1080, 1000, 563, 1.2, 0, 16}, | |
| 22 | + | {"maximum", 5120, 2160, 100, 42, 1, 0, 40}, | |
| 23 | + | {"override", 5120, 2160, 990, 420, 1.2, 24, 28.8}, | |
| 24 | + | } { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | require.InDelta(t, tt.want, FontPixels(tt.w, tt.h, tt.mw, tt.mh, tt.scale, tt.logical), .00001) | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestDerivedContrast(t *testing.T) { | |
| 32 | + | for _, s := range []string{"#f9e2af", "#89b4fa", "#808080", "#000000", "#ffffff", "#1e1e2e", "#747474", "#757575", "#ff0000", "#00ff00", "#0000ff"} { | |
| 33 | + | t.Run(s, func(t *testing.T) { | |
| 34 | + | bg, fg := Colors(s, "", "") | |
| 35 | + | require.GreaterOrEqual(t, contrast(bg, fg), 4.5) | |
| 36 | + | require.Contains(t, []color.RGBA{{17, 17, 17, 255}, {245, 245, 245, 255}}, fg) | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | // Sweep the gap between near-black and near-white contrast thresholds. | |
| 40 | + | for i := 0; i < 256; i++ { | |
| 41 | + | c := color.RGBA{uint8(i), uint8(i), uint8(i), 255} | |
| 42 | + | s := "#" + hexByte(c.R) + hexByte(c.G) + hexByte(c.B) | |
| 43 | + | bg, fg := Colors(s, "", "") | |
| 44 | + | require.GreaterOrEqual(t, contrast(bg, fg), 4.5, s) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func hexByte(v uint8) string { | |
| 49 | + | const digits = "0123456789abcdef" | |
| 50 | + | return string([]byte{digits[v>>4], digits[v&15]}) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestOverridesAndTyped(t *testing.T) { | |
| 54 | + | bg, fg := Colors("#ffffff", "#000000", "#89b4fa") | |
| 55 | + | require.Equal(t, color.RGBA{0, 0, 0, 255}, bg) | |
| 56 | + | require.Equal(t, color.RGBA{137, 180, 250, 255}, fg) | |
| 57 | + | require.Equal(t, color.RGBA{62, 81, 113, 255}, mix(fg, bg)) | |
| 58 | + | fallback, _ := Colors("", "", "") | |
| 59 | + | require.Equal(t, color.RGBA{128, 128, 128, 255}, fallback) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestStyledRasterSizeAndCache(t *testing.T) { | |
| 63 | + | var r Raster | |
| 64 | + | bg, fg := Colors("#f9e2af", "", "") | |
| 65 | + | style := Style{13, bg, fg} | |
| 66 | + | a, _, small := r.Label("fj", 0, 1, style) | |
| 67 | + | style.FontPx = 26 | |
| 68 | + | b, _, large := r.Label("fj", 0, 1, style) | |
| 69 | + | require.Greater(t, large, small) | |
| 70 | + | require.NotEqual(t, a, b) | |
| 71 | + | style.Background = color.RGBA{0, 0, 0, 255} | |
| 72 | + | c, _, _ := r.Label("fj", 0, 1, style) | |
| 73 | + | require.NotEqual(t, b, c) | |
| 74 | + | require.Equal(t, byte(89), c[3], "outline has 35 percent physical-pixel alpha") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestFontPixelsClamped(t *testing.T) { | |
| 78 | + | require.InDelta(t, 256, FontPixels(1920, 1080, 0, 0, 3, 1e9), .00001) | |
| 79 | + | require.InDelta(t, 256, FontPixels(1920, 1080, 0, 0, 4, 72), .00001) | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package hintlabel | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image/color" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestDensitySize(t *testing.T) { | |||
| 11 | + | for _, tt := range []struct { | |||
| 12 | + | name string | |||
| 13 | + | w, h, mw, mh int | |||
| 14 | + | scale, logical, want float64 | |||
| 15 | + | }{ | |||
| 16 | + | {"ultrawide", 5120, 2160, 990, 420, 1, 0, 17}, | |||
| 17 | + | {"4k", 3840, 2160, 600, 340, 1.2, 0, 20}, | |||
| 18 | + | {"unknown", 1920, 1080, 0, 0, 1, 0, 13}, | |||
| 19 | + | {"bogus aspect", 1920, 1080, 100, 100, 1, 0, 13}, | |||
| 20 | + | {"fallback high", 5120, 2160, 0, 0, 1, 0, 18.38477631085024}, | |||
| 21 | + | {"minimum", 1920, 1080, 1000, 563, 1.2, 0, 16}, | |||
| 22 | + | {"maximum", 5120, 2160, 100, 42, 1, 0, 40}, | |||
| 23 | + | {"override", 5120, 2160, 990, 420, 1.2, 24, 28.8}, | |||
| 24 | + | } { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | require.InDelta(t, tt.want, FontPixels(tt.w, tt.h, tt.mw, tt.mh, tt.scale, tt.logical), .00001) | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestDerivedContrast(t *testing.T) { | |||
| 32 | + | for _, s := range []string{"#f9e2af", "#89b4fa", "#808080", "#000000", "#ffffff", "#1e1e2e", "#747474", "#757575", "#ff0000", "#00ff00", "#0000ff"} { | |||
| 33 | + | t.Run(s, func(t *testing.T) { | |||
| 34 | + | bg, fg := Colors(s, "", "") | |||
| 35 | + | require.GreaterOrEqual(t, contrast(bg, fg), 4.5) | |||
| 36 | + | require.Contains(t, []color.RGBA{{17, 17, 17, 255}, {245, 245, 245, 255}}, fg) | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | // Sweep the gap between near-black and near-white contrast thresholds. | |||
| 40 | + | for i := 0; i < 256; i++ { | |||
| 41 | + | c := color.RGBA{uint8(i), uint8(i), uint8(i), 255} | |||
| 42 | + | s := "#" + hexByte(c.R) + hexByte(c.G) + hexByte(c.B) | |||
| 43 | + | bg, fg := Colors(s, "", "") | |||
| 44 | + | require.GreaterOrEqual(t, contrast(bg, fg), 4.5, s) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func hexByte(v uint8) string { | |||
| 49 | + | const digits = "0123456789abcdef" | |||
| 50 | + | return string([]byte{digits[v>>4], digits[v&15]}) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestOverridesAndTyped(t *testing.T) { | |||
| 54 | + | bg, fg := Colors("#ffffff", "#000000", "#89b4fa") | |||
| 55 | + | require.Equal(t, color.RGBA{0, 0, 0, 255}, bg) | |||
| 56 | + | require.Equal(t, color.RGBA{137, 180, 250, 255}, fg) | |||
| 57 | + | require.Equal(t, color.RGBA{62, 81, 113, 255}, mix(fg, bg)) | |||
| 58 | + | fallback, _ := Colors("", "", "") | |||
| 59 | + | require.Equal(t, color.RGBA{128, 128, 128, 255}, fallback) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestStyledRasterSizeAndCache(t *testing.T) { | |||
| 63 | + | var r Raster | |||
| 64 | + | bg, fg := Colors("#f9e2af", "", "") | |||
| 65 | + | style := Style{13, bg, fg} | |||
| 66 | + | a, _, small := r.Label("fj", 0, 1, style) | |||
| 67 | + | style.FontPx = 26 | |||
| 68 | + | b, _, large := r.Label("fj", 0, 1, style) | |||
| 69 | + | require.Greater(t, large, small) | |||
| 70 | + | require.NotEqual(t, a, b) | |||
| 71 | + | style.Background = color.RGBA{0, 0, 0, 255} | |||
| 72 | + | c, _, _ := r.Label("fj", 0, 1, style) | |||
| 73 | + | require.NotEqual(t, b, c) | |||
| 74 | + | require.Equal(t, byte(89), c[3], "outline has 35 percent physical-pixel alpha") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestFontPixelsClamped(t *testing.T) { | |||
| 78 | + | require.InDelta(t, 256, FontPixels(1920, 1080, 0, 0, 3, 1e9), .00001) | |||
| 79 | + | require.InDelta(t, 256, FontPixels(1920, 1080, 0, 0, 4, 72), .00001) | |||
| 80 | + | } | |||