▍ humdrum codex / glint v1.0.2

feat: markdown styling scanner with explicit foregrounds

4af618ab441be8b61eb806b876eeb8824b53a0b5
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-27 21:23

parent 57daa7f2

6 files changed

go.mod +19 −1
@@ -2,4 +2,22 @@ module glint
 
 go 1.26.4
 
-require github.com/BurntSushi/toml v1.6.0
+require (
+	github.com/BurntSushi/toml v1.6.0
+	github.com/charmbracelet/lipgloss v1.1.0
+)
+
+require (
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/x/ansi v0.8.0 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+	github.com/charmbracelet/x/term v0.2.1 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	golang.org/x/sys v0.30.0 // indirect
+)
go.sum +30 −0
@@ -1,2 +1,32 @@
 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
internal/editor/scanner.go +200 −0
@@ -0,0 +1,200 @@
+package editor
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	headingRe = regexp.MustCompile(`^\s*#{1,6}\s`)
+	listRe    = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`)
+)
+
+// blockState carries cross-line context (fenced code, leading frontmatter).
+type blockState struct {
+	inFence         bool
+	inFrontmatter   bool
+	frontmatterDone bool
+}
+
+// ScanLines styles every line, threading block state across lines. The returned
+// slice is parallel to the input: out[i] are the spans for lines[i], whose
+// concatenated text equals lines[i] exactly.
+func ScanLines(lines []string, th Theme) [][]Span {
+	st := &blockState{}
+	out := make([][]Span, len(lines))
+	for i, ln := range lines {
+		out[i] = scanLine(i, ln, st, th)
+	}
+	return out
+}
+
+func scanLine(row int, line string, st *blockState, th Theme) []Span {
+	code := lipgloss.NewStyle().Foreground(th.Code)
+	muted := lipgloss.NewStyle().Foreground(th.Blockquote)
+	trimmed := strings.TrimSpace(line)
+
+	// Fenced code block: body lines are entirely code-colored.
+	if st.inFence {
+		if strings.HasPrefix(trimmed, "```") {
+			st.inFence = false
+		}
+		return wholeLine(line, code)
+	}
+	if strings.HasPrefix(trimmed, "```") {
+		st.inFence = true
+		return wholeLine(line, code)
+	}
+
+	// Leading YAML frontmatter: only valid starting at row 0.
+	if st.inFrontmatter {
+		if trimmed == "---" {
+			st.inFrontmatter = false
+			st.frontmatterDone = true
+		}
+		return wholeLine(line, muted)
+	}
+	if row == 0 && trimmed == "---" {
+		st.inFrontmatter = true
+		return wholeLine(line, muted)
+	}
+
+	// Heading: color the whole line, hashes included.
+	if headingRe.MatchString(line) {
+		return wholeLine(line, lipgloss.NewStyle().Foreground(th.Heading).Bold(true))
+	}
+
+	// Blockquote.
+	if strings.HasPrefix(trimmed, ">") {
+		return wholeLine(line, muted)
+	}
+
+	// List marker, then inline-scan the remainder.
+	if loc := listRe.FindStringIndex(line); loc != nil {
+		marker := line[:loc[1]]
+		rest := line[loc[1]:]
+		spans := []Span{{Text: marker, Style: lipgloss.NewStyle().Foreground(th.ListMarker)}}
+		return append(spans, scanInline(rest, th)...)
+	}
+
+	return scanInline(line, th)
+}
+
+// wholeLine returns a single span for the whole line, or no spans if empty.
+func wholeLine(line string, style lipgloss.Style) []Span {
+	if line == "" {
+		return nil
+	}
+	return []Span{{Text: line, Style: style}}
+}
+
+// scanInline emits styled spans for inline constructs, keeping all markup
+// characters visible. Plain text gets the theme's Text foreground.
+func scanInline(text string, th Theme) []Span {
+	plain := lipgloss.NewStyle().Foreground(th.Text)
+	r := []rune(text)
+	var spans []Span
+	start, i := 0, 0
+	flush := func(end int) {
+		if end > start {
+			spans = append(spans, Span{Text: string(r[start:end]), Style: plain})
+		}
+	}
+	for i < len(r) {
+		if tok, style, n, ok := matchToken(r, i, th); ok {
+			flush(i)
+			spans = append(spans, Span{Text: tok, Style: style})
+			i += n
+			start = i
+			continue
+		}
+		i++
+	}
+	flush(len(r))
+	return spans
+}
+
+// matchToken tries to match an inline construct starting at r[i]. It returns the
+// token (including its markup), the style, the rune length, and whether matched.
+func matchToken(r []rune, i int, th Theme) (string, lipgloss.Style, int, bool) {
+	// Inline code: `...`
+	if r[i] == '`' {
+		if j := indexRune(r, '`', i+1); j > i {
+			return string(r[i : j+1]), lipgloss.NewStyle().Foreground(th.Code), j + 1 - i, true
+		}
+	}
+	// Wikilink: [[...]]
+	if r[i] == '[' && i+1 < len(r) && r[i+1] == '[' {
+		if j := indexSeq(r, "]]", i+2); j >= 0 {
+			end := j + 2
+			return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Wikilink), end - i, true
+		}
+	}
+	// Link: [text](url)
+	if r[i] == '[' {
+		if c := indexRune(r, ']', i+1); c > i && c+1 < len(r) && r[c+1] == '(' {
+			if p := indexRune(r, ')', c+2); p > c {
+				end := p + 1
+				return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Link), end - i, true
+			}
+		}
+	}
+	// Bold: ** or __
+	for _, d := range []string{"**", "__"} {
+		if hasPrefixRunes(r, i, d) {
+			if j := indexSeq(r, d, i+2); j >= 0 {
+				end := j + 2
+				return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Bold(true), end - i, true
+			}
+		}
+	}
+	// Italic: * or _
+	if r[i] == '*' || r[i] == '_' {
+		if j := indexRune(r, r[i], i+1); j > i {
+			end := j + 1
+			return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Italic(true), end - i, true
+		}
+	}
+	return "", lipgloss.Style{}, 0, false
+}
+
+func indexRune(r []rune, c rune, from int) int {
+	for i := from; i < len(r); i++ {
+		if r[i] == c {
+			return i
+		}
+	}
+	return -1
+}
+
+func indexSeq(r []rune, seq string, from int) int {
+	s := []rune(seq)
+	for i := from; i+len(s) <= len(r); i++ {
+		match := true
+		for k := range s {
+			if r[i+k] != s[k] {
+				match = false
+				break
+			}
+		}
+		if match {
+			return i
+		}
+	}
+	return -1
+}
+
+func hasPrefixRunes(r []rune, i int, prefix string) bool {
+	p := []rune(prefix)
+	if i+len(p) > len(r) {
+		return false
+	}
+	for k := range p {
+		if r[i+k] != p[k] {
+			return false
+		}
+	}
+	return true
+}
internal/editor/scanner_test.go +132 −0
@@ -0,0 +1,132 @@
+package editor
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// spanText concatenates the raw text of a line's spans. It must equal the
+// original raw line exactly (markup-visible invariant).
+func spanText(spans []Span) string {
+	s := ""
+	for _, sp := range spans {
+		s += sp.Text
+	}
+	return s
+}
+
+func TestDefaultThemeAllColorsSet(t *testing.T) {
+	th := DefaultDarkTheme()
+	colors := []lipgloss.Color{
+		th.Text, th.Heading, th.Code, th.Link,
+		th.Wikilink, th.ListMarker, th.Blockquote, th.Accent,
+	}
+	for i, c := range colors {
+		if c == "" {
+			t.Errorf("theme color %d is empty", i)
+		}
+	}
+}
+
+func TestRenderSpansPreservesText(t *testing.T) {
+	spans := []Span{
+		{Text: "ab", Style: lipgloss.NewStyle()},
+		{Text: "cd", Style: lipgloss.NewStyle()},
+	}
+	// rendering may add ANSI, but stripped content is checked elsewhere;
+	// here we only assert it does not panic and is non-empty.
+	if renderSpans(spans) == "" {
+		t.Error("renderSpans returned empty for non-empty spans")
+	}
+}
+
+func TestScanPlainTextGetsExplicitForeground(t *testing.T) {
+	th := DefaultDarkTheme()
+	out := ScanLines([]string{"hello world"}, th)
+	if len(out) != 1 || len(out[0]) == 0 {
+		t.Fatalf("expected spans for one line, got %v", out)
+	}
+	for _, sp := range out[0] {
+		// A style with no foreground returns "" from GetForeground().
+		if sp.Style.GetForeground() == lipgloss.Color("") {
+			t.Errorf("plain span %q has no explicit foreground", sp.Text)
+		}
+	}
+}
+
+func TestScanPreservesRawTextAcrossConstructs(t *testing.T) {
+	th := DefaultDarkTheme()
+	lines := []string{
+		"# Heading",
+		"plain **bold** and *italic* and `code`",
+		"- a list item with a [link](http://x) and [[wikilink]]",
+		"> a quote",
+		"```",
+		"raw **not bold** here",
+		"```",
+		"---",
+	}
+	out := ScanLines(lines, th)
+	for i, raw := range lines {
+		if got := spanText(out[i]); got != raw {
+			t.Errorf("line %d: span text %q != raw %q", i, got, raw)
+		}
+	}
+}
+
+func TestScanFencedCodeSuppressesInline(t *testing.T) {
+	th := DefaultDarkTheme()
+	lines := []string{"```", "**x**", "```"}
+	out := ScanLines(lines, th)
+	// The fence body line should be a single code-colored span, not split
+	// into bold spans.
+	if len(out[1]) != 1 {
+		t.Errorf("fenced line split into %d spans, want 1", len(out[1]))
+	}
+	if out[1][0].Style.GetForeground() != th.Code {
+		t.Errorf("fenced body not code-colored")
+	}
+}
+
+func TestScanLeadingFrontmatter(t *testing.T) {
+	th := DefaultDarkTheme()
+	lines := []string{"---", "title: x", "---", "body"}
+	out := ScanLines(lines, th)
+	if out[1][0].Style.GetForeground() != th.Blockquote {
+		t.Errorf("frontmatter body not muted")
+	}
+	// after the closing ---, normal text resumes
+	if out[3][0].Style.GetForeground() != th.Text {
+		t.Errorf("post-frontmatter line should be plain text")
+	}
+}
+
+func TestScanEmptyLineYieldsNoSpans(t *testing.T) {
+	out := ScanLines([]string{""}, DefaultDarkTheme())
+	if len(out[0]) != 0 {
+		t.Errorf("empty line should yield no spans, got %d", len(out[0]))
+	}
+}
+
+func TestScanHeadingKeepsHashes(t *testing.T) {
+	th := DefaultDarkTheme()
+	out := ScanLines([]string{"### Title"}, th)
+	if spanText(out[0]) != "### Title" {
+		t.Errorf("heading hashes dropped: %q", spanText(out[0]))
+	}
+	if out[0][0].Style.GetForeground() != th.Heading {
+		t.Errorf("heading not heading-colored")
+	}
+}
+
+func TestScanListMarkerThenInline(t *testing.T) {
+	th := DefaultDarkTheme()
+	out := ScanLines([]string{"- **x**"}, th)
+	if out[0][0].Style.GetForeground() != th.ListMarker {
+		t.Errorf("first span should be the list marker, got fg %v", out[0][0].Style.GetForeground())
+	}
+	if spanText(out[0]) != "- **x**" {
+		t.Errorf("list line text altered: %q", spanText(out[0]))
+	}
+}
internal/editor/span.go +44 −0
@@ -0,0 +1,44 @@
+package editor
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// Span is a run of text with a single Lipgloss style.
+type Span struct {
+	Text  string
+	Style lipgloss.Style
+}
+
+// renderSpans concatenates styled spans into a single string.
+func renderSpans(spans []Span) string {
+	var b strings.Builder
+	for _, s := range spans {
+		b.WriteString(s.Style.Render(s.Text))
+	}
+	return b.String()
+}
+
+// renderSpansCursor renders spans rune-by-rune, drawing the cursor cell at the
+// given rune column with cursorStyle. A cursor at or past the end of the line
+// is drawn as a styled space.
+func renderSpansCursor(spans []Span, col int, cursorStyle lipgloss.Style) string {
+	var b strings.Builder
+	idx := 0
+	for _, s := range spans {
+		for _, r := range s.Text {
+			if idx == col {
+				b.WriteString(cursorStyle.Render(string(r)))
+			} else {
+				b.WriteString(s.Style.Render(string(r)))
+			}
+			idx++
+		}
+	}
+	if col >= idx {
+		b.WriteString(cursorStyle.Render(" "))
+	}
+	return b.String()
+}
internal/editor/theme.go +30 −0
@@ -0,0 +1,30 @@
+package editor
+
+import "github.com/charmbracelet/lipgloss"
+
+// Theme holds the explicit foreground colors used by the styling scanner.
+// Every span gets one of these — none ever defaults to the terminal foreground.
+type Theme struct {
+	Text       lipgloss.Color
+	Heading    lipgloss.Color
+	Code       lipgloss.Color
+	Link       lipgloss.Color
+	Wikilink   lipgloss.Color
+	ListMarker lipgloss.Color
+	Blockquote lipgloss.Color
+	Accent     lipgloss.Color
+}
+
+// DefaultDarkTheme is a Tokyo-Night-ish palette that reads on dark terminals.
+func DefaultDarkTheme() Theme {
+	return Theme{
+		Text:       lipgloss.Color("#c0caf5"),
+		Heading:    lipgloss.Color("#7aa2f7"),
+		Code:       lipgloss.Color("#9ece6a"),
+		Link:       lipgloss.Color("#7dcfff"),
+		Wikilink:   lipgloss.Color("#bb9af7"),
+		ListMarker: lipgloss.Color("#bb9af7"),
+		Blockquote: lipgloss.Color("#565f89"),
+		Accent:     lipgloss.Color("#e0af68"),
+	}
+}