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 spellToggle // turn spellcheck on/off for the 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}, spellOption{label: "Toggle spellcheck", kind: spellToggle}, ) a.spell = spellPopup{word: word, row: row, start: start, end: end, options: opts} a.mode = ModeSpell a.status = "" return true } // 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}}} 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': return a.applySpell(a.kindIndex(spellIgnore)) case r == 't' || r == 'T': return a.applySpell(a.kindIndex(spellToggle)) } } } 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" } } 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) 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: label = fmt.Sprintf("%d %s", i+1, o.label) case spellAdd: label = "a Add" case spellIgnore: label = "i Ignore" case spellToggle: label = "t Toggle" } 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, " ") + " ") }