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 }