feat: add Toggle to the spellcheck popup (TASK-020)
1d757ec189f76136a1a5a8f4978328ee19b093aa
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 18:38
parent 1221e42d
feat: add Toggle to the spellcheck popup (TASK-020) The popup now carries a Toggle entry (key t) alongside suggestions/Add/Ignore, and Alt+; with no flagged word under the cursor opens a minimal toggle-only popup — so spellcheck can always be turned back on even when nothing is underlined. Docs updated (help, README). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
4 files changed
README.md +5 −3
@@ -70,7 +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 |
+| `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, `t` toggle spellcheck, `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 |
@@ -114,8 +114,10 @@ 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
+`a` to add the word to your personal dictionary, `i` to ignore it for the
+session, or `t` to toggle spellcheck off and on (`Alt+;` opens a toggle-only
+popup when no word is flagged). 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.
internal/app/spell.go +29 −5
@@ -15,6 +15,7 @@ const (
spellSuggest spellKind = iota // replace the word with value
spellAdd // add the word to the personal dictionary
spellIgnore // ignore the word for this session
+ spellToggle // turn spellcheck on/off for the session
)
// spellOption is one selectable row in the misspelled-word popup.
@@ -47,6 +48,7 @@ }
opts = append(opts,
spellOption{label: "Add to dictionary", kind: spellAdd},
spellOption{label: "Ignore", kind: spellIgnore},
+ spellOption{label: "Toggle spellcheck", kind: spellToggle},
)
a.spell = spellPopup{word: word, row: row, start: start, end: end, options: opts}
a.mode = ModeSpell
@@ -54,13 +56,21 @@ 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.
+// openSpellPopup is the Alt+; handler: it opens the full popup on a flagged word
+// at the cursor, or, with no misspelling there, a minimal popup offering just the
+// session toggle (so spellcheck can always be turned back on even when no
+// underlines are visible). It always opens something, so it never reports false.
func (a *App) openSpellPopup() bool {
if a.mode != ModeEditor {
return false
}
- return a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col)
+ if a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col) {
+ return true
+ }
+ a.spell = spellPopup{options: []spellOption{{label: "Toggle spellcheck", kind: spellToggle}}}
+ a.mode = ModeSpell
+ a.status = ""
+ return true
}
// handleSpellKey drives the popup: arrows/Tab move the selection, a number key
@@ -90,6 +100,8 @@ case r == 'a' || r == 'A':
return a.applySpell(a.kindIndex(spellAdd))
case r == 'i' || r == 'I':
return a.applySpell(a.kindIndex(spellIgnore))
+ case r == 't' || r == 'T':
+ return a.applySpell(a.kindIndex(spellToggle))
}
}
}
@@ -103,7 +115,7 @@ if o.kind == k {
return i
}
}
- return 0
+ return -1
}
// applySpell performs option i — replace, add, or ignore — then closes the popup.
@@ -126,6 +138,12 @@ }
case spellIgnore:
a.editor.IgnoreWord(a.spell.word)
a.status = "Ignored " + a.spell.word
+ case spellToggle:
+ if on := a.editor.ToggleSpell(); on {
+ a.status = "Spellcheck on"
+ } else {
+ a.status = "Spellcheck off"
+ }
}
a.mode = ModeEditor
return a, nil
@@ -142,7 +160,11 @@ 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+"” →")
+ if a.spell.word != "" {
+ parts = append(parts, "“"+a.spell.word+"” →")
+ } else {
+ parts = append(parts, "Spellcheck:")
+ }
for i, o := range a.spell.options {
var label string
switch o.kind {
@@ -152,6 +174,8 @@ case spellAdd:
label = "a Add"
case spellIgnore:
label = "i Ignore"
+ case spellToggle:
+ label = "t Toggle"
}
if i == a.spell.sel {
label = selStyle.Render(" " + label + " ")
internal/app/spell_test.go +30 −3
@@ -32,14 +32,19 @@ t.Errorf("want suggestions + add + ignore, got %d options", len(a.spell.options))
}
}
-func TestAltSemicolonNoMisspellingShowsStatus(t *testing.T) {
+func TestAltSemicolonNoMisspellingOpensToggleOnly(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")
+ // With no flagged word, Alt+; opens a minimal toggle-only popup (no word, a
+ // single Toggle option) so spellcheck can always be turned off/on.
+ if a.mode != ModeSpell {
+ t.Fatal("Alt+; did not open the toggle-only popup")
+ }
+ if a.spell.word != "" || len(a.spell.options) != 1 || a.spell.options[0].kind != spellToggle {
+ t.Errorf("want toggle-only popup, got word=%q opts=%+v", a.spell.word, a.spell.options)
}
}
@@ -80,3 +85,25 @@ if strings.Contains(a.status, "failed") {
t.Errorf("ignore reported failure: %q", a.status)
}
}
+
+func TestSpellPopupToggleOff(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)
+ a.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("t")}) // toggle off
+ if a.editor.SpellEnabled() {
+ t.Error("toggle did not disable spellcheck")
+ }
+ // With spellcheck off there are no underlines, but Alt+; still opens a
+ // toggle-only popup so it can be turned back on.
+ a.handleKey(altSemicolon)
+ if a.mode != ModeSpell {
+ t.Fatal("Alt+; with spellcheck off did not open the toggle popup")
+ }
+ a.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("t")}) // toggle on
+ if !a.editor.SpellEnabled() {
+ t.Error("toggle did not re-enable spellcheck")
+ }
+}
internal/help/help.go +2 −2
@@ -36,8 +36,8 @@ 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
+ (pick a suggestion 1-9, a add to dictionary, i ignore,
+ t toggle spellcheck); clicking an underlined word opens it
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