Detailed changes
@@ -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).
@@ -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 {
@@ -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`) |
@@ -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 {
@@ -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
@@ -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 {
@@ -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)
@@ -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{
@@ -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 {
@@ -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
@@ -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
+}