feat(plugins): custom keybinds (#458)

Drew Smirnoff and Steve created

Co-authored-by: Steve <steve@floatpane.com>

Change summary

docs/docs/Features/Plugins.md |  32 +++++++++++
main.go                       | 100 ++++++++++++++++++++++++++++++++++++
plugin/README.md              |   3 
plugin/api.go                 |  23 ++++++++
plugin/hooks.go               |  11 ++++
plugin/plugin.go              |  26 +++++++++
plugins/quick_label.lua       |  11 ++++
tui/composer.go               |  11 +++
tui/email_view.go             |   9 +++
tui/inbox.go                  |  18 ++++++
tui/messages.go               |   6 ++
11 files changed, 247 insertions(+), 3 deletions(-)

Detailed changes

docs/docs/Features/Plugins.md 🔗

@@ -102,6 +102,38 @@ matcha.on("composer_updated", function(state)
 end)
 ```
 
+### matcha.bind_key(key, area, description, callback)
+
+Register a custom keyboard shortcut. The shortcut is scoped to a specific view area and shows up in the help bar. The callback receives a context table when the key is pressed.
+
+**Parameters:**
+
+| Parameter     | Type     | Description                                                    |
+| ------------- | -------- | -------------------------------------------------------------- |
+| `key`         | string   | Key string (e.g. `"ctrl+k"`, `"g"`, `"ctrl+shift+a"`)         |
+| `area`        | string   | View area: `"inbox"`, `"email_view"`, or `"composer"`          |
+| `description` | string   | Short text shown in the help bar                               |
+| `callback`    | function | Called when the key is pressed; receives a context table        |
+
+**Context tables by area:**
+
+- **inbox / email_view**: Same email table as `email_viewed` (`uid`, `from`, `to`, `subject`, `date`, `is_read`, `account_id`, `folder`)
+- **composer**: Same state table as `composer_updated` (`body`, `body_len`, `subject`, `to`, `cc`, `bcc`)
+
+```lua
+-- Add a shortcut to show email subject in inbox
+matcha.bind_key("ctrl+i", "inbox", "info", function(email)
+  if email then
+    matcha.notify("Subject: " .. email.subject, 3)
+  end
+end)
+
+-- Add a shortcut to insert a greeting in the composer
+matcha.bind_key("ctrl+g", "composer", "greeting", function(state)
+  matcha.set_compose_field("body", "Hi there,\n\n" .. state.body)
+end)
+```
+
 ### matcha.notify(message [, seconds])
 
 Show a temporary notification in the Matcha UI. The optional second argument sets how long the notification is displayed (default 2 seconds).

main.go 🔗

@@ -34,6 +34,7 @@ import (
 	"github.com/floatpane/matcha/theme"
 	"github.com/floatpane/matcha/tui"
 	"github.com/google/uuid"
+	lua "github.com/yuin/gopher-lua"
 )
 
 const (
@@ -147,12 +148,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds = append(cmds, cmd)
 
 	// Fire composer_updated hook on key presses when the composer is active
-	if _, isKey := msg.(tea.KeyPressMsg); isKey {
+	if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
 		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
 			m.syncPluginStatus()
 			m.applyPluginFields(composer)
 		}
+
+		// Check plugin key bindings for the current view
+		if m.plugins != nil {
+			m.handlePluginKeyBinding(keyMsg)
+		}
 	}
 
 	switch msg := msg.(type) {
@@ -429,6 +435,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.plugins != nil {
 			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 			m.syncPluginStatus()
+			m.syncPluginKeyBindings()
 		}
 		// Use in-memory cache if available
 		if cached, ok := m.folderEmails[msg.FolderName]; ok {
@@ -503,6 +510,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 		m.folderInbox.SetLoadingEmails(false)
 		m.syncPluginStatus()
+		m.syncPluginKeyBindings()
 		return m, m.pluginNotifyCmd()
 
 	case tui.FetchFolderMoreEmailsMsg:
@@ -700,6 +708,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 		}
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
 	case tui.GoToDraftsMsg:
@@ -718,6 +727,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 		m.current = composer
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
 	case tui.DeleteSavedDraftMsg:
@@ -884,6 +894,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 		m.current = emailView
 		m.syncPluginStatus()
+		m.syncPluginKeyBindings()
 		cmds := []tea.Cmd{m.current.Init()}
 		if markReadCmd != nil {
 			cmds = append(cmds, markReadCmd)
@@ -923,6 +934,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		m.current = composer
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
 	case tui.ForwardEmailMsg:
@@ -958,6 +970,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		m.current = composer
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
 	case tui.OpenEditorMsg:
@@ -1319,6 +1332,91 @@ func (m *mainModel) syncPluginStatus() {
 	}
 }
 
+func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) {
+	keyStr := msg.String()
+
+	var area string
+	switch m.current.(type) {
+	case *tui.Inbox:
+		area = plugin.StatusInbox
+	case *tui.FolderInbox:
+		area = plugin.StatusInbox
+	case *tui.EmailView:
+		area = plugin.StatusEmailView
+	case *tui.Composer:
+		area = plugin.StatusComposer
+	default:
+		return
+	}
+
+	bindings := m.plugins.Bindings(area)
+	for _, binding := range bindings {
+		if binding.Key != keyStr {
+			continue
+		}
+
+		// Build context table based on the current view
+		switch v := m.current.(type) {
+		case *tui.Inbox:
+			if email := v.GetSelectedEmail(); email != nil {
+				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
+				m.plugins.CallKeyBinding(binding, t)
+			} else {
+				m.plugins.CallKeyBinding(binding)
+			}
+		case *tui.FolderInbox:
+			if email := v.GetInbox().GetSelectedEmail(); email != nil {
+				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
+				m.plugins.CallKeyBinding(binding, t)
+			} else {
+				m.plugins.CallKeyBinding(binding)
+			}
+		case *tui.EmailView:
+			email := v.GetEmail()
+			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
+			m.plugins.CallKeyBinding(binding, t)
+		case *tui.Composer:
+			L := m.plugins.LuaState()
+			t := L.NewTable()
+			t.RawSetString("body", lua.LString(v.GetBody()))
+			t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
+			t.RawSetString("subject", lua.LString(v.GetSubject()))
+			t.RawSetString("to", lua.LString(v.GetTo()))
+			t.RawSetString("cc", lua.LString(v.GetCc()))
+			t.RawSetString("bcc", lua.LString(v.GetBcc()))
+			m.plugins.CallKeyBinding(binding, t)
+			m.applyPluginFields(v)
+		}
+
+		m.syncPluginStatus()
+		return
+	}
+}
+
+func (m *mainModel) syncPluginKeyBindings() {
+	if m.plugins == nil {
+		return
+	}
+
+	toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
+		result := make([]tui.PluginKeyBinding, len(bindings))
+		for i, b := range bindings {
+			result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
+		}
+		return result
+	}
+
+	if m.folderInbox != nil {
+		m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
+	}
+	switch v := m.current.(type) {
+	case *tui.Composer:
+		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
+	case *tui.EmailView:
+		v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
+	}
+}
+
 func (m *mainModel) applyPluginFields(composer *tui.Composer) {
 	fields := m.plugins.TakePendingFields()
 	if fields == nil {

plugin/README.md 🔗

@@ -26,6 +26,7 @@ end)
 | `matcha.notify(msg [, seconds])` | Show a temporary notification in the TUI (default 2s) |
 | `matcha.set_status(area, text)` | Set a persistent status string for a view area (`"inbox"`, `"composer"`, `"email_view"`) |
 | `matcha.set_compose_field(field, value)` | Set a compose field value (`"to"`, `"cc"`, `"bcc"`, `"subject"`, `"body"`) |
+| `matcha.bind_key(key, area, description, callback)` | Register a custom keyboard shortcut for a view area (`"inbox"`, `"email_view"`, `"composer"`) |
 
 ## Hook events
 
@@ -53,4 +54,4 @@ The following example plugins ship in `~/.config/matcha/plugins/`:
 |------|-------------|
 | `plugin.go` | Plugin manager — Lua VM setup, plugin discovery and loading, notification/status state |
 | `hooks.go` | Hook definitions, callback registration, and hook invocation helpers |
-| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`) |
+| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`) |

plugin/api.go 🔗

@@ -16,6 +16,7 @@ func (m *Manager) registerAPI() {
 		"notify":            m.luaNotify,
 		"set_status":        m.luaSetStatus,
 		"set_compose_field": m.luaSetComposeField,
+		"bind_key":          m.luaBindKey,
 	})
 
 	L.SetField(mod, "_VERSION", lua.LString("0.1.0"))
@@ -53,6 +54,28 @@ func (m *Manager) luaNotify(L *lua.LState) int {
 	return 0
 }
 
+// matcha.bind_key(key, area, description, callback) — register a custom keyboard shortcut.
+// Valid areas: "inbox", "email_view", "composer".
+func (m *Manager) luaBindKey(L *lua.LState) int {
+	key := L.CheckString(1)
+	area := L.CheckString(2)
+	description := L.CheckString(3)
+	fn := L.CheckFunction(4)
+
+	switch area {
+	case "inbox", "email_view", "composer":
+		m.bindings = append(m.bindings, KeyBinding{
+			Key:         key,
+			Area:        area,
+			Description: description,
+			Fn:          fn,
+		})
+	default:
+		L.ArgError(2, "invalid area: must be \"inbox\", \"email_view\", or \"composer\"")
+	}
+	return 0
+}
+
 // matcha.set_compose_field(field, value) — set a compose field value.
 // Valid fields: "to", "cc", "bcc", "subject", "body".
 func (m *Manager) luaSetComposeField(L *lua.LState) int {

plugin/hooks.go 🔗

@@ -119,6 +119,17 @@ func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc stri
 	}
 }
 
+// CallKeyBinding invokes a plugin key binding callback with the given arguments.
+func (m *Manager) CallKeyBinding(binding KeyBinding, args ...lua.LValue) {
+	if err := m.state.CallByParam(lua.P{
+		Fn:      binding.Fn,
+		NRet:    0,
+		Protect: true,
+	}, args...); err != nil {
+		log.Printf("plugin keybinding %q error: %v", binding.Key, err)
+	}
+}
+
 // EmailToTable converts email fields into a Lua table.
 func (m *Manager) EmailToTable(uid uint32, from string, to []string, subject string, date time.Time, isRead bool, accountID string, folder string) *lua.LTable {
 	L := m.state

plugin/plugin.go 🔗

@@ -9,6 +9,14 @@ import (
 	lua "github.com/yuin/gopher-lua"
 )
 
+// KeyBinding represents a plugin-registered keyboard shortcut.
+type KeyBinding struct {
+	Key         string
+	Area        string // "inbox", "email_view", or "composer"
+	Description string
+	Fn          *lua.LFunction
+}
+
 // Manager manages the Lua VM and loaded plugins.
 type Manager struct {
 	state   *lua.LState
@@ -21,6 +29,8 @@ type Manager struct {
 	pendingDuration     float64 // seconds, 0 means default (2s)
 	// pendingFields holds compose field updates set by matcha.set_compose_field().
 	pendingFields map[string]string
+	// bindings holds plugin-registered keyboard shortcuts.
+	bindings []KeyBinding
 }
 
 // NewManager creates a new plugin manager with a Lua VM.
@@ -131,11 +141,27 @@ func (m *Manager) TakePendingFields() map[string]string {
 	return fields
 }
 
+// Bindings returns all plugin-registered key bindings for the given view area.
+func (m *Manager) Bindings(area string) []KeyBinding {
+	var result []KeyBinding
+	for _, b := range m.bindings {
+		if b.Area == area {
+			result = append(result, b)
+		}
+	}
+	return result
+}
+
 // StatusText returns the plugin status string for the given view area.
 func (m *Manager) StatusText(area string) string {
 	return m.statuses[area]
 }
 
+// LuaState returns the Lua VM state for building tables.
+func (m *Manager) LuaState() *lua.LState {
+	return m.state
+}
+
 // Close shuts down the Lua VM.
 func (m *Manager) Close() {
 	if m.state != nil {

plugins/quick_label.lua 🔗

@@ -0,0 +1,11 @@
+-- quick_label.lua
+-- Demonstrates custom keyboard shortcuts with matcha.bind_key().
+-- Press ctrl+i in the inbox to show the selected email's subject.
+
+local matcha = require("matcha")
+
+matcha.bind_key("ctrl+i", "inbox", "info", function(email)
+    if email then
+        matcha.notify(email.from .. ": " .. email.subject, 3)
+    end
+end)

tui/composer.go 🔗

@@ -84,7 +84,8 @@ type Composer struct {
 	quotedText string
 
 	// Plugin status text shown in the help bar
-	pluginStatus string
+	pluginStatus      string
+	pluginKeyBindings []PluginKeyBinding
 }
 
 // NewComposer initializes a new composer model.
@@ -603,6 +604,9 @@ func (m *Composer) View() tea.View {
 
 	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
 	helpText := "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit"
+	for _, pk := range m.pluginKeyBindings {
+		helpText += " • " + pk.Key + ": " + pk.Description
+	}
 	if m.pluginStatus != "" {
 		helpText += " • " + m.pluginStatus
 	}
@@ -786,6 +790,11 @@ func (m *Composer) SetPluginStatus(status string) {
 	m.pluginStatus = status
 }
 
+// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
+func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
+	m.pluginKeyBindings = bindings
+}
+
 // ToDraft converts the composer state to a Draft for saving.
 func (m *Composer) ToDraft() config.Draft {
 	return config.Draft{

tui/email_view.go 🔗

@@ -44,6 +44,7 @@ type EmailView struct {
 	isPGPEncrypted     bool
 	imagePlacements    []view.ImagePlacement
 	pluginStatus       string
+	pluginKeyBindings  []PluginKeyBinding
 }
 
 func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
@@ -298,6 +299,9 @@ func (m *EmailView) View() tea.View {
 		if view.ImageProtocolSupported() {
 			shortcuts = shortcuts + "• \uf03e i: toggle images"
 		}
+		for _, pk := range m.pluginKeyBindings {
+			shortcuts += " • " + pk.Key + ": " + pk.Description
+		}
 		if m.pluginStatus != "" {
 			shortcuts += " • " + m.pluginStatus
 		}
@@ -356,6 +360,11 @@ func (m *EmailView) SetPluginStatus(status string) {
 	m.pluginStatus = status
 }
 
+// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
+func (m *EmailView) SetPluginKeyBindings(bindings []PluginKeyBinding) {
+	m.pluginKeyBindings = bindings
+}
+
 func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
 	var imgs []view.InlineImage
 	for _, att := range atts {

tui/inbox.go 🔗

@@ -218,6 +218,7 @@ type Inbox struct {
 	noMoreByAccount    map[string]bool // Per-account: true when pagination returns 0 results
 	extraShortHelpKeys []key.Binding
 	pluginStatus       string // Persistent status text set by plugins
+	pluginKeyBindings  []PluginKeyBinding
 }
 
 func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
@@ -350,6 +351,9 @@ func (m *Inbox) updateList() {
 			)
 		}
 		bindings = append(bindings, m.extraShortHelpKeys...)
+		for _, pk := range m.pluginKeyBindings {
+			bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
+		}
 		return bindings
 	}
 
@@ -707,6 +711,15 @@ func (m *Inbox) GetMailbox() MailboxKind {
 	return m.mailbox
 }
 
+// GetSelectedEmail returns the currently selected email, or nil if none is selected.
+func (m *Inbox) GetSelectedEmail() *fetcher.Email {
+	selectedItem, ok := m.list.SelectedItem().(item)
+	if !ok {
+		return nil
+	}
+	return m.GetEmailAtIndex(selectedItem.originalIndex)
+}
+
 // MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
 func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
 	for i := range m.allEmails {
@@ -771,6 +784,11 @@ func (m *Inbox) SetPluginStatus(status string) {
 	m.list.Title = m.getTitle()
 }
 
+// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
+func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
+	m.pluginKeyBindings = bindings
+}
+
 // SetEmails updates all emails (used after fetch)
 func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
 	m.accounts = accounts

tui/messages.go 🔗

@@ -438,3 +438,9 @@ type PluginNotifyMsg struct {
 	Message  string
 	Duration float64 // Duration in seconds (default 2)
 }
+
+// PluginKeyBinding describes a plugin-registered keyboard shortcut for display in the help bar.
+type PluginKeyBinding struct {
+	Key         string
+	Description string
+}