detached 4b849d0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +385 -0
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
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+}

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