▍ humdrum codex / glint v1.0.2
license AGPL-3.0
2.8 KB raw
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package editor

import (
	"fmt"
	"strings"

	"github.com/charmbracelet/lipgloss"
)

// Span is a run of text with a single Lipgloss style. A misspelled span also
// carries a curly red underline (undercurl), which lipgloss/termenv can't
// express, so it is emitted as raw SGR around the styled text.
type Span struct {
	Text       string
	Style      lipgloss.Style
	Prose      bool           // natural-language text eligible for spellcheck (TASK-020)
	Wavy       bool           // draw a curly underline (misspelled word; TASK-020)
	UnderColor lipgloss.Color // undercurl color when Wavy
}

// undercurlOpen returns the SGR that turns on a curly underline in the span's
// color: ESC[4:3m (curly) + ESC[58:2::R:G:Bm (underline color). Terminals
// without 4:3 support ignore the subparameter or fall back to a straight
// underline, so unsupported terminals degrade gracefully. The trailing reset
// from the lipgloss style (or an explicit reset) clears it.
func undercurlOpen(c lipgloss.Color) string {
	r, g, b := hexRGB(string(c))
	return fmt.Sprintf("\x1b[4:3m\x1b[58:2::%d:%d:%dm", r, g, b)
}

const sgrReset = "\x1b[0m"

// styled renders one span's text, wrapping it in undercurl SGR when Wavy. The
// span's own text is untouched, preserving the markup-visible invariant: the
// concatenation of span texts still equals the raw line.
func (s Span) styled() string {
	if !s.Wavy {
		return s.Style.Render(s.Text)
	}
	return undercurlOpen(s.UnderColor) + s.Style.Render(s.Text) + sgrReset
}

// renderSpans concatenates styled spans into a single string.
func renderSpans(spans []Span) string {
	var b strings.Builder
	for _, s := range spans {
		b.WriteString(s.styled())
	}
	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. Wavy spans keep their undercurl on every rune
// except the cursor cell.
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 {
			switch {
			case idx == col:
				b.WriteString(cursorStyle.Render(string(r)))
			case s.Wavy:
				b.WriteString(undercurlOpen(s.UnderColor) + s.Style.Render(string(r)) + sgrReset)
			default:
				b.WriteString(s.Style.Render(string(r)))
			}
			idx++
		}
	}
	if col >= idx {
		b.WriteString(cursorStyle.Render(" "))
	}
	return b.String()
}

// hexRGB parses a "#RRGGBB" color into 8-bit components. A malformed or empty
// color yields the theme-agnostic fallback of pure red so an underline still
// shows rather than vanishing.
func hexRGB(hex string) (int, int, int) {
	if len(hex) == 7 && hex[0] == '#' {
		var r, g, b int
		if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err == nil {
			return r, g, b
		}
	}
	return 217, 54, 42
}