Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions cli/dict.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package cli

import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/floatpane/matcha/spellcheck"
)

// RunDict dispatches `matcha dict <subcommand>`.
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 <language-code>\n matcha dict remove <language-code>\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 <language-code>")
}
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 <language-code>")
}
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 <code>` (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
}
104 changes: 57 additions & 47 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +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"`
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.
Expand Down Expand Up @@ -424,18 +426,20 @@ 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"`
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"`
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"`
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.
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -576,19 +582,21 @@ 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"`
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"`
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"`
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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion config/default_keybinds.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions config/keybinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions docs/docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```
Expand All @@ -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
Expand All @@ -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/`:
Expand Down
32 changes: 32 additions & 0 deletions docs/docs/Features/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lang>.dic`.

```bash
matcha dict add <language-code> # download and install a dictionary
matcha dict remove <language-code> # 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`).
Expand Down
Loading
Loading