package app import ( "fmt" "sort" "strings" "glint/internal/grammar" 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 spellToggle // turn spellcheck on/off for the session grammarToggle // turn Harper grammar checking on/off (TASK-043) grammarFix // apply a Harper replacement (edits) (TASK-044) grammarIgnore // ignore this grammar lint for the session (TASK-044) ) // spellOption is one selectable row in the misspelled-word popup. type spellOption struct { label string kind spellKind value string // replacement word for spellSuggest edits []grammar.TextEdit // buffer edits for grammarFix (Harper's replacement) } // spellPopup is the state of the active proofing popup: the flagged word (or grammar // span text), its location, the grammar rule code (for grammarIgnore), the choice // list, and the cursor within it. type spellPopup struct { word string code string // Harper rule code, set for grammar popups 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 { // Spelling wins on overlap; with no misspelling here, try a grammar span. return a.openGrammarPopupAt(row, col) } 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}, spellOption{label: "Toggle spellcheck", kind: spellToggle}, spellOption{label: "Toggle grammar", kind: grammarToggle}, ) a.spell = spellPopup{word: word, row: row, start: start, end: end, options: opts} a.mode = ModeSpell a.status = "" return true } // openGrammarPopupAt opens the proofing popup on a grammar span at (row, col): it // asks harper for the fixes on that span and lists them as replacement rows, followed // by Ignore and the grammar toggle. Returns false when grammar is off or no grammar // span sits there. Fixes may be empty (harper offered none, or the request timed out) // — the popup still opens so the span can be ignored. func (a *App) openGrammarPopupAt(row, col int) bool { if a.grammar == nil { return false } start, end, ok := a.editor.GrammarSpanAt(row, col) if !ok { return false } text := a.editor.RuneRangeText(row, start, end) actions, _ := a.grammar.CodeActions(a.grammarPath, row, start, end) opts := make([]spellOption, 0, len(actions)+2) for _, ac := range actions { opts = append(opts, spellOption{label: ac.Title, kind: grammarFix, edits: ac.Edits}) } opts = append(opts, spellOption{label: "Ignore", kind: grammarIgnore}, spellOption{label: "Toggle grammar", kind: grammarToggle}, ) a.spell = spellPopup{ word: text, code: a.grammarCodeAt(row, start, end), row: row, start: start, end: end, options: opts, } a.mode = ModeSpell a.status = "" return true } // grammarCodeAt returns the Harper rule code of the retained diagnostic matching the // span (row, [start,end)), or "" if none — used to key the session ignore. func (a *App) grammarCodeAt(row, start, end int) string { for _, d := range a.lastGrammarDiags { if d.Line == row && d.StartCol == start && d.EndCol == end { return d.Code } } return "" } // 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 } if a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col) { return true } a.spell = spellPopup{options: []spellOption{ {label: "Toggle spellcheck", kind: spellToggle}, {label: "Toggle grammar", kind: grammarToggle}, }} a.mode = ModeSpell a.status = "" return true } // 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': if idx := a.kindIndex(spellIgnore); idx >= 0 { return a.applySpell(idx) } return a.applySpell(a.kindIndex(grammarIgnore)) case r == 't' || r == 'T': return a.applySpell(a.kindIndex(spellToggle)) case r == 'g' || r == 'G': return a.applySpell(a.kindIndex(grammarToggle)) } } } 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 -1 } // 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 case spellToggle: if on := a.editor.ToggleSpell(); on { a.status = "Spellcheck on" } else { a.status = "Spellcheck off" } case grammarFix: a.applyGrammarFix(opt.edits) a.status = "Applied: " + opt.label case grammarIgnore: a.ignoreGrammar(a.spell.code, a.spell.word) a.status = "Ignored grammar hint" case grammarToggle: cmd := a.toggleGrammar() a.mode = ModeEditor return a, cmd } a.mode = ModeEditor return a, nil } // applyGrammarFix applies Harper's replacement edits to the buffer. Edits are applied // last-first (by document position) so earlier edits don't shift later ranges. func (a *App) applyGrammarFix(edits []grammar.TextEdit) { sorted := make([]grammar.TextEdit, len(edits)) copy(sorted, edits) sort.Slice(sorted, func(i, j int) bool { if sorted[i].StartLine != sorted[j].StartLine { return sorted[i].StartLine > sorted[j].StartLine } return sorted[i].StartCol > sorted[j].StartCol }) for _, e := range sorted { a.editor.ReplaceRuneRange(e.StartLine, e.StartCol, e.EndLine, e.EndCol, e.NewText) } } // 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) 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 { case spellSuggest, grammarFix: label = fmt.Sprintf("%d %s", i+1, o.label) case spellAdd: label = "a Add" case spellIgnore, grammarIgnore: label = "i Ignore" case spellToggle: label = "t Spell" case grammarToggle: label = "g Grammar" } 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, " ") + " ") }