1package config
2
3import (
4 _ "embed"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9)
10
11const keyDelete = "delete"
12
13//go:embed default_keybinds.json
14var defaultKeybindsJSON []byte
15
16// Keybinds is the active keybind configuration. Initialized to defaults at
17// package init; overwritten by LoadKeybindsFromDir when config is loaded.
18var Keybinds = defaultKeybinds()
19
20// KeybindsConfig holds all configurable key bindings organized by area.
21type KeybindsConfig struct {
22 Global GlobalKeys `json:"global"`
23 Inbox InboxKeys `json:"inbox"`
24 Email EmailKeys `json:"email"`
25 Composer ComposerKeys `json:"composer"`
26 Folder FolderKeys `json:"folder"`
27 Drafts DraftsKeys `json:"drafts"`
28}
29
30type GlobalKeys struct {
31 Quit string `json:"quit"`
32 Cancel string `json:"cancel"`
33 NavUp string `json:"nav_up"`
34 NavDown string `json:"nav_down"`
35}
36
37type InboxKeys struct {
38 VisualMode string `json:"visual_mode"`
39 ToggleThreaded string `json:"toggle_threaded"`
40 Delete string `json:"delete"`
41 Archive string `json:"archive"`
42 Refresh string `json:"refresh"`
43 Search string `json:"search"`
44 Filter string `json:"filter"`
45 Open string `json:"open"`
46 NextTab string `json:"next_tab"`
47 PrevTab string `json:"prev_tab"`
48}
49
50type EmailKeys struct {
51 Reply string `json:"reply"`
52 Forward string `json:"forward"`
53 Delete string `json:"delete"`
54 Archive string `json:"archive"`
55 ToggleImages string `json:"toggle_images"`
56 RsvpAccept string `json:"rsvp_accept"`
57 RsvpDecline string `json:"rsvp_decline"`
58 RsvpTentative string `json:"rsvp_tentative"`
59 FocusAttachments string `json:"focus_attachments"`
60}
61
62type ComposerKeys struct {
63 ExternalEditor string `json:"external_editor"`
64 NextField string `json:"next_field"`
65 PrevField string `json:"prev_field"`
66 Delete string `json:"delete"`
67 SpellNext string `json:"spell_next"`
68 SpellPrev string `json:"spell_prev"`
69 SpellAccept string `json:"spell_accept"`
70 SpellDismiss string `json:"spell_dismiss"`
71}
72
73type FolderKeys struct {
74 NextFolder string `json:"next_folder"`
75 PrevFolder string `json:"prev_folder"`
76 Move string `json:"move"`
77 FocusPreview string `json:"focus_preview"`
78 FocusInbox string `json:"focus_inbox"`
79}
80
81type DraftsKeys struct {
82 Open string `json:"open"`
83 Delete string `json:"delete"`
84}
85
86func defaultKeybinds() KeybindsConfig {
87 var kb KeybindsConfig
88 if err := json.Unmarshal(defaultKeybindsJSON, &kb); err != nil {
89 panic("matcha: malformed default_keybinds.json: " + err.Error())
90 }
91 return kb
92}
93
94// LoadKeybindsFromDir reads keybinds.json from cfgDir, writing defaults if
95// the file does not exist, then updates the package-level Keybinds var.
96func LoadKeybindsFromDir(cfgDir string) error {
97 path := filepath.Join(cfgDir, "keybinds.json")
98
99 data, err := os.ReadFile(path)
100 if err != nil {
101 if !os.IsNotExist(err) {
102 return fmt.Errorf("keybinds: read %s: %w", path, err)
103 }
104 // File missing — write defaults.
105 if err := os.MkdirAll(cfgDir, 0700); err != nil {
106 return fmt.Errorf("keybinds: mkdir %s: %w", cfgDir, err)
107 }
108 if err := os.WriteFile(path, defaultKeybindsJSON, 0600); err != nil {
109 return fmt.Errorf("keybinds: write defaults to %s: %w", path, err)
110 }
111 Keybinds = defaultKeybinds()
112 return nil
113 }
114
115 kb := defaultKeybinds()
116 if err := json.Unmarshal(data, &kb); err != nil {
117 return fmt.Errorf("keybinds: parse %s: %w", path, err)
118 }
119 Keybinds = kb
120 return nil
121}
122
123// ValidateKeybinds returns a list of conflict descriptions where two different
124// actions within the same area are mapped to the same key. Cross-area
125// duplicates are intentional (e.g. "d" = delete in both inbox and email view).
126func ValidateKeybinds(kb KeybindsConfig) []string {
127 var conflicts []string
128
129 check := func(area string, bindings map[string]string) {
130 seen := make(map[string]string) // key → action name
131 for action, key := range bindings {
132 if key == "" {
133 continue
134 }
135 if prev, ok := seen[key]; ok {
136 conflicts = append(conflicts,
137 fmt.Sprintf("conflict in %s: key %q used for both %q and %q", area, key, prev, action))
138 } else {
139 seen[key] = action
140 }
141 }
142 }
143
144 check("global", map[string]string{
145 "quit": kb.Global.Quit,
146 "cancel": kb.Global.Cancel,
147 "nav_up": kb.Global.NavUp,
148 "nav_down": kb.Global.NavDown,
149 })
150 check("inbox", map[string]string{
151 "visual_mode": kb.Inbox.VisualMode,
152 "toggle_threaded": kb.Inbox.ToggleThreaded,
153 keyDelete: kb.Inbox.Delete,
154 "archive": kb.Inbox.Archive,
155 "refresh": kb.Inbox.Refresh,
156 "search": kb.Inbox.Search,
157 "filter": kb.Inbox.Filter,
158 "open": kb.Inbox.Open,
159 "next_tab": kb.Inbox.NextTab,
160 "prev_tab": kb.Inbox.PrevTab,
161 })
162 check("email", map[string]string{
163 "reply": kb.Email.Reply,
164 "forward": kb.Email.Forward,
165 keyDelete: kb.Email.Delete,
166 "archive": kb.Email.Archive,
167 "toggle_images": kb.Email.ToggleImages,
168 "rsvp_accept": kb.Email.RsvpAccept,
169 "rsvp_decline": kb.Email.RsvpDecline,
170 "rsvp_tentative": kb.Email.RsvpTentative,
171 "focus_attachments": kb.Email.FocusAttachments,
172 })
173 check("composer", map[string]string{
174 "external_editor": kb.Composer.ExternalEditor,
175 "next_field": kb.Composer.NextField,
176 "prev_field": kb.Composer.PrevField,
177 keyDelete: kb.Composer.Delete,
178 // spell_* bindings intentionally excluded from this conflict
179 // check — spell_accept reusing "tab" with next_field, and
180 // spell_dismiss reusing "esc" with cancel, are deliberate: the
181 // spellcheck popup intercepts before those handlers fire.
182 })
183 check("folder", map[string]string{
184 "next_folder": kb.Folder.NextFolder,
185 "prev_folder": kb.Folder.PrevFolder,
186 "move": kb.Folder.Move,
187 "focus_preview": kb.Folder.FocusPreview,
188 "focus_inbox": kb.Folder.FocusInbox,
189 })
190 check("drafts", map[string]string{
191 "open": kb.Drafts.Open,
192 keyDelete: kb.Drafts.Delete,
193 })
194
195 return conflicts
196}