▍ humdrum codex / glint v1.0.2
license AGPL-3.0

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