From 2e8f75c636798952f6457cfd0833df9d7ce9b188 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Mon, 6 Apr 2026 15:53:14 +0400 Subject: [PATCH] feat(plugins): custom keybinds (#458) Co-authored-by: Steve --- 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(-) create mode 100644 plugins/quick_label.lua diff --git a/docs/docs/Features/Plugins.md b/docs/docs/Features/Plugins.md index 25b69a41449c47f9dc94b413b9ad6fa7df13ba7c..932b5ed7de6c942e823c738dc6da87e9d0c6edd7 100644 --- a/docs/docs/Features/Plugins.md +++ b/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). diff --git a/main.go b/main.go index 57776d6a0ac60f69c365660c684a90185c52eb70..e02114aac67643bde0dd94c324625ecf2e43a3f4 100644 --- a/main.go +++ b/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 { diff --git a/plugin/README.md b/plugin/README.md index 1da2753ce69f87fd8353d961e98d10b1f3ee3593..1c390be7ef0096eca9e1fdbe76f35f3771b90514 100644 --- a/plugin/README.md +++ b/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`) | diff --git a/plugin/api.go b/plugin/api.go index 5a13c193be1802f613c3c0eb9490e263e2a80588..6bc161ebc0d44c1efba99e1b6d159b284f65cdf1 100644 --- a/plugin/api.go +++ b/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 { diff --git a/plugin/hooks.go b/plugin/hooks.go index 2f21bd37a1b9df7abbc5f921bbb802ebb42eee81..03cb7525204a2caffc859addda7410dc59fba100 100644 --- a/plugin/hooks.go +++ b/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 diff --git a/plugin/plugin.go b/plugin/plugin.go index de378ee71b0f88fc249efe60a0adc9022a570bdd..215c844187b6e6f8776474d355fec7900fed4ef5 100644 --- a/plugin/plugin.go +++ b/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 { diff --git a/plugins/quick_label.lua b/plugins/quick_label.lua new file mode 100644 index 0000000000000000000000000000000000000000..edafc1f6f21a177a41685f8d4d528cf8c710d3f7 --- /dev/null +++ b/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) diff --git a/tui/composer.go b/tui/composer.go index 401f09e4f4d8c1adffb5d7c8b3339e134a032855..e0159780e6b88b1236e1ab87ab7eb9c5f76c24f7 100644 --- a/tui/composer.go +++ b/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{ diff --git a/tui/email_view.go b/tui/email_view.go index 308a9d0de9425c551a4588af6b621ce8d04bb99a..d173d26522ef2d36a5ae3b9a66b1e7660a4aeffe 100644 --- a/tui/email_view.go +++ b/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 { diff --git a/tui/inbox.go b/tui/inbox.go index 379a468c4a989aad1fa8d6e628f21412810c2ef7..28bc648cdd6ce04d21e6263b117dc9723544d4da 100644 --- a/tui/inbox.go +++ b/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 diff --git a/tui/messages.go b/tui/messages.go index ad7311cf414edb77e5ffab8250537d65dec351cd..11429f212771ea564555046860ae4478c1023124 100644 --- a/tui/messages.go +++ b/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 +}