From df5e113dfea17eabb1a200c0fe8fd114a99d5eb3 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Tue, 28 Apr 2026 15:30:30 +0400 Subject: [PATCH] feat: custom keybindings (#1185) ## What? Adds customization to keybinds, as well as a safeguard saying if you have keybinds binded to the same action ## Why? Closes #399 --------- Signed-off-by: drew Co-authored-by: Lea Co-authored-by: Andriy Chernov --- config/config.go | 6 ++ config/default_keybinds.json | 44 ++++++++ config/keybinds.go | 178 +++++++++++++++++++++++++++++++++ config/keybinds_test.go | 86 ++++++++++++++++ docs/docs/Configuration.md | 1 + docs/docs/Features/Keybinds.md | 143 ++++++++++++++++++++++++++ tui/choice.go | 18 +++- tui/composer.go | 15 +-- tui/drafts.go | 9 +- tui/email_view.go | 32 +++--- tui/folder_inbox.go | 23 +++-- tui/inbox.go | 21 ++-- tui/mailing_list.go | 6 +- tui/marketplace.go | 12 ++- 14 files changed, 538 insertions(+), 56 deletions(-) create mode 100644 config/default_keybinds.json create mode 100644 config/keybinds.go create mode 100644 config/keybinds_test.go create mode 100644 docs/docs/Features/Keybinds.md diff --git a/config/config.go b/config/config.go index fa1bcca954e364719371124609bc357f3ce383ad..e2b9ed3def0d9cff4d55213a84762d341cbf878b 100644 --- a/config/config.go +++ b/config/config.go @@ -475,6 +475,12 @@ func LoadConfig() (*Config, error) { if err != nil { return nil, err } + + if dir, err := configDir(); err == nil { + if err := LoadKeybindsFromDir(dir); err != nil { + log.Printf("matcha: keybinds load error (using defaults): %v", err) + } + } data, err := SecureReadFile(path) if err != nil { return nil, err diff --git a/config/default_keybinds.json b/config/default_keybinds.json new file mode 100644 index 0000000000000000000000000000000000000000..54c1f41b344305d2087f4d9eaacb7b14b89e04f7 --- /dev/null +++ b/config/default_keybinds.json @@ -0,0 +1,44 @@ +{ + "global": { + "quit": "ctrl+c", + "cancel": "esc", + "nav_up": "k", + "nav_down": "j" + }, + "inbox": { + "visual_mode": "v", + "delete": "d", + "archive": "a", + "refresh": "r", + "open": "enter", + "next_tab": "l", + "prev_tab": "h" + }, + "email": { + "reply": "r", + "forward": "f", + "delete": "d", + "archive": "a", + "toggle_images": "i", + "rsvp_accept": "1", + "rsvp_decline": "2", + "rsvp_tentative": "3", + "focus_attachments": "tab" + }, + "composer": { + "external_editor": "ctrl+e", + "next_field": "tab", + "prev_field": "shift+tab" + }, + "folder": { + "next_folder": "tab", + "prev_folder": "shift+tab", + "move": "m", + "focus_preview": "]", + "focus_inbox": "[" + }, + "drafts": { + "open": "enter", + "delete": "d" + } +} diff --git a/config/keybinds.go b/config/keybinds.go new file mode 100644 index 0000000000000000000000000000000000000000..f01d60eb72f6ab38f85049e72e760258c0d2c5ff --- /dev/null +++ b/config/keybinds.go @@ -0,0 +1,178 @@ +package config + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +//go:embed default_keybinds.json +var defaultKeybindsJSON []byte + +// Keybinds is the active keybind configuration. Initialized to defaults at +// package init; overwritten by LoadKeybindsFromDir when config is loaded. +var Keybinds = defaultKeybinds() + +// KeybindsConfig holds all configurable key bindings organized by area. +type KeybindsConfig struct { + Global GlobalKeys `json:"global"` + Inbox InboxKeys `json:"inbox"` + Email EmailKeys `json:"email"` + Composer ComposerKeys `json:"composer"` + Folder FolderKeys `json:"folder"` + Drafts DraftsKeys `json:"drafts"` +} + +type GlobalKeys struct { + Quit string `json:"quit"` + Cancel string `json:"cancel"` + NavUp string `json:"nav_up"` + NavDown string `json:"nav_down"` +} + +type InboxKeys struct { + VisualMode string `json:"visual_mode"` + Delete string `json:"delete"` + Archive string `json:"archive"` + Refresh string `json:"refresh"` + Open string `json:"open"` + NextTab string `json:"next_tab"` + PrevTab string `json:"prev_tab"` +} + +type EmailKeys struct { + Reply string `json:"reply"` + Forward string `json:"forward"` + Delete string `json:"delete"` + Archive string `json:"archive"` + ToggleImages string `json:"toggle_images"` + RsvpAccept string `json:"rsvp_accept"` + RsvpDecline string `json:"rsvp_decline"` + RsvpTentative string `json:"rsvp_tentative"` + FocusAttachments string `json:"focus_attachments"` +} + +type ComposerKeys struct { + ExternalEditor string `json:"external_editor"` + NextField string `json:"next_field"` + PrevField string `json:"prev_field"` +} + +type FolderKeys struct { + NextFolder string `json:"next_folder"` + PrevFolder string `json:"prev_folder"` + Move string `json:"move"` + FocusPreview string `json:"focus_preview"` + FocusInbox string `json:"focus_inbox"` +} + +type DraftsKeys struct { + Open string `json:"open"` + Delete string `json:"delete"` +} + +func defaultKeybinds() KeybindsConfig { + var kb KeybindsConfig + if err := json.Unmarshal(defaultKeybindsJSON, &kb); err != nil { + panic("matcha: malformed default_keybinds.json: " + err.Error()) + } + return kb +} + +// LoadKeybindsFromDir reads keybinds.json from cfgDir, writing defaults if +// the file does not exist, then updates the package-level Keybinds var. +func LoadKeybindsFromDir(cfgDir string) error { + path := filepath.Join(cfgDir, "keybinds.json") + + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("keybinds: read %s: %w", path, err) + } + // File missing — write defaults. + if err := os.MkdirAll(cfgDir, 0700); err != nil { + return fmt.Errorf("keybinds: mkdir %s: %w", cfgDir, err) + } + if err := os.WriteFile(path, defaultKeybindsJSON, 0600); err != nil { + return fmt.Errorf("keybinds: write defaults to %s: %w", path, err) + } + Keybinds = defaultKeybinds() + return nil + } + + var kb KeybindsConfig + if err := json.Unmarshal(data, &kb); err != nil { + return fmt.Errorf("keybinds: parse %s: %w", path, err) + } + Keybinds = kb + return nil +} + +// ValidateKeybinds returns a list of conflict descriptions where two different +// actions within the same area are mapped to the same key. Cross-area +// duplicates are intentional (e.g. "d" = delete in both inbox and email view). +func ValidateKeybinds(kb KeybindsConfig) []string { + var conflicts []string + + check := func(area string, bindings map[string]string) { + seen := make(map[string]string) // key → action name + for action, key := range bindings { + if key == "" { + continue + } + if prev, ok := seen[key]; ok { + conflicts = append(conflicts, + fmt.Sprintf("conflict in %s: key %q used for both %q and %q", area, key, prev, action)) + } else { + seen[key] = action + } + } + } + + check("global", map[string]string{ + "quit": kb.Global.Quit, + "cancel": kb.Global.Cancel, + "nav_up": kb.Global.NavUp, + "nav_down": kb.Global.NavDown, + }) + check("inbox", map[string]string{ + "visual_mode": kb.Inbox.VisualMode, + "delete": kb.Inbox.Delete, + "archive": kb.Inbox.Archive, + "refresh": kb.Inbox.Refresh, + "open": kb.Inbox.Open, + "next_tab": kb.Inbox.NextTab, + "prev_tab": kb.Inbox.PrevTab, + }) + check("email", map[string]string{ + "reply": kb.Email.Reply, + "forward": kb.Email.Forward, + "delete": kb.Email.Delete, + "archive": kb.Email.Archive, + "toggle_images": kb.Email.ToggleImages, + "rsvp_accept": kb.Email.RsvpAccept, + "rsvp_decline": kb.Email.RsvpDecline, + "rsvp_tentative": kb.Email.RsvpTentative, + "focus_attachments": kb.Email.FocusAttachments, + }) + check("composer", map[string]string{ + "external_editor": kb.Composer.ExternalEditor, + "next_field": kb.Composer.NextField, + "prev_field": kb.Composer.PrevField, + }) + check("folder", map[string]string{ + "next_folder": kb.Folder.NextFolder, + "prev_folder": kb.Folder.PrevFolder, + "move": kb.Folder.Move, + "focus_preview": kb.Folder.FocusPreview, + "focus_inbox": kb.Folder.FocusInbox, + }) + check("drafts", map[string]string{ + "open": kb.Drafts.Open, + "delete": kb.Drafts.Delete, + }) + + return conflicts +} diff --git a/config/keybinds_test.go b/config/keybinds_test.go new file mode 100644 index 0000000000000000000000000000000000000000..23a86438ef24827abcc9d3562571302290080cea --- /dev/null +++ b/config/keybinds_test.go @@ -0,0 +1,86 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateKeybinds_NoConflicts(t *testing.T) { + kb := defaultKeybinds() + conflicts := ValidateKeybinds(kb) + if len(conflicts) != 0 { + t.Errorf("default keybinds have conflicts: %v", conflicts) + } +} + +func TestValidateKeybinds_InboxConflict(t *testing.T) { + kb := defaultKeybinds() + kb.Inbox.Archive = kb.Inbox.Delete // same key as delete + conflicts := ValidateKeybinds(kb) + if len(conflicts) == 0 { + t.Fatal("expected conflict, got none") + } + found := false + for _, c := range conflicts { + if strings.Contains(c, "inbox") { + found = true + break + } + } + if !found { + t.Errorf("expected inbox conflict, got: %v", conflicts) + } +} + +func TestValidateKeybinds_CrossAreaNotConflict(t *testing.T) { + kb := defaultKeybinds() + // "d" is delete in both inbox and email — intentional, not a conflict + kb.Inbox.Delete = "d" + kb.Email.Delete = "d" + conflicts := ValidateKeybinds(kb) + if len(conflicts) != 0 { + t.Errorf("cross-area duplicates should not be conflicts: %v", conflicts) + } +} + +func TestValidateKeybinds_EmptyKeySkipped(t *testing.T) { + kb := defaultKeybinds() + kb.Drafts.Delete = "" + kb.Drafts.Open = "" + conflicts := ValidateKeybinds(kb) + if len(conflicts) != 0 { + t.Errorf("empty keys should not produce conflicts: %v", conflicts) + } +} + +func TestLoadKeybindsFromDir_WritesDefault(t *testing.T) { + dir := t.TempDir() + if err := LoadKeybindsFromDir(dir); err != nil { + t.Fatalf("LoadKeybindsFromDir: %v", err) + } + if Keybinds.Inbox.Delete == "" { + t.Error("expected inbox.delete to be set after loading defaults") + } +} + +func TestLoadKeybindsFromDir_ParsesCustom(t *testing.T) { + dir := t.TempDir() + // Write defaults first + if err := LoadKeybindsFromDir(dir); err != nil { + t.Fatalf("write defaults: %v", err) + } + + // Override inbox delete key + custom := `{"inbox":{"delete":"x","archive":"a","refresh":"r","open":"enter","next_tab":"l","prev_tab":"h","visual_mode":"v"},"global":{"quit":"ctrl+c","cancel":"esc","nav_up":"k","nav_down":"j"},"email":{"reply":"r","forward":"f","delete":"d","archive":"a","toggle_images":"i","rsvp_accept":"1","rsvp_decline":"2","rsvp_tentative":"3","focus_attachments":"tab"},"composer":{"external_editor":"ctrl+e","next_field":"tab","prev_field":"shift+tab"},"folder":{"next_folder":"tab","prev_folder":"shift+tab","move":"m","focus_preview":"]","focus_inbox":"["},"drafts":{"open":"enter","delete":"d"}}` + if err := os.WriteFile(filepath.Join(dir, "keybinds.json"), []byte(custom), 0600); err != nil { + t.Fatalf("write custom: %v", err) + } + if err := LoadKeybindsFromDir(dir); err != nil { + t.Fatalf("LoadKeybindsFromDir custom: %v", err) + } + if Keybinds.Inbox.Delete != "x" { + t.Errorf("expected inbox.delete=x, got %q", Keybinds.Inbox.Delete) + } +} diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 66c7336effdaf4120242bb17d5a8d7aa6c44a2f0..d350c34ad1cf68a105e815534f3e7b35fc2e317c 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -60,6 +60,7 @@ Configuration and persistent data are stored in `~/.config/matcha/`: | File | Description | |------|-------------| | `config.json` | Account settings, preferences | +| `keybinds.json` | Custom keyboard shortcuts (see [Keybinds](/docs/Features/Keybinds)) | | `signatures/` | Email signatures | | `pgp/` | PGP keys | | `plugins/` | Installed Lua plugins | diff --git a/docs/docs/Features/Keybinds.md b/docs/docs/Features/Keybinds.md new file mode 100644 index 0000000000000000000000000000000000000000..e4b6d11f24da9f4bb4eece4553994f0b9fe62de3 --- /dev/null +++ b/docs/docs/Features/Keybinds.md @@ -0,0 +1,143 @@ +# Keybinds + +Matcha lets you remap every keyboard shortcut. Bindings live in plain JSON at `~/.config/matcha/keybinds.json` and are written automatically the first time you launch the app. + +## File location + +``` +~/.config/matcha/keybinds.json +``` + +Plain text, not encrypted. Edit with any text editor. Restart matcha to apply changes. + +## Default bindings + +```json +{ + "global": { + "quit": "ctrl+c", + "cancel": "esc", + "nav_up": "k", + "nav_down": "j" + }, + "inbox": { + "visual_mode": "v", + "delete": "d", + "archive": "a", + "refresh": "r", + "open": "enter", + "next_tab": "l", + "prev_tab": "h" + }, + "email": { + "reply": "r", + "forward": "f", + "delete": "d", + "archive": "a", + "toggle_images": "i", + "rsvp_accept": "1", + "rsvp_decline": "2", + "rsvp_tentative": "3", + "focus_attachments": "tab" + }, + "composer": { + "external_editor": "ctrl+e", + "next_field": "tab", + "prev_field": "shift+tab" + }, + "folder": { + "next_folder": "tab", + "prev_folder": "shift+tab", + "move": "m", + "focus_preview": "]", + "focus_inbox": "[" + }, + "drafts": { + "open": "enter", + "delete": "d" + } +} +``` + +## Areas + +| Area | Where it applies | +| ---------- | -------------------------------------------------------- | +| `global` | Quit, cancel, vertical navigation — everywhere | +| `inbox` | Email list view (visual select, delete, archive, tabs) | +| `email` | Single-email view (reply, forward, RSVP, attachments) | +| `composer` | New email / reply / forward editor | +| `folder` | Folder sidebar + split-pane preview | +| `drafts` | Draft list | + +The same key can appear in different areas without conflict — `d` is delete in both `inbox` and `email`, that's intentional. Conflicts only matter within one area. + +## Key syntax + +Standard [bubbletea](https://charm.land/bubbletea) key strings: + +| Form | Examples | +| ----------------- | --------------------------------- | +| Single character | `a`, `1`, `?` | +| Modifier + key | `ctrl+c`, `ctrl+e`, `shift+tab` | +| Named key | `enter`, `esc`, `tab`, `space` | +| Arrow | `up`, `down`, `left`, `right` | + +## Conflict warning + +If two actions inside the same area share a key, matcha shows a yellow warning at the top of the start menu: + +``` +⚠ keybind conflict in inbox: "d" used for both "delete" and "archive" +``` + +The warning stays until you fix the binding. Both actions still fire on the shared key, but only the first one wins. + +## What stays hardcoded + +A few keys are never read from config — they exist as universal fallbacks: + +- Arrow keys (`up`, `down`, `left`, `right`) — always navigate +- `y` / `n` on confirmation prompts +- `enter` inside modal pickers (file picker, account picker, move-to-folder) + +This means even an empty or broken `keybinds.json` still leaves the app navigable. + +## Reset to defaults + +Delete the file: + +```bash +rm ~/.config/matcha/keybinds.json +``` + +Next launch writes a fresh default file. + +## Example: Emacs-style nav + +```json +{ + "global": { + "quit": "ctrl+x", + "cancel": "esc", + "nav_up": "ctrl+p", + "nav_down": "ctrl+n" + } +} +``` + +## Example: Single-key actions + +```json +{ + "inbox": { + "delete": "x", + "archive": "e", + "refresh": "g", + "open": "enter", + "visual_mode": "V", + "next_tab": "n", + "prev_tab": "p" + } +} +``` diff --git a/tui/choice.go b/tui/choice.go index ecbba92ccee8d61a67b18b0a968c80f10d33450b..2ea375334b8f81bd540e486c014104396579bbb4 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -39,6 +39,7 @@ type Choice struct { CurrentVersion string width int height int + keybindWarnings []string } func NewChoice() Choice { @@ -58,8 +59,8 @@ func NewChoice() Choice { UpdateAvailable: false, LatestVersion: "", CurrentVersion: "", + keybindWarnings: config.ValidateKeybinds(config.Keybinds), } - } func (m Choice) Init() tea.Cmd { @@ -73,12 +74,13 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height return m, nil case tea.KeyPressMsg: + kb := config.Keybinds switch msg.String() { - case "up", "k": + case "up", kb.Global.NavUp: if m.cursor > 0 { m.cursor-- } - case "down", "j": + case "down", kb.Global.NavDown: if m.cursor < len(m.choices)-1 { m.cursor++ } @@ -134,6 +136,16 @@ func (m Choice) View() tea.View { b.WriteString(logoStyle.Render(choiceLogo)) b.WriteString("\n") + + if len(m.keybindWarnings) > 0 { + warnStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Warning).Padding(0, 1) + for _, w := range m.keybindWarnings { + b.WriteString(warnStyle.Render("⚠ keybind " + w)) + b.WriteString("\n") + } + b.WriteString("\n") + } + b.WriteString(listHeader.Render(t("choice.what_to_do"))) b.WriteString("\n\n") diff --git a/tui/composer.go b/tui/composer.go index 9eafeb4216d6a0adf7ed268a72cf8cd6c2812a14..18952c0db4ae85acce3fb14708ca59a694c2ed65 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -310,8 +310,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.suggestions = nil return m, nil } - // For shift+tab, close suggestions and let it fall through to normal handling - if msg.String() == "shift+tab" { + // For prev-field key, close suggestions and let it fall through to normal handling + if msg.String() == config.Keybinds.Composer.PrevField { m.showSuggestions = false m.suggestions = nil } @@ -366,17 +366,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + kb := config.Keybinds switch msg.String() { - case "ctrl+c": + case kb.Global.Quit: return m, tea.Quit - case "ctrl+e": + case kb.Composer.ExternalEditor: return m, func() tea.Msg { return OpenEditorMsg{} } - case "esc": + case kb.Global.Cancel: m.confirmingExit = true return m, nil - case "tab", "shift+tab": - if msg.String() == "shift+tab" { + case kb.Composer.NextField, kb.Composer.PrevField: + if msg.String() == kb.Composer.PrevField { m.focusIndex-- } else { m.focusIndex++ diff --git a/tui/drafts.go b/tui/drafts.go index 757268230e2f8645a87a959af7d029916a968f72..9e4558acb9e6fb115ba0427700828ebdd9af3ffc 100644 --- a/tui/drafts.go +++ b/tui/drafts.go @@ -130,7 +130,7 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return DeleteSavedDraftMsg{DraftID: draftID} } } - case "n", "N", "esc": + case "n", "N", config.Keybinds.Global.Cancel: m.confirmDelete = false m.selectedDraft = nil } @@ -142,16 +142,17 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } + kb := config.Keybinds switch msg.String() { - case "esc": + case kb.Global.Cancel: return m, func() tea.Msg { return GoToChoiceMenuMsg{} } - case "enter": + case kb.Drafts.Open: if item, ok := m.list.SelectedItem().(draftItem); ok { return m, func() tea.Msg { return OpenDraftMsg{Draft: item.draft} } } - case "d": + case kb.Drafts.Delete: if item, ok := m.list.SelectedItem().(draftItem); ok { m.confirmDelete = true m.selectedDraft = &item.draft diff --git a/tui/email_view.go b/tui/email_view.go index 84b01e4935b885b10d331dd1f16a92e68a15097c..73a26fba8dea57f94b31934b977c361712b62ed7 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -11,6 +11,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/calendar" + "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" "github.com/floatpane/matcha/theme" "github.com/floatpane/matcha/view" @@ -176,8 +177,9 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: - // Handle 'esc' key locally - if msg.String() == "esc" { + kb := config.Keybinds + // Handle cancel key locally + if msg.String() == kb.Global.Cancel { if m.focusOnAttachments { m.focusOnAttachments = false return m, nil @@ -189,11 +191,11 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.focusOnAttachments { switch msg.String() { - case "up", "k": + case "up", kb.Global.NavUp: if m.attachmentCursor > 0 { m.attachmentCursor-- } - case "down", "j": + case "down", kb.Global.NavDown: if m.attachmentCursor < len(m.email.Attachments)-1 { m.attachmentCursor++ } @@ -213,12 +215,12 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - case "tab": + case kb.Email.FocusAttachments: m.focusOnAttachments = false } } else { switch msg.String() { - case "i": + case kb.Email.ToggleImages: if view.ImageProtocolSupported() { m.showImages = !m.showImages ClearKittyGraphics() @@ -233,15 +235,15 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.SetContent(wrapped + "\n") return m, nil } - case "r": + case kb.Email.Reply: // Clear Kitty graphics before opening composer ClearKittyGraphics() return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} } - case "f": + case kb.Email.Forward: // Clear Kitty graphics before opening composer ClearKittyGraphics() return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} } - case "d": + case kb.Email.Delete: accountID := m.accountID uid := m.email.UID // Clear Kitty graphics before transitioning @@ -249,7 +251,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox} } - case "a": + case kb.Email.Archive: accountID := m.accountID uid := m.email.UID // Clear Kitty graphics before transitioning @@ -257,15 +259,15 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox} } - case "1", "2", "3": + case kb.Email.RsvpAccept, kb.Email.RsvpDecline, kb.Email.RsvpTentative: if m.hasCalendarInvite && m.calendarEvent != nil { var response string switch msg.String() { - case "1": + case kb.Email.RsvpAccept: response = "ACCEPTED" - case "2": + case kb.Email.RsvpDecline: response = "DECLINED" - case "3": + case kb.Email.RsvpTentative: response = "TENTATIVE" } @@ -280,7 +282,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - case "tab": + case kb.Email.FocusAttachments: if len(m.email.Attachments) > 0 { m.focusOnAttachments = true } diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 060c6fd4cf46d5bf6ece14d33ea26c7e6ca93922..91e08bdcac6081adb6fccc7ed18e0cce957eab01 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -168,10 +168,12 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } + kb := config.Keybinds + // Route input to preview pane when focused if m.previewPane != nil && m.focusedPane == FocusPreview { s := msg.String() - if s != "[" && s != "]" && s != "esc" && s != "q" { + if s != kb.Folder.FocusInbox && s != kb.Folder.FocusPreview && s != kb.Global.Cancel && s != "q" { var cmd tea.Cmd _, cmd = m.previewPane.Update(msg) return m, cmd @@ -179,38 +181,38 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg.String() { - case "]": + case kb.Folder.FocusPreview: // Switch focus to preview pane if m.previewPane != nil && m.focusedPane == FocusInbox { m.focusedPane = FocusPreview return m, nil } - case "[": + case kb.Folder.FocusInbox: // Switch focus to inbox pane if m.previewPane != nil && m.focusedPane == FocusPreview { m.focusedPane = FocusInbox return m, nil } - case "tab": + case kb.Folder.NextFolder: m.activeFolderIdx++ if m.activeFolderIdx >= len(m.folders) { m.activeFolderIdx = 0 } return m, m.switchFolder() - case "shift+tab": + case kb.Folder.PrevFolder: m.activeFolderIdx-- if m.activeFolderIdx < 0 { m.activeFolderIdx = len(m.folders) - 1 } return m, m.switchFolder() - case "esc": + case kb.Global.Cancel: // Close split preview if open if m.previewPane != nil { m.closeSplitPreview() return m, nil } // Otherwise let inbox handle (or parent) - case "m": + case kb.Folder.Move: // Start move-to-folder flow if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 { // Batch move @@ -377,19 +379,20 @@ func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd { } func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { + kb := config.Keybinds switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { - case "esc": + case kb.Global.Cancel: m.movingEmail = false return m, nil - case "up", "k": + case "up", kb.Global.NavUp: m.moveTargetIdx-- if m.moveTargetIdx < 0 { m.moveTargetIdx = len(m.moveFolderChoices()) - 1 } return m, nil - case "down", "j": + case "down", kb.Global.NavDown: m.moveTargetIdx++ choices := m.moveFolderChoices() if m.moveTargetIdx >= len(choices) { diff --git a/tui/inbox.go b/tui/inbox.go index 27b42c566f45deaaa1ebc1c5350577142368e126..731150ef3c34c779e657f0e5a4da67c2d3ebddf0 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -529,8 +529,9 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } break } + kb := config.Keybinds switch keypress := msg.String(); keypress { - case "v": + case kb.Inbox.VisualMode: if !m.visualMode { // Enter visual mode m.visualMode = true @@ -551,16 +552,16 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateListTitle() } return m, nil - case "esc": + case kb.Global.Cancel: if m.visualMode { - // Exit visual mode on ESC + // Exit visual mode on cancel key m.visualMode = false m.selectedUIDs = make(map[uint32]string) m.selectionOrder = []uint32{} m.updateListTitle() return m, nil } - case "j", "down", "k", "up": + case kb.Global.NavDown, "down", kb.Global.NavUp, "up": if m.visualMode { // Let the list handle navigation first var cmd tea.Cmd @@ -569,7 +570,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateVisualSelection() return m, cmd } - case "left", "h": + case "left", kb.Inbox.PrevTab: if len(m.tabs) > 1 { m.activeTabIndex-- if m.activeTabIndex < 0 { @@ -583,7 +584,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateList() return m, nil } - case "right", "l": + case "right", kb.Inbox.NextTab: if len(m.tabs) > 1 { m.activeTabIndex++ if m.activeTabIndex >= len(m.tabs) { @@ -597,7 +598,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateList() return m, nil } - case "d": + case kb.Inbox.Delete: if m.visualMode && len(m.selectedUIDs) > 0 { // Batch delete uids := make([]uint32, len(m.selectionOrder)) @@ -626,7 +627,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - case "a": + case kb.Inbox.Archive: if m.visualMode && len(m.selectedUIDs) > 0 { // Batch archive uids := make([]uint32, len(m.selectionOrder)) @@ -655,7 +656,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - case "r": + case kb.Inbox.Refresh: m.isRefreshing = true m.list.Title = m.getTitle() // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data) @@ -666,7 +667,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts} } - case "enter": + case kb.Inbox.Open: selectedItem, ok := m.list.SelectedItem().(item) if ok { idx := selectedItem.originalIndex diff --git a/tui/mailing_list.go b/tui/mailing_list.go index f6500b3614b41683f98ecf2a9e29f0fd1f535503..aebead1e9bd60a026c26ee939d4736a1a7789222 100644 --- a/tui/mailing_list.go +++ b/tui/mailing_list.go @@ -6,6 +6,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/config" ) // MailingListEditor displays the screen to add or edit a mailing list. @@ -66,10 +67,11 @@ func (m *MailingListEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyPressMsg: + kb := config.Keybinds switch msg.String() { - case "ctrl+c": + case kb.Global.Quit: return m, tea.Quit - case "esc": + case kb.Global.Cancel: return m, func() tea.Msg { return GoToSettingsMsg{} } case "tab", "shift+tab", "up", "down": if m.focus == 0 { diff --git a/tui/marketplace.go b/tui/marketplace.go index f637960d6faad169a8fdfe7403d23d26a06dabf0..353c5dbf9242993cdfee877408f4aec71b00e9f4 100644 --- a/tui/marketplace.go +++ b/tui/marketplace.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/plugins" "github.com/floatpane/matcha/theme" ) @@ -115,8 +116,9 @@ func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyPressMsg: + kb := config.Keybinds if m.state != marketplaceReady { - if msg.String() == "q" || msg.String() == "esc" || msg.String() == "ctrl+c" { + if msg.String() == "q" || msg.String() == kb.Global.Cancel || msg.String() == kb.Global.Quit { if m.standalone { return m, tea.Quit } @@ -126,21 +128,21 @@ func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg.String() { - case "q", "esc": + case "q", kb.Global.Cancel: if m.standalone { return m, tea.Quit } return m, func() tea.Msg { return GoToChoiceMenuMsg{} } - case "ctrl+c": + case kb.Global.Quit: return m, tea.Quit - case "up", "k": + case "up", kb.Global.NavUp: if m.cursor > 0 { m.cursor-- if m.cursor < m.offset { m.offset = m.cursor } } - case "down", "j": + case "down", kb.Global.NavDown: if m.cursor < len(m.entries)-1 { m.cursor++ visible := m.visibleRows()