From 1bff24c19bbc9d140526f4503534f13f4d4e937b Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 22 Apr 2026 18:54:58 -0500 Subject: [PATCH 1/2] feat: add plugin configuration API Plugins declare a typed PluginConfigSchema and receive persisted values via OnConfigChanged. The host stores per-plugin JSON under %LocalAppData%/SharpFM/plugin-config/ and renders a generic settings dialog from the schema in the Plugin Manager. Plugins with an empty schema incur zero overhead and show a disabled Configure button. Closes #183 --- .../ClipInspectorPanel.axaml | 4 +- .../ClipInspectorPlugin.cs | 35 +- .../ClipInspectorViewModel.cs | 6 + .../XmlViewerPlugin.cs | 2 + src/SharpFM.Plugin/IPlugin.cs | 16 + src/SharpFM.Plugin/PluginConfigSchema.cs | 48 ++ src/SharpFM/App.axaml.cs | 5 +- src/SharpFM/MainWindow.axaml.cs | 8 +- .../PluginManager/PluginConfigDialog.axaml | 33 ++ .../PluginManager/PluginConfigDialog.axaml.cs | 158 +++++++ .../PluginManager/PluginManagerViewModel.cs | 10 +- .../PluginManager/PluginManagerWindow.axaml | 2 + .../PluginManagerWindow.axaml.cs | 22 +- src/SharpFM/Services/PluginConfigService.cs | 197 +++++++++ src/SharpFM/Services/PluginService.cs | 9 +- .../PluginConfigSchemaTests.cs | 46 ++ .../PluginConfigServiceTests.cs | 413 ++++++++++++++++++ .../PluginManagerViewModelTests.cs | 2 + .../PluginServiceInstallTests.cs | 5 +- .../PluginServiceTests.cs | 8 +- .../ViewModels/MainWindowViewModelTests.cs | 2 + 21 files changed, 1015 insertions(+), 16 deletions(-) create mode 100644 src/SharpFM.Plugin/PluginConfigSchema.cs create mode 100644 src/SharpFM/PluginManager/PluginConfigDialog.axaml create mode 100644 src/SharpFM/PluginManager/PluginConfigDialog.axaml.cs create mode 100644 src/SharpFM/Services/PluginConfigService.cs create mode 100644 tests/SharpFM.Plugin.Tests/PluginConfigSchemaTests.cs create mode 100644 tests/SharpFM.Plugin.Tests/PluginConfigServiceTests.cs diff --git a/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml b/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml index 2cdf87a..6730aa5 100644 --- a/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml +++ b/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml @@ -19,12 +19,12 @@ - + - + diff --git a/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs b/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs index 98d1274..22685d5 100644 --- a/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs +++ b/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs @@ -17,8 +17,37 @@ public class ClipInspectorPlugin : IPanelPlugin public IReadOnlyList KeyBindings => []; public IReadOnlyList MenuActions => []; + public PluginConfigSchema ConfigSchema { get; } = new(new[] + { + new PluginConfigField( + Key: "ShowElementCount", + Label: "Show XML element count", + Type: PluginConfigFieldType.Bool, + DefaultValue: true, + Description: "Display the number of XML elements in the selected clip."), + new PluginConfigField( + Key: "ShowXmlSize", + Label: "Show approximate size", + Type: PluginConfigFieldType.Bool, + DefaultValue: true, + Description: "Display the approximate byte size of the clip's XML."), + }); + + public void OnConfigChanged(IReadOnlyDictionary values) + { + _showElementCount = values.TryGetValue("ShowElementCount", out var a) && a is bool ba ? ba : true; + _showXmlSize = values.TryGetValue("ShowXmlSize", out var b) && b is bool bb ? bb : true; + if (_viewModel is not null) + { + _viewModel.ShowElementCount = _showElementCount; + _viewModel.ShowXmlSize = _showXmlSize; + } + } + private IPluginHost? _host; private ClipInspectorViewModel? _viewModel; + private bool _showElementCount = true; + private bool _showXmlSize = true; public void Initialize(IPluginHost host) { @@ -29,7 +58,11 @@ public void Initialize(IPluginHost host) public Control CreatePanel() { - _viewModel = new ClipInspectorViewModel(); + _viewModel = new ClipInspectorViewModel + { + ShowElementCount = _showElementCount, + ShowXmlSize = _showXmlSize, + }; _viewModel.Update(_host?.SelectedClip); return new ClipInspectorPanel { DataContext = _viewModel }; } diff --git a/src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs b/src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs index fb3ad96..e4460f5 100644 --- a/src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs +++ b/src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs @@ -30,6 +30,12 @@ private void Notify([CallerMemberName] string name = "") private bool _hasClip; public bool HasClip { get => _hasClip; private set { _hasClip = value; Notify(); } } + private bool _showElementCount = true; + public bool ShowElementCount { get => _showElementCount; set { _showElementCount = value; Notify(); } } + + private bool _showXmlSize = true; + public bool ShowXmlSize { get => _showXmlSize; set { _showXmlSize = value; Notify(); } } + public void Update(ClipData? clip) { if (clip is null) diff --git a/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs b/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs index 8e2af88..a773a8c 100644 --- a/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs +++ b/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs @@ -22,6 +22,8 @@ public class XmlViewerPlugin : IPanelPlugin [new PluginKeyBinding("Ctrl+Shift+X", "Toggle XML Viewer", () => { })]; public IReadOnlyList MenuActions => []; + public PluginConfigSchema ConfigSchema => PluginConfigSchema.Empty; + public void OnConfigChanged(IReadOnlyDictionary values) { } public void Initialize(IPluginHost host) { diff --git a/src/SharpFM.Plugin/IPlugin.cs b/src/SharpFM.Plugin/IPlugin.cs index 5788939..dcf1105 100644 --- a/src/SharpFM.Plugin/IPlugin.cs +++ b/src/SharpFM.Plugin/IPlugin.cs @@ -46,4 +46,20 @@ public interface IPlugin : IDisposable /// as a submenu with "Toggle Panel" plus these custom actions. /// IReadOnlyList MenuActions { get; } + + /// + /// Schema for user-tunable configuration values. The host persists these on the + /// plugin's behalf and renders a generic settings UI from the schema. Return + /// if the plugin has no configuration. + /// + PluginConfigSchema ConfigSchema { get; } + + /// + /// Called by the host with the current values for fields declared in + /// : once after with the + /// persisted (or default) values, and again whenever the user saves edits in the + /// Plugin Manager. Keys match ; values are + /// coerced to the CLR type implied by . + /// + void OnConfigChanged(IReadOnlyDictionary values); } diff --git a/src/SharpFM.Plugin/PluginConfigSchema.cs b/src/SharpFM.Plugin/PluginConfigSchema.cs new file mode 100644 index 0000000..b801fcb --- /dev/null +++ b/src/SharpFM.Plugin/PluginConfigSchema.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace SharpFM.Plugin; + +/// +/// Supported field types for a plugin configuration schema. The host renders a +/// different control per type and coerces persisted values to the matching CLR type. +/// +public enum PluginConfigFieldType +{ + String, + MultilineString, + Bool, + Int, + Double, + Enum +} + +/// +/// A single configurable value declared by a plugin. +/// +/// Dictionary key used when values are passed back to the plugin. +/// Human-readable label shown in the settings UI. +/// Field type — determines the rendered control and value coercion. +/// +/// Value returned when the field has never been set or the persisted value is invalid. +/// Must be assignable to the CLR type implied by . +/// +/// Optional caption shown beneath the control. +/// Allowed values when is . +public sealed record PluginConfigField( + string Key, + string Label, + PluginConfigFieldType Type, + object? DefaultValue = null, + string? Description = null, + IReadOnlyList? EnumValues = null); + +/// +/// Describes a plugin's user-tunable configuration. The host uses this to +/// persist values, generate a settings UI, and push the current values into the plugin. +/// +public sealed record PluginConfigSchema(IReadOnlyList Fields) +{ + /// Schema for plugins with no configuration. + public static PluginConfigSchema Empty { get; } = new(Array.Empty()); +} diff --git a/src/SharpFM/App.axaml.cs b/src/SharpFM/App.axaml.cs index 22b3ea3..dd02f7f 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -49,7 +49,8 @@ public override void OnFrameworkInitializationCompleted() // Load plugins var pluginHost = new PluginHost(viewModel, loggerFactory); var pluginUIHost = new PluginUIHost(pluginHost); - var pluginService = new PluginService(logger); + var pluginConfigService = new PluginConfigService(logger); + var pluginService = new PluginService(logger, pluginConfigService); pluginService.LoadPlugins(pluginUIHost); viewModel.AllPlugins = pluginService.AllPlugins; @@ -62,7 +63,7 @@ public override void OnFrameworkInitializationCompleted() // Give the window access to plugin services for the manager dialog if (desktop.MainWindow is MainWindow mainWindow) - mainWindow.SetPluginServices(pluginService, pluginUIHost); + mainWindow.SetPluginServices(pluginService, pluginUIHost, pluginConfigService); desktop.MainWindow.DataContext = viewModel; diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs index 8fc40aa..0927958 100644 --- a/src/SharpFM/MainWindow.axaml.cs +++ b/src/SharpFM/MainWindow.axaml.cs @@ -26,6 +26,7 @@ public partial class MainWindow : Window private TextMate.Installation? _scriptTextMateInstallation; private PluginService? _pluginService; private PluginUIHost? _pluginHost; + private PluginConfigService? _pluginConfigService; public MainWindow() { @@ -58,10 +59,11 @@ public MainWindow() DataContextChanged += OnDataContextChanged; } - public void SetPluginServices(PluginService pluginService, PluginUIHost pluginHost) + public void SetPluginServices(PluginService pluginService, PluginUIHost pluginHost, PluginConfigService pluginConfigService) { _pluginService = pluginService; _pluginHost = pluginHost; + _pluginConfigService = pluginConfigService; } private void OnDataContextChanged(object? sender, EventArgs e) @@ -229,11 +231,11 @@ private void UpdatePluginPanelContent() private void ShowPluginManager() { - if (_pluginService is null || _pluginHost is null) return; + if (_pluginService is null || _pluginHost is null || _pluginConfigService is null) return; if (DataContext is not MainWindowViewModel vm) return; var window = new PluginManagerWindow(); - window.Configure(_pluginService, _pluginHost, vm); + window.Configure(_pluginService, _pluginHost, vm, _pluginConfigService); window.ShowDialog(this); } diff --git a/src/SharpFM/PluginManager/PluginConfigDialog.axaml b/src/SharpFM/PluginManager/PluginConfigDialog.axaml new file mode 100644 index 0000000..80c6e6a --- /dev/null +++ b/src/SharpFM/PluginManager/PluginConfigDialog.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + +