▍ humdrum codex / glint v1.1.2
license AGPL-3.0

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)
+	}
+}