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