refactor: move color helpers to theme package
cda48477e98ee732cd3fd30da56a38764bc7004b
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-30 18:51
parent 083406d3
refactor: move color helpers to theme package HexToRGB/LegibleText/RelLuminance move out of internal/preview so the editor filename header and the preview title bar derive identical colors from one place (TASK-046).
6 files changed
internal/preview/header.go +6 −4
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/charmbracelet/lipgloss"
+
+ "glint/internal/theme"
)
// SGR escapes. The header writes them by hand rather than through lipgloss so
@@ -60,8 +62,8 @@ pad = 0
}
line := " " + title + " " + strings.Repeat(" ", pad)
open := sgrBold
- if bg := hexToRGB(m.colors.Heading); bg != "" {
- open += "\x1b[38;2;" + hexToRGB(legibleText(m.colors.Heading)) + ";48;2;" + bg + "m"
+ if bg := theme.HexToRGB(m.colors.Heading); bg != "" {
+ open += "\x1b[38;2;" + theme.HexToRGB(theme.LegibleText(m.colors.Heading)) + ";48;2;" + bg + "m"
}
return open + line + sgrReset
}
@@ -107,11 +109,11 @@
// propStyles returns the SGR prefixes for a property key (dim) and value
// (italic), falling back to plain text when the theme colors are unset.
func (m *Model) propStyles() (key, val string) {
- if c := hexToRGB(m.colors.Muted); c != "" {
+ if c := theme.HexToRGB(m.colors.Muted); c != "" {
key = "\x1b[38;2;" + c + "m"
}
val = sgrItalic
- if c := hexToRGB(m.colors.Text); c != "" {
+ if c := theme.HexToRGB(m.colors.Text); c != "" {
val += "\x1b[38;2;" + c + "m"
}
return key, val
internal/preview/preview.go +5 −46
@@ -3,10 +3,8 @@ // read-only viewport — the full glow read experience, markup concealed.
package preview
import (
- "math"
"os"
"reflect"
- "strconv"
"strings"
"github.com/charmbracelet/bubbles/viewport"
@@ -14,6 +12,8 @@ tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
+
+ "glint/internal/theme"
)
// applyTheme rewrites a glamour style so it matches the glint theme: prose,
@@ -58,7 +58,7 @@ cfg.LinkText.Color = &link
// H1: filled bar in the heading color (text inherits this bg), bold.
yes := true
- ht := legibleText(heading)
+ ht := theme.LegibleText(heading)
cfg.H1.Color = &ht
cfg.H1.BackgroundColor = &heading
cfg.H1.Bold = &yes
@@ -102,12 +102,12 @@ // the terminal foreground matches the paper (light terminal + dark theme, and
// the reverse). Spans that set their own colors still override immediately, so
// styled text is unaffected.
func fillBackground(s, bgHex, fgHex string) string {
- bg := hexToRGB(bgHex)
+ bg := theme.HexToRGB(bgHex)
if bg == "" {
return s
}
base := "\x1b[48;2;" + bg + "m"
- if fg := hexToRGB(fgHex); fg != "" {
+ if fg := theme.HexToRGB(fgHex); fg != "" {
base = "\x1b[38;2;" + fg + ";48;2;" + bg + "m"
}
paper := base
@@ -121,47 +121,6 @@ for i, ln := range lines {
lines[i] = paper + ln
}
return strings.Join(lines, "\n")
-}
-
-// hexToRGB converts "#RRGGBB" to the "R;G;B" decimal form used in SGR codes.
-func hexToRGB(hex string) string {
- h := strings.TrimPrefix(hex, "#")
- if len(h) != 6 {
- return ""
- }
- r, err1 := strconv.ParseInt(h[0:2], 16, 0)
- g, err2 := strconv.ParseInt(h[2:4], 16, 0)
- b, err3 := strconv.ParseInt(h[4:6], 16, 0)
- if err1 != nil || err2 != nil || err3 != nil {
- return ""
- }
- return strconv.FormatInt(r, 10) + ";" + strconv.FormatInt(g, 10) + ";" + strconv.FormatInt(b, 10)
-}
-
-// legibleText returns a near-black or near-paper text color, whichever contrasts
-// better with the background hex (so H1 text stays readable on any heading color).
-func legibleText(bgHex string) string {
- if relLuminance(bgHex) > 0.5 {
- return "#100F0F"
- }
- return "#FFFCF0"
-}
-
-// relLuminance is the WCAG relative luminance of an "#RRGGBB" color.
-func relLuminance(hex string) float64 {
- h := strings.TrimPrefix(hex, "#")
- if len(h) != 6 {
- return 0
- }
- chan8 := func(s string) float64 {
- n, _ := strconv.ParseInt(s, 16, 0)
- c := float64(n) / 255
- if c <= 0.03928 {
- return c / 12.92
- }
- return math.Pow((c+0.055)/1.055, 2.4)
- }
- return 0.2126*chan8(h[0:2]) + 0.7152*chan8(h[2:4]) + 0.0722*chan8(h[4:6])
}
// Model wraps a Glamour renderer and a viewport.
internal/preview/preview_test.go +8 −6
@@ -7,18 +7,20 @@ "strings"
"testing"
"github.com/charmbracelet/glamour/styles"
+
+ "glint/internal/theme"
)
func TestLegibleTextContrasts(t *testing.T) {
// Dark/mid accents → light text; very light bg → dark text.
- if got := legibleText("#4385BE"); got != "#FFFCF0" {
- t.Errorf("legibleText(blue) = %q, want light", got)
+ if got := theme.LegibleText("#4385BE"); got != "#FFFCF0" {
+ t.Errorf("LegibleText(blue) = %q, want light", got)
}
- if got := legibleText("#FF5FAF"); got != "#FFFCF0" {
- t.Errorf("legibleText(pink) = %q, want light", got)
+ if got := theme.LegibleText("#FF5FAF"); got != "#FFFCF0" {
+ t.Errorf("LegibleText(pink) = %q, want light", got)
}
- if got := legibleText("#F0E6BE"); got != "#100F0F" {
- t.Errorf("legibleText(pale yellow) = %q, want dark", got)
+ if got := theme.LegibleText("#F0E6BE"); got != "#100F0F" {
+ t.Errorf("LegibleText(pale yellow) = %q, want dark", got)
}
}
internal/preview/theme_test.go +4 −2
@@ -3,6 +3,8 @@
import (
"strings"
"testing"
+
+ "glint/internal/theme"
)
var testColors = Colors{
@@ -73,8 +75,8 @@ // H1 heading text must use the legible color for the heading background, not the
// prose text color (which can be illegible on the heading bar in light themes).
func TestH1TextUsesLegibleColor(t *testing.T) {
out := renderPreview(t, "# glint\n")
- want := legibleText(testColors.Heading) // e.g. #FFFCF0 -> 255;252;240
- rgb := hexToRGB(want)
+ want := theme.LegibleText(testColors.Heading) // e.g. #FFFCF0 -> 255;252;240
+ rgb := theme.HexToRGB(want)
if !strings.Contains(out, "38;2;"+rgb+";48;2;209;77;65;1mglint") {
t.Fatalf("H1 text not using legible color %s (%s):\n%q", want, rgb, out)
}
internal/theme/theme.go +51 −1
@@ -3,7 +3,13 @@ // every theme gets an explicit foreground — no terminal-default fallbacks — so
// the editor reads cleanly on both light and dark terminals.
package theme
-import "github.com/charmbracelet/lipgloss"
+import (
+ "math"
+ "strconv"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
// Theme holds every color glint paints, plus its name and the glamour style the
// read-preview should use to stay visually in sync.
@@ -87,3 +93,47 @@ }
t, _ := ByName(Detect())
return t
}
+
+// HexToRGB converts "#RRGGBB" to the "R;G;B" decimal form used in SGR codes.
+// A malformed color yields "" so callers can fall back to unstyled output.
+func HexToRGB(hex string) string {
+ h := strings.TrimPrefix(hex, "#")
+ if len(h) != 6 {
+ return ""
+ }
+ r, err1 := strconv.ParseInt(h[0:2], 16, 0)
+ g, err2 := strconv.ParseInt(h[2:4], 16, 0)
+ b, err3 := strconv.ParseInt(h[4:6], 16, 0)
+ if err1 != nil || err2 != nil || err3 != nil {
+ return ""
+ }
+ return strconv.FormatInt(r, 10) + ";" + strconv.FormatInt(g, 10) + ";" + strconv.FormatInt(b, 10)
+}
+
+// LegibleText returns a near-black or near-paper text color, whichever
+// contrasts better with the background hex, so heading text stays readable on
+// any heading color.
+func LegibleText(bgHex string) string {
+ if RelLuminance(bgHex) > 0.5 {
+ return "#100F0F"
+ }
+ return "#FFFCF0"
+}
+
+// RelLuminance is the WCAG relative luminance of an "#RRGGBB" color, 0 for a
+// malformed one.
+func RelLuminance(hex string) float64 {
+ h := strings.TrimPrefix(hex, "#")
+ if len(h) != 6 {
+ return 0
+ }
+ chan8 := func(s string) float64 {
+ n, _ := strconv.ParseInt(s, 16, 0)
+ c := float64(n) / 255
+ if c <= 0.03928 {
+ return c / 12.92
+ }
+ return math.Pow((c+0.055)/1.055, 2.4)
+ }
+ return 0.2126*chan8(h[0:2]) + 0.7152*chan8(h[2:4]) + 0.0722*chan8(h[4:6])
+}
internal/theme/theme_test.go +18 −0
@@ -73,3 +73,21 @@ t.Errorf("Resolve(%q) returned unregistered theme %q", v, got.Name)
}
}
}
+
+func TestHexToRGB(t *testing.T) {
+ if got := HexToRGB("#FFFCF0"); got != "255;252;240" {
+ t.Errorf("HexToRGB(#FFFCF0) = %q, want 255;252;240", got)
+ }
+ if got := HexToRGB("nope"); got != "" {
+ t.Errorf("HexToRGB(nope) = %q, want empty", got)
+ }
+}
+
+func TestLegibleTextPicksContrast(t *testing.T) {
+ if got := LegibleText("#FFFCF0"); got != "#100F0F" {
+ t.Errorf("LegibleText(light bg) = %q, want #100F0F", got)
+ }
+ if got := LegibleText("#100F0F"); got != "#FFFCF0" {
+ t.Errorf("LegibleText(dark bg) = %q, want #FFFCF0", got)
+ }
+}