keybinds.go

  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}