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