From 9d535d253f00250249997bca62c9d68f41203f43 Mon Sep 17 00:00:00 2001 From: Lea Date: Mon, 25 May 2026 18:29:14 +0400 Subject: [PATCH 1/3] feat: spellcheck and suggestions Co-authored-by: Drew Smirnoff Co-authored-by: Andriy Chernov Co-authored-by: Steve Evans --- cli/dict.go | 105 ++++++++++ config/config.go | 72 ++++--- config/default_keybinds.json | 6 +- config/keybinds.go | 8 + docs/docs/Configuration.md | 7 + docs/docs/Features/CLI.md | 32 +++ docs/docs/Features/Spellcheck.md | 117 +++++++++++ i18n/locales/ar.json | 2 + i18n/locales/de.json | 2 + i18n/locales/en.json | 2 + i18n/locales/es.json | 2 + i18n/locales/fr.json | 2 + i18n/locales/ja.json | 2 + i18n/locales/pl.json | 2 + i18n/locales/pt.json | 2 + i18n/locales/ru.json | 2 + i18n/locales/uk.json | 2 + i18n/locales/zh.json | 2 + main.go | 37 +++- spellcheck/checker.go | 200 ++++++++++++++++++ spellcheck/dict.go | 108 ++++++++++ spellcheck/download.go | 89 ++++++++ spellcheck/highlight.go | 153 ++++++++++++++ spellcheck/spellcheck_test.go | 192 +++++++++++++++++ spellcheck/suggest.go | 180 ++++++++++++++++ tui/composer.go | 340 ++++++++++++++++++++++++++++++- tui/overlay.go | 58 ++++++ tui/settings_general.go | 16 +- 28 files changed, 1698 insertions(+), 44 deletions(-) create mode 100644 cli/dict.go create mode 100644 docs/docs/Features/Spellcheck.md create mode 100644 spellcheck/checker.go create mode 100644 spellcheck/dict.go create mode 100644 spellcheck/download.go create mode 100644 spellcheck/highlight.go create mode 100644 spellcheck/spellcheck_test.go create mode 100644 spellcheck/suggest.go create mode 100644 tui/overlay.go diff --git a/cli/dict.go b/cli/dict.go new file mode 100644 index 00000000..91861abd --- /dev/null +++ b/cli/dict.go @@ -0,0 +1,105 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/floatpane/matcha/spellcheck" +) + +// RunDict dispatches `matcha dict `. +func RunDict(args []string) error { + if len(args) == 0 { + return dictUsage() + } + switch args[0] { + case "add": + return RunDictAdd(args[1:]) + case "remove", "rm": + return RunDictRemove(args[1:]) + case "list", "ls": + return RunDictList() + default: + return dictUsage() + } +} + +func dictUsage() error { + return fmt.Errorf("usage:\n matcha dict add \n matcha dict remove \n matcha dict list") +} + +// RunDictAdd downloads and installs a spellcheck dictionary. +func RunDictAdd(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: matcha dict add ") + } + lang := strings.TrimSpace(args[0]) + if lang == "" { + return fmt.Errorf("empty language code") + } + fmt.Printf("Downloading %s dictionary...\n", lang) + path, err := spellcheck.Download(lang) + if err != nil { + return err + } + fmt.Printf("Installed %s -> %s\n", lang, path) + return nil +} + +// RunDictRemove deletes an installed dictionary. +func RunDictRemove(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: matcha dict remove ") + } + lang := strings.TrimSpace(args[0]) + path, err := spellcheck.DictPath(lang) + if err != nil { + return err + } + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("dictionary not installed: %s", lang) + } + return fmt.Errorf("remove %s: %w", lang, err) + } + fmt.Printf("Removed %s\n", lang) + return nil +} + +// RunDictList prints all installed dictionaries. +func RunDictList() error { + dir, err := spellcheck.DictsDir() + if err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read dicts dir: %w", err) + } + var langs []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".dic") { + continue + } + langs = append(langs, strings.TrimSuffix(name, ".dic")) + } + sort.Strings(langs) + if len(langs) == 0 { + fmt.Println("No dictionaries installed.") + fmt.Printf("Run `matcha dict add ` (e.g. en, en-GB, de, fr).\n") + fmt.Printf("Dictionaries are stored in: %s\n", dir) + return nil + } + for _, l := range langs { + path := filepath.Join(dir, l+".dic") + fmt.Println(l, " ", path) + } + return nil +} diff --git a/config/config.go b/config/config.go index 98520028..44dc1396 100644 --- a/config/config.go +++ b/config/config.go @@ -103,13 +103,15 @@ type MailingList struct { // Config stores the user's email configuration with multiple accounts. type Config struct { - Accounts []Account `json:"accounts"` - DisableImages bool `json:"disable_images,omitempty"` - HideTips bool `json:"hide_tips,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` - EnableSplitPane bool `json:"enable_split_pane,omitempty"` - EnableThreaded bool `json:"enable_threaded,omitempty"` - EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + Accounts []Account `json:"accounts"` + DisableImages bool `json:"disable_images,omitempty"` + HideTips bool `json:"hide_tips,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` + EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` + DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` @@ -424,13 +426,15 @@ type secureDiskAccount struct { } type secureDiskConfig struct { - Accounts []secureDiskAccount `json:"accounts"` - DisableImages bool `json:"disable_images,omitempty"` - HideTips bool `json:"hide_tips,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` - EnableSplitPane bool `json:"enable_split_pane,omitempty"` - EnableThreaded bool `json:"enable_threaded,omitempty"` - EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + Accounts []secureDiskAccount `json:"accounts"` + DisableImages bool `json:"disable_images,omitempty"` + HideTips bool `json:"hide_tips,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` + EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` + DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` @@ -473,16 +477,18 @@ func SaveConfig(config *Config) error { if secureMode { // In secure mode, include passwords in the JSON (they'll be encrypted on disk) sdc := secureDiskConfig{ - DisableImages: config.DisableImages, - HideTips: config.HideTips, - DisableNotifications: config.DisableNotifications, - EnableSplitPane: config.EnableSplitPane, - EnableThreaded: config.EnableThreaded, - EnableDetailedDates: config.EnableDetailedDates, - Theme: config.Theme, - MailingLists: config.MailingLists, - DateFormat: config.DateFormat, - PluginSettings: config.PluginSettings, + DisableImages: config.DisableImages, + HideTips: config.HideTips, + DisableNotifications: config.DisableNotifications, + EnableSplitPane: config.EnableSplitPane, + EnableThreaded: config.EnableThreaded, + EnableDetailedDates: config.EnableDetailedDates, + DisableSpellcheck: config.DisableSpellcheck, + DisableSpellSuggestions: config.DisableSpellSuggestions, + Theme: config.Theme, + MailingLists: config.MailingLists, + DateFormat: config.DateFormat, + PluginSettings: config.PluginSettings, } for _, acc := range config.Accounts { sdc.Accounts = append(sdc.Accounts, secureDiskAccount{ @@ -576,13 +582,15 @@ func LoadConfig() (*Config, error) { CatchAll bool `json:"catch_all,omitempty"` } type diskConfig struct { - Accounts []rawAccount `json:"accounts"` - DisableImages bool `json:"disable_images,omitempty"` - HideTips bool `json:"hide_tips,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` - EnableSplitPane bool `json:"enable_split_pane,omitempty"` - EnableThreaded bool `json:"enable_threaded,omitempty"` - EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + Accounts []rawAccount `json:"accounts"` + DisableImages bool `json:"disable_images,omitempty"` + HideTips bool `json:"hide_tips,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` + EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` + DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` @@ -623,6 +631,8 @@ func LoadConfig() (*Config, error) { config.EnableSplitPane = raw.EnableSplitPane config.EnableThreaded = raw.EnableThreaded config.EnableDetailedDates = raw.EnableDetailedDates + config.DisableSpellcheck = raw.DisableSpellcheck + config.DisableSpellSuggestions = raw.DisableSpellSuggestions config.Theme = raw.Theme config.MailingLists = raw.MailingLists config.DateFormat = raw.DateFormat diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 19b4ec09..2e4e8385 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -32,7 +32,11 @@ "external_editor": "ctrl+e", "next_field": "tab", "prev_field": "shift+tab", - "delete": "d" + "delete": "d", + "spell_next": "ctrl+n", + "spell_prev": "ctrl+p", + "spell_accept": "tab", + "spell_dismiss": "esc" }, "folder": { "next_folder": "tab", diff --git a/config/keybinds.go b/config/keybinds.go index 65dd9e45..7f7c54b1 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -64,6 +64,10 @@ type ComposerKeys struct { NextField string `json:"next_field"` PrevField string `json:"prev_field"` Delete string `json:"delete"` + SpellNext string `json:"spell_next"` + SpellPrev string `json:"spell_prev"` + SpellAccept string `json:"spell_accept"` + SpellDismiss string `json:"spell_dismiss"` } type FolderKeys struct { @@ -171,6 +175,10 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "next_field": kb.Composer.NextField, "prev_field": kb.Composer.PrevField, keyDelete: kb.Composer.Delete, + // spell_* bindings intentionally excluded from this conflict + // check — spell_accept reusing "tab" with next_field, and + // spell_dismiss reusing "esc" with cancel, are deliberate: the + // spellcheck popup intercepts before those handlers fire. }) check("folder", map[string]string{ "next_folder": kb.Folder.NextFolder, diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index e37c70f0..5068d2ed 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -48,6 +48,8 @@ Configuration is stored in `~/.config/matcha/config.json`. "date_format": "DD/MM/YYYY HH:MM", "disable_images": true, "hide_tips": true, + "disable_spellcheck": false, + "disable_spell_suggestions": false, "body_cache_threshold_mb": 100 } ``` @@ -58,6 +60,10 @@ Configuration is stored in `~/.config/matcha/config.json`. `enable_detailed_dates` shows absolute inbox dates using your configured `date_format` instead of relative labels like "2 hours ago". +`disable_spellcheck` (default `false`) turns off the composer spellcheck entirely — no underline highlights, no dictionary download, no popup. Toggle via Settings → General → Spellcheck. + +`disable_spell_suggestions` (default `false`) keeps the misspelled-word underline but suppresses the inline suggestion popup. Useful if you want a quiet check without an autocomplete-style overlay. Toggle via Settings → General → Spell Suggestions. + `body_cache_threshold_mb` sets the maximum size (in megabytes) for the local email body cache. When this limit is reached, least recently accessed cached emails are evicted across all folders to make room for new ones. Defaults to `100` MB if not specified. ## Data Locations @@ -72,6 +78,7 @@ Configuration and persistent data are stored in `~/.config/matcha/`: | `pgp/` | PGP keys | | `plugins/` | Installed Lua plugins | | `themes/` | Custom theme JSON files | +| `dicts/` | Hunspell spellcheck dictionaries (see [Spellcheck](/docs/Features/Spellcheck)) | | `secure.meta` | Encryption metadata (only when encryption is enabled) | Cache data is stored in `~/.cache/matcha/`: diff --git a/docs/docs/Features/CLI.md b/docs/docs/Features/CLI.md index 047026fa..387404dc 100644 --- a/docs/docs/Features/CLI.md +++ b/docs/docs/Features/CLI.md @@ -182,6 +182,38 @@ If encryption is enabled, you will be prompted for your password before the cont **CSV** exports a header row (`name,email,last_used,use_count`) followed by one row per contact. Use `--no-header` to omit the header row. +## matcha dict + +Manage spellcheck dictionaries. Dictionaries are downloaded from the +[wooorm/dictionaries](https://github.com/wooorm/dictionaries) Hunspell +repository and stored in `~/.config/matcha/dicts/.dic`. + +```bash +matcha dict add # download and install a dictionary +matcha dict remove # delete an installed dictionary +matcha dict list # show installed dictionaries +``` + +The English dictionary (`en`) is downloaded automatically the first time +you open the composer — `matcha dict add` is only needed for additional +languages. + +### Examples + +```bash +matcha dict add en-GB # British English +matcha dict add de # German +matcha dict add fr # French +matcha dict add es # Spanish +matcha dict add ru # Russian +matcha dict list +matcha dict remove fr +``` + +Language codes match the directory names under +[`dictionaries/`](https://github.com/wooorm/dictionaries/tree/main/dictionaries) +in the upstream repository. + ## matcha config Open a configuration file in your `$EDITOR` (falls back to `vi`). diff --git a/docs/docs/Features/Spellcheck.md b/docs/docs/Features/Spellcheck.md new file mode 100644 index 00000000..473064a6 --- /dev/null +++ b/docs/docs/Features/Spellcheck.md @@ -0,0 +1,117 @@ +--- +title: Spellcheck +sidebar_position: 15 +--- + +# Spellcheck + +Matcha highlights misspelled words in the composer body with a red dotted +underline and shows an inline suggestion popup at the cursor — similar to +the experience you get in VS Code or other modern editors. + +## How it works + +- A Hunspell `.dic` word list is loaded into memory the first time the + composer opens. +- The body text is post-processed on every render: words that aren't in + the loaded dictionary get a red dotted underline (extended SGR + sub-parameters). +- When the cursor sits at the end of a misspelled word, a bordered + suggestion popup floats below the cursor with up to five candidates + ranked by edit distance. + +The English dictionary (`en`) is **downloaded automatically** the first +time you open the composer. Nothing else is required to get started. + +## Dictionaries + +Dictionaries are stored in `~/.config/matcha/dicts/.dic` and are +sourced from the [wooorm/dictionaries](https://github.com/wooorm/dictionaries) +Hunspell repository. + +Manage them with the [`matcha dict`](./CLI.md#matcha-dict) CLI command: + +```bash +matcha dict add en-GB # British English +matcha dict add de # German +matcha dict add fr # French +matcha dict add es # Spanish +matcha dict add ru # Russian +matcha dict list +matcha dict remove fr +``` + +Language codes match the upstream directory names — see the +[full list](https://github.com/wooorm/dictionaries/tree/main/dictionaries). + +Only one dictionary is active at a time per composer (currently English by +default). Additional dictionaries are stored on disk but not yet selected +automatically; the loader picks `en` first if present. + +### Unknown scripts are skipped + +If a word contains characters that don't appear in the loaded dictionary +(for example, Cyrillic text against an English-only dictionary, or French +accented characters when only `en` is installed), it is **not** flagged. +The check assumes you're writing in a language the dictionary can't judge +and leaves the text alone. + +## Keybindings + +The suggestion popup is controlled via the composer keybinds in +`~/.config/matcha/keybinds.json`: + +| Action | Default | +|---|---| +| Next suggestion | `ctrl+n` (`composer.spell_next`) | +| Previous suggestion | `ctrl+p` (`composer.spell_prev`) | +| Accept selected | `tab` (`composer.spell_accept`) | +| Dismiss popup | `esc` (`composer.spell_dismiss`) | + +Example — rebind to VS Code-style arrow + Enter navigation: + +```json +{ + "composer": { + "spell_next": "down", + "spell_prev": "up", + "spell_accept": "enter", + "spell_dismiss": "esc" + } +} +``` + +While the popup is visible, the bound keys are intercepted before the +textarea sees them — so taking over the arrow keys is safe; the popup +closes the moment your cursor leaves the word and the keys go back to +normal navigation. + +## Disabling + +Both features can be toggled from **Settings → General**, or in +`config.json`: + +```json +{ + "disable_spellcheck": true, + "disable_spell_suggestions": true +} +``` + +- `disable_spellcheck` — disables underlines, popup, and dictionary + download entirely. +- `disable_spell_suggestions` — keeps the underline, hides the popup. + +Both default to `false` (i.e. spellcheck is on out of the box). + +## Terminal support + +The red dotted underline uses extended SGR sub-parameters +(`\e[4:4;58:2::255:0:0m`). Terminals that fully support it: + +- kitty, Ghostty, WezTerm, foot +- iTerm2, modern xterm + +Terminals that ignore the sub-parameters render a plain underline instead +— the misspelled word is still marked, just without the dotted style or +the red colour. diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index 3107724b..05084f9e 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -157,6 +157,8 @@ "enable_split_pane": "عرض مقسم", "enable_threaded": "عرض المحادثات", "enable_detailed_dates": "تواريخ مفصلة", + "spellcheck": "التدقيق الإملائي", + "spell_suggestions": "اقتراحات الإملاء", "date_format": "تنسيق التاريخ", "language": "اللغة", "signature": "تعديل التوقيع", diff --git a/i18n/locales/de.json b/i18n/locales/de.json index d07d0244..d3364417 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -153,6 +153,8 @@ "enable_split_pane": "Geteilte Ansicht", "enable_threaded": "Konversations-Threads", "enable_detailed_dates": "Detaillierte Datumsangaben", + "spellcheck": "Rechtschreibprüfung", + "spell_suggestions": "Rechtschreibvorschläge", "date_format": "Datumsformat", "language": "Sprache", "signature": "Signatur Bearbeiten", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index f553c1e4..63b02153 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -155,6 +155,8 @@ "enable_split_pane": "Split Pane View", "enable_threaded": "Threaded Conversation View", "enable_detailed_dates": "Detailed Dates", + "spellcheck": "Spellcheck", + "spell_suggestions": "Spell Suggestions", "date_format": "Date Format", "language": "Language", "signature": "Edit Signature", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 91a4f626..8a760108 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -153,6 +153,8 @@ "enable_split_pane": "Vista dividida", "enable_threaded": "Vista de conversación", "enable_detailed_dates": "Fechas detalladas", + "spellcheck": "Corrector ortográfico", + "spell_suggestions": "Sugerencias ortográficas", "date_format": "Formato de Fecha", "language": "Idioma", "signature": "Editar Firma", diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index 286469db..7d8d5ea1 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -153,6 +153,8 @@ "enable_split_pane": "Vue divisée", "enable_threaded": "Vue par conversation", "enable_detailed_dates": "Dates détaillées", + "spellcheck": "Correcteur orthographique", + "spell_suggestions": "Suggestions orthographiques", "date_format": "Format de Date", "language": "Langue", "signature": "Modifier la Signature", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index cb0859ee..061dd310 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -151,6 +151,8 @@ "enable_split_pane": "分割ビュー", "enable_threaded": "スレッド表示", "enable_detailed_dates": "詳細な日付", + "spellcheck": "スペルチェック", + "spell_suggestions": "スペル候補", "date_format": "日付形式", "language": "言語", "signature": "署名を編集", diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index 1e1ba18c..5c287077 100644 --- a/i18n/locales/pl.json +++ b/i18n/locales/pl.json @@ -157,6 +157,8 @@ "enable_split_pane": "Widok podzielony", "enable_threaded": "Widok wątków", "enable_detailed_dates": "Szczegółowe daty", + "spellcheck": "Sprawdzanie pisowni", + "spell_suggestions": "Sugestie pisowni", "date_format": "Format Daty", "language": "Język", "signature": "Edytuj Podpis", diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index 22276611..fafd1b17 100644 --- a/i18n/locales/pt.json +++ b/i18n/locales/pt.json @@ -153,6 +153,8 @@ "enable_split_pane": "Vista dividida", "enable_threaded": "Vista de conversação", "enable_detailed_dates": "Datas detalhadas", + "spellcheck": "Verificação ortográfica", + "spell_suggestions": "Sugestões ortográficas", "date_format": "Formato de Data", "language": "Idioma", "signature": "Editar Assinatura", diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index 3d86353c..fccc487f 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -157,6 +157,8 @@ "enable_split_pane": "Разделённый вид", "enable_threaded": "Просмотр беседами", "enable_detailed_dates": "Подробные даты", + "spellcheck": "Проверка орфографии", + "spell_suggestions": "Подсказки орфографии", "date_format": "Формат Даты", "language": "Язык", "signature": "Редактировать Подпись", diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index 740e5ca3..f18b8edc 100644 --- a/i18n/locales/uk.json +++ b/i18n/locales/uk.json @@ -155,6 +155,8 @@ "enable_split_pane": "Розділений вигляд", "enable_threaded": "Перегляд розмов", "enable_detailed_dates": "Детальні дати", + "spellcheck": "Перевірка орфографії", + "spell_suggestions": "Підказки орфографії", "date_format": "Формат дати", "language": "Мова", "signature": "Редагувати підпис", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index fa8266ae..47258832 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -151,6 +151,8 @@ "enable_split_pane": "分屏视图", "enable_threaded": "会话视图", "enable_detailed_dates": "详细日期", + "spellcheck": "拼写检查", + "spell_suggestions": "拼写建议", "date_format": "日期格式", "language": "语言", "signature": "编辑签名", diff --git a/main.go b/main.go index 1acbcafb..3bb6f376 100644 --- a/main.go +++ b/main.go @@ -157,7 +157,9 @@ func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel { } subject := mailtoURL.Query().Get("subject") body := mailtoURL.Query().Get("body") - initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) + composer := tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) + composer.SetSpellcheckOptions(cfg.DisableSpellcheck, cfg.DisableSpellSuggestions) + initialModel.current = composer } else { initialModel.current = tui.NewChoice() } @@ -177,6 +179,15 @@ func (m *mainModel) newSettings() *tui.Settings { return s } +// applySpellcheckOptions propagates the current Config's spellcheck +// preferences onto a freshly-constructed Composer. +func (m *mainModel) applySpellcheckOptions(c *tui.Composer) { + if c == nil || m.config == nil { + return + } + c.SetSpellcheckOptions(m.config.DisableSpellcheck, m.config.DisableSpellSuggestions) +} + func (m *mainModel) ensureProviders() { if m.config == nil { return @@ -1090,13 +1101,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo if m.config != nil { hideTips = m.config.HideTips } + var composer *tui.Composer if m.config != nil && len(m.config.Accounts) > 0 { firstAccount := m.config.GetFirstAccount() - composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips) - m.current = composer + composer = tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips) } else { - m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips) + composer = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips) } + m.applySpellcheckOptions(composer) + m.current = composer m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() return m, m.current.Init() @@ -1115,6 +1128,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo hideTips = m.config.HideTips } composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips) + m.applySpellcheckOptions(composer) m.current = composer m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() @@ -1287,7 +1301,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } subject := m.mailtoURL.Query().Get("subject") body := m.mailtoURL.Query().Get("body") - m.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) + composer := tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) + m.applySpellcheckOptions(composer) + m.current = composer } else { m.current = tui.NewChoice() } @@ -1550,6 +1566,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo references := append(msg.Email.References, msg.Email.MessageID) //nolint:gocritic composer.SetReplyContext(inReplyTo, references) + m.applySpellcheckOptions(composer) m.current = composer m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() @@ -1586,6 +1603,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo composer = tui.NewComposer("", "", subject, body, hideTips) } + m.applySpellcheckOptions(composer) m.current = composer m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() @@ -3958,6 +3976,15 @@ func main() { //nolint:gocyclo } } + // Dict CLI subcommand: matcha dict [lang] + if len(os.Args) > 1 && os.Args[1] == "dict" { + if err := matchaCli.RunDict(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "dict: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + // setup-mailto CLI subcommand: matcha setup-mailto if len(os.Args) > 1 && os.Args[1] == "setup-mailto" { if err := matchaCli.SetupMailto(); err != nil { diff --git a/spellcheck/checker.go b/spellcheck/checker.go new file mode 100644 index 00000000..e884cbc0 --- /dev/null +++ b/spellcheck/checker.go @@ -0,0 +1,200 @@ +package spellcheck + +import ( + "strings" + "sync" + "unicode" +) + +// Checker holds a loaded word set and reports whether tokens are known. +type Checker struct { + mu sync.RWMutex + words map[string]struct{} + runes map[rune]struct{} + loaded bool + language string +} + +// NewChecker returns an empty checker. Load must be called before Check +// returns useful results. +func NewChecker() *Checker { + return &Checker{words: make(map[string]struct{}), runes: make(map[rune]struct{})} +} + +// Load reads a dictionary file from disk and replaces the current word set. +func (c *Checker) Load(path, language string) error { + w, runes, err := parseHunspellDic(path) + if err != nil { + return err + } + c.mu.Lock() + c.words = w + c.runes = runes + c.loaded = true + c.language = language + c.mu.Unlock() + return nil +} + +// LoadLang loads the dictionary for the given language code from the +// configured dicts directory. +func (c *Checker) LoadLang(lang string) error { + path, err := DictPath(lang) + if err != nil { + return err + } + return c.Load(path, lang) +} + +// Loaded reports whether the checker has a dictionary ready. +func (c *Checker) Loaded() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.loaded +} + +// Language returns the language code of the loaded dictionary. +func (c *Checker) Language() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.language +} + +// Check reports whether the word is recognised. Words shorter than 2 runes, +// numeric, or containing only punctuation are always treated as correct. +// Words that contain letter runes outside the loaded dictionary's +// alphabet (e.g. Cyrillic text against an English dictionary, or accented +// characters not present in the dictionary's base forms) are also treated +// as correct — we have no signal to judge them. +func (c *Checker) Check(word string) bool { + if !IsCheckable(word) { + return true + } + c.mu.RLock() + defer c.mu.RUnlock() + if !c.loaded { + return true + } + if !c.coversWord(word) { + return true + } + lower := strings.ToLower(word) + if _, ok := c.words[lower]; ok { + return true + } + // Strip a trailing apostrophe-suffix ('s, 'd, 'll, 're, 've, 't, 'm) + // so possessives and common contractions don't get flagged when the + // dictionary lists only the base form. + if idx := strings.IndexByte(lower, '\''); idx > 0 { + base := lower[:idx] + if _, ok := c.words[base]; ok { + return true + } + } + return false +} + +// coversWord returns true when every letter rune in word is present in +// the loaded dictionary's rune set. Caller must hold c.mu. +func (c *Checker) coversWord(word string) bool { + if len(c.runes) == 0 { + return true + } + for _, r := range word { + if !unicode.IsLetter(r) { + continue + } + lr := unicode.ToLower(r) + if _, ok := c.runes[lr]; !ok { + return false + } + } + return true +} + +// IsCheckable returns true when the token looks like a natural-language +// word worth spell-checking. URLs, email-like fragments, numbers, single +// letters, and all-uppercase short tokens (likely acronyms) are skipped. +func IsCheckable(word string) bool { + runes := []rune(word) + if len(runes) < 2 { + return false + } + if strings.ContainsAny(word, "@/\\") { + return false + } + hasLetter := false + hasDigit := false + allUpper := true + for _, r := range runes { + switch { + case unicode.IsLetter(r): + hasLetter = true + if !unicode.IsUpper(r) { + allUpper = false + } + case unicode.IsDigit(r): + hasDigit = true + } + } + if !hasLetter { + return false + } + if hasDigit { + return false + } + if allUpper && len(runes) <= 5 { + return false + } + return true +} + +// Token records a word and its byte offsets inside the original text. +type Token struct { + Word string + Start int + End int +} + +// Tokenize splits s into word tokens. A word is a maximal run of letters +// optionally containing internal apostrophes or hyphens. Leading and +// trailing connector characters are stripped. +func Tokenize(s string) []Token { + var tokens []Token + start := -1 + lastLetter := -1 + for i, r := range s { + switch { + case unicode.IsLetter(r): + if start < 0 { + start = i + } + lastLetter = i + utf8RuneLen(r) + case start >= 0 && (r == '\'' || r == '’' || r == '-'): + // connector — keep word open + default: + if start >= 0 && lastLetter > start { + tokens = append(tokens, Token{Word: s[start:lastLetter], Start: start, End: lastLetter}) + } + start = -1 + lastLetter = -1 + } + } + if start >= 0 && lastLetter > start { + tokens = append(tokens, Token{Word: s[start:lastLetter], Start: start, End: lastLetter}) + } + return tokens +} + +func utf8RuneLen(r rune) int { + switch { + case r < 0x80: + return 1 + case r < 0x800: + return 2 + case r < 0x10000: + return 3 + default: + return 4 + } +} diff --git a/spellcheck/dict.go b/spellcheck/dict.go new file mode 100644 index 00000000..03e09ab2 --- /dev/null +++ b/spellcheck/dict.go @@ -0,0 +1,108 @@ +// Package spellcheck provides dictionary-backed spell checking for the composer. +// +// Dictionaries follow the Hunspell .dic format (word list, optional /flags +// per line). Affix rules are ignored: each base form is added to a flat +// word set. Dictionaries are downloaded from the wooorm/dictionaries +// GitHub repository on demand. +package spellcheck + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +// DictsDir returns the directory where dictionaries are stored. +func DictsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find home directory: %w", err) + } + dir := filepath.Join(home, ".config", "matcha", "dicts") + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", fmt.Errorf("cannot create dicts directory: %w", err) + } + return dir, nil +} + +// DictPath returns the on-disk path for a given language code. +func DictPath(lang string) (string, error) { + dir, err := DictsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, lang+".dic"), nil +} + +// DictInstalled reports whether the dictionary for lang exists on disk. +func DictInstalled(lang string) bool { + path, err := DictPath(lang) + if err != nil { + return false + } + info, err := os.Stat(path) + return err == nil && !info.IsDir() && info.Size() > 0 +} + +// parseHunspellDic reads a Hunspell .dic file and returns the set of base +// words plus the set of letter runes that appear in those words. The +// first line (when numeric) is treated as a count and skipped. Each entry +// may carry "/FLAGS" affix metadata which we strip — we don't expand +// affix rules, so the checker recognises base forms only. +func parseHunspellDic(path string) (map[string]struct{}, map[rune]struct{}, error) { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return nil, nil, fmt.Errorf("open dict: %w", err) + } + defer f.Close() //nolint:errcheck + + words := make(map[string]struct{}, 50000) + runes := make(map[rune]struct{}, 64) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + first := true + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if first { + first = false + if _, err := fmt.Sscanf(line, "%d", new(int)); err == nil && !strings.ContainsAny(line, " \t") { + continue + } + } + if idx := strings.IndexByte(line, '/'); idx >= 0 { + line = line[:idx] + } + if idx := strings.IndexByte(line, '\t'); idx >= 0 { + line = line[:idx] + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + lower := strings.ToLower(line) + words[lower] = struct{}{} + for _, r := range lower { + if isDictLetter(r) { + runes[r] = struct{}{} + } + } + } + if err := scanner.Err(); err != nil { + return nil, nil, fmt.Errorf("scan dict: %w", err) + } + return words, runes, nil +} + +func isDictLetter(r rune) bool { + if r < 0x80 { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + } + return unicode.IsLetter(r) +} diff --git a/spellcheck/download.go b/spellcheck/download.go new file mode 100644 index 00000000..edb54aa4 --- /dev/null +++ b/spellcheck/download.go @@ -0,0 +1,89 @@ +package spellcheck + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/floatpane/matcha/internal/httpclient" +) + +// DefaultLanguage is the language code installed automatically the first +// time the composer opens. +const DefaultLanguage = "en" + +// DictURLTemplate is the URL used to fetch Hunspell .dic files. It is a +// variable to allow tests and the CLI to override the source. +var DictURLTemplate = "https://raw.githubusercontent.com/wooorm/dictionaries/main/dictionaries/%s/index.dic" + +// Download fetches the dictionary for lang from DictURLTemplate and writes +// it atomically to the dicts directory. +func Download(lang string) (string, error) { + if lang == "" { + return "", fmt.Errorf("empty language code") + } + dest, err := DictPath(lang) + if err != nil { + return "", err + } + + url := fmt.Sprintf(DictURLTemplate, urlPathLang(lang)) + client := httpclient.New(httpclient.InstallTimeout) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("download %s: %w", lang, err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download %s: status %d", lang, resp.StatusCode) + } + + tmp, err := os.CreateTemp(filepath.Dir(dest), ".dl-*") + if err != nil { + return "", fmt.Errorf("create temp: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) //nolint:errcheck + + if _, err := io.Copy(tmp, resp.Body); err != nil { + tmp.Close() //nolint:errcheck,gosec + return "", fmt.Errorf("write dict: %w", err) + } + if err := tmp.Close(); err != nil { + return "", fmt.Errorf("close dict: %w", err) + } + if err := os.Rename(tmpPath, dest); err != nil { + return "", fmt.Errorf("install dict: %w", err) + } + return dest, nil +} + +// EnsureDefault downloads the default English dictionary if it is not +// already installed and returns the language code that is available. +func EnsureDefault() (string, error) { + if DictInstalled(DefaultLanguage) { + return DefaultLanguage, nil + } + _, err := Download(DefaultLanguage) + if err != nil { + return "", err + } + return DefaultLanguage, nil +} + +// urlPathLang converts a language code into the directory name used by the +// wooorm/dictionaries repository ("en", "en-GB", "de", ...). The code is +// passed through after normalising the region separator. +func urlPathLang(lang string) string { + lang = strings.TrimSpace(lang) + lang = strings.ReplaceAll(lang, "_", "-") + return lang +} diff --git a/spellcheck/highlight.go b/spellcheck/highlight.go new file mode 100644 index 00000000..423c6358 --- /dev/null +++ b/spellcheck/highlight.go @@ -0,0 +1,153 @@ +package spellcheck + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// Red dotted underline (extended SGR sub-parameter form, supported by +// kitty, WezTerm, iTerm2, Ghostty, foot, modern xterm). Terminals that +// ignore the sub-parameters render a plain underline instead. +const ( + openSGR = "\x1b[4:4;58:2::255:0:0m" + closeSGR = "\x1b[4:0;59m" +) + +// Highlight walks rendered text and wraps misspelled words in a red dotted +// underline. ANSI sequences already present in the input are preserved. +// +// The text is processed line by line. The line at index skipLine is left +// untouched — pass -1 to highlight every line. +func Highlight(text string, c *Checker, skipLine int) string { + if c == nil || !c.Loaded() || text == "" { + return text + } + lines := strings.Split(text, "\n") + for i, line := range lines { + if i == skipLine { + continue + } + lines[i] = highlightLine(line, c) + } + return strings.Join(lines, "\n") +} + +type wordSpan struct { + start, end int + word string +} + +func highlightLine(line string, c *Checker) string { + if line == "" { + return line + } + + spans := scanWords(line) + if len(spans) == 0 { + return line + } + + // Splice from end to start so earlier offsets stay valid. + out := line + wrapped := false + for i := len(spans) - 1; i >= 0; i-- { + s := spans[i] + if !IsCheckable(s.word) { + continue + } + if c.Check(s.word) { + continue + } + out = out[:s.start] + openSGR + out[s.start:s.end] + closeSGR + out[s.end:] + wrapped = true + } + if !wrapped { + return line + } + return out +} + +// scanWords walks the raw line and returns word runs by byte offset. +// ANSI CSI/OSC escape sequences are skipped so they don't fragment words. +func scanWords(line string) []wordSpan { + var spans []wordSpan + var b strings.Builder + start := -1 + + flush := func(end int) { + if b.Len() == 0 { + return + } + w := strings.TrimRight(b.String(), "'’-") + if w != "" { + spans = append(spans, wordSpan{start: start, end: start + len(w), word: w}) + } + b.Reset() + start = -1 + } + + i := 0 + for i < len(line) { + if line[i] == 0x1b { + flush(i) + i += ansiSkip(line, i) + continue + } + r, size := utf8.DecodeRuneInString(line[i:]) + if unicode.IsLetter(r) { + if start < 0 { + start = i + } + b.WriteRune(r) + i += size + continue + } + if b.Len() > 0 && (r == '\'' || r == '’' || r == '-') { + b.WriteRune(r) + i += size + continue + } + flush(i) + i += size + } + flush(len(line)) + return spans +} + +// ansiSkip returns the byte length of the escape sequence beginning at +// line[i] (which must be ESC). Malformed/truncated sequences consume the +// remainder of the line. +func ansiSkip(line string, i int) int { + if i+1 >= len(line) { + return 1 + } + switch line[i+1] { + case '[': + // CSI: ESC [ params final (0x40..0x7e) + j := i + 2 + for j < len(line) { + c := line[j] + if c >= 0x40 && c <= 0x7e { + return j - i + 1 + } + j++ + } + return len(line) - i + case ']': + // OSC: terminated by BEL or ST (ESC \). + j := i + 2 + for j < len(line) { + if line[j] == 0x07 { + return j - i + 1 + } + if line[j] == 0x1b && j+1 < len(line) && line[j+1] == '\\' { + return j - i + 2 + } + j++ + } + return len(line) - i + default: + return 2 + } +} diff --git a/spellcheck/spellcheck_test.go b/spellcheck/spellcheck_test.go new file mode 100644 index 00000000..653275f1 --- /dev/null +++ b/spellcheck/spellcheck_test.go @@ -0,0 +1,192 @@ +package spellcheck + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func newTestChecker(t *testing.T, words ...string) *Checker { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "test.dic") + content := []byte("# header\n" + strings.Join(words, "\n") + "\n") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("write dic: %v", err) + } + c := NewChecker() + if err := c.Load(path, "test"); err != nil { + t.Fatalf("load: %v", err) + } + return c +} + +func TestCheckerCheck(t *testing.T) { + c := newTestChecker(t, "hello", "world", "go") + if !c.Check("hello") { + t.Error("hello should be known") + } + if !c.Check("Hello") { + t.Error("Hello should match case-insensitively") + } + if c.Check("helo") { + t.Error("helo should be unknown") + } + // Short / numeric / uppercase tokens are skipped. + if !c.Check("Z") { + t.Error("single rune skipped") + } + if !c.Check("ABC") { + t.Error("short uppercase acronym skipped") + } + if !c.Check("42") { + t.Error("numeric skipped") + } +} + +func TestTokenize(t *testing.T) { + got := Tokenize("hello, world! it's nice") + want := []struct { + w string + start, end int + }{ + {"hello", 0, 5}, + {"world", 7, 12}, + {"it's", 14, 18}, + {"nice", 19, 23}, + } + if len(got) != len(want) { + t.Fatalf("tokens = %d, want %d (%+v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].Word != w.w || got[i].Start != w.start || got[i].End != w.end { + t.Errorf("token %d = %+v, want %s [%d:%d]", i, got[i], w.w, w.start, w.end) + } + } +} + +func TestParseHunspellDicSkipsCountLine(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "x.dic") + // First line is a count, words follow, with hunspell-style flags. + body := "3\nfoo/AB\nbar\nbaz\n" + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + w, _, err := parseHunspellDic(p) + if err != nil { + t.Fatal(err) + } + for _, k := range []string{"foo", "bar", "baz"} { + if _, ok := w[k]; !ok { + t.Errorf("missing %q", k) + } + } +} + +func TestHighlightWrapsMisspelled(t *testing.T) { + c := newTestChecker(t, "hello", "world", "abcdefghijklmnopqrstuvwxyz") + out := Highlight("hello wurld", c, -1) + if !strings.Contains(out, "wurld") { + t.Fatalf("output missing word: %q", out) + } + if !strings.Contains(out, openSGR) || !strings.Contains(out, closeSGR) { + t.Errorf("expected SGR markers, got %q", out) + } + // "hello" is correct and must not be wrapped. + idxHello := strings.Index(out, "hello") + idxOpen := strings.Index(out, openSGR) + if idxOpen < idxHello { + t.Errorf("opener appeared before hello: open=%d hello=%d", idxOpen, idxHello) + } +} + +func TestHighlightPreservesANSI(t *testing.T) { + c := newTestChecker(t, "good", "abcdefghijklmnopqrstuvwxyz") + // Pretend the line was rendered with a colour style around the whole + // content: ESC[31m...ESC[0m. Misspelled token "bd" inside. + in := "\x1b[31mgood bd\x1b[0m" + out := Highlight(in, c, -1) + if !strings.Contains(out, "\x1b[31m") { + t.Errorf("original colour ANSI lost: %q", out) + } + if !strings.Contains(out, openSGR) { + t.Errorf("missing underline open: %q", out) + } +} + +func TestHighlightNoCheckerIsNoop(t *testing.T) { + in := "anything goes" + if got := Highlight(in, nil, -1); got != in { + t.Errorf("nil checker should be no-op, got %q", got) + } +} + +func TestSuggest(t *testing.T) { + c := newTestChecker(t, "hello", "help", "world", "word", "ward", "wild") + got := c.Suggest("wurld", 5) + if len(got) == 0 { + t.Fatal("expected at least one suggestion") + } + // "world" should outrank "ward" / "wild" by edit distance. + if got[0] != "world" { + t.Errorf("top suggestion = %q, want world (all: %v)", got[0], got) + } +} + +func TestSuggestCaseMatch(t *testing.T) { + c := newTestChecker(t, "hello", "world") + got := c.Suggest("Wurld", 3) + if len(got) == 0 || got[0] != "World" { + t.Errorf("expected capitalised World, got %v", got) + } +} + +func TestCheckSkipsForeignScript(t *testing.T) { + c := newTestChecker(t, "hello", "world") + // Cyrillic — dict has no cyrillic runes, so we must NOT flag it. + if !c.Check("привет") { + t.Error("cyrillic word should be skipped against latin dict") + } + // Accented French not in dict ('é' absent) — must not flag. + if !c.Check("café") { + t.Error("accented word with foreign rune should be skipped") + } + // Plain ASCII typo still flagged. + if c.Check("helo") { + t.Error("ASCII typo should still be flagged") + } +} + +func TestCheckRecognisesAccentsWhenDictHasThem(t *testing.T) { + // Dictionary that legitimately contains an accented word — its rune + // set covers 'é' so accented words can be evaluated normally. + c := newTestChecker(t, "café", "hello") + if !c.Check("café") { + t.Error("café should be recognised when present in dict") + } + if c.Check("cofé") { + t.Error("misspelled accented word should still be flagged") + } +} + +func TestIsCheckable(t *testing.T) { + cases := map[string]bool{ + "hello": true, + "a": false, + "42": false, + "hello42": false, + "NASA": false, + "hi@there": false, + "path/to": false, + "don't": true, + "HelloWorld": true, // mixed case, not an acronym + "INTERNATION": true, // > 5 upper letters, treated as a word + } + for in, want := range cases { + if got := IsCheckable(in); got != want { + t.Errorf("IsCheckable(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/spellcheck/suggest.go b/spellcheck/suggest.go new file mode 100644 index 00000000..8f1e7ae7 --- /dev/null +++ b/spellcheck/suggest.go @@ -0,0 +1,180 @@ +package spellcheck + +import ( + "sort" + "strings" + "unicode" +) + +// Suggest returns up to max candidate corrections for word, ranked by +// edit distance ascending then alphabetically. Returns nil when the +// checker has no dictionary loaded or when word is too short. +func (c *Checker) Suggest(word string, max int) []string { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + if !c.loaded || len(c.words) == 0 { + return nil + } + if max <= 0 { + max = 5 + } + + lower := strings.ToLower(word) + wRunes := []rune(lower) + if len(wRunes) < 2 { + return nil + } + + // Allow up to 2 edits for short-to-medium words, 3 for longer ones. + maxDist := 2 + if len(wRunes) >= 8 { + maxDist = 3 + } + + type cand struct { + word string + dist int + } + var cands []cand + + for w := range c.words { + // Length filter: prune impossible candidates without an alloc. + ld := len(w) - len(lower) + if ld < 0 { + ld = -ld + } + if ld > maxDist { + continue + } + // First-rune similarity prunes most mismatched candidates cheaply. + if !firstRuneClose(w, lower) { + continue + } + d := levenshtein(wRunes, []rune(w), maxDist) + if d > maxDist { + continue + } + cands = append(cands, cand{w, d}) + } + + sort.Slice(cands, func(i, j int) bool { + if cands[i].dist != cands[j].dist { + return cands[i].dist < cands[j].dist + } + return cands[i].word < cands[j].word + }) + + if len(cands) > max { + cands = cands[:max] + } + out := make([]string, len(cands)) + upper := unicode.IsUpper([]rune(word)[0]) + for i, c := range cands { + out[i] = matchCase(c.word, upper) + } + return out +} + +// firstRuneClose returns true when the first runes of a and b are equal, +// adjacent on a QWERTY keyboard, or one of them is missing. +func firstRuneClose(a, b string) bool { + if a == "" || b == "" { + return true + } + var ar, br rune + for _, r := range a { + ar = r + break + } + for _, r := range b { + br = r + break + } + if ar == br { + return true + } + return keyboardAdjacent(ar, br) +} + +// keyboardAdjacent returns true when a and b are neighbours on a QWERTY +// keyboard. Used purely to widen the candidate pool around typos like +// "guzzy"→"fuzzy" (g↔f) without exploding the cost of suggestion. +func keyboardAdjacent(a, b rune) bool { + neighbours := map[rune]string{ + 'a': "qwsz", 'b': "vghn", 'c': "xdfv", 'd': "serfcx", 'e': "wsdr", + 'f': "drtgcv", 'g': "ftyhvb", 'h': "gyujnb", 'i': "ujko", 'j': "huikmn", + 'k': "jiolm", 'l': "kop", 'm': "njk", 'n': "bhjm", 'o': "iklp", + 'p': "ol", 'q': "wa", 'r': "edft", 's': "awedxz", 't': "rfgy", + 'u': "yhji", 'v': "cfgb", 'w': "qase", 'x': "zsdc", 'y': "tghu", + 'z': "asx", + } + a = unicode.ToLower(a) + b = unicode.ToLower(b) + if ns, ok := neighbours[a]; ok { + return strings.ContainsRune(ns, b) + } + return false +} + +// levenshtein computes the edit distance between a and b, returning early +// once the running minimum exceeds cutoff. Two-row dynamic programming +// keeps the allocation small. +func levenshtein(a, b []rune, cutoff int) int { + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + prev := make([]int, lb+1) + curr := make([]int, lb+1) + for j := 0; j <= lb; j++ { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr[0] = i + minRow := curr[0] + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min3(curr[j-1]+1, prev[j]+1, prev[j-1]+cost) + if curr[j] < minRow { + minRow = curr[j] + } + } + if minRow > cutoff { + return cutoff + 1 + } + prev, curr = curr, prev + } + return prev[lb] +} + +func min3(a, b, c int) int { + m := a + if b < m { + m = b + } + if c < m { + m = c + } + return m +} + +// matchCase capitalises the first rune of s when the original word +// started with an uppercase letter, so suggestions blend back into the +// user's writing. +func matchCase(s string, upperFirst bool) string { + if !upperFirst || s == "" { + return s + } + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} diff --git a/tui/composer.go b/tui/composer.go index 9ba552d3..ddb08de1 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -7,15 +7,24 @@ import ( "path/filepath" "strings" "time" + "unicode" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/spellcheck" "github.com/google/uuid" ) +// spellcheckReadyMsg is delivered when the background spellcheck loader +// finishes (either downloading the default dictionary or loading an +// already-installed one). +type spellcheckReadyMsg struct { + checker *spellcheck.Checker +} + var ( suggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) @@ -102,6 +111,23 @@ type Composer struct { showPluginPrompt bool pluginPromptInput textinput.Model pluginPromptPlaceholder string + + // Spellcheck (loaded asynchronously; nil until ready). + spellChecker *spellcheck.Checker + spellSuggestions []string + spellSelected int + spellShow bool + spellWordStart int // byte offset of misspelled word in body + spellWordEnd int + spellWordOnLine int // index of the logical line containing the word + spellWordLineStart int // byte offset of the word within its logical line + spellWordLineEnd int + spellWord string // the misspelled word (as currently in body) + spellLastBody string // last body value we computed suggestions for + spellLastCursorRow int + spellLastCursorCol int + disableSpellcheck bool + disableSpellSuggestions bool } // NewComposer initializes a new composer model. @@ -312,8 +338,180 @@ func (m *Composer) SetFromOverride(addr string) { m.fromInput.SetValue(addr) } +// SetSpellcheckOptions toggles spellcheck features for this composer. Pass +// disableCheck=true to skip dictionary download/highlighting entirely; +// disableSuggestions=true keeps inline underlines but suppresses the popup. +func (m *Composer) SetSpellcheckOptions(disableCheck, disableSuggestions bool) { + m.disableSpellcheck = disableCheck + m.disableSpellSuggestions = disableSuggestions + if disableCheck { + m.spellChecker = nil + m.spellShow = false + m.spellSuggestions = nil + } +} + func (m *Composer) Init() tea.Cmd { - return textinput.Blink + cmds := []tea.Cmd{textinput.Blink} + if !m.disableSpellcheck { + cmds = append(cmds, loadSpellcheckCmd()) + } + return tea.Batch(cmds...) +} + +// loadSpellcheckCmd ensures the default dictionary is downloaded and +// loaded into a new Checker. Network errors are swallowed: spellcheck is a +// non-essential overlay, so the composer continues to work normally. +func loadSpellcheckCmd() tea.Cmd { + return func() tea.Msg { + lang, err := spellcheck.EnsureDefault() + if err != nil { + return spellcheckReadyMsg{checker: nil} + } + c := spellcheck.NewChecker() + if err := c.LoadLang(lang); err != nil { + return spellcheckReadyMsg{checker: nil} + } + return spellcheckReadyMsg{checker: c} + } +} + +// updateSpellSuggestions inspects the body cursor position and refreshes +// the suggestion popup. It only fires when the cursor sits at the end of +// a misspelled word. +func (m *Composer) updateSpellSuggestions() { + m.spellShow = false + m.spellSuggestions = nil + m.spellWord = "" + + if m.disableSpellcheck || m.disableSpellSuggestions { + return + } + if m.spellChecker == nil || !m.spellChecker.Loaded() { + return + } + if m.focusIndex != focusBody { + return + } + + value := m.bodyInput.Value() + row := m.bodyInput.Line() + col := m.bodyInput.Column() + lines := strings.Split(value, "\n") + if row < 0 || row >= len(lines) { + return + } + line := lines[row] + lineRunes := []rune(line) + if col > len(lineRunes) { + col = len(lineRunes) + } + + // Walk back from cursor while we have letters or internal connectors. + end := col + start := col + for start > 0 { + r := lineRunes[start-1] + if isWordContinuation(r) { + start-- + continue + } + break + } + // Trim leading connectors so the word starts on a letter. + for start < end && !isLetter(lineRunes[start]) { + start++ + } + // Trim trailing connectors so we don't suggest replacements while the + // user is still mid-apostrophe. + for end > start && !isLetter(lineRunes[end-1]) { + end-- + } + if end-start < 2 { + return + } + + word := string(lineRunes[start:end]) + if !spellcheck.IsCheckable(word) { + return + } + if m.spellChecker.Check(word) { + return + } + + suggestions := m.spellChecker.Suggest(word, 5) + if len(suggestions) == 0 { + return + } + + m.spellSuggestions = suggestions + m.spellSelected = 0 + m.spellShow = true + m.spellWord = word + + // Byte offsets within the current line, needed by the accept handler. + m.spellWordLineStart = len(string(lineRunes[:start])) + m.spellWordLineEnd = len(string(lineRunes[:end])) + m.spellWordOnLine = row + + // Cache cursor position so a no-op key (e.g. arrow without movement) + // doesn't redundantly recompute suggestions. + m.spellLastBody = value + m.spellLastCursorRow = row + m.spellLastCursorCol = col +} + +// acceptSpellSuggestion replaces the misspelled word currently under the +// cursor with the selected suggestion. It works by sending backspace key +// events to the textarea (so the textarea's own bookkeeping stays in +// sync) and then inserting the replacement text. +func (m *Composer) acceptSpellSuggestion() tea.Cmd { + if !m.spellShow || len(m.spellSuggestions) == 0 { + return nil + } + if m.spellSelected < 0 || m.spellSelected >= len(m.spellSuggestions) { + return nil + } + suggestion := m.spellSuggestions[m.spellSelected] + + // Only replace when the cursor is still at the end of the word we + // recorded — otherwise the user moved and the popup is stale. + row := m.bodyInput.Line() + col := m.bodyInput.Column() + lines := strings.Split(m.bodyInput.Value(), "\n") + if row != m.spellWordOnLine || row >= len(lines) { + m.spellShow = false + m.spellSuggestions = nil + return nil + } + endRunes := len([]rune(lines[row][:m.spellWordLineEnd])) + if col != endRunes { + m.spellShow = false + m.spellSuggestions = nil + return nil + } + + wordRuneLen := len([]rune(m.spellWord)) + for i := 0; i < wordRuneLen; i++ { + m.bodyInput, _ = m.bodyInput.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + } + m.bodyInput.InsertString(suggestion) + + m.spellShow = false + m.spellSuggestions = nil + m.spellWord = "" + return nil +} + +func isWordContinuation(r rune) bool { + return isLetter(r) || r == '\'' || r == '’' || r == '-' +} + +func isLetter(r rune) bool { + if r < 0x80 { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + } + return unicode.IsLetter(r) } func (m *Composer) getFromAddress() string { @@ -449,6 +647,13 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.hideComposerNotice() return m, nil + case spellcheckReadyMsg: + if msg.checker != nil { + m.spellChecker = msg.checker + m.updateSpellSuggestions() + } + return m, nil + case FileSelectedMsg: // Avoid duplicates and add all selected paths for _, newPath := range msg.Paths { @@ -588,6 +793,29 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo return m, nil } + // Spellcheck suggestion popup (only while body is focused). + if m.focusIndex == focusBody && m.spellShow && len(m.spellSuggestions) > 0 { + sk := config.Keybinds.Composer + switch msg.String() { + case sk.SpellPrev: + if m.spellSelected > 0 { + m.spellSelected-- + } + return m, nil + case sk.SpellNext: + if m.spellSelected < len(m.spellSuggestions)-1 { + m.spellSelected++ + } + return m, nil + case sk.SpellAccept: + return m, m.acceptSpellSuggestion() + case sk.SpellDismiss: + m.spellShow = false + m.spellSuggestions = nil + return m, nil + } + } + kb := config.Keybinds attachmentPathSize := len(m.attachmentPaths) if m.focusIndex == focusAttachment && attachmentPathSize > 0 { @@ -644,6 +872,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.subjectInput.Blur() m.bodyInput.Blur() m.signatureInput.Blur() + m.spellShow = false + m.spellSuggestions = nil switch m.focusIndex { case focusFrom: @@ -787,8 +1017,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.subjectInput, cmd = m.subjectInput.Update(msg) cmds = append(cmds, cmd) case focusBody: + prevBody := m.bodyInput.Value() + prevRow := m.bodyInput.Line() + prevCol := m.bodyInput.Column() m.bodyInput, cmd = m.bodyInput.Update(msg) cmds = append(cmds, cmd) + // Only recompute suggestions when the body state actually changes. + // Cursor-blink ticks otherwise reset spellSelected to 0 every blink. + if m.bodyInput.Value() != prevBody || + m.bodyInput.Line() != prevRow || + m.bodyInput.Column() != prevCol { + m.updateSpellSuggestions() + } case focusSignature: m.signatureInput, cmd = m.signatureInput.Update(msg) cmds = append(cmds, cmd) @@ -926,7 +1166,13 @@ func (m *Composer) View() tea.View { //nolint:gocyclo case focusSubject: tip = "The subject line of your email." case focusBody: - tip = "The main content of your email. Markdown and HTML are supported." + if m.spellShow && len(m.spellSuggestions) > 0 { + sk := config.Keybinds.Composer + tip = fmt.Sprintf("Spelling: %s accept • %s/%s navigate • %s dismiss", + sk.SpellAccept, sk.SpellNext, sk.SpellPrev, sk.SpellDismiss) + } else { + tip = "The main content of your email. Markdown and HTML are supported." + } case focusSignature: tip = "Your email signature. This will be appended to the end of the email." case focusAttachment: @@ -937,6 +1183,11 @@ func (m *Composer) View() tea.View { //nolint:gocyclo tip = "Press Enter to send the email." } + bodyView := m.bodyInput.View() + if !m.disableSpellcheck && m.spellChecker != nil && m.spellChecker.Loaded() { + bodyView = spellcheck.Highlight(bodyView, m.spellChecker, -1) + } + composerViewElements := []string{ t("composer.title"), fromField, @@ -944,7 +1195,7 @@ func (m *Composer) View() tea.View { //nolint:gocyclo ccFieldView, bccFieldView, m.subjectInput.View(), - m.bodyInput.View(), + bodyView, signatureLabel, m.signatureInput.View(), attachmentStyle.Render(attachmentField), @@ -1044,7 +1295,88 @@ func (m *Composer) View() tea.View { //nolint:gocyclo return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)) } - return tea.NewView(composerView.String()) + out := composerView.String() + if m.spellShow && len(m.spellSuggestions) > 0 && m.focusIndex == focusBody { + out = m.overlaySpellPopup(out, composerViewElements) + } + return tea.NewView(out) +} + +// overlaySpellPopup floats the suggestion box at the body cursor position +// in the rendered composer view. It returns the view unchanged when the +// cursor can't be located. +func (m *Composer) overlaySpellPopup(view string, elementsBeforeBody []string) string { + // Body is the 7th element (index 6) of composerViewElements: title, + // from, to, cc, bcc, subject, body, ... + const bodyIdx = 6 + if bodyIdx > len(elementsBeforeBody) { + return view + } + bodyStartRow := 0 + for i := 0; i < bodyIdx; i++ { + bodyStartRow += lipgloss.Height(elementsBeforeBody[i]) + } + + li := m.bodyInput.LineInfo() + const promptWidth = 2 // "> " + cursorRow := bodyStartRow + li.RowOffset + cursorCol := li.CharOffset + promptWidth + + popup := m.renderSpellPopupLines() + if len(popup) == 0 { + return view + } + + // Anchor below cursor. If popup would clip the bottom, raise it above + // the cursor row instead. + anchorRow := cursorRow + 1 + if m.height > 0 && anchorRow+len(popup) > m.height-1 && cursorRow-len(popup) >= 0 { + anchorRow = cursorRow - len(popup) + } + anchorCol := cursorCol + popupWidth := lipgloss.Width(popup[0]) + if m.width > 0 && anchorCol+popupWidth > m.width { + anchorCol = max(0, m.width-popupWidth) + } + + return overlayBlock(view, popup, anchorRow, anchorCol) +} + +// renderSpellPopupLines builds the styled, bordered suggestion box and +// returns its rendered lines. Each row carries an "abc" badge to mirror +// the language-server look familiar from VSCode. +func (m *Composer) renderSpellPopupLines() []string { + if !m.spellShow || len(m.spellSuggestions) == 0 { + return nil + } + maxWidth := 0 + for _, s := range m.spellSuggestions { + if w := len(s); w > maxWidth { + maxWidth = w + } + } + rowWidth := maxWidth + 6 // " abc " badge + word + trailing space + + iconStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + selStyle := lipgloss.NewStyle().Background(lipgloss.Color("24")).Foreground(lipgloss.Color("231")) + + var rows []string + for i, s := range m.spellSuggestions { + text := " " + iconStyle.Render("abc") + " " + s + pad := rowWidth - lipgloss.Width(text) + if pad < 0 { + pad = 0 + } + text += strings.Repeat(" ", pad) + if i == m.spellSelected { + rows = append(rows, selStyle.Render(text)) + } else { + rows = append(rows, rowStyle.Render(text)) + } + } + box := suggestionBoxStyle.Render(strings.Join(rows, "\n")) + return strings.Split(box, "\n") } // SetAccounts sets the available accounts for sending. diff --git a/tui/overlay.go b/tui/overlay.go new file mode 100644 index 00000000..b263f711 --- /dev/null +++ b/tui/overlay.go @@ -0,0 +1,58 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// overlayBlock paints the lines of block on top of base starting at the +// (row, col) cell position. Lines that extend past the bottom of base are +// appended. The result preserves existing ANSI styling around the +// overlaid region. +func overlayBlock(base string, block []string, row, col int) string { + if len(block) == 0 { + return base + } + lines := strings.Split(base, "\n") + for i, overlay := range block { + r := row + i + for r >= len(lines) { + lines = append(lines, "") + } + lines[r] = overlayLine(lines[r], overlay, col) + } + return strings.Join(lines, "\n") +} + +// overlayLine returns base with overlay painted starting at column col. +// Existing cells under the overlay are removed; cells to the left and +// right of the overlay are preserved with their ANSI styling intact. +// When col exceeds the visible width of base the gap is padded with +// spaces. +func overlayLine(base, overlay string, col int) string { + if overlay == "" { + return base + } + overlayWidth := ansi.StringWidth(overlay) + baseWidth := ansi.StringWidth(base) + + left := ansi.Truncate(base, col, "") + leftWidth := ansi.StringWidth(left) + + var pad string + if leftWidth < col { + pad = strings.Repeat(" ", col-leftWidth) + } + + var right string + rightStart := col + overlayWidth + if rightStart < baseWidth { + right = ansi.Cut(base, rightStart, baseWidth) + } + + // Reset SGR after the overlay so the overlay's styles don't bleed + // into the surrounding cells (the rest of the row may inherit ANSI + // from earlier in the string). + return left + pad + overlay + "\x1b[0m" + right +} diff --git a/tui/settings_general.go b/tui/settings_general.go index a39f1fbc..d8c6704b 100644 --- a/tui/settings_general.go +++ b/tui/settings_general.go @@ -23,6 +23,8 @@ func (m *Settings) buildGeneralOptions() []generalOption { {"settings_general.enable_split_pane", onOff(m.cfg.EnableSplitPane), "View inbox and email side-by-side."}, {"settings_general.enable_threaded", onOff(m.cfg.EnableThreaded), "Group emails into conversations by reply chain. Per-folder overrides are kept."}, {"settings_general.enable_detailed_dates", onOff(m.cfg.EnableDetailedDates), "Show detailed inbox dates."}, + {"settings_general.spellcheck", onOff(!m.cfg.DisableSpellcheck), "Underline misspelled words while composing."}, + {"settings_general.spell_suggestions", onOff(!m.cfg.DisableSpellSuggestions), "Show suggestion popup for misspelled words."}, {"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."}, {"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly."}, {"settings_general.signature", getSignatureStatus(), "Configure the global signature appended to your outgoing emails."}, @@ -67,7 +69,15 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cfg.EnableDetailedDates = !m.cfg.EnableDetailedDates _ = config.SaveConfig(m.cfg) saved = true - case 6: // Date Format + case 6: // Spellcheck + m.cfg.DisableSpellcheck = !m.cfg.DisableSpellcheck + _ = config.SaveConfig(m.cfg) + saved = true + case 7: // Spell Suggestions + m.cfg.DisableSpellSuggestions = !m.cfg.DisableSpellSuggestions + _ = config.SaveConfig(m.cfg) + saved = true + case 8: // Date Format switch m.cfg.DateFormat { case config.DateFormatEU: m.cfg.DateFormat = config.DateFormatUS @@ -78,7 +88,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } _ = config.SaveConfig(m.cfg) saved = true - case 7: // Language + case 9: // Language // Cycle through available languages langs := i18n.LanguageCodes() currentLang := m.cfg.GetLanguage() @@ -99,7 +109,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func() tea.Msg { return ConfigSavedMsg{} }, func() tea.Msg { return LanguageChangedMsg{} }, ) - case 8: // Edit Signature + case 10: // Edit Signature if msg.String() == keyEnter || msg.String() == keyRight || msg.String() == "l" { return m, func() tea.Msg { return GoToSignatureEditorMsg{} } } From d6bc2bc641373c8ba455a38addbb2a29f74690f3 Mon Sep 17 00:00:00 2001 From: drew Date: Mon, 25 May 2026 18:32:59 +0400 Subject: [PATCH 2/3] make lint Signed-off-by: drew --- config/config.go | 50 ++++++++++++++++++++++++------------------------ tui/composer.go | 4 ++-- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/config/config.go b/config/config.go index 44dc1396..3a4bc2bb 100644 --- a/config/config.go +++ b/config/config.go @@ -103,20 +103,20 @@ type MailingList struct { // Config stores the user's email configuration with multiple accounts. type Config struct { - Accounts []Account `json:"accounts"` - DisableImages bool `json:"disable_images,omitempty"` - HideTips bool `json:"hide_tips,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` - EnableSplitPane bool `json:"enable_split_pane,omitempty"` - EnableThreaded bool `json:"enable_threaded,omitempty"` - EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` - DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` - DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` - Theme string `json:"theme,omitempty"` - MailingLists []MailingList `json:"mailing_lists,omitempty"` - DateFormat string `json:"date_format,omitempty"` - Language string `json:"language,omitempty"` // Language code (e.g., "en", "es", "de") - BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` + Accounts []Account `json:"accounts"` + DisableImages bool `json:"disable_images,omitempty"` + HideTips bool `json:"hide_tips,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` + EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` + DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` + Theme string `json:"theme,omitempty"` + MailingLists []MailingList `json:"mailing_lists,omitempty"` + DateFormat string `json:"date_format,omitempty"` + Language string `json:"language,omitempty"` // Language code (e.g., "en", "es", "de") + BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` // PluginSettings stores user-configurable values for installed plugins, // keyed by plugin name then setting key. Values are JSON-native types // (bool, float64, string) matching the plugin's declared schema. @@ -435,11 +435,11 @@ type secureDiskConfig struct { EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` - Theme string `json:"theme,omitempty"` - MailingLists []MailingList `json:"mailing_lists,omitempty"` - DateFormat string `json:"date_format,omitempty"` - Language string `json:"language,omitempty"` - PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"` + Theme string `json:"theme,omitempty"` + MailingLists []MailingList `json:"mailing_lists,omitempty"` + DateFormat string `json:"date_format,omitempty"` + Language string `json:"language,omitempty"` + PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"` } // SaveConfig saves the given configuration to the config file and passwords to the keyring. @@ -591,12 +591,12 @@ func LoadConfig() (*Config, error) { EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` - Theme string `json:"theme,omitempty"` - MailingLists []MailingList `json:"mailing_lists,omitempty"` - DateFormat string `json:"date_format,omitempty"` - Language string `json:"language,omitempty"` - BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` - PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"` + Theme string `json:"theme,omitempty"` + MailingLists []MailingList `json:"mailing_lists,omitempty"` + DateFormat string `json:"date_format,omitempty"` + Language string `json:"language,omitempty"` + BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` + PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"` } var raw diskConfig diff --git a/tui/composer.go b/tui/composer.go index ddb08de1..4d84268b 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -119,8 +119,8 @@ type Composer struct { spellShow bool spellWordStart int // byte offset of misspelled word in body spellWordEnd int - spellWordOnLine int // index of the logical line containing the word - spellWordLineStart int // byte offset of the word within its logical line + spellWordOnLine int // index of the logical line containing the word + spellWordLineStart int // byte offset of the word within its logical line spellWordLineEnd int spellWord string // the misspelled word (as currently in body) spellLastBody string // last body value we computed suggestions for From 54b884f330c0f364f79e9cd25f606b9dc4b7c3b6 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 26 May 2026 12:16:54 +0400 Subject: [PATCH 3/3] fix lint Signed-off-by: drew --- spellcheck/dict.go | 2 +- spellcheck/download.go | 3 ++- spellcheck/highlight.go | 8 ++++---- spellcheck/spellcheck_test.go | 20 ++++++++++---------- spellcheck/suggest.go | 12 ++++++------ tui/composer.go | 22 ++++++++++------------ 6 files changed, 33 insertions(+), 34 deletions(-) diff --git a/spellcheck/dict.go b/spellcheck/dict.go index 03e09ab2..86401080 100644 --- a/spellcheck/dict.go +++ b/spellcheck/dict.go @@ -53,7 +53,7 @@ func DictInstalled(lang string) bool { // may carry "/FLAGS" affix metadata which we strip — we don't expand // affix rules, so the checker recognises base forms only. func parseHunspellDic(path string) (map[string]struct{}, map[rune]struct{}, error) { - f, err := os.Open(path) //nolint:gosec + f, err := os.Open(path) if err != nil { return nil, nil, fmt.Errorf("open dict: %w", err) } diff --git a/spellcheck/download.go b/spellcheck/download.go index edb54aa4..d9cd6890 100644 --- a/spellcheck/download.go +++ b/spellcheck/download.go @@ -1,6 +1,7 @@ package spellcheck import ( + "context" "fmt" "io" "net/http" @@ -32,7 +33,7 @@ func Download(lang string) (string, error) { url := fmt.Sprintf(DictURLTemplate, urlPathLang(lang)) client := httpclient.New(httpclient.InstallTimeout) - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("build request: %w", err) } diff --git a/spellcheck/highlight.go b/spellcheck/highlight.go index 423c6358..b53038d1 100644 --- a/spellcheck/highlight.go +++ b/spellcheck/highlight.go @@ -75,7 +75,7 @@ func scanWords(line string) []wordSpan { var b strings.Builder start := -1 - flush := func(end int) { + flush := func() { if b.Len() == 0 { return } @@ -90,7 +90,7 @@ func scanWords(line string) []wordSpan { i := 0 for i < len(line) { if line[i] == 0x1b { - flush(i) + flush() i += ansiSkip(line, i) continue } @@ -108,10 +108,10 @@ func scanWords(line string) []wordSpan { i += size continue } - flush(i) + flush() i += size } - flush(len(line)) + flush() return spans } diff --git a/spellcheck/spellcheck_test.go b/spellcheck/spellcheck_test.go index 653275f1..9800ab8d 100644 --- a/spellcheck/spellcheck_test.go +++ b/spellcheck/spellcheck_test.go @@ -173,16 +173,16 @@ func TestCheckRecognisesAccentsWhenDictHasThem(t *testing.T) { func TestIsCheckable(t *testing.T) { cases := map[string]bool{ - "hello": true, - "a": false, - "42": false, - "hello42": false, - "NASA": false, - "hi@there": false, - "path/to": false, - "don't": true, - "HelloWorld": true, // mixed case, not an acronym - "INTERNATION": true, // > 5 upper letters, treated as a word + "hello": true, + "a": false, + "42": false, + "hello42": false, + "NASA": false, + "hi@there": false, + "path/to": false, + "don't": true, + "HelloWorld": true, // mixed case, not an acronym + "INTERNATIONAL": true, // > 5 upper letters, treated as a word } for in, want := range cases { if got := IsCheckable(in); got != want { diff --git a/spellcheck/suggest.go b/spellcheck/suggest.go index 8f1e7ae7..e8047a55 100644 --- a/spellcheck/suggest.go +++ b/spellcheck/suggest.go @@ -6,10 +6,10 @@ import ( "unicode" ) -// Suggest returns up to max candidate corrections for word, ranked by +// Suggest returns up to limit candidate corrections for word, ranked by // edit distance ascending then alphabetically. Returns nil when the // checker has no dictionary loaded or when word is too short. -func (c *Checker) Suggest(word string, max int) []string { +func (c *Checker) Suggest(word string, limit int) []string { if c == nil { return nil } @@ -18,8 +18,8 @@ func (c *Checker) Suggest(word string, max int) []string { if !c.loaded || len(c.words) == 0 { return nil } - if max <= 0 { - max = 5 + if limit <= 0 { + limit = 5 } lower := strings.ToLower(word) @@ -67,8 +67,8 @@ func (c *Checker) Suggest(word string, max int) []string { return cands[i].word < cands[j].word }) - if len(cands) > max { - cands = cands[:max] + if len(cands) > limit { + cands = cands[:limit] } out := make([]string, len(cands)) upper := unicode.IsUpper([]rune(word)[0]) diff --git a/tui/composer.go b/tui/composer.go index 4d84268b..92d2819b 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -117,11 +117,9 @@ type Composer struct { spellSuggestions []string spellSelected int spellShow bool - spellWordStart int // byte offset of misspelled word in body - spellWordEnd int - spellWordOnLine int // index of the logical line containing the word - spellWordLineStart int // byte offset of the word within its logical line - spellWordLineEnd int + spellWordOnLine int // index of the logical line containing the word + spellWordLineStart int // byte offset of the word within its logical line + spellWordLineEnd int // byte offset of the word's end within its logical line spellWord string // the misspelled word (as currently in body) spellLastBody string // last body value we computed suggestions for spellLastCursorRow int @@ -465,12 +463,12 @@ func (m *Composer) updateSpellSuggestions() { // cursor with the selected suggestion. It works by sending backspace key // events to the textarea (so the textarea's own bookkeeping stays in // sync) and then inserting the replacement text. -func (m *Composer) acceptSpellSuggestion() tea.Cmd { +func (m *Composer) acceptSpellSuggestion() { if !m.spellShow || len(m.spellSuggestions) == 0 { - return nil + return } if m.spellSelected < 0 || m.spellSelected >= len(m.spellSuggestions) { - return nil + return } suggestion := m.spellSuggestions[m.spellSelected] @@ -482,13 +480,13 @@ func (m *Composer) acceptSpellSuggestion() tea.Cmd { if row != m.spellWordOnLine || row >= len(lines) { m.spellShow = false m.spellSuggestions = nil - return nil + return } endRunes := len([]rune(lines[row][:m.spellWordLineEnd])) if col != endRunes { m.spellShow = false m.spellSuggestions = nil - return nil + return } wordRuneLen := len([]rune(m.spellWord)) @@ -500,7 +498,6 @@ func (m *Composer) acceptSpellSuggestion() tea.Cmd { m.spellShow = false m.spellSuggestions = nil m.spellWord = "" - return nil } func isWordContinuation(r rune) bool { @@ -808,7 +805,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } return m, nil case sk.SpellAccept: - return m, m.acceptSpellSuggestion() + m.acceptSpellSuggestion() + return m, nil case sk.SpellDismiss: m.spellShow = false m.spellSuggestions = nil