From a9fc2f0a603e5f8ffdf70c82d0ff3382e6688076 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Mon, 11 May 2026 12:19:24 +0400 Subject: [PATCH] feat: plugin settings SDK (#1266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Adds user-configurable plugin settings. Plugins declare typed options via a new `matcha.settings(spec)` Lua API supporting `boolean`, `number`, and `string` types with defaults, labels, and descriptions. The call returns a read-only proxy table whose fields reflect live values, so hook callbacks read current settings through closure capture. A second helper `matcha.get_setting(key [, plugin])` provides explicit lookup. The plugin Manager tracks the loading plugin so schemas attribute correctly, and gains methods for reading, writing, loading, and exporting setting values with type coercion. Values persist in `config.json` under a new `plugin_settings` field (both regular and secure-mode disk formats). ## Why? Plugins shipped hardcoded constants (keyword lists, thresholds, toggles). Users had to edit Lua source to tweak them — fragile and lost on plugin reinstall. Plugins now declare schemas; users edit values in TUI; values persist across upgrades. Bool toggle avoids parser errors from typing `true`/`false`/`1`/`0` inconsistently. --------- Signed-off-by: drew Co-authored-by: Lea Co-authored-by: Steve Evans --- config/config.go | 50 ++--- i18n/locales/ar.json | 1 + i18n/locales/de.json | 1 + i18n/locales/en.json | 1 + i18n/locales/es.json | 1 + i18n/locales/fr.json | 1 + i18n/locales/ja.json | 1 + i18n/locales/pl.json | 1 + i18n/locales/pt.json | 1 + i18n/locales/ru.json | 1 + i18n/locales/uk.json | 1 + i18n/locales/zh.json | 1 + main.go | 25 ++- plugin/README.md | 36 ++++ plugin/api.go | 18 ++ plugin/plugin.go | 14 ++ plugin/settings.go | 315 ++++++++++++++++++++++++++++++++ plugin/settings_test.go | 200 ++++++++++++++++++++ plugins/subject_length_warn.lua | 29 ++- tui/navigation_wrap_test.go | 2 +- tui/settings.go | 36 +++- tui/settings_plugins.go | 254 +++++++++++++++++++++++++ 22 files changed, 958 insertions(+), 32 deletions(-) create mode 100644 plugin/settings.go create mode 100644 plugin/settings_test.go create mode 100644 tui/settings_plugins.go 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" +}