Detailed changes
@@ -97,6 +97,10 @@ type Config struct {
DateFormat string `json:"date_format,omitempty"`
Language string `json:"language,omitempty"` // Language code (e.g., "en", "es", "de")
BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
+ // PluginSettings stores user-configurable values for installed plugins,
+ // keyed by plugin name then setting key. Values are JSON-native types
+ // (bool, float64, string) matching the plugin's declared schema.
+ PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
}
// GetBodyCacheThreshold returns the email body cache threshold in bytes.
@@ -394,16 +398,17 @@ type secureDiskAccount struct {
}
type secureDiskConfig struct {
- Accounts []secureDiskAccount `json:"accounts"`
- DisableImages bool `json:"disable_images,omitempty"`
- HideTips bool `json:"hide_tips,omitempty"`
- DisableNotifications bool `json:"disable_notifications,omitempty"`
- EnableSplitPane bool `json:"enable_split_pane,omitempty"`
- EnableThreaded bool `json:"enable_threaded,omitempty"`
- Theme string `json:"theme,omitempty"`
- MailingLists []MailingList `json:"mailing_lists,omitempty"`
- DateFormat string `json:"date_format,omitempty"`
- Language string `json:"language,omitempty"`
+ Accounts []secureDiskAccount `json:"accounts"`
+ DisableImages bool `json:"disable_images,omitempty"`
+ HideTips bool `json:"hide_tips,omitempty"`
+ DisableNotifications bool `json:"disable_notifications,omitempty"`
+ EnableSplitPane bool `json:"enable_split_pane,omitempty"`
+ EnableThreaded bool `json:"enable_threaded,omitempty"`
+ Theme string `json:"theme,omitempty"`
+ MailingLists []MailingList `json:"mailing_lists,omitempty"`
+ DateFormat string `json:"date_format,omitempty"`
+ Language string `json:"language,omitempty"`
+ PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
}
// SaveConfig saves the given configuration to the config file and passwords to the keyring.
@@ -448,6 +453,7 @@ func SaveConfig(config *Config) error {
Theme: config.Theme,
MailingLists: config.MailingLists,
DateFormat: config.DateFormat,
+ PluginSettings: config.PluginSettings,
}
for _, acc := range config.Accounts {
sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
@@ -541,17 +547,18 @@ func LoadConfig() (*Config, error) {
CatchAll bool `json:"catch_all,omitempty"`
}
type diskConfig struct {
- Accounts []rawAccount `json:"accounts"`
- DisableImages bool `json:"disable_images,omitempty"`
- HideTips bool `json:"hide_tips,omitempty"`
- DisableNotifications bool `json:"disable_notifications,omitempty"`
- EnableSplitPane bool `json:"enable_split_pane,omitempty"`
- EnableThreaded bool `json:"enable_threaded,omitempty"`
- Theme string `json:"theme,omitempty"`
- MailingLists []MailingList `json:"mailing_lists,omitempty"`
- DateFormat string `json:"date_format,omitempty"`
- Language string `json:"language,omitempty"`
- BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
+ Accounts []rawAccount `json:"accounts"`
+ DisableImages bool `json:"disable_images,omitempty"`
+ HideTips bool `json:"hide_tips,omitempty"`
+ DisableNotifications bool `json:"disable_notifications,omitempty"`
+ EnableSplitPane bool `json:"enable_split_pane,omitempty"`
+ EnableThreaded bool `json:"enable_threaded,omitempty"`
+ Theme string `json:"theme,omitempty"`
+ MailingLists []MailingList `json:"mailing_lists,omitempty"`
+ DateFormat string `json:"date_format,omitempty"`
+ Language string `json:"language,omitempty"`
+ BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
+ PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
}
var raw diskConfig
@@ -589,6 +596,7 @@ func LoadConfig() (*Config, error) {
config.DateFormat = raw.DateFormat
config.Language = raw.Language
config.BodyCacheThresholdMB = raw.BodyCacheThresholdMB
+ config.PluginSettings = raw.PluginSettings
for _, rawAcc := range raw.Accounts {
acc := Account{
@@ -118,6 +118,7 @@
"category_theme": "المظهر",
"category_mailing_lists": "القوائم البريدية",
"category_encryption": "تشفير التطبيق",
+ "category_plugins": "الإضافات",
"help_menu": "↑/↓: التنقل • يمين/enter: اختيار • esc: رجوع",
"help_content": "esc: العودة للقائمة"
},
@@ -116,6 +116,7 @@
"category_theme": "Design",
"category_mailing_lists": "Mailinglisten",
"category_encryption": "App-Verschlüsselung",
+ "category_plugins": "Plugins",
"help_menu": "↑/↓: navigieren • rechts/enter: auswählen • esc: zurück",
"help_content": "esc: zurück zum Menü"
},
@@ -116,6 +116,7 @@
"category_theme": "Theme",
"category_mailing_lists": "Mailing Lists",
"category_encryption": "App Encryption",
+ "category_plugins": "Plugins",
"help_menu": "↑/↓: navigate • right/enter: select • esc: go back",
"help_content": "esc: back to menu"
},
@@ -116,6 +116,7 @@
"category_theme": "Tema",
"category_mailing_lists": "Listas de Correo",
"category_encryption": "Cifrado de Aplicación",
+ "category_plugins": "Plugins",
"help_menu": "↑/↓: navegar • derecha/enter: seleccionar • esc: volver",
"help_content": "esc: volver al menú"
},
@@ -116,6 +116,7 @@
"category_theme": "Thème",
"category_mailing_lists": "Listes de Diffusion",
"category_encryption": "Chiffrement de l'Application",
+ "category_plugins": "Plugins",
"help_menu": "↑/↓: naviguer • droite/entrée: sélectionner • esc: retour",
"help_content": "esc: retour au menu"
},
@@ -115,6 +115,7 @@
"category_theme": "テーマ",
"category_mailing_lists": "メーリングリスト",
"category_encryption": "アプリの暗号化",
+ "category_plugins": "プラグイン",
"help_menu": "↑/↓: 移動 • 右/enter: 選択 • esc: 戻る",
"help_content": "esc: メニューに戻る"
},
@@ -118,6 +118,7 @@
"category_theme": "Motyw",
"category_mailing_lists": "Listy Mailingowe",
"category_encryption": "Szyfrowanie Aplikacji",
+ "category_plugins": "Wtyczki",
"help_menu": "↑/↓: nawigacja • prawo/enter: wybierz • esc: wstecz",
"help_content": "esc: powrót do menu"
},
@@ -116,6 +116,7 @@
"category_theme": "Tema",
"category_mailing_lists": "Listas de E-mail",
"category_encryption": "Criptografia do Aplicativo",
+ "category_plugins": "Plugins",
"help_menu": "↑/↓: navegar • direita/enter: selecionar • esc: voltar",
"help_content": "esc: voltar ao menu"
},
@@ -118,6 +118,7 @@
"category_theme": "Тема",
"category_mailing_lists": "Списки Рассылки",
"category_encryption": "Шифрование Приложения",
+ "category_plugins": "Плагины",
"help_menu": "↑/↓: навигация • вправо/enter: выбор • esc: назад",
"help_content": "esc: назад в меню"
},
@@ -117,6 +117,7 @@
"category_theme": "Тема",
"category_mailing_lists": "Списки розсилки",
"category_encryption": "Шифрування додатка",
+ "category_plugins": "Плагіни",
"help_menu": "↑/↓: навігація • right/enter: вибрати • esc: назад",
"help_content": "esc: назад до меню"
},
@@ -115,6 +115,7 @@
"category_theme": "主题",
"category_mailing_lists": "邮件列表",
"category_encryption": "应用加密",
+ "category_plugins": "插件",
"help_menu": "↑/↓: 导航 • 右/enter: 选择 • esc: 返回",
"help_content": "esc: 返回菜单"
},
@@ -151,6 +151,16 @@ func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
}
// ensureProviders creates backend providers for all configured accounts.
+// newSettings constructs a settings model and wires it to the plugin manager
+// so the Plugins category can list and edit plugin-declared settings.
+func (m *mainModel) newSettings() *tui.Settings {
+ s := tui.NewSettings(m.config)
+ if m.plugins != nil {
+ s.SetPlugins(m.plugins)
+ }
+ return s
+}
+
func (m *mainModel) ensureProviders() {
if m.config == nil {
return
@@ -445,7 +455,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if isEdit {
- m.current = tui.NewSettings(m.config)
+ m.current = m.newSettings()
} else {
m.current = tui.NewChoice()
}
@@ -1046,7 +1056,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch curr := m.current.(type) {
case *tui.Settings:
// Preserve settings state when rebuilding
- newSettings := tui.NewSettings(m.config)
+ newSettings := m.newSettings()
newSettings.RestoreState(curr.GetState())
m.current = newSettings
case *tui.Composer:
@@ -1056,7 +1066,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.current = tui.NewChoice()
case *tui.FolderInbox:
// Just rebuild settings view, folder inbox will be recreated on next navigation
- m.current = tui.NewSettings(m.config)
+ m.current = m.newSettings()
default:
// For other views, return to choice menu
m.current = tui.NewChoice()
@@ -1065,7 +1075,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.current.Init()
case tui.GoToSettingsMsg:
- m.current = tui.NewSettings(m.config)
+ m.current = m.newSettings()
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
return m, m.current.Init()
@@ -1125,7 +1135,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Return to settings
- m.current = tui.NewSettings(m.config)
+ m.current = m.newSettings()
// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
return m, m.current.Init()
@@ -1217,7 +1227,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.emails = allEmails
// Go back to settings
- m.current = tui.NewSettings(m.config)
+ m.current = m.newSettings()
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
}
return m, m.current.Init()
@@ -3898,6 +3908,9 @@ func main() {
// Initialize plugin system
plugins := plugin.NewManager()
plugins.LoadPlugins()
+ if initialModel.config != nil {
+ plugins.LoadSettingValues(initialModel.config.PluginSettings)
+ }
initialModel.plugins = plugins
tui.BodyTransformer = func(body string, email fetcher.Email) string {
folder := "INBOX"
@@ -30,6 +30,8 @@ end)
| `matcha.http(options)` | Make an HTTP request (see below) |
| `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) |
| `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) |
+| `matcha.settings(spec)` | Declare configurable settings; returns a read-only proxy table for live values (see below) |
+| `matcha.get_setting(key [, plugin])` | Look up a setting value by key (defaults to current plugin) |
## Hook events
@@ -146,6 +148,40 @@ Caveats:
build styled output from scratch, compose with `matcha.style` and join with
newlines.
+## User-configurable settings
+
+`matcha.settings(spec)` declares configurable options for a plugin. Call it
+once at the top level of the plugin file. `spec` is a table mapping a setting
+key to `{ type, default, label, description }`. Supported types:
+
+- `"boolean"` — toggled in the TUI with a checkbox-style on/off selector
+- `"number"` — edited with a numeric input
+- `"string"` — edited with a text input
+
+The function returns a read-only proxy table whose fields reflect the
+currently saved value (or the default when unset). Read fields anywhere,
+including inside hook callbacks:
+
+```lua
+local matcha = require("matcha")
+
+local cfg = matcha.settings({
+ threshold = { type = "number", default = 5, label = "Subject length threshold" },
+ enabled = { type = "boolean", default = true, label = "Enable warnings" },
+ suffix = { type = "string", default = "!", label = "Notification suffix" },
+})
+
+matcha.on("email_received", function(email)
+ if cfg.enabled and #email.subject > cfg.threshold then
+ matcha.notify("Long subject" .. cfg.suffix, 3)
+ end
+end)
+```
+
+Values are persisted in `~/.config/matcha/config.json` under
+`plugin_settings`. Edit them in **Settings → Plugins** in the TUI; booleans
+toggle with `enter`/`space`, numbers and strings open a text editor.
+
## Available plugins
The following example plugins ship in `~/.config/matcha/plugins/`:
@@ -21,6 +21,8 @@ func (m *Manager) registerAPI() {
"http": m.luaHTTP,
"prompt": m.luaPrompt,
"style": m.luaStyle,
+ "settings": m.luaSettings,
+ "get_setting": m.luaGetSetting,
})
L.SetField(mod, "_VERSION", lua.LString("0.1.0"))
@@ -131,6 +133,22 @@ func (m *Manager) luaStyle(L *lua.LState) int {
return 1
}
+// matcha.settings(spec) — declare configurable settings for the current
+// plugin. spec is a table mapping setting key -> { type, default, label,
+// description }. Valid types: "boolean", "number", "string". Must be called
+// while the plugin file is being loaded (typically at the top level).
+func (m *Manager) luaSettings(L *lua.LState) int {
+ spec := L.CheckTable(1)
+ return m.declareSettings(L, spec)
+}
+
+// matcha.get_setting(key [, plugin_name]) — return the current value of a
+// setting. The optional second argument allows reading another plugin's
+// setting; defaults to the current plugin when called during load.
+func (m *Manager) luaGetSetting(L *lua.LState) int {
+ return m.getSetting(L)
+}
+
// 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 {
@@ -33,6 +33,14 @@ type Manager struct {
bindings []KeyBinding
// pendingPrompt is set by matcha.prompt() and consumed by the orchestrator.
pendingPrompt *PendingPrompt
+
+ // pluginSchemas holds settings declarations per plugin.
+ pluginSchemas map[string][]SettingDef
+ // pluginValues holds current setting values per plugin.
+ pluginValues map[string]map[string]interface{}
+ // currentPlugin names the plugin file currently being loaded; used to
+ // attribute matcha.settings() declarations.
+ currentPlugin string
}
// NewManager creates a new plugin manager with a Lua VM.
@@ -41,6 +49,8 @@ func NewManager() *Manager {
hooks: make(map[string][]*lua.LFunction),
statuses: make(map[string]string),
pendingFields: make(map[string]string),
+ pluginSchemas: make(map[string][]SettingDef),
+ pluginValues: make(map[string]map[string]interface{}),
}
L := lua.NewState(lua.Options{
@@ -100,6 +110,10 @@ func (m *Manager) LoadPlugins() {
}
func (m *Manager) loadPlugin(name, path string) {
+ prev := m.currentPlugin
+ m.currentPlugin = name
+ defer func() { m.currentPlugin = prev }()
+
if err := m.state.DoFile(path); err != nil {
log.Printf("plugin %q: load error: %v", name, err)
return
@@ -0,0 +1,315 @@
+package plugin
+
+import (
+ "sort"
+
+ lua "github.com/yuin/gopher-lua"
+)
+
+// SettingType identifies the kind of value a plugin setting holds.
+type SettingType string
+
+const (
+ SettingBool SettingType = "boolean"
+ SettingNumber SettingType = "number"
+ SettingString SettingType = "string"
+)
+
+// SettingDef describes a single configurable plugin setting.
+type SettingDef struct {
+ Key string
+ Type SettingType
+ Default interface{}
+ Label string
+ Description string
+}
+
+// PluginSettings holds the schema for one plugin's settings, ordered by
+// declaration order so the TUI lists them predictably.
+type PluginSettings struct {
+ Plugin string
+ Defs []SettingDef
+}
+
+// declareSettings registers the schema for the currently-loading plugin and
+// returns a Lua table whose __index reads the live current value for a key.
+// Plugins typically capture the returned table in a local and read fields
+// from it at any later time, including inside hook callbacks:
+//
+// local cfg = matcha.settings({
+// threshold = {type = "number", default = 5},
+// enabled = {type = "boolean", default = true},
+// })
+// matcha.on("email_received", function(email)
+// if cfg.enabled and #email.subject > cfg.threshold then ... end
+// end)
+func (m *Manager) declareSettings(L *lua.LState, spec *lua.LTable) int {
+ if m.currentPlugin == "" {
+ L.RaiseError("matcha.settings() must be called from a plugin file")
+ return 0
+ }
+ plugin := m.currentPlugin
+
+ defs := []SettingDef{}
+ keys := []string{}
+ specs := map[string]SettingDef{}
+
+ spec.ForEach(func(k, v lua.LValue) {
+ key, ok := k.(lua.LString)
+ if !ok {
+ return
+ }
+ entry, ok := v.(*lua.LTable)
+ if !ok {
+ return
+ }
+
+ def := SettingDef{Key: string(key)}
+
+ if t, ok := entry.RawGetString("type").(lua.LString); ok {
+ def.Type = SettingType(string(t))
+ }
+ if l, ok := entry.RawGetString("label").(lua.LString); ok {
+ def.Label = string(l)
+ }
+ if d, ok := entry.RawGetString("description").(lua.LString); ok {
+ def.Description = string(d)
+ }
+
+ raw := entry.RawGetString("default")
+ switch def.Type {
+ case SettingBool:
+ def.Default = lua.LVAsBool(raw)
+ case SettingNumber:
+ if n, ok := raw.(lua.LNumber); ok {
+ def.Default = float64(n)
+ } else {
+ def.Default = float64(0)
+ }
+ case SettingString:
+ if s, ok := raw.(lua.LString); ok {
+ def.Default = string(s)
+ } else {
+ def.Default = ""
+ }
+ default:
+ // Unknown type — skip.
+ return
+ }
+
+ keys = append(keys, def.Key)
+ specs[def.Key] = def
+ })
+
+ sort.Strings(keys)
+ for _, k := range keys {
+ defs = append(defs, specs[k])
+ }
+
+ m.pluginSchemas[plugin] = defs
+ if _, ok := m.pluginValues[plugin]; !ok {
+ m.pluginValues[plugin] = map[string]interface{}{}
+ }
+
+ proxy := L.NewTable()
+ mt := L.NewTable()
+ L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int {
+ key := L.CheckString(2)
+ def, ok := m.findDef(plugin, key)
+ if !ok {
+ L.Push(lua.LNil)
+ return 1
+ }
+ L.Push(toLuaValue(L, m.lookupValue(plugin, key, def)))
+ return 1
+ }))
+ L.SetField(mt, "__newindex", L.NewFunction(func(L *lua.LState) int {
+ L.RaiseError("plugin settings table is read-only; edit values in the TUI settings")
+ return 0
+ }))
+ L.SetMetatable(proxy, mt)
+ L.Push(proxy)
+ return 1
+}
+
+// getSetting returns the value of a setting for the currently-running plugin.
+// During plugin load, "currently running" means the loading plugin; outside
+// load (e.g. inside a hook callback), it falls back to the plugin that owns
+// the running closure — for now we use currentPlugin and only allow lookups
+// to the plugin that declared the schema by name.
+func (m *Manager) getSetting(L *lua.LState) int {
+ plugin := m.currentPlugin
+ key := L.CheckString(1)
+
+ if plugin == "" {
+ // Allow optional second argument: explicit plugin name.
+ if L.GetTop() >= 2 {
+ plugin = L.CheckString(2)
+ }
+ }
+
+ def, ok := m.findDef(plugin, key)
+ if !ok {
+ L.Push(lua.LNil)
+ return 1
+ }
+
+ val := m.lookupValue(plugin, key, def)
+ L.Push(toLuaValue(L, val))
+ return 1
+}
+
+func (m *Manager) findDef(plugin, key string) (SettingDef, bool) {
+ for _, d := range m.pluginSchemas[plugin] {
+ if d.Key == key {
+ return d, true
+ }
+ }
+ return SettingDef{}, false
+}
+
+func (m *Manager) lookupValue(plugin, key string, def SettingDef) interface{} {
+ if vals, ok := m.pluginValues[plugin]; ok {
+ if v, ok := vals[key]; ok {
+ return v
+ }
+ }
+ return def.Default
+}
+
+func toLuaValue(L *lua.LState, v interface{}) lua.LValue {
+ switch x := v.(type) {
+ case bool:
+ return lua.LBool(x)
+ case float64:
+ return lua.LNumber(x)
+ case int:
+ return lua.LNumber(x)
+ case string:
+ return lua.LString(x)
+ default:
+ return lua.LNil
+ }
+}
+
+// Schemas returns all plugin setting schemas, sorted by plugin name.
+func (m *Manager) Schemas() []PluginSettings {
+ names := make([]string, 0, len(m.pluginSchemas))
+ for name := range m.pluginSchemas {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ out := make([]PluginSettings, 0, len(names))
+ for _, n := range names {
+ out = append(out, PluginSettings{Plugin: n, Defs: m.pluginSchemas[n]})
+ }
+ return out
+}
+
+// Schema returns the schema for a single plugin.
+func (m *Manager) Schema(plugin string) []SettingDef {
+ return m.pluginSchemas[plugin]
+}
+
+// GetSettingValue returns the current value (or default) for a plugin setting.
+func (m *Manager) GetSettingValue(plugin, key string) (interface{}, bool) {
+ def, ok := m.findDef(plugin, key)
+ if !ok {
+ return nil, false
+ }
+ return m.lookupValue(plugin, key, def), true
+}
+
+// SetSettingValue updates a plugin setting in-memory. Coerces value to the
+// declared type. Returns false if the plugin/key is unknown.
+func (m *Manager) SetSettingValue(plugin, key string, val interface{}) bool {
+ def, ok := m.findDef(plugin, key)
+ if !ok {
+ return false
+ }
+
+ if _, ok := m.pluginValues[plugin]; !ok {
+ m.pluginValues[plugin] = map[string]interface{}{}
+ }
+ m.pluginValues[plugin][key] = coerceValue(def.Type, val)
+ return true
+}
+
+// LoadSettingValues replaces in-memory values with the given snapshot. Values
+// for unknown plugins/keys are kept as-is so freshly-disabled plugins don't
+// lose their saved settings on next launch.
+func (m *Manager) LoadSettingValues(values map[string]map[string]interface{}) {
+ if values == nil {
+ return
+ }
+ for plugin, vals := range values {
+ if _, ok := m.pluginValues[plugin]; !ok {
+ m.pluginValues[plugin] = map[string]interface{}{}
+ }
+ for k, v := range vals {
+ if def, ok := m.findDef(plugin, k); ok {
+ m.pluginValues[plugin][k] = coerceValue(def.Type, v)
+ } else {
+ m.pluginValues[plugin][k] = v
+ }
+ }
+ }
+}
+
+// AllSettingValues returns a deep copy of all plugin setting values.
+func (m *Manager) AllSettingValues() map[string]map[string]interface{} {
+ out := make(map[string]map[string]interface{}, len(m.pluginValues))
+ for p, vals := range m.pluginValues {
+ inner := make(map[string]interface{}, len(vals))
+ for k, v := range vals {
+ inner[k] = v
+ }
+ out[p] = inner
+ }
+ return out
+}
+
+func coerceValue(t SettingType, v interface{}) interface{} {
+ switch t {
+ case SettingBool:
+ switch x := v.(type) {
+ case bool:
+ return x
+ case string:
+ return x == "true"
+ case float64:
+ return x != 0
+ }
+ return false
+ case SettingNumber:
+ switch x := v.(type) {
+ case float64:
+ return x
+ case int:
+ return float64(x)
+ case bool:
+ if x {
+ return float64(1)
+ }
+ return float64(0)
+ case string:
+ return float64(0)
+ }
+ return float64(0)
+ case SettingString:
+ switch x := v.(type) {
+ case string:
+ return x
+ case float64:
+ return ""
+ case bool:
+ if x {
+ return "true"
+ }
+ return "false"
+ }
+ return ""
+ }
+ return v
+}
@@ -0,0 +1,200 @@
+package plugin
+
+import (
+ "testing"
+
+ lua "github.com/yuin/gopher-lua"
+)
+
+// declareForTest mimics what loadPlugin does so we can drive declareSettings
+// without writing a real plugin file to disk.
+func declareForTest(t *testing.T, m *Manager, name, src string) {
+ t.Helper()
+ prev := m.currentPlugin
+ m.currentPlugin = name
+ defer func() { m.currentPlugin = prev }()
+ if err := m.state.DoString(src); err != nil {
+ t.Fatalf("Lua error loading %q: %v", name, err)
+ }
+}
+
+func TestPluginSettingsSchemaAndProxy(t *testing.T) {
+ m := NewManager()
+ defer m.Close()
+
+ src := `
+ local matcha = require("matcha")
+ cfg = matcha.settings({
+ enabled = {type = "boolean", default = true, label = "Enabled"},
+ limit = {type = "number", default = 5, label = "Limit"},
+ suffix = {type = "string", default = "!"},
+ })
+ `
+ declareForTest(t, m, "demo", src)
+
+ defs := m.Schema("demo")
+ if len(defs) != 3 {
+ t.Fatalf("expected 3 defs, got %d", len(defs))
+ }
+
+ v, ok := m.GetSettingValue("demo", "enabled")
+ if !ok || v.(bool) != true {
+ t.Fatalf("expected default enabled=true, got %v ok=%v", v, ok)
+ }
+ v, _ = m.GetSettingValue("demo", "limit")
+ if v.(float64) != 5 {
+ t.Fatalf("expected default limit=5, got %v", v)
+ }
+
+ // proxy table should reflect live values
+ check := `
+ assert(cfg.enabled == true, "enabled default")
+ assert(cfg.limit == 5, "limit default")
+ assert(cfg.suffix == "!", "suffix default")
+ `
+ if err := m.state.DoString(check); err != nil {
+ t.Fatalf("proxy check failed: %v", err)
+ }
+
+ // Override via SetSettingValue and re-check via proxy
+ if !m.SetSettingValue("demo", "enabled", false) {
+ t.Fatal("SetSettingValue rejected known key")
+ }
+ if !m.SetSettingValue("demo", "limit", float64(42)) {
+ t.Fatal("SetSettingValue rejected number")
+ }
+ if !m.SetSettingValue("demo", "suffix", "?!") {
+ t.Fatal("SetSettingValue rejected string")
+ }
+
+ check = `
+ assert(cfg.enabled == false, "enabled override")
+ assert(cfg.limit == 42, "limit override")
+ assert(cfg.suffix == "?!", "suffix override")
+ `
+ if err := m.state.DoString(check); err != nil {
+ t.Fatalf("proxy override check failed: %v", err)
+ }
+}
+
+func TestPluginSettingsLoadValues(t *testing.T) {
+ m := NewManager()
+ defer m.Close()
+
+ declareForTest(t, m, "demo", `
+ local matcha = require("matcha")
+ cfg = matcha.settings({
+ enabled = {type = "boolean", default = true},
+ limit = {type = "number", default = 5},
+ })
+ `)
+
+ // Simulate loading values from config (JSON unmarshals booleans as bool,
+ // numbers as float64).
+ m.LoadSettingValues(map[string]map[string]interface{}{
+ "demo": {
+ "enabled": false,
+ "limit": float64(99),
+ },
+ })
+
+ v, _ := m.GetSettingValue("demo", "enabled")
+ if v.(bool) != false {
+ t.Fatalf("expected enabled=false after load, got %v", v)
+ }
+ v, _ = m.GetSettingValue("demo", "limit")
+ if v.(float64) != 99 {
+ t.Fatalf("expected limit=99 after load, got %v", v)
+ }
+
+ // AllSettingValues should round-trip through JSON-friendly types.
+ all := m.AllSettingValues()
+ if all["demo"]["enabled"] != false || all["demo"]["limit"].(float64) != 99 {
+ t.Fatalf("AllSettingValues mismatch: %#v", all)
+ }
+}
+
+func TestPluginSettingsProxyReadOnly(t *testing.T) {
+ m := NewManager()
+ defer m.Close()
+
+ declareForTest(t, m, "demo", `
+ local matcha = require("matcha")
+ cfg = matcha.settings({enabled = {type = "boolean", default = true}})
+ `)
+
+ err := m.state.DoString(`cfg.enabled = false`)
+ if err == nil {
+ t.Fatal("expected error writing to read-only proxy")
+ }
+}
+
+func TestPluginSettingsRequiresLoadingPlugin(t *testing.T) {
+ m := NewManager()
+ defer m.Close()
+
+ // currentPlugin is empty (no loadPlugin in flight)
+ err := m.state.DoString(`require("matcha").settings({foo = {type = "boolean", default = false}})`)
+ if err == nil {
+ t.Fatal("expected error when calling matcha.settings outside plugin load")
+ }
+}
+
+func TestPluginSettingsCoercion(t *testing.T) {
+ m := NewManager()
+ defer m.Close()
+
+ declareForTest(t, m, "demo", `
+ local matcha = require("matcha")
+ matcha.settings({
+ flag = {type = "boolean", default = false},
+ n = {type = "number", default = 0},
+ })
+ `)
+
+ // Strings from a JSON file or older config should coerce.
+ m.LoadSettingValues(map[string]map[string]interface{}{
+ "demo": {
+ "flag": "true",
+ "n": "ignored",
+ },
+ })
+
+ v, _ := m.GetSettingValue("demo", "flag")
+ if v.(bool) != true {
+ t.Fatalf("expected flag=true after coercion, got %v", v)
+ }
+ v, _ = m.GetSettingValue("demo", "n")
+ if _, ok := v.(float64); !ok {
+ t.Fatalf("expected n coerced to float64, got %T %v", v, v)
+ }
+}
+
+// Verify that hook callbacks see live values (regression test for the closure
+// capture pattern).
+func TestPluginSettingsAccessibleFromHook(t *testing.T) {
+ m := NewManager()
+ defer m.Close()
+
+ declareForTest(t, m, "demo", `
+ local matcha = require("matcha")
+ local cfg = matcha.settings({n = {type = "number", default = 1}})
+ seen = nil
+ matcha.on("custom", function() seen = cfg.n end)
+ `)
+
+ // Fire hook before any override
+ m.CallHook("custom")
+ v := m.state.GetGlobal("seen")
+ if n, ok := v.(lua.LNumber); !ok || float64(n) != 1 {
+ t.Fatalf("expected seen=1, got %v", v)
+ }
+
+ // Override and fire again
+ m.SetSettingValue("demo", "n", float64(7))
+ m.CallHook("custom")
+ v = m.state.GetGlobal("seen")
+ if n, ok := v.(lua.LNumber); !ok || float64(n) != 7 {
+ t.Fatalf("expected seen=7 after override, got %v", v)
+ }
+}
@@ -1,14 +1,39 @@
-- subject_length_warn.lua
-- Warns when your subject line is getting too long.
-- Most email clients truncate subjects beyond ~60 characters.
+--
+-- Thresholds are configurable in Settings → Plugins.
local matcha = require("matcha")
+local cfg = matcha.settings({
+ enabled = {
+ type = "boolean",
+ default = true,
+ label = "Enable subject length warnings",
+ },
+ soft_limit = {
+ type = "number",
+ default = 60,
+ label = "Soft limit (chars)",
+ description = "Warn that the subject may truncate above this length.",
+ },
+ hard_limit = {
+ type = "number",
+ default = 78,
+ label = "Hard limit (chars)",
+ description = "Warn that the subject is too long above this length.",
+ },
+})
+
matcha.on("composer_updated", function(state)
+ if not cfg.enabled then
+ return
+ end
local len = #state.subject
- if len > 78 then
+ if len > cfg.hard_limit then
matcha.set_status("composer", "Subject too long (" .. len .. " chars)")
- elseif len > 60 then
+ elseif len > cfg.soft_limit then
matcha.set_status("composer", "Subject may truncate (" .. len .. " chars)")
end
end)
@@ -26,7 +26,7 @@ func TestSettingsNavigationWraps(t *testing.T) {
settings.menuCursor = 0
model, _ := settings.updateMenu(tea.KeyPressMsg{Code: tea.KeyUp})
settings = model.(*Settings)
- if settings.menuCursor != int(CategoryEncryption) {
+ if settings.menuCursor != int(CategoryPlugins) {
t.Fatalf("up from first menu item should wrap to last, got %d", settings.menuCursor)
}
@@ -7,6 +7,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
+ "github.com/floatpane/matcha/plugin"
"github.com/floatpane/matcha/theme"
)
@@ -35,6 +36,7 @@ const (
CategoryTheme
CategoryMailingLists
CategoryEncryption
+ CategoryPlugins
)
type Settings struct {
@@ -73,6 +75,16 @@ type Settings struct {
encError string
encEnabling bool
confirmingDisable bool
+
+ // Plugin settings state
+ plugins *plugin.Manager
+ pluginListCursor int
+ pluginSelected string // name of plugin whose settings are open ("" = list view)
+ pluginSettingCursor int
+ pluginEditing bool
+ pluginEditingKey string
+ pluginEditingType plugin.SettingType
+ pluginInput textinput.Model
}
type SettingsState struct {
@@ -83,6 +95,7 @@ type SettingsState struct {
AccountsCursor int
ThemeCursor int
ListsCursor int
+ PluginCursor int
}
func NewSettings(cfg *config.Config) *Settings {
@@ -117,9 +130,16 @@ func NewSettings(cfg *config.Config) *Settings {
pgpKeySource: "file",
encPasswordInput: newInput("Password", "> ", true),
encConfirmInput: newInput("Confirm Password", "> ", true),
+ pluginInput: newInput("", "> ", false),
}
}
+// SetPlugins attaches the plugin manager so the settings view can list and
+// edit plugin-declared settings.
+func (m *Settings) SetPlugins(p *plugin.Manager) {
+ m.plugins = p
+}
+
func (m *Settings) GetState() SettingsState {
return SettingsState{
ActivePane: m.activePane,
@@ -129,6 +149,7 @@ func (m *Settings) GetState() SettingsState {
AccountsCursor: m.accountsCursor,
ThemeCursor: m.themeCursor,
ListsCursor: m.listsCursor,
+ PluginCursor: m.pluginListCursor,
}
}
@@ -140,6 +161,7 @@ func (m *Settings) RestoreState(state SettingsState) {
m.accountsCursor = state.AccountsCursor
m.themeCursor = state.ThemeCursor
m.listsCursor = state.ListsCursor
+ m.pluginListCursor = state.PluginCursor
}
func (m *Settings) Init() tea.Cmd {
@@ -163,6 +185,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pgpPublicKeyInput.SetWidth(inputWidth)
m.pgpPrivateKeyInput.SetWidth(inputWidth)
m.pgpPINInput.SetWidth(inputWidth)
+ m.pluginInput.SetWidth(inputWidth)
return m, nil
case tea.KeyPressMsg:
@@ -170,7 +193,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.activePane == PaneContent && msg.String() == "esc" {
// unless we are in crypto config or encryption editing which have their own esc logic
if !(m.activeCategory == CategoryAccounts && m.isCryptoConfig) &&
- !(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) {
+ !(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) &&
+ !(m.activeCategory == CategoryPlugins && (m.pluginEditing || m.pluginSelected != "")) {
m.activePane = PaneMenu
return m, nil
}
@@ -190,6 +214,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateMailingLists(msg)
case CategoryEncryption:
return m.updateEncryption(msg)
+ case CategoryPlugins:
+ return m.updatePlugins(msg)
}
}
@@ -230,6 +256,9 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
cmds = append(cmds, cmd)
+ } else if m.activeCategory == CategoryPlugins && m.pluginEditing {
+ m.pluginInput, cmd = m.pluginInput.Update(msg)
+ cmds = append(cmds, cmd)
}
}
@@ -237,7 +266,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
- categoryCount := int(CategoryEncryption) + 1
+ categoryCount := int(CategoryPlugins) + 1
switch msg.String() {
case "up", "k":
@@ -291,6 +320,7 @@ func (m *Settings) View() tea.View {
t("settings.category_theme"),
t("settings.category_mailing_lists"),
t("settings.category_encryption"),
+ t("settings.category_plugins"),
}
for i, c := range categories {
cursor := " "
@@ -330,6 +360,8 @@ func (m *Settings) View() tea.View {
right = m.viewMailingLists()
case CategoryEncryption:
right = m.viewEncryption()
+ case CategoryPlugins:
+ right = m.viewPlugins()
}
rightPanel := lipgloss.NewStyle().
@@ -0,0 +1,254 @@
+package tui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/floatpane/matcha/config"
+ "github.com/floatpane/matcha/plugin"
+)
+
+// updatePlugins handles input for the plugins settings category. The view has
+// three states:
+//
+// 1. Plugin list (m.pluginSelected == ""): pick a plugin to configure.
+// 2. Plugin settings list (m.pluginSelected != "", m.pluginEditing == false):
+// navigate keys; enter/space toggles booleans, enter on number/string
+// opens an editor.
+// 3. Editing input (m.pluginEditing == true): textinput for number/string;
+// enter commits, esc cancels.
+func (m *Settings) updatePlugins(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ if m.plugins == nil {
+ return m, nil
+ }
+
+ if m.pluginEditing {
+ return m.updatePluginEditor(msg)
+ }
+
+ if m.pluginSelected == "" {
+ return m.updatePluginList(msg)
+ }
+
+ return m.updatePluginSettings(msg)
+}
+
+func (m *Settings) updatePluginList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ schemas := m.plugins.Schemas()
+ if len(schemas) == 0 {
+ return m, nil
+ }
+
+ kb := config.Keybinds.Global
+ key := msg.String()
+ switch {
+ case key == "up" || key == kb.NavUp:
+ m.pluginListCursor = (m.pluginListCursor - 1 + len(schemas)) % len(schemas)
+ case key == "down" || key == kb.NavDown:
+ m.pluginListCursor = (m.pluginListCursor + 1) % len(schemas)
+ case key == "enter" || key == "right" || key == "l":
+ m.pluginSelected = schemas[m.pluginListCursor].Plugin
+ m.pluginSettingCursor = 0
+ }
+ return m, nil
+}
+
+func (m *Settings) updatePluginSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ defs := m.plugins.Schema(m.pluginSelected)
+ if len(defs) == 0 {
+ m.pluginSelected = ""
+ return m, nil
+ }
+
+ kb := config.Keybinds.Global
+ key := msg.String()
+ switch {
+ case key == "esc" || key == "left" || key == "h" || key == kb.Cancel:
+ m.pluginSelected = ""
+ return m, nil
+ case key == "up" || key == kb.NavUp:
+ m.pluginSettingCursor = (m.pluginSettingCursor - 1 + len(defs)) % len(defs)
+ case key == "down" || key == kb.NavDown:
+ m.pluginSettingCursor = (m.pluginSettingCursor + 1) % len(defs)
+ case key == "enter" || key == "space" || key == "right" || key == "l":
+ def := defs[m.pluginSettingCursor]
+ switch def.Type {
+ case plugin.SettingBool:
+ cur, _ := m.plugins.GetSettingValue(m.pluginSelected, def.Key)
+ b, _ := cur.(bool)
+ m.plugins.SetSettingValue(m.pluginSelected, def.Key, !b)
+ m.persistPluginSettings()
+ return m, func() tea.Msg { return ConfigSavedMsg{} }
+ case plugin.SettingNumber, plugin.SettingString:
+ m.beginPluginEdit(def)
+ }
+ }
+ return m, nil
+}
+
+func (m *Settings) beginPluginEdit(def plugin.SettingDef) {
+ m.pluginEditing = true
+ m.pluginEditingKey = def.Key
+ m.pluginEditingType = def.Type
+ cur, _ := m.plugins.GetSettingValue(m.pluginSelected, def.Key)
+ m.pluginInput.SetValue(formatSettingValue(cur))
+ m.pluginInput.Focus()
+}
+
+func (m *Settings) updatePluginEditor(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ m.pluginEditing = false
+ m.pluginInput.Blur()
+ return m, nil
+ case "enter":
+ raw := m.pluginInput.Value()
+ switch m.pluginEditingType {
+ case plugin.SettingNumber:
+ n, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+ if err != nil {
+ return m, nil
+ }
+ m.plugins.SetSettingValue(m.pluginSelected, m.pluginEditingKey, n)
+ case plugin.SettingString:
+ m.plugins.SetSettingValue(m.pluginSelected, m.pluginEditingKey, raw)
+ }
+ m.pluginEditing = false
+ m.pluginInput.Blur()
+ m.persistPluginSettings()
+ return m, func() tea.Msg { return ConfigSavedMsg{} }
+ }
+
+ // Forward all other keys (typing, backspace, arrows, etc.) to textinput.
+ var cmd tea.Cmd
+ m.pluginInput, cmd = m.pluginInput.Update(msg)
+ return m, cmd
+}
+
+func (m *Settings) persistPluginSettings() {
+ if m.cfg == nil || m.plugins == nil {
+ return
+ }
+ m.cfg.PluginSettings = m.plugins.AllSettingValues()
+ _ = config.SaveConfig(m.cfg)
+}
+
+func (m *Settings) viewPlugins() string {
+ var b strings.Builder
+ b.WriteString(titleStyle.Render(t("settings.category_plugins")) + "\n\n")
+
+ if m.plugins == nil {
+ b.WriteString(accountEmailStyle.Render(" Plugin manager unavailable.\n"))
+ return b.String()
+ }
+
+ if m.pluginSelected == "" {
+ schemas := m.plugins.Schemas()
+ if len(schemas) == 0 {
+ b.WriteString(accountEmailStyle.Render(" No plugins declare configurable settings.\n"))
+ b.WriteString("\n")
+ b.WriteString(helpStyle.Render("Plugins use matcha.settings(...) to expose options."))
+ return b.String()
+ }
+
+ for i, s := range schemas {
+ cursor := " "
+ style := accountItemStyle
+ if m.pluginListCursor == i {
+ cursor = "> "
+ style = selectedAccountItemStyle
+ }
+ line := fmt.Sprintf("%s (%d %s)", s.Plugin, len(s.Defs), pluralSettings(len(s.Defs)))
+ b.WriteString(style.Render(cursor+line) + "\n")
+ }
+ b.WriteString("\n")
+ b.WriteString(helpStyle.Render("↑/↓ navigate • enter open • esc back"))
+ return b.String()
+ }
+
+ defs := m.plugins.Schema(m.pluginSelected)
+ b.WriteString(accountEmailStyle.Render(m.pluginSelected) + "\n\n")
+
+ for i, def := range defs {
+ cursor := " "
+ style := accountItemStyle
+ if m.pluginSettingCursor == i {
+ cursor = "> "
+ style = selectedAccountItemStyle
+ }
+
+ label := def.Label
+ if label == "" {
+ label = def.Key
+ }
+ val, _ := m.plugins.GetSettingValue(m.pluginSelected, def.Key)
+ display := formatDisplayValue(def.Type, val)
+ line := fmt.Sprintf("%s: %s", label, display)
+ b.WriteString(style.Render(cursor+line) + "\n")
+ }
+
+ if m.pluginEditing {
+ b.WriteString("\n")
+ b.WriteString(settingsFocusedStyle.Render("Edit "+m.pluginEditingKey) + "\n")
+ b.WriteString(m.pluginInput.View() + "\n")
+ b.WriteString("\n")
+ b.WriteString(helpStyle.Render("enter save • esc cancel"))
+ } else {
+ if m.pluginSettingCursor < len(defs) {
+ tip := defs[m.pluginSettingCursor].Description
+ if tip != "" && !m.cfg.HideTips {
+ b.WriteString("\n")
+ b.WriteString(TipStyle.Render("Tip: " + tip))
+ }
+ }
+ b.WriteString("\n\n")
+ b.WriteString(helpStyle.Render("↑/↓ navigate • enter toggle/edit • esc back"))
+ }
+
+ return b.String()
+}
+
+func formatSettingValue(v interface{}) string {
+ switch x := v.(type) {
+ case bool:
+ if x {
+ return "true"
+ }
+ return "false"
+ case float64:
+ if x == float64(int64(x)) {
+ return strconv.FormatInt(int64(x), 10)
+ }
+ return strconv.FormatFloat(x, 'f', -1, 64)
+ case string:
+ return x
+ case nil:
+ return ""
+ default:
+ return fmt.Sprintf("%v", v)
+ }
+}
+
+func formatDisplayValue(typ plugin.SettingType, v interface{}) string {
+ if typ == plugin.SettingBool {
+ b, _ := v.(bool)
+ if b {
+ return "[x] on"
+ }
+ return "[ ] off"
+ }
+ s := formatSettingValue(v)
+ if s == "" && typ == plugin.SettingString {
+ return "(empty)"
+ }
+ return s
+}
+
+func pluralSettings(n int) string {
+ if n == 1 {
+ return "setting"
+ }
+ return "settings"
+}