keybinds.go

  1package config
  2
  3import (
  4	_ "embed"
  5	"encoding/json"
  6
  7	keybind "github.com/floatpane/go-keybind"
  8)
  9
 10const keyDelete = "delete" // used in ValidateKeybinds action map keys
 11
 12//go:embed default_keybinds.json
 13var defaultKeybindsJSON []byte
 14
 15// Keybinds is the active keybind configuration. Initialized to defaults at
 16// package init; overwritten by LoadKeybindsFromDir when config is loaded.
 17var Keybinds = defaultKeybinds()
 18
 19// KeybindsConfig holds all configurable key bindings organized by area.
 20type KeybindsConfig struct {
 21	Global   GlobalKeys   `json:"global"`
 22	Inbox    InboxKeys    `json:"inbox"`
 23	Email    EmailKeys    `json:"email"`
 24	Composer ComposerKeys `json:"composer"`
 25	Folder   FolderKeys   `json:"folder"`
 26	Drafts   DraftsKeys   `json:"drafts"`
 27}
 28
 29type GlobalKeys struct {
 30	Quit    string `json:"quit"`
 31	Cancel  string `json:"cancel"`
 32	NavUp   string `json:"nav_up"`
 33	NavDown string `json:"nav_down"`
 34}
 35
 36type InboxKeys struct {
 37	VisualMode     string `json:"visual_mode"`
 38	ToggleThreaded string `json:"toggle_threaded"`
 39	Delete         string `json:"delete"`
 40	Archive        string `json:"archive"`
 41	Refresh        string `json:"refresh"`
 42	Search         string `json:"search"`
 43	Filter         string `json:"filter"`
 44	Open           string `json:"open"`
 45	NextTab        string `json:"next_tab"`
 46	PrevTab        string `json:"prev_tab"`
 47}
 48
 49type EmailKeys struct {
 50	Reply            string `json:"reply"`
 51	Forward          string `json:"forward"`
 52	Delete           string `json:"delete"`
 53	Archive          string `json:"archive"`
 54	ToggleImages     string `json:"toggle_images"`
 55	RsvpAccept       string `json:"rsvp_accept"`
 56	RsvpDecline      string `json:"rsvp_decline"`
 57	RsvpTentative    string `json:"rsvp_tentative"`
 58	FocusAttachments string `json:"focus_attachments"`
 59}
 60
 61type ComposerKeys struct {
 62	ExternalEditor string `json:"external_editor"`
 63	NextField      string `json:"next_field"`
 64	PrevField      string `json:"prev_field"`
 65	Delete         string `json:"delete"`
 66	SpellNext      string `json:"spell_next"`
 67	SpellPrev      string `json:"spell_prev"`
 68	SpellAccept    string `json:"spell_accept"`
 69	SpellDismiss   string `json:"spell_dismiss"`
 70	UndoSend       string `json:"undo_send"`
 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	kb, err := keybind.Load(cfgDir, "keybinds.json", defaultKeybinds())
 98	if err != nil {
 99		return err
100	}
101	Keybinds = kb
102	return nil
103}
104
105// ValidateKeybinds returns a list of conflict descriptions where two different
106// actions within the same area are mapped to the same key. Cross-area
107// duplicates are intentional (e.g. "d" = delete in both inbox and email view).
108func ValidateKeybinds(kb KeybindsConfig) []string {
109	return keybind.Validate(map[string]map[string]string{
110		"global": {
111			"quit":     kb.Global.Quit,
112			"cancel":   kb.Global.Cancel,
113			"nav_up":   kb.Global.NavUp,
114			"nav_down": kb.Global.NavDown,
115		},
116		"inbox": {
117			"visual_mode":     kb.Inbox.VisualMode,
118			"toggle_threaded": kb.Inbox.ToggleThreaded,
119			keyDelete:         kb.Inbox.Delete,
120			"archive":         kb.Inbox.Archive,
121			"refresh":         kb.Inbox.Refresh,
122			"search":          kb.Inbox.Search,
123			"filter":          kb.Inbox.Filter,
124			"open":            kb.Inbox.Open,
125			"next_tab":        kb.Inbox.NextTab,
126			"prev_tab":        kb.Inbox.PrevTab,
127		},
128		"email": {
129			"reply":             kb.Email.Reply,
130			"forward":           kb.Email.Forward,
131			keyDelete:           kb.Email.Delete,
132			"archive":           kb.Email.Archive,
133			"toggle_images":     kb.Email.ToggleImages,
134			"rsvp_accept":       kb.Email.RsvpAccept,
135			"rsvp_decline":      kb.Email.RsvpDecline,
136			"rsvp_tentative":    kb.Email.RsvpTentative,
137			"focus_attachments": kb.Email.FocusAttachments,
138		},
139		"composer": {
140			"undo_send":       kb.Composer.UndoSend,
141			"external_editor": kb.Composer.ExternalEditor,
142			"next_field":      kb.Composer.NextField,
143			"prev_field":      kb.Composer.PrevField,
144			keyDelete:         kb.Composer.Delete,
145			// spell_* bindings intentionally excluded — spell_accept reusing
146			// "tab" with next_field and spell_dismiss reusing "esc" with cancel
147			// are deliberate: the spellcheck popup intercepts before those handlers.
148		},
149		"folder": {
150			"next_folder":   kb.Folder.NextFolder,
151			"prev_folder":   kb.Folder.PrevFolder,
152			"move":          kb.Folder.Move,
153			"focus_preview": kb.Folder.FocusPreview,
154			"focus_inbox":   kb.Folder.FocusInbox,
155		},
156		"drafts": {
157			"open":    kb.Drafts.Open,
158			keyDelete: kb.Drafts.Delete,
159		},
160	})
161}