keybinds.go

  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}