feat: inline spellcheck rendering + engine in the editor (TASK-020)
92f9461e5066aca5b617130fed7c0eee8750c885
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 18:20
parent 0b443645
feat: inline spellcheck rendering + engine in the editor (TASK-020) Wire the spell package into the editor's visual model: - Span gains Prose (spellcheck-eligible) and Wavy/UnderColor (undercurl). The markdown scanner tags prose-bearing spans (plain text, heading/blockquote text, bold/italic/highlight content); code, markup, URLs, wikilinks, link targets, and frontmatter values stay non-prose and are never flagged. - renderSpans/renderSpansCursor emit raw undercurl SGR (ESC[4:3m + ESC[58:2 color) around misspelled spans, preserving the markup-visible invariant; terminals without 4:3 degrade gracefully. sliceSpans carries the undercurl through wrapping; selection highlight supersedes it. - spellPass splits prose spans into words, skipping URLs/emails, acronyms, camel/brand casing, and sub-3-letter tokens, flagging the rest via a cached membership test. Gated off for code files and when toggled off. - theme.Spell red added to all three palettes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
10 files changed
internal/editor/editor.go +5 −0
@@ -4,6 +4,7 @@ import (
"path/filepath"
"strings"
+ "glint/internal/spell"
"glint/internal/theme"
tea "github.com/charmbracelet/bubbletea"
@@ -39,6 +40,10 @@
codeFile string // filename for the chroma lexer; "" = prose/markdown scanner (TASK-018)
buildCount int // count of buildVisual scans; perf guard for tests (TASK-004)
+
+ dict *spell.Dict // loaded spellchecker; nil = inert (TASK-020)
+ spellOn bool // session spellcheck toggle
+ spellCache map[string]bool // word -> known, cleared when the personal dict changes
}
// SetLanguage selects the scanner from the file's extension: markdown/text/no
internal/editor/scanner.go +19 −6
@@ -89,7 +89,7 @@ // Heading: dim the hashes, bold + accent the text.
if loc := headingRe.FindStringIndex(line); loc != nil {
spans := []Span{{Text: line[:loc[1]], Style: muted}}
if rest := line[loc[1]:]; rest != "" {
- spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Heading).Bold(true)})
+ spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Heading).Bold(true), Prose: true})
}
return spans
}
@@ -98,7 +98,7 @@ // Blockquote: dim the > marker, italicize the quoted text at base color.
if loc := blockquoteRe.FindStringIndex(line); loc != nil {
spans := []Span{{Text: line[:loc[1]], Style: muted}}
if rest := line[loc[1]:]; rest != "" {
- spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Text).Italic(true)})
+ spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Text).Italic(true), Prose: true})
}
return spans
}
@@ -174,7 +174,7 @@ 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})
+ spans = append(spans, Span{Text: string(r[start:end]), Style: plain, Prose: true})
}
}
for i < len(r) {
@@ -207,7 +207,7 @@ }
// Highlight: ==...==
if r[i] == '=' && i+1 < len(r) && r[i+1] == '=' {
if j := indexSeq(r, "==", i+2); j >= 0 {
- return wrap("==", string(r[i+2:j]), "==", muted,
+ return wrapProse("==", string(r[i+2:j]), "==", muted,
lipgloss.NewStyle().Foreground(th.Text).Background(th.Highlight)), j + 2 - i, true
}
}
@@ -259,7 +259,7 @@ // Bold: ** or __
for _, d := range []string{"**", "__"} {
if hasPrefixRunes(r, i, d) {
if j := indexSeq(r, d, i+2); j >= 0 {
- return wrap(d, string(r[i+2:j]), d, muted,
+ return wrapProse(d, string(r[i+2:j]), d, muted,
lipgloss.NewStyle().Foreground(th.Emphasis).Bold(true)), j + 2 - i, true
}
}
@@ -268,7 +268,7 @@ // Italic: * or _
if r[i] == '*' || r[i] == '_' {
if j := indexRune(r, r[i], i+1); j > i {
m := string(r[i])
- return wrap(m, string(r[i+1:j]), m, muted,
+ return wrapProse(m, string(r[i+1:j]), m, muted,
lipgloss.NewStyle().Foreground(th.Emphasis).Italic(true)), j + 1 - i, true
}
}
@@ -283,6 +283,19 @@ if content != "" {
spans = append(spans, Span{Text: content, Style: role})
}
return append(spans, Span{Text: closing, Style: markup})
+}
+
+// wrapProse is wrap for constructs whose content is natural-language prose
+// (bold, italic, ==highlight==), tagging the content span so spellcheck examines
+// it. The markup delimiters stay non-prose.
+func wrapProse(open, content, closing string, markup, role lipgloss.Style) []Span {
+ spans := wrap(open, content, closing, markup, role)
+ for i := range spans {
+ if spans[i].Text == content && content != "" {
+ spans[i].Prose = true
+ }
+ }
+ return spans
}
func indexRune(r []rune, c rune, from int) int {
internal/editor/selection.go +1 −0
@@ -37,6 +37,7 @@ out := sliceSpans(spans, 0, a)
mid := sliceSpans(spans, a, b)
for i := range mid {
mid[i].Style = sel
+ mid[i].Wavy = false // selection highlight supersedes the misspell undercurl
}
out = append(out, mid...)
return append(out, sliceSpans(spans, b, total)...)
internal/editor/span.go +52 −7
@@ -1,37 +1,69 @@
package editor
import (
+ "fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
-// Span is a run of text with a single Lipgloss style.
+// 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
+ 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.Style.Render(s.Text))
+ 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.
+// 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 {
- if idx == col {
+ switch {
+ case idx == col:
b.WriteString(cursorStyle.Render(string(r)))
- } else {
+ case s.Wavy:
+ b.WriteString(undercurlOpen(s.UnderColor) + s.Style.Render(string(r)) + sgrReset)
+ default:
b.WriteString(s.Style.Render(string(r)))
}
idx++
@@ -42,3 +74,16 @@ 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
+}
internal/editor/span_test.go +53 −0
@@ -0,0 +1,53 @@
+package editor
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+var ansiRe = regexp.MustCompile("\x1b\\[[0-9;:]*m")
+
+func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") }
+
+const undercurlSGR = "\x1b[4:3m" // curly underline
+
+func TestWavySpanEmitsUndercurl(t *testing.T) {
+ sp := []Span{{Text: "teh", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#CECDC3")), Wavy: true, UnderColor: lipgloss.Color("#D14D41")}}
+ out := renderSpans(sp)
+ if !strings.Contains(out, undercurlSGR) {
+ t.Errorf("wavy span missing undercurl SGR %q in %q", undercurlSGR, out)
+ }
+ // Underline-color SGR: 58:2::R:G:B (D14D41 = 209,77,65).
+ if !strings.Contains(out, "58:2::209:77:65") {
+ t.Errorf("wavy span missing underline-color SGR in %q", out)
+ }
+ if got := stripANSI(out); got != "teh" {
+ t.Errorf("invariant broken: stripped %q != %q", got, "teh")
+ }
+}
+
+func TestNonWavySpanHasNoUndercurl(t *testing.T) {
+ sp := []Span{{Text: "the", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#CECDC3"))}}
+ out := renderSpans(sp)
+ if strings.Contains(out, undercurlSGR) {
+ t.Errorf("non-wavy span unexpectedly emitted undercurl: %q", out)
+ }
+ if got := stripANSI(out); got != "the" {
+ t.Errorf("stripped %q != %q", got, "the")
+ }
+}
+
+func TestWavySpanCursorRenderKeepsText(t *testing.T) {
+ sp := []Span{{Text: "teh", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#CECDC3")), Wavy: true, UnderColor: lipgloss.Color("#D14D41")}}
+ cur := lipgloss.NewStyle().Foreground(lipgloss.Color("#100F0F")).Background(lipgloss.Color("#CE5D97"))
+ out := renderSpansCursor(sp, 1, cur)
+ if got := stripANSI(out); got != "teh" {
+ t.Errorf("cursor render invariant broken: stripped %q != %q", got, "teh")
+ }
+ if !strings.Contains(out, undercurlSGR) {
+ t.Errorf("wavy cursor render missing undercurl on non-cursor runes: %q", out)
+ }
+}
internal/editor/spellcheck.go +157 −0
@@ -0,0 +1,157 @@
+package editor
+
+import (
+ "regexp"
+ "strings"
+
+ "glint/internal/spell"
+)
+
+// urlRe matches URLs and bare emails so their letters aren't spellchecked.
+var urlRe = regexp.MustCompile(`(?:https?://|www\.|mailto:)\S+|\S+@\S+\.\S+`)
+
+// wordRe matches a candidate word: a letter followed by letters or apostrophes
+// (straight or curly), so possessives ride along with their base.
+var wordRe = regexp.MustCompile(`\p{L}[\p{L}'’]*`)
+
+// SetDict gives the editor a loaded dictionary; spellcheck is inert until one is
+// set. SetSpell toggles the user preference.
+func (e *Editor) SetDict(d *spell.Dict) {
+ e.dict = d
+ e.spellCache = map[string]bool{}
+}
+
+// SetSpell sets whether spellcheck is enabled for the session.
+func (e *Editor) SetSpell(on bool) { e.spellOn = on }
+
+// ToggleSpell flips spellcheck and returns the new state.
+func (e *Editor) ToggleSpell() bool { e.spellOn = !e.spellOn; return e.spellOn }
+
+// SpellEnabled reports the user's session toggle (independent of whether a dict
+// is loaded or the file is code).
+func (e *Editor) SpellEnabled() bool { return e.spellOn }
+
+// spellActive reports whether spellcheck should actually run: enabled, a
+// dictionary is loaded, and the buffer is prose (not a recognized code file,
+// which reuses the TASK-018 extension routing via codeFile).
+func (e *Editor) spellActive() bool {
+ return e.spellOn && e.dict != nil && e.codeFile == ""
+}
+
+// AddToDictionary adds the word to the personal dictionary and invalidates the
+// spell cache so its underline clears on the next render.
+func (e *Editor) AddToDictionary(word string) error {
+ if e.dict == nil {
+ return nil
+ }
+ err := e.dict.Add(word)
+ e.spellCache = map[string]bool{} // membership changed; drop cached verdicts
+ return err
+}
+
+// spellKnown is a cached membership test; the cache is dropped when the personal
+// dictionary changes.
+func (e *Editor) spellKnown(word string) bool {
+ lw := strings.ToLower(word)
+ if v, ok := e.spellCache[lw]; ok {
+ return v
+ }
+ k := e.dict.Known(lw)
+ if e.spellCache == nil {
+ e.spellCache = map[string]bool{}
+ }
+ e.spellCache[lw] = k
+ return k
+}
+
+// spellPass splits every prose span into words and re-emits misspelled ones as
+// Wavy (undercurl) spans, leaving all other spans untouched. The per-line span
+// text is unchanged, so the markup-visible invariant holds.
+func (e *Editor) spellPass(all [][]Span) [][]Span {
+ for li, spans := range all {
+ var out []Span
+ changed := false
+ for _, sp := range spans {
+ if !sp.Prose {
+ out = append(out, sp)
+ continue
+ }
+ split := e.splitProse(sp)
+ if len(split) != 1 {
+ changed = true
+ }
+ out = append(out, split...)
+ }
+ if changed {
+ all[li] = out
+ }
+ }
+ return all
+}
+
+// splitProse partitions one prose span's text, marking misspelled words with the
+// theme's undercurl. Words inside URLs/emails, acronyms, camel/brand casing, and
+// words under three letters are left alone. When nothing is misspelled the
+// original span is returned unchanged.
+func (e *Editor) splitProse(sp Span) []Span {
+ text := sp.Text
+ skips := urlRe.FindAllStringIndex(text, -1)
+ var out []Span
+ last := 0
+ emit := func(s string, wavy bool) {
+ if s == "" {
+ return
+ }
+ ns := sp
+ ns.Text = s
+ ns.Wavy = wavy
+ if wavy {
+ ns.UnderColor = e.theme.Spell
+ }
+ out = append(out, ns)
+ }
+ for _, w := range wordRe.FindAllStringIndex(text, -1) {
+ word := text[w[0]:w[1]]
+ if overlapsAny(w, skips) || !checkableWord(word) || e.spellKnown(word) {
+ continue
+ }
+ emit(text[last:w[0]], false)
+ emit(word, true)
+ last = w[1]
+ }
+ if last == 0 {
+ return []Span{sp} // nothing flagged
+ }
+ emit(text[last:], false)
+ return out
+}
+
+// overlapsAny reports whether byte range r overlaps any range in rs.
+func overlapsAny(r []int, rs [][]int) bool {
+ for _, s := range rs {
+ if r[0] < s[1] && s[0] < r[1] {
+ return true
+ }
+ }
+ return false
+}
+
+// checkableWord filters out tokens that aren't ordinary prose words: anything
+// under three letters, all-caps acronyms (NASA, API), and internal-capital
+// brand/camelCase (iOS, GitHub). Only the first letter may be uppercase.
+func checkableWord(w string) bool {
+ r := []rune(w)
+ if len(r) < 3 {
+ return false
+ }
+ allUpper := true
+ for i, c := range r {
+ if c >= 'a' && c <= 'z' || c == '\'' || c == '’' {
+ allUpper = false
+ }
+ if i > 0 && c >= 'A' && c <= 'Z' {
+ return false // internal uppercase: brand / camelCase
+ }
+ }
+ return !allUpper
+}
internal/editor/spellcheck_test.go +108 −0
@@ -0,0 +1,108 @@
+package editor
+
+import (
+ "testing"
+
+ "glint/internal/spell"
+)
+
+func spellEditor(t *testing.T, content string) *Editor {
+ t.Helper()
+ d, err := spell.Load()
+ if err != nil {
+ t.Fatalf("spell.Load: %v", err)
+ }
+ e := New()
+ e.SetContent([]byte(content))
+ e.SetDict(d)
+ e.SetSpell(true)
+ return e
+}
+
+// wavyWords returns the set of span texts rendered with the misspell undercurl.
+func wavyWords(e *Editor) map[string]bool {
+ got := map[string]bool{}
+ for _, vr := range e.buildVisual() {
+ for _, sp := range vr.spans {
+ if sp.Wavy {
+ got[sp.Text] = true
+ }
+ }
+ }
+ return got
+}
+
+func TestSpellFlagsMisspelledProse(t *testing.T) {
+ e := spellEditor(t, "This sentence has a recieve typo")
+ w := wavyWords(e)
+ if !w["recieve"] {
+ t.Errorf("misspelled \"recieve\" not flagged; wavy=%v", w)
+ }
+ if w["sentence"] || w["typo"] || w["This"] {
+ t.Errorf("correct words flagged; wavy=%v", w)
+ }
+}
+
+func TestSpellSkipsInlineCode(t *testing.T) {
+ e := spellEditor(t, "use the `recieve` function")
+ if wavyWords(e)["recieve"] {
+ t.Error("inline code content should not be spellchecked")
+ }
+}
+
+func TestSpellSkipsURLs(t *testing.T) {
+ e := spellEditor(t, "see https://recieve.example.com/seperate for more")
+ w := wavyWords(e)
+ if w["recieve"] || w["seperate"] {
+ t.Errorf("URL words should not be flagged; wavy=%v", w)
+ }
+}
+
+func TestSpellSkipsWikilinks(t *testing.T) {
+ e := spellEditor(t, "linked [[recieve note]] here")
+ if wavyWords(e)["recieve"] {
+ t.Error("wikilink target should not be spellchecked")
+ }
+}
+
+func TestSpellSkipsFrontmatter(t *testing.T) {
+ e := spellEditor(t, "---\ntitle: recieve draft\n---\nbody text")
+ if wavyWords(e)["recieve"] {
+ t.Error("frontmatter value should not be spellchecked")
+ }
+}
+
+func TestSpellSkipsAcronymsAndShortWords(t *testing.T) {
+ e := spellEditor(t, "the NASA API is ok")
+ w := wavyWords(e)
+ if w["NASA"] || w["API"] || w["ok"] || w["is"] {
+ t.Errorf("acronyms/short words should not be flagged; wavy=%v", w)
+ }
+}
+
+func TestSpellOffWhenDisabled(t *testing.T) {
+ e := spellEditor(t, "a recieve typo")
+ e.SetSpell(false)
+ if len(wavyWords(e)) != 0 {
+ t.Error("no words should be flagged when spellcheck is off")
+ }
+}
+
+func TestSpellOffForCodeFiles(t *testing.T) {
+ e := spellEditor(t, "recieve := 1")
+ e.SetLanguage("main.go") // code file: spellcheck must stay off
+ if len(wavyWords(e)) != 0 {
+ t.Error("code files should not be spellchecked")
+ }
+}
+
+func TestSpellHonorsPersonalAdd(t *testing.T) {
+ e := spellEditor(t, "my zzplonk word")
+ if !wavyWords(e)["zzplonk"] {
+ t.Fatal("precondition: zzplonk should flag")
+ }
+ e.AddToDictionary("zzplonk")
+ if wavyWords(e)["zzplonk"] {
+ t.Error("word added to dictionary should clear its underline live")
+ }
+}
internal/editor/wrap.go +10 −1
@@ -60,7 +60,13 @@ pos = spEnd
lo := max(spStart, start)
hi := min(spEnd, end)
if lo < hi {
- out = append(out, Span{Text: string(r[lo-spStart : hi-spStart]), Style: sp.Style})
+ out = append(out, Span{
+ Text: string(r[lo-spStart : hi-spStart]),
+ Style: sp.Style,
+ Prose: sp.Prose,
+ Wavy: sp.Wavy,
+ UnderColor: sp.UnderColor,
+ })
}
}
return out
@@ -85,6 +91,9 @@ if e.codeFile != "" {
all = ScanCode(e.Lines, e.codeFile, e.theme)
} else {
all = ScanLines(e.Lines, e.theme)
+ }
+ if e.spellActive() {
+ all = e.spellPass(all)
}
var rows []vrow
for li := range e.Lines {
internal/theme/theme.go +1 −0
@@ -23,6 +23,7 @@ Blockquote lipgloss.Color // blockquote marker + border (muted tone)
Comment lipgloss.Color // HTML / %% comments — visible, not dimmed
Accent lipgloss.Color // frontmatter keys, selection
Highlight lipgloss.Color // ==highlight== background tint
+ Spell lipgloss.Color // misspelled-word undercurl (red)
// UI colors.
Background lipgloss.Color
internal/theme/themes.go +3 −0
@@ -28,6 +28,7 @@ Blockquote: lipgloss.Color("#878580"), // base-500 — quote marker/border
Comment: lipgloss.Color("#DA702C"), // orange-400 — visible meta
Accent: lipgloss.Color("#D0A215"), // yellow-400
Highlight: lipgloss.Color("#3A3517"), // deep olive — ==highlight== bg
+ Spell: lipgloss.Color("#D14D41"), // red-400 — misspell undercurl
StatusFg: lipgloss.Color("#100F0F"),
StatusBg: lipgloss.Color("#4385BE"),
SelFg: lipgloss.Color("#100F0F"),
@@ -55,6 +56,7 @@ Blockquote: lipgloss.Color("#6F6E69"), // base-600 — quote marker/border
Comment: lipgloss.Color("#BC5215"), // orange-600 — visible meta
Accent: lipgloss.Color("#AD8301"), // yellow-600
Highlight: lipgloss.Color("#F0E6BE"), // pale yellow — ==highlight== bg
+ Spell: lipgloss.Color("#AF3029"), // red-600 — misspell undercurl
StatusFg: lipgloss.Color("#FFFCF0"),
StatusBg: lipgloss.Color("#205EA6"),
SelFg: lipgloss.Color("#FFFCF0"),
@@ -82,6 +84,7 @@ Blockquote: lipgloss.Color("#6C6C8A"),
Comment: lipgloss.Color("#FFB454"),
Accent: lipgloss.Color("#FFD500"),
Highlight: lipgloss.Color("#3A2E4D"),
+ Spell: lipgloss.Color("#FF5F87"), // pink-red — misspell undercurl
StatusFg: lipgloss.Color("#16161E"),
StatusBg: lipgloss.Color("#6B50FF"),
SelFg: lipgloss.Color("#16161E"),