feat: spellcheck popup, mouse trigger, config + docs (TASK-020)
1221e42d9efe8f54372d5cd8b13de7993a101408
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 18:32
parent 92f9461e
feat: spellcheck popup, mouse trigger, config + docs (TASK-020) Wire inline spellcheck into the app end-to-end: - App loads the embedded dictionary + personal dict (~/.config/glint/dict.txt) at startup and enables spellcheck per the new spellcheck=auto|on|off config key. - Alt+; opens a popup on the misspelled word at the cursor; clicking an underlined word opens it too. The popup lists up to five edit-distance suggestions plus Add-to-dictionary and Ignore: pick 1-9 to replace in place (marks dirty, clears the underline), a to add, i to ignore for the session, Esc to dismiss. Rendered as a themed bottom bar like find/goto. - Editor gains FlaggedWordAt / ReplaceWordAt / Suggest / IgnoreWord. - glint -c walkthrough, glint -h reference, the in-editor help overlay, README, and CHANGELOG document the feature, including the tmux undercurl passthrough. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
12 files changed
CHANGELOG.md +15 −0
@@ -4,6 +4,21 @@ All notable changes to glint are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow
[Semantic Versioning](https://semver.org/).
+## [Unreleased]
+
+### Added
+- Inline spellcheck: a red curly underline (undercurl) under misspelled prose,
+ backed by an embedded ~60k common-English dictionary (pure Go, no cgo). Code,
+ inline code, URLs, wikilinks, link targets, and frontmatter are never flagged;
+ code files are skipped entirely. `Alt+;` (or clicking an underlined word) opens
+ a popup with edit-distance suggestions — replace in place, add to a personal
+ dictionary at `~/.config/glint/dict.txt`, or ignore for the session. New
+ `spellcheck = auto | on | off` config key.
+
+### Changed
+- Vertical cursor moves (Up/Down) and clicks rebuild the visual model once per
+ keystroke instead of twice.
+
## [0.1.0] — 2026-06-28
First public release.
README.md +28 −1
@@ -7,7 +7,8 @@ preview, fuzzy-find notes with a live preview pane, and theme it to match your
terminal (Flexoki light/dark + charm). Open any file, jump to today's daily
note, or start a fresh document from anywhere. Code files open with calm,
minimal syntax highlighting (strings, comments, and numbers only) so glint works
-as a quick `$EDITOR` too.
+as a quick `$EDITOR` too. Prose gets light inline spellcheck — a red curly
+underline under obvious typos, with one-key suggestions.
## Install
@@ -69,6 +70,7 @@ | `Ctrl+D` | today's daily note |
| `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
| `Ctrl+B` | new note in the inbox |
| `Ctrl+T` | cycle theme (flexoki-light → flexoki-dark → charm) |
+| `Alt+;` · click | spellcheck popup on the misspelled word at the cursor (or click an underlined word): pick a suggestion `1`–`9`, `a` add to dictionary, `i` ignore, `Esc` close |
| `Ctrl+/` | toggle the in-editor help overlay (keys + commands) |
| `Ctrl+Q` | quit (press twice if there are unsaved changes) |
| `Esc` | clear the selection, or close find / back to the editor |
@@ -86,6 +88,7 @@ daily_subdir = "Daily" # daily notes live in <vault>/<daily_subdir>/ (absolute path used as-is)
daily_format = "2006-01-02" # Go time layout for daily-note filenames
theme = "auto" # auto | flexoki-light | flexoki-dark | charm (auto detects macOS appearance)
glamour_style = "" # override the preview style; "" follows the theme
+spellcheck = "auto" # auto | on | off (auto = on for prose/notes, off for code files)
```
**Two roots.** Bare `glint`, `glint -n`, and `-d` operate on the **working
@@ -100,6 +103,30 @@ Three palettes, every span explicitly colored so text stays readable on light
and dark terminals: **flexoki-light**, **flexoki-dark**, and **charm**. `auto`
picks light or dark from the macOS system appearance; `Ctrl+T` cycles live. The
read preview and the whole canvas background follow the active theme.
+
+## Spellcheck
+
+Misspelled prose gets a calm red curly underline (undercurl). It's deliberately
+light — an embedded ~60k common-English dictionary catches obvious typos without
+the false positives of a heavy morphological checker. Code fences, inline code,
+URLs, wikilinks, link targets, and frontmatter are never flagged, and recognized
+code files are skipped entirely (same extension routing as syntax highlighting).
+
+`Alt+;` (or clicking an underlined word) opens a popup with up to five
+suggestions ranked by edit distance — pick one with `1`–`9` to replace in place,
+`a` to add the word to your personal dictionary, or `i` to ignore it for the
+session. The personal dictionary is a plain, hand-editable file at
+`~/.config/glint/dict.txt` (one word per line). Set `spellcheck = off` to disable
+it, or `on` to force it on.
+
+The curly underline uses the `4:3` SGR underline-style and `58` underline-color
+codes — supported by Ghostty, kitty, WezTerm, foot, and recent VTE terminals.
+Terminals without them degrade to a straight underline or none. Inside **tmux**,
+enable passthrough so the styles reach the terminal:
+
+```tmux
+set -as terminal-features ',*:usstyle'
+```
## License
- → Spellcheck-with-undercurl-underlines.md +3 −1
@@ -4,7 +4,7 @@ title: Spellcheck with undercurl underlines
status: "\U0001F7E2 In progress"
assignee: []
created_date: '2026-06-29 18:11'
-updated_date: '2026-06-30 01:14'
+updated_date: '2026-06-30 01:20'
labels:
- feature
- release-1
@@ -80,4 +80,6 @@ ## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Slice A/B/C done (commit 2c0a77e9): internal/spell — 60k embedded dict (freq∩curated, rejects common typos), Known() w/ possessive leniency, BK-tree Suggest() OSA-reranked + freq tie-break, personal dict load/Add at ~/.config/glint/dict.txt. 11 tests green.
+
+Slice D/E done (commits 92f9461e, +theme.Spell): undercurl Span rendering (raw SGR 4:3 + 58:2 color, invariant-preserving, graceful degrade), scanner Prose tagging, spellPass with skip rules (code/inline-code/URL/email/wikilink/link-target/frontmatter/acronym/camelcase/<3-char), word->ok cache, codeFile + toggle gates, AddToDictionary clears cache live. 13 editor tests green.
<!-- SECTION:NOTES:END -->
internal/app/app.go +33 −0
@@ -16,6 +16,7 @@ "glint/internal/editor"
"glint/internal/help"
"glint/internal/picker"
"glint/internal/preview"
+ "glint/internal/spell"
"glint/internal/theme"
"github.com/atotto/clipboard"
@@ -36,6 +37,7 @@ ModeSaveAs
ModeFind
ModeHelp
ModeGotoLine
+ ModeSpell
)
// pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -70,6 +72,7 @@ findInput textinput.Model // one-line in-document find prompt (TASK-007)
gotoInput textinput.Model // one-line go-to-line prompt (TASK-012)
helpView viewport.Model // scrollable keybind overlay (TASK-011)
cursorMem map[string]editor.Position // per-file cursor memory, this session (TASK-012)
+ spell spellPopup // active misspelled-word popup (TASK-020)
path string
pickerRoot string // directory the current picker is browsing
saveDir string // where an unnamed buffer's save-as lands ("" → inbox)
@@ -110,9 +113,24 @@ cursorMem: map[string]editor.Position{},
}
a.preview = preview.New(a.glamourStyle())
a.preview.SetColors(previewColors(th))
+ a.initSpell()
return a
}
+// initSpell loads the embedded dictionary and personal word list, then sets the
+// session spellcheck toggle from config (auto/on enable it; off disables). A load
+// failure leaves spellcheck inert rather than blocking startup.
+func (a *App) initSpell() {
+ d, err := spell.Load()
+ if err != nil {
+ return
+ }
+ d.SetPersonalPath(config.DictPath())
+ _ = d.LoadPersonal()
+ a.editor.SetDict(d)
+ a.editor.SetSpell(!strings.EqualFold(a.cfg.Spellcheck, "off"))
+}
+
// previewColors maps a theme to the glamour preview's color set.
func previewColors(th theme.Theme) preview.Colors {
return preview.Colors{
@@ -193,6 +211,8 @@ // screen row Y is that many rows into the viewport.
vi := a.editor.Scroll + msg.Y - a.topPad() + 1
col := msg.X - a.leftMargin()
a.editor.MoveToVisual(vi, col)
+ // Clicking a flagged (underlined) word opens its suggestion popup.
+ a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col)
}
return a, nil
}
@@ -236,6 +256,15 @@ if !repressed {
a.pending = discardNone
}
+ // Alt+; opens the spellcheck popup on a flagged word at the cursor.
+ if msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == ";" {
+ if a.openSpellPopup() {
+ return a, nil
+ }
+ a.status = "No misspelling at the cursor"
+ return a, nil
+ }
+
switch msg.Type {
case tea.KeyCtrlQ:
if a.editor.Dirty && !a.quitArmed {
@@ -316,6 +345,8 @@ case ModeFind:
return a.handleFindKey(msg)
case ModeGotoLine:
return a.handleGotoKey(msg)
+ case ModeSpell:
+ return a.handleSpellKey(msg)
case ModeHelp:
var cmd tea.Cmd
a.helpView, cmd = a.helpView.Update(msg) // arrows / PgUp / PgDn scroll
@@ -793,6 +824,8 @@ case ModeFind:
bottom = a.findBar()
case ModeGotoLine:
bottom = a.gotoBar()
+ case ModeSpell:
+ bottom = a.spellBar()
}
return a.paintCanvas(body) + bottom
}
internal/app/spell.go +165 −0
@@ -0,0 +1,165 @@
+package app
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// spellKind distinguishes the popup's action rows.
+type spellKind int
+
+const (
+ spellSuggest spellKind = iota // replace the word with value
+ spellAdd // add the word to the personal dictionary
+ spellIgnore // ignore the word for this session
+)
+
+// spellOption is one selectable row in the misspelled-word popup.
+type spellOption struct {
+ label string
+ kind spellKind
+ value string // replacement word for spellSuggest
+}
+
+// spellPopup is the state of the active misspelled-word popup: the flagged word,
+// its location, the choice list, and the cursor within it.
+type spellPopup struct {
+ word string
+ row, start, end int
+ options []spellOption
+ sel int
+}
+
+// openSpellPopupAt opens the suggestion popup for a flagged word at (row, col),
+// returning false (and doing nothing) when no misspelled word sits there.
+func (a *App) openSpellPopupAt(row, col int) bool {
+ word, start, end, ok := a.editor.FlaggedWordAt(row, col)
+ if !ok {
+ return false
+ }
+ opts := make([]spellOption, 0, 7)
+ for _, s := range a.editor.Suggest(word, 5) {
+ opts = append(opts, spellOption{label: s, kind: spellSuggest, value: s})
+ }
+ opts = append(opts,
+ spellOption{label: "Add to dictionary", kind: spellAdd},
+ spellOption{label: "Ignore", kind: spellIgnore},
+ )
+ a.spell = spellPopup{word: word, row: row, start: start, end: end, options: opts}
+ a.mode = ModeSpell
+ a.status = ""
+ return true
+}
+
+// openSpellPopup triggers the popup for a flagged word at or next to the cursor
+// (the Alt+; handler). It reports whether a popup opened.
+func (a *App) openSpellPopup() bool {
+ if a.mode != ModeEditor {
+ return false
+ }
+ return a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col)
+}
+
+// handleSpellKey drives the popup: arrows/Tab move the selection, a number key
+// jumps to and applies that row, Enter applies the selection, Esc dismisses.
+func (a *App) handleSpellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ n := len(a.spell.options)
+ switch msg.Type {
+ case tea.KeyEsc:
+ a.mode = ModeEditor
+ return a, nil
+ case tea.KeyUp, tea.KeyShiftTab:
+ a.spell.sel = (a.spell.sel - 1 + n) % n
+ return a, nil
+ case tea.KeyDown, tea.KeyTab:
+ a.spell.sel = (a.spell.sel + 1) % n
+ return a, nil
+ case tea.KeyEnter:
+ return a.applySpell(a.spell.sel)
+ case tea.KeyRunes:
+ if len(msg.Runes) == 1 {
+ switch r := msg.Runes[0]; {
+ case r >= '1' && r <= '9':
+ if i := int(r - '1'); i < n {
+ return a.applySpell(i)
+ }
+ case r == 'a' || r == 'A':
+ return a.applySpell(a.kindIndex(spellAdd))
+ case r == 'i' || r == 'I':
+ return a.applySpell(a.kindIndex(spellIgnore))
+ }
+ }
+ }
+ return a, nil
+}
+
+// kindIndex returns the option index of the first row of the given kind.
+func (a *App) kindIndex(k spellKind) int {
+ for i, o := range a.spell.options {
+ if o.kind == k {
+ return i
+ }
+ }
+ return 0
+}
+
+// applySpell performs option i — replace, add, or ignore — then closes the popup.
+func (a *App) applySpell(i int) (tea.Model, tea.Cmd) {
+ if i < 0 || i >= len(a.spell.options) {
+ a.mode = ModeEditor
+ return a, nil
+ }
+ opt := a.spell.options[i]
+ switch opt.kind {
+ case spellSuggest:
+ a.editor.ReplaceWordAt(a.spell.row, a.spell.start, a.spell.end, opt.value)
+ a.status = "Replaced with " + opt.value
+ case spellAdd:
+ if err := a.editor.AddToDictionary(a.spell.word); err != nil {
+ a.status = "Add to dictionary failed: " + err.Error()
+ } else {
+ a.status = "Added " + a.spell.word + " to dictionary"
+ }
+ case spellIgnore:
+ a.editor.IgnoreWord(a.spell.word)
+ a.status = "Ignored " + a.spell.word
+ }
+ a.mode = ModeEditor
+ return a, nil
+}
+
+// spellBar renders the popup as a themed full-width bottom bar: the misspelled
+// word, then numbered choices with the current selection highlighted, and the
+// add/ignore hints.
+func (a *App) spellBar() string {
+ bar := lipgloss.NewStyle().
+ Foreground(a.theme.StatusFg).
+ Background(a.theme.StatusBg).
+ Width(maxInt(a.width, 1))
+ selStyle := lipgloss.NewStyle().Foreground(a.theme.SelFg).Background(a.theme.SelBg)
+
+ parts := make([]string, 0, len(a.spell.options)+1)
+ parts = append(parts, "“"+a.spell.word+"” →")
+ for i, o := range a.spell.options {
+ var label string
+ switch o.kind {
+ case spellSuggest:
+ label = fmt.Sprintf("%d %s", i+1, o.label)
+ case spellAdd:
+ label = "a Add"
+ case spellIgnore:
+ label = "i Ignore"
+ }
+ if i == a.spell.sel {
+ label = selStyle.Render(" " + label + " ")
+ } else {
+ label = " " + label + " "
+ }
+ parts = append(parts, label)
+ }
+ parts = append(parts, " Esc")
+ return bar.Render(" " + strings.Join(parts, " ") + " ")
+}
internal/app/spell_test.go +82 −0
@@ -0,0 +1,82 @@
+package app
+
+import (
+ "strings"
+ "testing"
+
+ "glint/internal/editor"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// altSemicolon is the Alt+; key event that opens the spell popup.
+var altSemicolon = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";"), Alt: true}
+
+func editorPos(row, col int) editor.Position { return editor.Position{Row: row, Col: col} }
+
+func TestAltSemicolonOpensPopupOnMisspelling(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetContent([]byte("a recieve here"))
+ a.editor.SetCursor(editorPos(0, 4)) // inside "recieve"
+
+ a.handleKey(altSemicolon)
+ if a.mode != ModeSpell {
+ t.Fatalf("mode = %d, want ModeSpell", a.mode)
+ }
+ if a.spell.word != "recieve" {
+ t.Errorf("popup word = %q, want recieve", a.spell.word)
+ }
+ if len(a.spell.options) < 3 {
+ t.Errorf("want suggestions + add + ignore, got %d options", len(a.spell.options))
+ }
+}
+
+func TestAltSemicolonNoMisspellingShowsStatus(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetContent([]byte("all correct words"))
+ a.editor.SetCursor(editorPos(0, 1))
+ a.handleKey(altSemicolon)
+ if a.mode == ModeSpell {
+ t.Error("popup opened with no misspelling under the cursor")
+ }
+}
+
+func TestSpellPopupNumberKeyReplaces(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetContent([]byte("a recieve here"))
+ a.editor.SetCursor(editorPos(0, 4))
+ a.handleKey(altSemicolon)
+
+ // The first suggestion for "recieve" is "receive"; press "1" to apply it.
+ if a.spell.options[0].value != "receive" {
+ t.Fatalf("first suggestion = %q, want receive", a.spell.options[0].value)
+ }
+ a.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
+ if a.mode != ModeEditor {
+ t.Errorf("popup did not close after applying; mode = %d", a.mode)
+ }
+ if got := a.editor.Lines[0]; got != "a receive here" {
+ t.Errorf("line after replace = %q, want \"a receive here\"", got)
+ }
+}
+
+func TestSpellPopupAddKey(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetContent([]byte("my zzplonk word"))
+ a.editor.SetCursor(editorPos(0, 4))
+ a.handleKey(altSemicolon)
+ if a.mode != ModeSpell {
+ t.Fatalf("popup did not open on zzplonk")
+ }
+ a.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")}) // ignore for session
+ if a.mode != ModeEditor {
+ t.Errorf("popup did not close after ignore")
+ }
+ if strings.Contains(a.status, "failed") {
+ t.Errorf("ignore reported failure: %q", a.status)
+ }
+}
internal/config/config.go +15 −0
@@ -19,6 +19,7 @@ DailyFormat string `toml:"daily_format"`
GlamourStyle string `toml:"glamour_style"`
Theme string `toml:"theme"`
InboxDir string `toml:"inbox_dir"`
+ Spellcheck string `toml:"spellcheck"` // auto | on | off (TASK-020)
}
// Default returns the built-in configuration used when no file is present.
@@ -29,6 +30,7 @@ return Config{
DailySubdir: "Daily",
DailyFormat: "2006-01-02",
Theme: "auto",
+ Spellcheck: "auto",
}
}
@@ -62,6 +64,16 @@ }
return filepath.Join(home, ".config", "glint", "config.toml")
}
+// DictPath is the personal spellcheck dictionary location:
+// ~/.config/glint/dict.txt (TASK-020).
+func DictPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return filepath.Join(".config", "glint", "dict.txt")
+ }
+ return filepath.Join(home, ".config", "glint", "dict.txt")
+}
+
// Load reads the config file and overlays it onto the defaults.
func Load() (Config, error) {
return loadFromFile(Path())
@@ -115,6 +127,9 @@ cfg.Theme = fileCfg.Theme
}
if fileCfg.InboxDir != "" {
cfg.InboxDir = fileCfg.InboxDir
+ }
+ if fileCfg.Spellcheck != "" {
+ cfg.Spellcheck = fileCfg.Spellcheck
}
return cfg, nil
}
internal/configui/configui.go +10 −0
@@ -32,6 +32,7 @@ vaultDir := cfg.VaultDir
inboxDir := cfg.InboxDir
dailySubdir := orDefault(cfg.DailySubdir, "Daily")
glamour := cfg.GlamourStyle
+ spellcheck := orDefault(cfg.Spellcheck, "auto")
// Match the current layout to a preset; otherwise offer it as custom.
fmtChoice := customLayout
@@ -86,6 +87,14 @@ huh.NewOption("light", "light"),
huh.NewOption("dracula", "dracula"),
huh.NewOption("tokyo-night", "tokyo-night"),
).Value(&glamour),
+ huh.NewSelect[string]().
+ Title("Spellcheck").
+ Description("Red undercurl on misspelled prose. auto = on for notes, off for code files.").
+ Options(
+ huh.NewOption("auto", "auto"),
+ huh.NewOption("on", "on"),
+ huh.NewOption("off", "off"),
+ ).Value(&spellcheck),
),
huh.NewGroup(
huh.NewInput().
@@ -111,6 +120,7 @@ InboxDir: inboxDir,
DailySubdir: dailySubdir,
DailyFormat: dailyFmt,
GlamourStyle: glamour,
+ Spellcheck: spellcheck,
}
path := config.Path()
if err := config.Save(out, path); err != nil {
internal/editor/editor.go +4 −3
@@ -41,9 +41,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
+ 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
+ spellIgnore map[string]bool // words ignored for this session only
}
// SetLanguage selects the scanner from the file's extension: markdown/text/no
internal/editor/spellcheck.go +62 −1
@@ -50,9 +50,12 @@ return err
}
// spellKnown is a cached membership test; the cache is dropped when the personal
-// dictionary changes.
+// dictionary changes. Session-ignored words always count as known.
func (e *Editor) spellKnown(word string) bool {
lw := strings.ToLower(word)
+ if e.spellIgnore[lw] {
+ return true
+ }
if v, ok := e.spellCache[lw]; ok {
return v
}
@@ -62,6 +65,64 @@ e.spellCache = map[string]bool{}
}
e.spellCache[lw] = k
return k
+}
+
+// IgnoreWord marks word correct for this session only (the popup's Ignore
+// action); it is not written to the personal dictionary.
+func (e *Editor) IgnoreWord(word string) {
+ if e.spellIgnore == nil {
+ e.spellIgnore = map[string]bool{}
+ }
+ e.spellIgnore[strings.ToLower(word)] = true
+}
+
+// Suggest returns up to max spelling corrections for word, or nil without a
+// dictionary.
+func (e *Editor) Suggest(word string, max int) []string {
+ if e.dict == nil {
+ return nil
+ }
+ return e.dict.Suggest(word, max)
+}
+
+// FlaggedWordAt returns the misspelled word covering rune column col on the given
+// row (inclusive of the column just past the word, so a cursor resting at the
+// word's end still resolves it), plus its rune range [start,end). ok is false
+// when spellcheck is inactive or no flagged word is there.
+func (e *Editor) FlaggedWordAt(row, col int) (word string, start, end int, ok bool) {
+ if !e.spellActive() || row < 0 || row >= len(e.Lines) {
+ return "", 0, 0, false
+ }
+ all := ScanLines(e.Lines, e.theme)
+ all = e.spellPass(all)
+ pos := 0
+ for _, sp := range all[row] {
+ n := len([]rune(sp.Text))
+ if sp.Wavy && col >= pos && col <= pos+n {
+ return sp.Text, pos, pos + n, true
+ }
+ pos += n
+ }
+ return "", 0, 0, false
+}
+
+// ReplaceWordAt swaps the rune range [start,end) on row for replacement, parks
+// the cursor after it, marks the buffer dirty, and records an undo checkpoint.
+func (e *Editor) ReplaceWordAt(row, start, end int, replacement string) {
+ if row < 0 || row >= len(e.Lines) {
+ return
+ }
+ e.PushUndo()
+ r := []rune(e.Lines[row])
+ if start < 0 || end > len(r) || start > end {
+ return
+ }
+ e.Lines[row] = string(r[:start]) + replacement + string(r[end:])
+ e.Cursor = Position{Row: row, Col: start + len([]rune(replacement))}
+ e.anchor = nil
+ e.Dirty = true
+ e.setGoal()
+ e.followCursor()
}
// spellPass splits every prose span into words and re-emits misspelled ones as
internal/editor/spellcheck_test.go +58 −0
@@ -106,3 +106,61 @@ if wavyWords(e)["zzplonk"] {
t.Error("word added to dictionary should clear its underline live")
}
}
+
+func TestFlaggedWordAtFindsMisspelling(t *testing.T) {
+ e := spellEditor(t, "a recieve here")
+ // "recieve" spans columns 2..9.
+ word, start, end, ok := e.FlaggedWordAt(0, 4)
+ if !ok || word != "recieve" {
+ t.Fatalf("FlaggedWordAt(0,4) = %q,%d,%d,%v; want recieve", word, start, end, ok)
+ }
+ if start != 2 || end != 9 {
+ t.Errorf("range = [%d,%d), want [2,9)", start, end)
+ }
+}
+
+func TestFlaggedWordAtNoneOnCorrectWord(t *testing.T) {
+ e := spellEditor(t, "all correct words here")
+ if _, _, _, ok := e.FlaggedWordAt(0, 2); ok {
+ t.Error("FlaggedWordAt found a flag on a correct line")
+ }
+}
+
+func TestReplaceWordAt(t *testing.T) {
+ e := spellEditor(t, "a recieve here")
+ e.ReplaceWordAt(0, 2, 9, "receive")
+ if e.Lines[0] != "a receive here" {
+ t.Errorf("after replace, line = %q", e.Lines[0])
+ }
+ if !e.Dirty {
+ t.Error("replace should mark the buffer dirty")
+ }
+ if wavyWords(e)["receive"] {
+ t.Error("replaced word should no longer be flagged")
+ }
+}
+
+func TestIgnoreWordClearsFlag(t *testing.T) {
+ e := spellEditor(t, "my zzplonk word")
+ if !wavyWords(e)["zzplonk"] {
+ t.Fatal("precondition: zzplonk flagged")
+ }
+ e.IgnoreWord("zzplonk")
+ if wavyWords(e)["zzplonk"] {
+ t.Error("ignored word should not be flagged for the session")
+ }
+}
+
+func TestEditorSuggest(t *testing.T) {
+ e := spellEditor(t, "recieve")
+ got := e.Suggest("recieve", 5)
+ found := false
+ for _, s := range got {
+ if s == "receive" {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("Suggest(recieve) = %v, want receive", got)
+ }
+}
internal/help/help.go +9 −0
@@ -35,6 +35,9 @@ Ctrl+D today's daily note
Ctrl+N new note in the current directory
Ctrl+B new note in the inbox
Ctrl+T cycle theme (flexoki-light / flexoki-dark / charm)
+ Alt+; spellcheck popup on the misspelled word at the cursor
+ (pick a suggestion 1-9, a add to dictionary, i ignore);
+ clicking an underlined word opens it too
Ctrl+C / Ctrl+X / Ctrl+V copy / cut / paste (system clipboard)
Shift+arrows select text (Ctrl+Shift+left/right by word)
Alt+left / Alt+right move by word
@@ -50,6 +53,12 @@ Home jump to the first non-blank column, then to column 0
( [ and backtick auto-close (no selection): inserts the matching closer
with the cursor between; type the closer to step past
paste a URL on a selection wraps it as a [selection](url) link
+
+SPELLCHECK
+ Misspelled prose gets a red curly underline. Code, inline code, URLs,
+ wikilinks, link targets, and frontmatter are never flagged; code files are
+ off entirely. Personal words live in ~/.config/glint/dict.txt (hand-editable).
+ Config key spellcheck = auto | on | off.
CONFIG
~/.config/glint/config.toml (run 'glint -c' to set it up)