Detailed changes
@@ -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
@@ -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"
+ }
+}
@@ -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
+}
@@ -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)
+ }
+}
@@ -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 |
@@ -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"
+ }
+}
+```
@@ -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")
@@ -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++
@@ -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
@@ -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
}
@@ -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) {
@@ -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
@@ -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 {
@@ -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()