Merge branch 'feat/hint-style' into feat/hint-hybrid
# Conflicts: # docs/config.md
15 files · +280 -25
M docs/config.md +8 -1
| @@ -56,6 +56,9 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 56 | 56 | | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` | | |
| 57 | 57 | | `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` | | |
| 58 | 58 | | `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) | | |
| 59 | + | | `hint.size` | `auto` | Label text size in logical pixels (positive number), or `auto` for screen pixel density | | |
| 60 | + | | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` | | |
| 61 | + | | `hint.foreground` | empty | Label text (`#rrggbb`); empty chooses a contrasting colour | | |
| 59 | 62 | | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace | | |
| 60 | 63 | | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` | | |
| 61 | 64 | | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout | | |
| @@ -301,7 +304,11 @@ Otherwise, left from the first column unfolds the stash. Up/down walk the cards, | |||
| 301 | 304 | ## Hint mode | |
| 302 | 305 | ||
| 303 | 306 | `hint` labels the links and buttons on the focused output. Type a label to | |
| 304 | - | click its target; BackSpace erases a letter, Escape cancels. | |
| 307 | + | click its target; BackSpace erases a letter, Escape cancels. No bind is set | |
| 308 | + | by default; for example `bind.cmd+g = hint`. | |
| 309 | + | ||
| 310 | + | Labels use the `border.active` colour with a contrasting text colour, and | |
| 311 | + | their size follows the screen's pixel density. | |
| 305 | 312 | ||
| 306 | 313 | ### Where targets come from | |
| 307 | 314 | ||
| @@ -56,6 +56,9 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 56 | | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` | | 56 | | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` | | ||
| 57 | | `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` | | 57 | | `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` | | ||
| 58 | | `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) | | 58 | | `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) | | ||
| 59 | + | | `hint.size` | `auto` | Label text size in logical pixels (positive number), or `auto` for screen pixel density | | |||
| 60 | + | | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` | | |||
| 61 | + | | `hint.foreground` | empty | Label text (`#rrggbb`); empty chooses a contrasting colour | | |||
| 59 | | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace | | 62 | | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace | | ||
| 60 | | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` | | 63 | | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` | | ||
| 61 | | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout | | 64 | | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout | | ||
| @@ -301,7 +304,11 @@ Otherwise, left from the first column unfolds the stash. Up/down walk the cards, | |||||
| 301 | ## Hint mode | 304 | ## Hint mode | ||
| 302 | 305 | ||||
| 303 | `hint` labels the links and buttons on the focused output. Type a label to | 306 | `hint` labels the links and buttons on the focused output. Type a label to | ||
| 304 | - | click its target; BackSpace erases a letter, Escape cancels. | 307 | + | click its target; BackSpace erases a letter, Escape cancels. No bind is set |
| 308 | + | by default; for example `bind.cmd+g = hint`. | |||
| 309 | + | ||||
| 310 | + | Labels use the `border.active` colour with a contrasting text colour, and | |||
| 311 | + | their size follows the screen's pixel density. | |||
| 305 | 312 | ||||
| 306 | ### Where targets come from | 313 | ### Where targets come from | ||
| 307 | 314 | ||||
M internal/adapters/config/config.go +19 -0
| @@ -848,6 +848,25 @@ func set(c *ports.Config, key, v string) error { | |||
| 848 | 848 | return err | |
| 849 | 849 | } | |
| 850 | 850 | 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 | 870 | case "hint.targets": | |
| 852 | 871 | switch v { | |
| 853 | 872 | case "clickable": | |
| @@ -848,6 +848,25 @@ func set(c *ports.Config, key, v string) error { | |||||
| 848 | return err | 848 | return err | ||
| 849 | } | 849 | } | ||
| 850 | c.Focus.Flash = b | 850 | 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) { | |||
| 976 | 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 | + | } | |
| @@ -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 ( | |||
| 26 | 26 | ) | |
| 27 | 27 | ||
| 28 | 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 | 29 | parseOnce sync.Once | |
| 34 | 30 | parsed *opentype.Font | |
| 35 | 31 | parseErr error | |
| @@ -39,6 +35,7 @@ type key struct { | |||
| 39 | 35 | text string | |
| 40 | 36 | typed int | |
| 41 | 37 | scale float64 | |
| 38 | + | style Style | |
| 42 | 39 | } | |
| 43 | 40 | ||
| 44 | 41 | type entry struct { | |
| @@ -58,7 +55,7 @@ type Raster struct { | |||
| 58 | 55 | // pixel. The slice is owned by the cache and stays valid until the cache is | |
| 59 | 56 | // cleared by a later call: do not modify it. Empty text, or a font that does | |
| 60 | 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 | 59 | if text == "" { | |
| 63 | 60 | 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 | 63 | scale = 1 | |
| 67 | 64 | } | |
| 68 | 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 | 72 | if e, ok := r.cache[k]; ok { | |
| 71 | 73 | return e.pix, e.w, e.h | |
| 72 | 74 | } | |
| 73 | - | face := r.face(scale) | |
| 75 | + | face := r.face(style.FontPx) | |
| 74 | 76 | if face == nil { | |
| 75 | 77 | return nil, 0, 0 | |
| 76 | 78 | } | |
| 77 | 79 | if r.cache == nil || len(r.cache) >= maxEntries { | |
| 78 | 80 | r.cache = map[key]entry{} | |
| 79 | 81 | } | |
| 80 | - | e := render(face, text, typed, scale) | |
| 82 | + | e := render(face, text, typed, style) | |
| 81 | 83 | r.cache[k] = e | |
| 82 | 84 | return e.pix, e.w, e.h | |
| 83 | 85 | } | |
| @@ -90,7 +92,7 @@ func (r *Raster) face(scale float64) font.Face { | |||
| 90 | 92 | if parseErr != nil { | |
| 91 | 93 | 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 | 96 | if err != nil { | |
| 95 | 97 | return nil | |
| 96 | 98 | } | |
| @@ -104,9 +106,13 @@ func (r *Raster) face(scale float64) font.Face { | |||
| 104 | 106 | return f | |
| 105 | 107 | } | |
| 106 | 108 | ||
| 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} | |
| 110 | 116 | m := face.Metrics() | |
| 111 | 117 | px := int(math.Round(padX * scale)) | |
| 112 | 118 | py := int(math.Round(padY * scale)) | |
| @@ -115,14 +121,14 @@ func render(face font.Face, text string, typed int, scale float64) entry { | |||
| 115 | 121 | w := adv + 2*px + 2*border | |
| 116 | 122 | h := m.Height.Ceil() + 2*py + 2*border | |
| 117 | 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 | 125 | draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src) | |
| 120 | 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 | 127 | d.Src = image.NewUniform(typedInk) | |
| 122 | 128 | d.DrawString(text[:typed]) | |
| 123 | 129 | d.Src = image.NewUniform(ink) | |
| 124 | 130 | d.DrawString(text[typed:]) | |
| 125 | - | // RGBA to BGRA in place; alpha is 0xff everywhere. | |
| 131 | + | // Premultiplied RGBA to BGRA in place. | |
| 126 | 132 | pix := img.Pix | |
| 127 | 133 | for i := 0; i+3 < len(pix); i += 4 { | |
| 128 | 134 | 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.Once | 29 | parseOnce sync.Once | ||
| 34 | parsed *opentype.Font | 30 | parsed *opentype.Font | ||
| 35 | parseErr error | 31 | parseErr error | ||
| @@ -39,6 +35,7 @@ type key struct { | |||||
| 39 | text string | 35 | text string | ||
| 40 | typed int | 36 | typed int | ||
| 41 | scale float64 | 37 | 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 is | 55 | // pixel. The slice is owned by the cache and stays valid until the cache is | ||
| 59 | // cleared by a later call: do not modify it. Empty text, or a font that does | 56 | // 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, 0 | 60 | 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 = 1 | 63 | 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.h | 73 | 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, 0 | 77 | 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] = e | 83 | r.cache[k] = e | ||
| 82 | return e.pix, e.w, e.h | 84 | 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 nil | 93 | 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 nil | 97 | return nil | ||
| 96 | } | 98 | } | ||
| @@ -104,9 +106,13 @@ func (r *Raster) face(scale float64) font.Face { | |||||
| 104 | return f | 106 | return f | ||
| 105 | } | 107 | } | ||
| 106 | 108 | ||||
| 107 | - | // render draws the box. Colours are opaque, so the result is premultiplied | 109 | + | // 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*border | 121 | w := adv + 2*px + 2*border | ||
| 116 | h := m.Height.Ceil() + 2*py + 2*border | 122 | 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.Pix | 132 | 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 | |||
| 273 | 273 | if old.Security != cur.Security || old.Output != cur.Output || | |
| 274 | 274 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |
| 275 | 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 | 277 | old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | |
| 278 | 278 | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | |
| 279 | 279 | !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 ( | |||
| 17 | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | 18 | // lists the fields that force a full redraw. | |
| 19 | 19 | func TestSceneFieldCount(t *testing.T) { | |
| 20 | - | // 24 with Animating, which sceneDelta intentionally ignores: it only | |
| 20 | + | // 25 with Animating, which sceneDelta intentionally ignores: it only | |
| 21 | 21 | // schedules the next frame, it draws nothing. | |
| 22 | - | const fields = 24 | |
| 22 | + | const fields = 25 | |
| 23 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 24 | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | |
| 25 | 25 | } | |
| @@ -17,9 +17,9 @@ import ( | |||||
| 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | ||
| 18 | // lists the fields that force a full redraw. | 18 | // lists the fields that force a full redraw. | ||
| 19 | func TestSceneFieldCount(t *testing.T) { | 19 | func TestSceneFieldCount(t *testing.T) { | ||
| 20 | - | // 24 with Animating, which sceneDelta intentionally ignores: it only | 20 | + | // 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 = 24 | 22 | + | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |
| 5 | 7 | ) | |
| 6 | 8 | ||
| 7 | 9 | // hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over | |
| @@ -14,8 +16,11 @@ func (w *sceneWalk) hintLabels() { | |||
| 14 | 16 | return | |
| 15 | 17 | } | |
| 16 | 18 | 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 | 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 | 24 | if lw <= 0 || lh <= 0 { | |
| 20 | 25 | continue | |
| 21 | 26 | } | |
| @@ -26,7 +31,7 @@ func (w *sceneWalk) hintLabels() { | |||
| 26 | 31 | if rect.Intersect(w.bounds).Empty() { | |
| 27 | 32 | 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 | 35 | st := shmState{w: lw, h: lh, seq: 1} | |
| 31 | 36 | c := r.shmCached(key, st) | |
| 32 | 37 | 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) over | 9 | // hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over | ||
| @@ -14,8 +16,11 @@ func (w *sceneWalk) hintLabels() { | |||||
| 14 | return | 16 | return | ||
| 15 | } | 17 | } | ||
| 16 | r := w.r | 18 | 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 | continue | 25 | 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 | continue | 32 | 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) { | |||
| 51 | 51 | t.Skipf("Vulkan unavailable: %v", err) | |
| 52 | 52 | } | |
| 53 | 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 | 55 | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |
| 56 | 56 | require.Len(t, ds, 1) | |
| 57 | 57 | require.LessOrEqual(t, ds[0].pc.rect[2], int32(128)) | |
| @@ -71,6 +71,15 @@ func TestHintLabelsRenderAndUploadOnce(t *testing.T) { | |||
| 71 | 71 | s.Seq = 2 | |
| 72 | 72 | require.NoError(t, render(r, s, nil)) | |
| 73 | 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 | 85 | func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) { | |
| @@ -95,3 +104,12 @@ func TestHintLabelsHeldByTarget(t *testing.T) { | |||
| 95 | 104 | s.HintLabels[0].Text = "j" | |
| 96 | 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 | + | } | |
| @@ -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 = 2 | 71 | 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 ( | |||
| 6 | 6 | "runtime/debug" | |
| 7 | 7 | "unsafe" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |
| 9 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 11 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 11 | 12 | "golang.org/x/sys/unix" | |
| @@ -160,6 +161,7 @@ type shmKey struct { | |||
| 160 | 161 | label string | |
| 161 | 162 | typed int | |
| 162 | 163 | scale float64 | |
| 164 | + | style hintlabel.Style | |
| 163 | 165 | } | |
| 164 | 166 | ||
| 165 | 167 | // 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 string | 161 | label string | ||
| 161 | typed int | 162 | typed int | ||
| 162 | scale float64 | 163 | 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 content | 167 | // 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 | |||
| 124 | 124 | scene.Flash = c.flashFor(sc) | |
| 125 | 125 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 126 | 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 | 128 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 128 | 129 | // The springs of this frame are advanced: a scene shown while one still | |
| 129 | 130 | // 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 still | 129 | // 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 { | |||
| 165 | 165 | Hint struct { | |
| 166 | 166 | // LinksOnly limits the targets to links and URLs; false also | |
| 167 | 167 | // 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 | |
| 169 | 171 | } | |
| 170 | 172 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 171 | 173 | 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 also | 166 | // LinksOnly limits the targets to links and URLs; false also | ||
| 167 | // includes buttons (hint.targets = clickable). | 167 | // 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 | |||
| 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 []WorkspaceConfig | 173 | Workspaces []WorkspaceConfig | ||
M internal/ports/scene.go +9 -1
| @@ -73,6 +73,7 @@ type Scene struct { | |||
| 73 | 73 | // everything, after CaptureIndicators; nil when hint mode is not shown | |
| 74 | 74 | // here. Captures never contain them. | |
| 75 | 75 | HintLabels []HintLabel | |
| 76 | + | HintStyle HintStyle | |
| 76 | 77 | // Flash is the focus flash drawn over everything of this output; nil | |
| 77 | 78 | // draws nothing. | |
| 78 | 79 | Flash *FocusFlash | |
| @@ -117,7 +118,7 @@ func (s Scene) SameAs(o Scene) bool { | |||
| 117 | 118 | return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) && | |
| 118 | 119 | slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) && | |
| 119 | 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 | 124 | // Shows reports whether the scene draws the surface of id: only its | |
| @@ -238,3 +239,10 @@ type SceneWindow struct { | |||
| 238 | 239 | func (w SceneWindow) Tile() bool { | |
| 239 | 240 | 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 | + | } | |
| @@ -73,6 +73,7 @@ type Scene struct { | |||||
| 73 | // everything, after CaptureIndicators; nil when hint mode is not shown | 73 | // everything, after CaptureIndicators; nil when hint mode is not shown | ||
| 74 | // here. Captures never contain them. | 74 | // here. Captures never contain them. | ||
| 75 | HintLabels []HintLabel | 75 | HintLabels []HintLabel | ||
| 76 | + | HintStyle HintStyle | |||
| 76 | // Flash is the focus flash drawn over everything of this output; nil | 77 | // Flash is the focus flash drawn over everything of this output; nil | ||
| 77 | // draws nothing. | 78 | // draws nothing. | ||
| 78 | Flash *FocusFlash | 79 | 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 its | 124 | // 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 == 0 | 240 | 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) { | |||
| 62 | 62 | "drop hint": func(s *Scene) { s.DropHints[0].W++ }, | |
| 63 | 63 | "layer": func(s *Scene) { s.Layers[0].Rect.H++ }, | |
| 64 | 64 | "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true }, | |
| 65 | + | "hint style": func(s *Scene) { s.HintStyle.Size = 24 }, | |
| 65 | 66 | "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 }, | |
| 66 | 67 | "transform": func(s *Scene) { s.Transform = 1 }, | |
| 67 | 68 | "capture shown": func(s *Scene) { s.Capture.Shown++ }, | |
| @@ -90,9 +91,9 @@ func TestSceneSameAs(t *testing.T) { | |||
| 90 | 91 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | |
| 91 | 92 | // update Scene.SameAs, then this count. | |
| 92 | 93 | 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 | |
| 94 | 95 | // only schedules the next frame, it draws nothing. | |
| 95 | - | const fields = 24 | |
| 96 | + | const fields = 25 | |
| 96 | 97 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 97 | 98 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 98 | 99 | } | |
| @@ -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: it | 94 | + | // 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 = 24 | 96 | + | 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 | } | ||