detached 7b9b265

feat(hintlabel): derive label size and colours from the output and theme

commit
7b9b265ff9cd035da871aab0d0703e83096c3bac
author
brice · · 2026-10-19 13:26 UTC
parent
78dc04f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +272 -24
M internal/adapters/config/config.go +19 -0
@@ -848,6 +848,25 @@ func set(c *ports.Config, key, v string) error {
848848 return err
849849 }
850850 c.Focus.Flash = b
851+ case "hint.size":
852+ if v == "auto" {
853+ c.Hint.Size = 0
854+ break
855+ }
856+ n, err := strconv.ParseFloat(v, 64)
857+ if err != nil || n <= 0 || math.IsInf(n, 0) || math.IsNaN(n) {
858+ return fmt.Errorf("must be auto or a finite positive number")
859+ }
860+ c.Hint.Size = n
861+ case "hint.background", "hint.foreground":
862+ if v != "" && !color.MatchString(v) {
863+ return fmt.Errorf("must be #rrggbb or empty (derived)")
864+ }
865+ if key == "hint.background" {
866+ c.Hint.Background = v
867+ } else {
868+ c.Hint.Foreground = v
869+ }
851870 case "hint.targets":
852871 switch v {
853872 case "clickable":
@@ -848,6 +848,25 @@ func set(c *ports.Config, key, v string) error {
848 return err848 return err
849 }849 }
850 c.Focus.Flash = b850 c.Focus.Flash = b
851+ case "hint.size":
852+ if v == "auto" {
853+ c.Hint.Size = 0
854+ break
855+ }
856+ n, err := strconv.ParseFloat(v, 64)
857+ if err != nil || n <= 0 || math.IsInf(n, 0) || math.IsNaN(n) {
858+ return fmt.Errorf("must be auto or a finite positive number")
859+ }
860+ c.Hint.Size = n
861+ case "hint.background", "hint.foreground":
862+ if v != "" && !color.MatchString(v) {
863+ return fmt.Errorf("must be #rrggbb or empty (derived)")
864+ }
865+ if key == "hint.background" {
866+ c.Hint.Background = v
867+ } else {
868+ c.Hint.Foreground = v
869+ }
851 case "hint.targets":870 case "hint.targets":
852 switch v {871 switch v {
853 case "clickable":872 case "clickable":
M internal/adapters/config/config_test.go +17 -0
@@ -976,3 +976,20 @@ func TestHintTargets(t *testing.T) {
976976 t.Fatal(c.Binds["Cmd+g"], w)
977977 }
978978 }
979+
980+func TestHintStyleConfig(t *testing.T) {
981+ c, warnings := parseString(t, "hint.size = 17.5\nhint.background = #89b4fa\nhint.foreground = #111111\n")
982+ if len(warnings) != 0 || c.Hint.Size != 17.5 || c.Hint.Background != "#89b4fa" || c.Hint.Foreground != "#111111" {
983+ t.Fatalf("style: %+v, warnings: %v", c.Hint, warnings)
984+ }
985+ c, warnings = parseString(t, "hint.size = auto\nhint.background =\nhint.foreground =\n")
986+ if len(warnings) != 0 || c.Hint.Size != 0 || c.Hint.Background != "" || c.Hint.Foreground != "" {
987+ t.Fatalf("automatic: %+v %v", c.Hint, warnings)
988+ }
989+ for _, line := range []string{"hint.size = 0", "hint.size = -1", "hint.size = NaN", "hint.size = Inf", "hint.size = huge", "hint.background = red", "hint.foreground = #fff"} {
990+ _, warnings := parseString(t, line+"\n")
991+ if len(warnings) == 0 {
992+ t.Errorf("expected validate-config warning for %s", line)
993+ }
994+ }
995+}
@@ -976,3 +976,20 @@ func TestHintTargets(t *testing.T) {
976 t.Fatal(c.Binds["Cmd+g"], w)976 t.Fatal(c.Binds["Cmd+g"], w)
977 }977 }
978 }978 }
979+
980+func TestHintStyleConfig(t *testing.T) {
981+ c, warnings := parseString(t, "hint.size = 17.5\nhint.background = #89b4fa\nhint.foreground = #111111\n")
982+ if len(warnings) != 0 || c.Hint.Size != 17.5 || c.Hint.Background != "#89b4fa" || c.Hint.Foreground != "#111111" {
983+ t.Fatalf("style: %+v, warnings: %v", c.Hint, warnings)
984+ }
985+ c, warnings = parseString(t, "hint.size = auto\nhint.background =\nhint.foreground =\n")
986+ if len(warnings) != 0 || c.Hint.Size != 0 || c.Hint.Background != "" || c.Hint.Foreground != "" {
987+ t.Fatalf("automatic: %+v %v", c.Hint, warnings)
988+ }
989+ for _, line := range []string{"hint.size = 0", "hint.size = -1", "hint.size = NaN", "hint.size = Inf", "hint.size = huge", "hint.background = red", "hint.foreground = #fff"} {
990+ _, warnings := parseString(t, line+"\n")
991+ if len(warnings) == 0 {
992+ t.Errorf("expected validate-config warning for %s", line)
993+ }
994+ }
995+}
M internal/adapters/hintlabel/hintlabel.go +20 -14
@@ -26,10 +26,6 @@ const (
2626 )
2727
2828 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-
3329 parseOnce sync.Once
3430 parsed *opentype.Font
3531 parseErr error
@@ -39,6 +35,7 @@ type key struct {
3935 text string
4036 typed int
4137 scale float64
38+ style Style
4239 }
4340
4441 type entry struct {
@@ -58,7 +55,7 @@ type Raster struct {
5855 // pixel. The slice is owned by the cache and stays valid until the cache is
5956 // cleared by a later call: do not modify it. Empty text, or a font that does
6057 // not load, yields a nil slice.
61-func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h int) {
58+func (r *Raster) Label(text string, typed int, scale float64, styles ...Style) (pix []byte, w, h int) {
6259 if text == "" {
6360 return nil, 0, 0
6461 }
@@ -66,18 +63,23 @@ func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h
6663 scale = 1
6764 }
6865 typed = max(0, min(typed, len(text)))
69- k := key{text, typed, scale}
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}
7072 if e, ok := r.cache[k]; ok {
7173 return e.pix, e.w, e.h
7274 }
73- face := r.face(scale)
75+ face := r.face(style.FontPx)
7476 if face == nil {
7577 return nil, 0, 0
7678 }
7779 if r.cache == nil || len(r.cache) >= maxEntries {
7880 r.cache = map[key]entry{}
7981 }
80- e := render(face, text, typed, scale)
82+ e := render(face, text, typed, style)
8183 r.cache[k] = e
8284 return e.pix, e.w, e.h
8385 }
@@ -90,7 +92,7 @@ func (r *Raster) face(scale float64) font.Face {
9092 if parseErr != nil {
9193 return nil
9294 }
93- f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: fontSize * scale, DPI: 72, Hinting: font.HintingFull})
95+ f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: scale, DPI: 72, Hinting: font.HintingFull})
9496 if err != nil {
9597 return nil
9698 }
@@ -104,9 +106,13 @@ func (r *Raster) face(scale float64) font.Face {
104106 return f
105107 }
106108
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 {
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}
110116 m := face.Metrics()
111117 px := int(math.Round(padX * scale))
112118 py := int(math.Round(padY * scale))
@@ -115,14 +121,14 @@ func render(face font.Face, text string, typed int, scale float64) entry {
115121 w := adv + 2*px + 2*border
116122 h := m.Height.Ceil() + 2*py + 2*border
117123 img := image.NewRGBA(image.Rect(0, 0, w, h))
118- draw.Draw(img, img.Bounds(), image.NewUniform(ink), image.Point{}, draw.Src)
124+ draw.Draw(img, img.Bounds(), image.NewUniform(outline), image.Point{}, draw.Src)
119125 draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src)
120126 d := font.Drawer{Dst: img, Face: face, Dot: fixed.Point26_6{X: fixed.I(border + px), Y: fixed.I(border+py) + m.Ascent}}
121127 d.Src = image.NewUniform(typedInk)
122128 d.DrawString(text[:typed])
123129 d.Src = image.NewUniform(ink)
124130 d.DrawString(text[typed:])
125- // RGBA to BGRA in place; alpha is 0xff everywhere.
131+ // Premultiplied RGBA to BGRA in place.
126132 pix := img.Pix
127133 for i := 0; i+3 < len(pix); i += 4 {
128134 pix[i], pix[i+2] = pix[i+2], pix[i]
@@ -26,10 +26,6 @@ const (
26 )26 )
27 27
28 var (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.Once29 parseOnce sync.Once
34 parsed *opentype.Font30 parsed *opentype.Font
35 parseErr error31 parseErr error
@@ -39,6 +35,7 @@ type key struct {
39 text string35 text string
40 typed int36 typed int
41 scale float6437 scale float64
38+ style Style
42 }39 }
43 40
44 type entry struct {41 type entry struct {
@@ -58,7 +55,7 @@ type Raster struct {
58 // pixel. The slice is owned by the cache and stays valid until the cache is55 // 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 does56 // cleared by a later call: do not modify it. Empty text, or a font that does
60 // not load, yields a nil slice.57 // not load, yields a nil slice.
61-func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h int) {58+func (r *Raster) Label(text string, typed int, scale float64, styles ...Style) (pix []byte, w, h int) {
62 if text == "" {59 if text == "" {
63 return nil, 0, 060 return nil, 0, 0
64 }61 }
@@ -66,18 +63,23 @@ func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h
66 scale = 163 scale = 1
67 }64 }
68 typed = max(0, min(typed, len(text)))65 typed = max(0, min(typed, len(text)))
69- k := key{text, typed, scale}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}
70 if e, ok := r.cache[k]; ok {72 if e, ok := r.cache[k]; ok {
71 return e.pix, e.w, e.h73 return e.pix, e.w, e.h
72 }74 }
73- face := r.face(scale)75+ face := r.face(style.FontPx)
74 if face == nil {76 if face == nil {
75 return nil, 0, 077 return nil, 0, 0
76 }78 }
77 if r.cache == nil || len(r.cache) >= maxEntries {79 if r.cache == nil || len(r.cache) >= maxEntries {
78 r.cache = map[key]entry{}80 r.cache = map[key]entry{}
79 }81 }
80- e := render(face, text, typed, scale)82+ e := render(face, text, typed, style)
81 r.cache[k] = e83 r.cache[k] = e
82 return e.pix, e.w, e.h84 return e.pix, e.w, e.h
83 }85 }
@@ -90,7 +92,7 @@ func (r *Raster) face(scale float64) font.Face {
90 if parseErr != nil {92 if parseErr != nil {
91 return nil93 return nil
92 }94 }
93- f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: fontSize * scale, DPI: 72, Hinting: font.HintingFull})95+ f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: scale, DPI: 72, Hinting: font.HintingFull})
94 if err != nil {96 if err != nil {
95 return nil97 return nil
96 }98 }
@@ -104,9 +106,13 @@ func (r *Raster) face(scale float64) font.Face {
104 return f106 return f
105 }107 }
106 108
107-// render draws the box. Colours are opaque, so the result is premultiplied109+// render draws an opaque box inside a one-physical-pixel translucent outline.
108-// by construction.110+// All colours are premultiplied.
109-func render(face font.Face, text string, typed int, scale float64) entry {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}
110 m := face.Metrics()116 m := face.Metrics()
111 px := int(math.Round(padX * scale))117 px := int(math.Round(padX * scale))
112 py := int(math.Round(padY * scale))118 py := int(math.Round(padY * scale))
@@ -115,14 +121,14 @@ func render(face font.Face, text string, typed int, scale float64) entry {
115 w := adv + 2*px + 2*border121 w := adv + 2*px + 2*border
116 h := m.Height.Ceil() + 2*py + 2*border122 h := m.Height.Ceil() + 2*py + 2*border
117 img := image.NewRGBA(image.Rect(0, 0, w, h))123 img := image.NewRGBA(image.Rect(0, 0, w, h))
118- draw.Draw(img, img.Bounds(), image.NewUniform(ink), image.Point{}, draw.Src)124+ draw.Draw(img, img.Bounds(), image.NewUniform(outline), image.Point{}, draw.Src)
119 draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src)125 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}}126 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)127 d.Src = image.NewUniform(typedInk)
122 d.DrawString(text[:typed])128 d.DrawString(text[:typed])
123 d.Src = image.NewUniform(ink)129 d.Src = image.NewUniform(ink)
124 d.DrawString(text[typed:])130 d.DrawString(text[typed:])
125- // RGBA to BGRA in place; alpha is 0xff everywhere.131+ // Premultiplied RGBA to BGRA in place.
126 pix := img.Pix132 pix := img.Pix
127 for i := 0; i+3 < len(pix); i += 4 {133 for i := 0; i+3 < len(pix); i += 4 {
128 pix[i], pix[i+2] = pix[i+2], pix[i]134 pix[i], pix[i+2] = pix[i+2], pix[i]
A internal/adapters/hintlabel/style.go +94 -0
@@ -0,0 +1,94 @@
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+ if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
19+ scale = 1
20+ }
21+ if logical > 0 && !math.IsInf(logical, 0) {
22+ return logical * scale
23+ }
24+ if width > 0 && height > 0 && mmWidth > 0 && mmHeight > 0 {
25+ ratio := (float64(width) / float64(height)) / (float64(mmWidth) / float64(mmHeight))
26+ if math.Abs(ratio-1) <= .2 {
27+ return min(math.Round(40*scale), max(math.Round(13*scale), math.Round(3.2*float64(width)/float64(mmWidth))))
28+ }
29+ }
30+ return 13 * scale * math.Sqrt(max(1, float64(height)/(1080*scale)))
31+}
32+
33+func parseColor(s string, fallback color.RGBA) color.RGBA {
34+ if len(s) != 7 || s[0] != '#' {
35+ return fallback
36+ }
37+ n, err := strconv.ParseUint(s[1:], 16, 24)
38+ if err != nil {
39+ return fallback
40+ }
41+ return color.RGBA{uint8(n >> 16), uint8(n >> 8), uint8(n), 255}
42+}
43+
44+func luminance(c color.RGBA) float64 {
45+ linear := func(v uint8) float64 {
46+ x := float64(v) / 255
47+ if x <= .04045 {
48+ return x / 12.92
49+ }
50+ return math.Pow((x+.055)/1.055, 2.4)
51+ }
52+ return .2126*linear(c.R) + .7152*linear(c.G) + .0722*linear(c.B)
53+}
54+
55+func contrast(a, b color.RGBA) float64 {
56+ x, y := luminance(a), luminance(b)
57+ return (max(x, y) + .05) / (min(x, y) + .05)
58+}
59+
60+// Colors derives ink from the background. Near-black and near-white have a
61+// narrow luminance gap where neither meets AA; in that gap move HSL
62+// lightness away from .5, preserving hue and saturation.
63+// Explicit foreground overrides are honoured as supplied.
64+func Colors(active, background, foreground string) (color.RGBA, color.RGBA) {
65+ bg := parseColor(background, parseColor(active, color.RGBA{128, 128, 128, 255}))
66+ dark, light := color.RGBA{17, 17, 17, 255}, color.RGBA{245, 245, 245, 255}
67+ ink := dark
68+ if contrast(bg, light) > contrast(bg, dark) {
69+ ink = light
70+ }
71+ for contrast(bg, ink) < 4.5 {
72+ // Move HSL lightness toward zero while preserving hue and saturation.
73+ hi, lo := max(bg.R, bg.G, bg.B), min(bg.R, bg.G, bg.B)
74+ l := (float64(hi) + float64(lo)) / 510
75+ if l > .5 {
76+ // Above .5, moving toward white increases contrast with dark ink.
77+ bg.R += uint8(math.Ceil(float64(255-bg.R) * .01))
78+ bg.G += uint8(math.Ceil(float64(255-bg.G) * .01))
79+ bg.B += uint8(math.Ceil(float64(255-bg.B) * .01))
80+ ink = dark
81+ } else {
82+ bg.R = uint8(float64(bg.R) * .99)
83+ bg.G = uint8(float64(bg.G) * .99)
84+ bg.B = uint8(float64(bg.B) * .99)
85+ ink = light
86+ }
87+ }
88+ return bg, parseColor(foreground, ink)
89+}
90+
91+func mix(ink, bg color.RGBA) color.RGBA {
92+ channel := func(a, b uint8) uint8 { return uint8(math.Round(.45*float64(a) + .55*float64(b))) }
93+ return color.RGBA{channel(ink.R, bg.R), channel(ink.G, bg.G), channel(ink.B, bg.B), 255}
94+}
@@ -0,0 +1,94 @@
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+ if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
19+ scale = 1
20+ }
21+ if logical > 0 && !math.IsInf(logical, 0) {
22+ return logical * scale
23+ }
24+ if width > 0 && height > 0 && mmWidth > 0 && mmHeight > 0 {
25+ ratio := (float64(width) / float64(height)) / (float64(mmWidth) / float64(mmHeight))
26+ if math.Abs(ratio-1) <= .2 {
27+ return min(math.Round(40*scale), max(math.Round(13*scale), math.Round(3.2*float64(width)/float64(mmWidth))))
28+ }
29+ }
30+ return 13 * scale * math.Sqrt(max(1, float64(height)/(1080*scale)))
31+}
32+
33+func parseColor(s string, fallback color.RGBA) color.RGBA {
34+ if len(s) != 7 || s[0] != '#' {
35+ return fallback
36+ }
37+ n, err := strconv.ParseUint(s[1:], 16, 24)
38+ if err != nil {
39+ return fallback
40+ }
41+ return color.RGBA{uint8(n >> 16), uint8(n >> 8), uint8(n), 255}
42+}
43+
44+func luminance(c color.RGBA) float64 {
45+ linear := func(v uint8) float64 {
46+ x := float64(v) / 255
47+ if x <= .04045 {
48+ return x / 12.92
49+ }
50+ return math.Pow((x+.055)/1.055, 2.4)
51+ }
52+ return .2126*linear(c.R) + .7152*linear(c.G) + .0722*linear(c.B)
53+}
54+
55+func contrast(a, b color.RGBA) float64 {
56+ x, y := luminance(a), luminance(b)
57+ return (max(x, y) + .05) / (min(x, y) + .05)
58+}
59+
60+// Colors derives ink from the background. Near-black and near-white have a
61+// narrow luminance gap where neither meets AA; in that gap move HSL
62+// lightness away from .5, preserving hue and saturation.
63+// Explicit foreground overrides are honoured as supplied.
64+func Colors(active, background, foreground string) (color.RGBA, color.RGBA) {
65+ bg := parseColor(background, parseColor(active, color.RGBA{128, 128, 128, 255}))
66+ dark, light := color.RGBA{17, 17, 17, 255}, color.RGBA{245, 245, 245, 255}
67+ ink := dark
68+ if contrast(bg, light) > contrast(bg, dark) {
69+ ink = light
70+ }
71+ for contrast(bg, ink) < 4.5 {
72+ // Move HSL lightness toward zero while preserving hue and saturation.
73+ hi, lo := max(bg.R, bg.G, bg.B), min(bg.R, bg.G, bg.B)
74+ l := (float64(hi) + float64(lo)) / 510
75+ if l > .5 {
76+ // Above .5, moving toward white increases contrast with dark ink.
77+ bg.R += uint8(math.Ceil(float64(255-bg.R) * .01))
78+ bg.G += uint8(math.Ceil(float64(255-bg.G) * .01))
79+ bg.B += uint8(math.Ceil(float64(255-bg.B) * .01))
80+ ink = dark
81+ } else {
82+ bg.R = uint8(float64(bg.R) * .99)
83+ bg.G = uint8(float64(bg.G) * .99)
84+ bg.B = uint8(float64(bg.B) * .99)
85+ ink = light
86+ }
87+ }
88+ return bg, parseColor(foreground, ink)
89+}
90+
91+func mix(ink, bg color.RGBA) color.RGBA {
92+ channel := func(a, b uint8) uint8 { return uint8(math.Round(.45*float64(a) + .55*float64(b))) }
93+ return color.RGBA{channel(ink.R, bg.R), channel(ink.G, bg.G), channel(ink.B, bg.B), 255}
94+}
A internal/adapters/hintlabel/style_test.go +75 -0
@@ -0,0 +1,75 @@
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+}
@@ -0,0 +1,75 @@
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+}
M internal/adapters/vulkan/damage.go +1 -1
@@ -273,7 +273,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
273273 if old.Security != cur.Security || old.Output != cur.Output ||
274274 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
275275 old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||
276- old.Background != cur.Background || old.Border != cur.Border ||
276+ old.HintStyle != cur.HintStyle || old.Background != cur.Background || old.Border != cur.Border ||
277277 old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
278278 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
279279 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
@@ -273,7 +273,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
273 if old.Security != cur.Security || old.Output != cur.Output ||273 if old.Security != cur.Security || old.Output != cur.Output ||
274 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||274 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
275 old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||275 old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||
276- old.Background != cur.Background || old.Border != cur.Border ||276+ old.HintStyle != cur.HintStyle || old.Background != cur.Background || old.Border != cur.Border ||
277 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 ||
278 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 ||
279 !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) ||
M internal/adapters/vulkan/damage_delta_test.go +2 -2
@@ -17,9 +17,9 @@ import (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 func TestSceneFieldCount(t *testing.T) {
20- // 24 with Animating, which sceneDelta intentionally ignores: it only
20+ // 25 with Animating, which sceneDelta intentionally ignores: it only
2121 // schedules the next frame, it draws nothing.
22- const fields = 24
22+ const fields = 25
2323 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2424 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2525 }
@@ -17,9 +17,9 @@ import (
17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18 // lists the fields that force a full redraw.18 // lists the fields that force a full redraw.
19 func TestSceneFieldCount(t *testing.T) {19 func TestSceneFieldCount(t *testing.T) {
20- // 24 with Animating, which sceneDelta intentionally ignores: it only20+ // 25 with Animating, which sceneDelta intentionally ignores: it only
21 // schedules the next frame, it draws nothing.21 // schedules the next frame, it draws nothing.
22- const fields = 2422+ const fields = 25
23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
25 }25 }
M internal/adapters/vulkan/hint_labels.go +7 -2
@@ -2,6 +2,8 @@ package vulkan
22
33 import (
44 "image"
5+
6+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
57 )
68
79 // hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over
@@ -14,8 +16,11 @@ func (w *sceneWalk) hintLabels() {
1416 return
1517 }
1618 r := w.r
19+ hs := w.s.HintStyle
20+ bg, fg := hintlabel.Colors(w.s.Border.Active, hs.Background, hs.Foreground)
21+ style := hintlabel.Style{FontPx: hintlabel.FontPixels(hs.Width, hs.Height, hs.PhysicalW, hs.PhysicalH, w.scale, hs.Size), Background: bg, Foreground: fg}
1722 for _, l := range w.s.HintLabels {
18- pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale)
23+ pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale, style)
1924 if lw <= 0 || lh <= 0 {
2025 continue
2126 }
@@ -26,7 +31,7 @@ func (w *sceneWalk) hintLabels() {
2631 if rect.Intersect(w.bounds).Empty() {
2732 continue
2833 }
29- key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale}
34+ key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale, style: style}
3035 st := shmState{w: lw, h: lh, seq: 1}
3136 c := r.shmCached(key, st)
3237 if c == nil {
@@ -2,6 +2,8 @@ package vulkan
2 2
3 import (3 import (
4 "image"4 "image"
5+
6+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
5 )7 )
6 8
7 // hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over9 // hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over
@@ -14,8 +16,11 @@ func (w *sceneWalk) hintLabels() {
14 return16 return
15 }17 }
16 r := w.r18 r := w.r
19+ hs := w.s.HintStyle
20+ bg, fg := hintlabel.Colors(w.s.Border.Active, hs.Background, hs.Foreground)
21+ style := hintlabel.Style{FontPx: hintlabel.FontPixels(hs.Width, hs.Height, hs.PhysicalW, hs.PhysicalH, w.scale, hs.Size), Background: bg, Foreground: fg}
17 for _, l := range w.s.HintLabels {22 for _, l := range w.s.HintLabels {
18- pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale)23+ pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale, style)
19 if lw <= 0 || lh <= 0 {24 if lw <= 0 || lh <= 0 {
20 continue25 continue
21 }26 }
@@ -26,7 +31,7 @@ func (w *sceneWalk) hintLabels() {
26 if rect.Intersect(w.bounds).Empty() {31 if rect.Intersect(w.bounds).Empty() {
27 continue32 continue
28 }33 }
29- key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale}34+ key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale, style: style}
30 st := shmState{w: lw, h: lh, seq: 1}35 st := shmState{w: lw, h: lh, seq: 1}
31 c := r.shmCached(key, st)36 c := r.shmCached(key, st)
32 if c == nil {37 if c == nil {
M internal/adapters/vulkan/hint_labels_test.go +19 -1
@@ -51,7 +51,7 @@ func TestHintLabelsClampedInsideOutput(t *testing.T) {
5151 t.Skipf("Vulkan unavailable: %v", err)
5252 }
5353 defer r.Close()
54- s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}}
54+ s := ports.Scene{Scale: 1, HintStyle: ports.HintStyle{Size: 40}, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}}
5555 ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
5656 require.Len(t, ds, 1)
5757 require.LessOrEqual(t, ds[0].pc.rect[2], int32(128))
@@ -71,6 +71,15 @@ func TestHintLabelsRenderAndUploadOnce(t *testing.T) {
7171 s.Seq = 2
7272 require.NoError(t, render(r, s, nil))
7373 require.Equal(t, first, r.copied, "an unchanged label is not uploaded again")
74+ s.HintStyle.Size = 26
75+ s.Seq++
76+ require.NoError(t, render(r, s, nil))
77+ require.Greater(t, r.copied, first, "size reload uploads a new raster")
78+ sized := r.copied
79+ s.HintStyle.Background = "#89b4fa"
80+ s.Seq++
81+ require.NoError(t, render(r, s, nil))
82+ require.Greater(t, r.copied, sized, "colour reload uploads a new raster")
7483 }
7584
7685 func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) {
@@ -95,3 +104,12 @@ func TestHintLabelsHeldByTarget(t *testing.T) {
95104 s.HintLabels[0].Text = "j"
96105 require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels")
97106 }
107+
108+func TestHintStyleChangesSceneDamage(t *testing.T) {
109+ old := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Text: "f"}}}
110+ changed := old
111+ changed.HintStyle.Size = 24
112+ require.False(t, old.SameAs(changed))
113+ _, partial := sceneDelta(old, changed, func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }, 10)
114+ require.False(t, partial)
115+}
@@ -51,7 +51,7 @@ func TestHintLabelsClampedInsideOutput(t *testing.T) {
51 t.Skipf("Vulkan unavailable: %v", err)51 t.Skipf("Vulkan unavailable: %v", err)
52 }52 }
53 defer r.Close()53 defer r.Close()
54- s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}}54+ s := ports.Scene{Scale: 1, HintStyle: ports.HintStyle{Size: 40}, 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)))55 ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
56 require.Len(t, ds, 1)56 require.Len(t, ds, 1)
57 require.LessOrEqual(t, ds[0].pc.rect[2], int32(128))57 require.LessOrEqual(t, ds[0].pc.rect[2], int32(128))
@@ -71,6 +71,15 @@ func TestHintLabelsRenderAndUploadOnce(t *testing.T) {
71 s.Seq = 271 s.Seq = 2
72 require.NoError(t, render(r, s, nil))72 require.NoError(t, render(r, s, nil))
73 require.Equal(t, first, r.copied, "an unchanged label is not uploaded again")73 require.Equal(t, first, r.copied, "an unchanged label is not uploaded again")
74+ s.HintStyle.Size = 26
75+ s.Seq++
76+ require.NoError(t, render(r, s, nil))
77+ require.Greater(t, r.copied, first, "size reload uploads a new raster")
78+ sized := r.copied
79+ s.HintStyle.Background = "#89b4fa"
80+ s.Seq++
81+ require.NoError(t, render(r, s, nil))
82+ require.Greater(t, r.copied, sized, "colour reload uploads a new raster")
74 }83 }
75 84
76 func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) {85 func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) {
@@ -95,3 +104,12 @@ func TestHintLabelsHeldByTarget(t *testing.T) {
95 s.HintLabels[0].Text = "j"104 s.HintLabels[0].Text = "j"
96 require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels")105 require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels")
97 }106 }
107+
108+func TestHintStyleChangesSceneDamage(t *testing.T) {
109+ old := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Text: "f"}}}
110+ changed := old
111+ changed.HintStyle.Size = 24
112+ require.False(t, old.SameAs(changed))
113+ _, partial := sceneDelta(old, changed, func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }, 10)
114+ require.False(t, partial)
115+}
M internal/adapters/vulkan/shm.go +2 -0
@@ -6,6 +6,7 @@ import (
66 "runtime/debug"
77 "unsafe"
88
9+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
910 "github.com/bnema/neferwl/internal/ports"
1011 vk "github.com/bnema/purego-vulkan/vulkan"
1112 "golang.org/x/sys/unix"
@@ -160,6 +161,7 @@ type shmKey struct {
160161 label string
161162 typed int
162163 scale float64
164+ style hintlabel.Style
163165 }
164166
165167 // shmState is what a GPU buffer holds: a w×h client buffer at a content
@@ -6,6 +6,7 @@ import (
6 "runtime/debug"6 "runtime/debug"
7 "unsafe"7 "unsafe"
8 8
9+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
9 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
10 vk "github.com/bnema/purego-vulkan/vulkan"11 vk "github.com/bnema/purego-vulkan/vulkan"
11 "golang.org/x/sys/unix"12 "golang.org/x/sys/unix"
@@ -160,6 +161,7 @@ type shmKey struct {
160 label string161 label string
161 typed int162 typed int
162 scale float64163 scale float64
164+ style hintlabel.Style
163 }165 }
164 166
165 // shmState is what a GPU buffer holds: a w×h client buffer at a content167 // shmState is what a GPU buffer holds: a w×h client buffer at a content
M internal/core/scene.go +1 -0
@@ -124,6 +124,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
124124 scene.Flash = c.flashFor(sc)
125125 scene.CaptureIndicators = c.captureIndicators(sc)
126126 scene.HintLabels = c.hintLabels(sc)
127+ scene.HintStyle = ports.HintStyle{Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH}
127128 scene.Capture = c.captureSceneFor(sc, capture)
128129 // The springs of this frame are advanced: a scene shown while one still
129130 // runs wakes core when it flips; the one a spring settles on does not.
@@ -124,6 +124,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
124 scene.Flash = c.flashFor(sc)124 scene.Flash = c.flashFor(sc)
125 scene.CaptureIndicators = c.captureIndicators(sc)125 scene.CaptureIndicators = c.captureIndicators(sc)
126 scene.HintLabels = c.hintLabels(sc)126 scene.HintLabels = c.hintLabels(sc)
127+ scene.HintStyle = ports.HintStyle{Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH}
127 scene.Capture = c.captureSceneFor(sc, capture)128 scene.Capture = c.captureSceneFor(sc, capture)
128 // The springs of this frame are advanced: a scene shown while one still129 // The springs of this frame are advanced: a scene shown while one still
129 // runs wakes core when it flips; the one a spring settles on does not.130 // runs wakes core when it flips; the one a spring settles on does not.
M internal/ports/config.go +3 -1
@@ -165,7 +165,9 @@ type Config struct {
165165 Hint struct {
166166 // LinksOnly limits the targets to links and URLs; false also
167167 // includes buttons (hint.targets = clickable).
168- LinksOnly bool
168+ LinksOnly bool
169+ Size float64 // logical pixels; zero selects automatic density sizing
170+ Background, Foreground string // empty derives from Border.Active
169171 }
170172 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
171173 Workspaces []WorkspaceConfig
@@ -165,7 +165,9 @@ type Config struct {
165 Hint struct {165 Hint struct {
166 // LinksOnly limits the targets to links and URLs; false also166 // LinksOnly limits the targets to links and URLs; false also
167 // includes buttons (hint.targets = clickable).167 // includes buttons (hint.targets = clickable).
168- LinksOnly bool168+ LinksOnly bool
169+ Size float64 // logical pixels; zero selects automatic density sizing
170+ Background, Foreground string // empty derives from Border.Active
169 }171 }
170 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.172 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
171 Workspaces []WorkspaceConfig173 Workspaces []WorkspaceConfig
M internal/ports/scene.go +9 -1
@@ -73,6 +73,7 @@ type Scene struct {
7373 // everything, after CaptureIndicators; nil when hint mode is not shown
7474 // here. Captures never contain them.
7575 HintLabels []HintLabel
76+ HintStyle HintStyle
7677 // Flash is the focus flash drawn over everything of this output; nil
7778 // draws nothing.
7879 Flash *FocusFlash
@@ -117,7 +118,7 @@ func (s Scene) SameAs(o Scene) bool {
117118 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
118119 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
119120 slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
120- slices.Equal(s.HintLabels, o.HintLabels)
121+ s.HintStyle == o.HintStyle && slices.Equal(s.HintLabels, o.HintLabels)
121122 }
122123
123124 // Shows reports whether the scene draws the surface of id: only its
@@ -238,3 +239,10 @@ type SceneWindow struct {
238239 func (w SceneWindow) Tile() bool {
239240 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
240241 }
242+
243+// HintStyle carries reloadable label settings and untransformed output density.
244+type HintStyle struct {
245+ Size float64
246+ Background, Foreground string
247+ Width, Height, PhysicalW, PhysicalH int
248+}
@@ -73,6 +73,7 @@ type Scene struct {
73 // everything, after CaptureIndicators; nil when hint mode is not shown73 // everything, after CaptureIndicators; nil when hint mode is not shown
74 // here. Captures never contain them.74 // here. Captures never contain them.
75 HintLabels []HintLabel75 HintLabels []HintLabel
76+ HintStyle HintStyle
76 // Flash is the focus flash drawn over everything of this output; nil77 // Flash is the focus flash drawn over everything of this output; nil
77 // draws nothing.78 // draws nothing.
78 Flash *FocusFlash79 Flash *FocusFlash
@@ -117,7 +118,7 @@ func (s Scene) SameAs(o Scene) bool {
117 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&118 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
118 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&119 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
119 slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&120 slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
120- slices.Equal(s.HintLabels, o.HintLabels)121+ s.HintStyle == o.HintStyle && slices.Equal(s.HintLabels, o.HintLabels)
121 }122 }
122 123
123 // Shows reports whether the scene draws the surface of id: only its124 // Shows reports whether the scene draws the surface of id: only its
@@ -238,3 +239,10 @@ type SceneWindow struct {
238 func (w SceneWindow) Tile() bool {239 func (w SceneWindow) Tile() bool {
239 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0240 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
240 }241 }
242+
243+// HintStyle carries reloadable label settings and untransformed output density.
244+type HintStyle struct {
245+ Size float64
246+ Background, Foreground string
247+ Width, Height, PhysicalW, PhysicalH int
248+}
M internal/ports/scene_same_test.go +3 -2
@@ -62,6 +62,7 @@ func TestSceneSameAs(t *testing.T) {
6262 "drop hint": func(s *Scene) { s.DropHints[0].W++ },
6363 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
6464 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
65+ "hint style": func(s *Scene) { s.HintStyle.Size = 24 },
6566 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
6667 "transform": func(s *Scene) { s.Transform = 1 },
6768 "capture shown": func(s *Scene) { s.Capture.Shown++ },
@@ -90,9 +91,9 @@ func TestSceneSameAs(t *testing.T) {
9091 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
9192 // update Scene.SameAs, then this count.
9293 func TestSceneSameAsCoversEveryField(t *testing.T) {
93- // 24 with Animating, which SameAs intentionally does not compare: it
94+ // 25 with Animating, which SameAs intentionally does not compare: it
9495 // only schedules the next frame, it draws nothing.
95- const fields = 24
96+ const fields = 25
9697 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
9798 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
9899 }
@@ -62,6 +62,7 @@ func TestSceneSameAs(t *testing.T) {
62 "drop hint": func(s *Scene) { s.DropHints[0].W++ },62 "drop hint": func(s *Scene) { s.DropHints[0].W++ },
63 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },63 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
64 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },64 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
65+ "hint style": func(s *Scene) { s.HintStyle.Size = 24 },
65 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },66 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
66 "transform": func(s *Scene) { s.Transform = 1 },67 "transform": func(s *Scene) { s.Transform = 1 },
67 "capture shown": func(s *Scene) { s.Capture.Shown++ },68 "capture shown": func(s *Scene) { s.Capture.Shown++ },
@@ -90,9 +91,9 @@ func TestSceneSameAs(t *testing.T) {
90 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:91 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
91 // update Scene.SameAs, then this count.92 // update Scene.SameAs, then this count.
92 func TestSceneSameAsCoversEveryField(t *testing.T) {93 func TestSceneSameAsCoversEveryField(t *testing.T) {
93- // 24 with Animating, which SameAs intentionally does not compare: it94+ // 25 with Animating, which SameAs intentionally does not compare: it
94 // only schedules the next frame, it draws nothing.95 // only schedules the next frame, it draws nothing.
95- const fields = 2496+ const fields = 25
96 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),97 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
97 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")98 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
98 }99 }

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 26

Tags 10

↑↓ moveenter openesc close