diff --git a/config/config.go b/config/config.go index 19e95f1f7bce8d6b5fbca92bacb937fe582367e4..f3c3c5171e9bcd3f5673e985eb19538d3c92363a 100644 --- a/config/config.go +++ b/config/config.go @@ -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{ diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index 7322919bbb7f5408b09dbc8e70c16aeca1a7b138..44b011b4619e3e7e59f277a56aa5daba4276b36b 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -118,6 +118,7 @@ "category_theme": "المظهر", "category_mailing_lists": "القوائم البريدية", "category_encryption": "تشفير التطبيق", + "category_plugins": "الإضافات", "help_menu": "↑/↓: التنقل • يمين/enter: اختيار • esc: رجوع", "help_content": "esc: العودة للقائمة" }, diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 0ef214d95a2f6de2d4ad4135ecba16baa44ca258..579a00d226ab0a438ebe7084c3286b4b7c16a30f 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -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ü" }, diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 7d669ecaa39e435ee9fe1b8520a25dc01138c8c8..68426d73a3038c52d5e6034dc767507d0dab35a0 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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" }, diff --git a/i18n/locales/es.json b/i18n/locales/es.json index ce21590df358ae756158e483450dbf5f18167b37..24d23e7d4aeb54dbb903c7803c8bc2fa38104617 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -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ú" }, diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index ae1c10c167486cf1f4b31b423e47598f9dc62783..e026c028190513cf5aeffb8a5709ed5f04477ac8 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -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" }, diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index b4d2a7d87c184c3da2502ffce27beef41a920faf..a4e23caf5cedeb161857caff029b286146afa729 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -115,6 +115,7 @@ "category_theme": "テーマ", "category_mailing_lists": "メーリングリスト", "category_encryption": "アプリの暗号化", + "category_plugins": "プラグイン", "help_menu": "↑/↓: 移動 • 右/enter: 選択 • esc: 戻る", "help_content": "esc: メニューに戻る" }, diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index e88fe43e26171fd7c747d83df59453c1bef4f9b3..a69377cc9a307c9541cef0c5cda9385ca5a8caa1 100644 --- a/i18n/locales/pl.json +++ b/i18n/locales/pl.json @@ -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" }, diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index 4587a052439fee17c9cc6845bc147e6577d8cce3..023286f050b331fe7da7b71b550cd7b2dbe95d62 100644 --- a/i18n/locales/pt.json +++ b/i18n/locales/pt.json @@ -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" }, diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index 307a58158bba7d10077087f353766bc99310cc35..5233f0210cd7a9403cfd94e9fdd6a7b5a0bd122c 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -118,6 +118,7 @@ "category_theme": "Тема", "category_mailing_lists": "Списки Рассылки", "category_encryption": "Шифрование Приложения", + "category_plugins": "Плагины", "help_menu": "↑/↓: навигация • вправо/enter: выбор • esc: назад", "help_content": "esc: назад в меню" }, diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index e10b3b5e9f73d07654b7c7b9cd21a7ef9447b7d6..203303da074225012ffd51a5a676873843abab28 100644 --- a/i18n/locales/uk.json +++ b/i18n/locales/uk.json @@ -117,6 +117,7 @@ "category_theme": "Тема", "category_mailing_lists": "Списки розсилки", "category_encryption": "Шифрування додатка", + "category_plugins": "Плагіни", "help_menu": "↑/↓: навігація • right/enter: вибрати • esc: назад", "help_content": "esc: назад до меню" }, diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index a144819fa19d97f4d9008bc4675570beb8b98478..4d07bcae6b0a68cfd0ed9c91e2a74cabf0c793d6 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -115,6 +115,7 @@ "category_theme": "主题", "category_mailing_lists": "邮件列表", "category_encryption": "应用加密", + "category_plugins": "插件", "help_menu": "↑/↓: 导航 • 右/enter: 选择 • esc: 返回", "help_content": "esc: 返回菜单" }, diff --git a/main.go b/main.go index e338f5233b2d27b1b5c0e12c1ea172d2a2e344a8..498d3403fd43509cbaac9d37ebaa15770ee259df 100644 --- a/main.go +++ b/main.go @@ -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" diff --git a/plugin/README.md b/plugin/README.md index f5a47bdcfa5346782e521c843aeb97c45b3b9cd9..4d279039660f45db505ae527750e29acba834f71 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -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/`: diff --git a/plugin/api.go b/plugin/api.go index 987923f00e5f376b9d95548b9d467f4c52609f33..0f3f49783570e96964916b25e9da811385ceb08b 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -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 { diff --git a/plugin/plugin.go b/plugin/plugin.go index 82c2ea63f50e9e6fc5430c652c171086cb6c7a31..9a3ee4485f2ebf652c686bc056fcde20a49b802d 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -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 diff --git a/plugin/settings.go b/plugin/settings.go new file mode 100644 index 0000000000000000000000000000000000000000..b4a2308571659794710cef25ecaff11df18f52d8 --- /dev/null +++ b/plugin/settings.go @@ -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 +} diff --git a/plugin/settings_test.go b/plugin/settings_test.go new file mode 100644 index 0000000000000000000000000000000000000000..31483412700ed22c6e2c1fe774aa4649729abe68 --- /dev/null +++ b/plugin/settings_test.go @@ -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) + } +} diff --git a/plugins/subject_length_warn.lua b/plugins/subject_length_warn.lua index c5ea1d6efe6c49cacce02856681cdd9802ae766d..38f8298dc47d5dc85979db455278dcc084e0ef0c 100644 --- a/plugins/subject_length_warn.lua +++ b/plugins/subject_length_warn.lua @@ -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) diff --git a/tui/navigation_wrap_test.go b/tui/navigation_wrap_test.go index dd906f93b74a3d198d8be36e42ddc7f558fac9ef..b475e85055badc2ea888e9e4cce456bbdb4c7c94 100644 --- a/tui/navigation_wrap_test.go +++ b/tui/navigation_wrap_test.go @@ -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) } diff --git a/tui/settings.go b/tui/settings.go index 7f20e803ed24f868964313f9ba86c8bff6241294..f0d4e8c64a10bbecc050e694641bb70c917ad23d 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -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(). diff --git a/tui/settings_plugins.go b/tui/settings_plugins.go new file mode 100644 index 0000000000000000000000000000000000000000..418e062332bd2e090ff1293965f50f39757bd874 --- /dev/null +++ b/tui/settings_plugins.go @@ -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" +}