From dc84c9773bc876c06bf91035796ac332ca3b5dc1 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 1 Apr 2026 18:10:35 -0500 Subject: [PATCH 1/2] feat: add plugin architecture with IClipEditor abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add extensible plugin system with GPL plugin exception clause: - SharpFM.Plugin contract library (IPanelPlugin, IPluginHost, IClipEditor) - Plugin discovery/loading from plugins/ directory - Plugin Manager UI for install/remove - Keyboard shortcut registration for plugins Introduce IClipEditor abstraction for clip-type-agnostic bidirectional sync between structured editors and plugins: - ScriptClipEditor, TableClipEditor, FallbackXmlEditor implementations - Generation counter prevents editor↔host sync loops - Origin tagging on ClipContentChanged enables multi-plugin coordination - Debounced change detection (500ms) for all editor types Add FmField INotifyPropertyChanged for live table editor tracking. Refactor ClipViewModel to delegate all sync to IClipEditor. Include two bundled plugins: - Clip Inspector (sample/demo plugin) - XML Viewer (replaces built-in XML window, Ctrl+Shift+X) --- README.md | 19 ++ SharpFM.sln | 30 +++ .../ClipInspectorPanel.axaml | 40 ++++ .../ClipInspectorPanel.axaml.cs | 12 ++ .../ClipInspectorPlugin.cs | 47 +++++ .../ClipInspectorViewModel.cs | 67 ++++++ .../SharpFM.Plugin.Sample.csproj | 17 ++ .../SharpFM.Plugin.XmlViewer.csproj | 20 ++ .../XmlViewerPanel.axaml | 36 ++++ .../XmlViewerPanel.axaml.cs | 30 +++ .../XmlViewerPlugin.cs | 50 +++++ .../XmlViewerViewModel.cs | 92 +++++++++ src/SharpFM.Plugin/ClipContentChangedArgs.cs | 14 ++ src/SharpFM.Plugin/ClipInfo.cs | 11 + src/SharpFM.Plugin/IPanelPlugin.cs | 44 ++++ src/SharpFM.Plugin/IPluginHost.cs | 48 +++++ src/SharpFM.Plugin/PluginKeyBinding.cs | 16 ++ src/SharpFM.Plugin/SharpFM.Plugin.csproj | 11 + src/SharpFM/App.axaml.cs | 14 +- src/SharpFM/Editors/FallbackXmlEditor.cs | 46 +++++ src/SharpFM/Editors/IClipEditor.cs | 34 +++ src/SharpFM/Editors/ScriptClipEditor.cs | 64 ++++++ src/SharpFM/Editors/TableClipEditor.cs | 193 ++++++++++++++++++ src/SharpFM/MainWindow.axaml | 88 +++++--- src/SharpFM/MainWindow.axaml.cs | 170 +++++++++------ .../PluginManager/PluginManagerViewModel.cs | 59 ++++++ .../PluginManager/PluginManagerWindow.axaml | 73 +++++++ .../PluginManagerWindow.axaml.cs | 80 ++++++++ src/SharpFM/Schema/Model/FmField.cs | 99 ++++++--- .../Scripting/Handlers/ControlFlowHandler.cs | 9 +- src/SharpFM/Services/PluginHost.cs | 92 +++++++++ src/SharpFM/Services/PluginService.cs | 159 +++++++++++++++ src/SharpFM/SharpFM.csproj | 20 ++ src/SharpFM/ViewModels/ClipViewModel.cs | 128 +++++------- src/SharpFM/ViewModels/MainWindowViewModel.cs | 61 +++++- .../ClipInspectorPluginTests.cs | 88 ++++++++ tests/SharpFM.Plugin.Tests/PluginHostTests.cs | 146 +++++++++++++ .../PluginManagerViewModelTests.cs | 84 ++++++++ .../PluginServiceInstallTests.cs | 97 +++++++++ .../PluginServiceTests.cs | 53 +++++ .../SharpFM.Plugin.Tests.csproj | 33 +++ .../XmlViewerPluginTests.cs | 150 ++++++++++++++ .../Editors/FallbackXmlEditorTests.cs | 60 ++++++ .../Editors/ScriptClipEditorTests.cs | 67 ++++++ .../Editors/TableClipEditorTests.cs | 133 ++++++++++++ tests/SharpFM.Tests/Schema/FmFieldTests.cs | 36 +++- .../ViewModels/ClipViewModelTests.cs | 11 +- .../ViewModels/MainWindowViewModelTests.cs | 84 ++++++++ 48 files changed, 2826 insertions(+), 209 deletions(-) create mode 100644 src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml create mode 100644 src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml.cs create mode 100644 src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs create mode 100644 src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs create mode 100644 src/SharpFM.Plugin.Sample/SharpFM.Plugin.Sample.csproj create mode 100644 src/SharpFM.Plugin.XmlViewer/SharpFM.Plugin.XmlViewer.csproj create mode 100644 src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml create mode 100644 src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml.cs create mode 100644 src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs create mode 100644 src/SharpFM.Plugin.XmlViewer/XmlViewerViewModel.cs create mode 100644 src/SharpFM.Plugin/ClipContentChangedArgs.cs create mode 100644 src/SharpFM.Plugin/ClipInfo.cs create mode 100644 src/SharpFM.Plugin/IPanelPlugin.cs create mode 100644 src/SharpFM.Plugin/IPluginHost.cs create mode 100644 src/SharpFM.Plugin/PluginKeyBinding.cs create mode 100644 src/SharpFM.Plugin/SharpFM.Plugin.csproj create mode 100644 src/SharpFM/Editors/FallbackXmlEditor.cs create mode 100644 src/SharpFM/Editors/IClipEditor.cs create mode 100644 src/SharpFM/Editors/ScriptClipEditor.cs create mode 100644 src/SharpFM/Editors/TableClipEditor.cs create mode 100644 src/SharpFM/PluginManager/PluginManagerViewModel.cs create mode 100644 src/SharpFM/PluginManager/PluginManagerWindow.axaml create mode 100644 src/SharpFM/PluginManager/PluginManagerWindow.axaml.cs create mode 100644 src/SharpFM/Services/PluginHost.cs create mode 100644 src/SharpFM/Services/PluginService.cs create mode 100644 tests/SharpFM.Plugin.Tests/ClipInspectorPluginTests.cs create mode 100644 tests/SharpFM.Plugin.Tests/PluginHostTests.cs create mode 100644 tests/SharpFM.Plugin.Tests/PluginManagerViewModelTests.cs create mode 100644 tests/SharpFM.Plugin.Tests/PluginServiceInstallTests.cs create mode 100644 tests/SharpFM.Plugin.Tests/PluginServiceTests.cs create mode 100644 tests/SharpFM.Plugin.Tests/SharpFM.Plugin.Tests.csproj create mode 100644 tests/SharpFM.Plugin.Tests/XmlViewerPluginTests.cs create mode 100644 tests/SharpFM.Tests/Editors/FallbackXmlEditorTests.cs create mode 100644 tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs create mode 100644 tests/SharpFM.Tests/Editors/TableClipEditorTests.cs diff --git a/README.md b/README.md index 81c8283..2fe7d70 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,25 @@ SharpFM persists clips between sessions as XML files in a local folder. - [x] DataGrid table/field editor with inline editing, calculation editor, and type/kind selection. - [x] View and edit raw XML alongside structured editors. +## Plugins + +SharpFM supports plugins via the `SharpFM.Plugin` contract library. Plugins implement `IPanelPlugin` and are loaded from the `plugins/` directory at startup. You can also install and manage plugins from the **View > Manage Plugins...** menu. + +A sample "Clip Inspector" plugin is included to demonstrate the plugin API. + +### Writing a Plugin + +1. Create a new .NET 8 class library referencing `SharpFM.Plugin`. +2. Implement `IPanelPlugin` — provide an `Id`, `DisplayName`, and `CreatePanel()` returning an Avalonia `Control`. +3. Use `IPluginHost` in `Initialize()` to observe clip selection and push XML updates. +4. Build your DLL and drop it in the `plugins/` directory. + +See `src/SharpFM.Plugin.Sample/` for a complete working example. + +### License + +While SharpFM is licensed under GPL v3, plugins that communicate solely through the interfaces in `SharpFM.Plugin` are not required to be GPL-licensed. See the plugin interface source files for the full exception clause. + ## Troubleshooting Logs are stored in `${specialfolder:folder=CommonApplicationData}\SharpFM` and are automatically rotated after thirty days. diff --git a/SharpFM.sln b/SharpFM.sln index 8c23fc6..f95571e 100644 --- a/SharpFM.sln +++ b/SharpFM.sln @@ -9,6 +9,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{E2FF2BB3 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpFM.Tests", "tests\SharpFM.Tests\SharpFM.Tests.csproj", "{5B228160-ECB9-4DFC-91D7-413AE9900617}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{1515B0F2-1419-4778-92A8-430A8B4931F7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpFM.Plugin", "src\SharpFM.Plugin\SharpFM.Plugin.csproj", "{2D7BC534-E63F-4FC2-84F1-62BC0E8A1395}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpFM.Plugin.Sample", "src\SharpFM.Plugin.Sample\SharpFM.Plugin.Sample.csproj", "{0ACF3F64-A87C-487C-B780-B39327C1B801}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpFM.Plugin.Tests", "tests\SharpFM.Plugin.Tests\SharpFM.Plugin.Tests.csproj", "{74337D8E-5EC6-4E5F-9E9E-F2B59E8ECABB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpFM.Plugin.XmlViewer", "src\SharpFM.Plugin.XmlViewer\SharpFM.Plugin.XmlViewer.csproj", "{E988ECF3-E096-4F29-88C0-27B50FD6C703}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,8 +40,28 @@ Global {5B228160-ECB9-4DFC-91D7-413AE9900617}.Debug|Any CPU.Build.0 = Debug|Any CPU {5B228160-ECB9-4DFC-91D7-413AE9900617}.Release|Any CPU.ActiveCfg = Release|Any CPU {5B228160-ECB9-4DFC-91D7-413AE9900617}.Release|Any CPU.Build.0 = Release|Any CPU + {2D7BC534-E63F-4FC2-84F1-62BC0E8A1395}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D7BC534-E63F-4FC2-84F1-62BC0E8A1395}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D7BC534-E63F-4FC2-84F1-62BC0E8A1395}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D7BC534-E63F-4FC2-84F1-62BC0E8A1395}.Release|Any CPU.Build.0 = Release|Any CPU + {0ACF3F64-A87C-487C-B780-B39327C1B801}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0ACF3F64-A87C-487C-B780-B39327C1B801}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0ACF3F64-A87C-487C-B780-B39327C1B801}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0ACF3F64-A87C-487C-B780-B39327C1B801}.Release|Any CPU.Build.0 = Release|Any CPU + {74337D8E-5EC6-4E5F-9E9E-F2B59E8ECABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74337D8E-5EC6-4E5F-9E9E-F2B59E8ECABB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74337D8E-5EC6-4E5F-9E9E-F2B59E8ECABB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74337D8E-5EC6-4E5F-9E9E-F2B59E8ECABB}.Release|Any CPU.Build.0 = Release|Any CPU + {E988ECF3-E096-4F29-88C0-27B50FD6C703}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E988ECF3-E096-4F29-88C0-27B50FD6C703}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E988ECF3-E096-4F29-88C0-27B50FD6C703}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E988ECF3-E096-4F29-88C0-27B50FD6C703}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {5B228160-ECB9-4DFC-91D7-413AE9900617} = {E2FF2BB3-AF37-44BA-BD84-999B352D814E} + {2D7BC534-E63F-4FC2-84F1-62BC0E8A1395} = {1515B0F2-1419-4778-92A8-430A8B4931F7} + {0ACF3F64-A87C-487C-B780-B39327C1B801} = {1515B0F2-1419-4778-92A8-430A8B4931F7} + {74337D8E-5EC6-4E5F-9E9E-F2B59E8ECABB} = {E2FF2BB3-AF37-44BA-BD84-999B352D814E} + {E988ECF3-E096-4F29-88C0-27B50FD6C703} = {1515B0F2-1419-4778-92A8-430A8B4931F7} EndGlobalSection EndGlobal diff --git a/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml b/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml new file mode 100644 index 0000000..2cdf87a --- /dev/null +++ b/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml.cs b/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml.cs new file mode 100644 index 0000000..b6f187c --- /dev/null +++ b/src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace SharpFM.Plugin.Sample; + +public partial class ClipInspectorPanel : UserControl +{ + public ClipInspectorPanel() + { + InitializeComponent(); + } +} diff --git a/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs b/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs new file mode 100644 index 0000000..c6169b0 --- /dev/null +++ b/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using SharpFM.Plugin; + +namespace SharpFM.Plugin.Sample; + +public class ClipInspectorPlugin : IPanelPlugin +{ + public string Id => "clip-inspector"; + public string DisplayName => "Clip Inspector"; + public IReadOnlyList KeyBindings => []; + + private IPluginHost? _host; + private ClipInspectorViewModel? _viewModel; + + public void Initialize(IPluginHost host) + { + _host = host; + _host.SelectedClipChanged += OnSelectedClipChanged; + _host.ClipContentChanged += OnClipContentChanged; + } + + public Control CreatePanel() + { + _viewModel = new ClipInspectorViewModel(); + _viewModel.Update(_host?.SelectedClip); + return new ClipInspectorPanel { DataContext = _viewModel }; + } + + private void OnSelectedClipChanged(object? sender, ClipInfo? clip) + { + _viewModel?.Update(clip); + } + + private void OnClipContentChanged(object? sender, ClipContentChangedArgs args) + { + _viewModel?.Update(args.Clip); + } + + public void Dispose() + { + if (_host is null) return; + _host.SelectedClipChanged -= OnSelectedClipChanged; + _host.ClipContentChanged -= OnClipContentChanged; + } +} diff --git a/src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs b/src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs new file mode 100644 index 0000000..cecd3c3 --- /dev/null +++ b/src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs @@ -0,0 +1,67 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Xml.Linq; +using SharpFM.Plugin; + +namespace SharpFM.Plugin.Sample; + +public class ClipInspectorViewModel : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + private void Notify([CallerMemberName] string name = "") + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + private string _clipName = "(no clip selected)"; + public string ClipName { get => _clipName; private set { _clipName = value; Notify(); } } + + private string _clipType = "-"; + public string ClipType { get => _clipType; private set { _clipType = value; Notify(); } } + + private string _elementCount = "-"; + public string ElementCount { get => _elementCount; private set { _elementCount = value; Notify(); } } + + private string _xmlSize = "-"; + public string XmlSize { get => _xmlSize; private set { _xmlSize = value; Notify(); } } + + private bool _hasClip; + public bool HasClip { get => _hasClip; private set { _hasClip = value; Notify(); } } + + public void Update(ClipInfo? clip) + { + if (clip is null) + { + ClipName = "(no clip selected)"; + ClipType = "-"; + ElementCount = "-"; + XmlSize = "-"; + HasClip = false; + return; + } + + HasClip = true; + ClipName = clip.Name; + ClipType = clip.ClipType; + XmlSize = FormatBytes(clip.Xml.Length * 2); // rough UTF-16 estimate + + try + { + var doc = XDocument.Parse(clip.Xml); + var count = doc.Descendants().Count(); + ElementCount = count.ToString(); + } + catch + { + ElementCount = "(invalid XML)"; + } + } + + private static string FormatBytes(int bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB" + }; +} diff --git a/src/SharpFM.Plugin.Sample/SharpFM.Plugin.Sample.csproj b/src/SharpFM.Plugin.Sample/SharpFM.Plugin.Sample.csproj new file mode 100644 index 0000000..f7cb882 --- /dev/null +++ b/src/SharpFM.Plugin.Sample/SharpFM.Plugin.Sample.csproj @@ -0,0 +1,17 @@ + + + net8.0 + enable + latest + + + + + + + + + + + + diff --git a/src/SharpFM.Plugin.XmlViewer/SharpFM.Plugin.XmlViewer.csproj b/src/SharpFM.Plugin.XmlViewer/SharpFM.Plugin.XmlViewer.csproj new file mode 100644 index 0000000..c2f95e6 --- /dev/null +++ b/src/SharpFM.Plugin.XmlViewer/SharpFM.Plugin.XmlViewer.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + latest + + + + + + + + + + + + + + + diff --git a/src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml b/src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml new file mode 100644 index 0000000..772910d --- /dev/null +++ b/src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + diff --git a/src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml.cs b/src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml.cs new file mode 100644 index 0000000..99226a4 --- /dev/null +++ b/src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; +using AvaloniaEdit; +using AvaloniaEdit.TextMate; +using TextMateSharp.Grammars; + +namespace SharpFM.Plugin.XmlViewer; + +public partial class XmlViewerPanel : UserControl +{ + private TextMate.Installation? _textMateInstallation; + + public XmlViewerPanel() + { + InitializeComponent(); + + var editor = this.FindControl("xmlEditor"); + if (editor is null) return; + + var registryOptions = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); + _textMateInstallation = editor.InstallTextMate(registryOptions); + var xmlLang = registryOptions.GetLanguageByExtension(".xml"); + _textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId(xmlLang.Id)); + } + + protected override void OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _textMateInstallation?.Dispose(); + } +} diff --git a/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs b/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs new file mode 100644 index 0000000..34264ed --- /dev/null +++ b/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using SharpFM.Plugin; + +namespace SharpFM.Plugin.XmlViewer; + +public class XmlViewerPlugin : IPanelPlugin +{ + public string Id => "xml-viewer"; + public string DisplayName => "XML Viewer"; + + private IPluginHost? _host; + private XmlViewerViewModel? _viewModel; + + public IReadOnlyList KeyBindings { get; } = + [new PluginKeyBinding("Ctrl+Shift+X", "Toggle XML Viewer", () => { })]; + + public void Initialize(IPluginHost host) + { + _host = host; + _host.SelectedClipChanged += OnClipChanged; + _host.ClipContentChanged += OnClipContentChanged; + } + + public Control CreatePanel() + { + _viewModel = new XmlViewerViewModel(_host!, Id); + _viewModel.RefreshFromHost(); + return new XmlViewerPanel { DataContext = _viewModel }; + } + + private void OnClipChanged(object? sender, ClipInfo? clip) + { + _viewModel?.LoadClip(clip); + } + + private void OnClipContentChanged(object? sender, ClipContentChangedArgs args) + { + if (args.Origin == Id) return; // I caused this, skip + _viewModel?.LoadClip(args.Clip); + } + + public void Dispose() + { + if (_host is null) return; + _host.SelectedClipChanged -= OnClipChanged; + _host.ClipContentChanged -= OnClipContentChanged; + } +} diff --git a/src/SharpFM.Plugin.XmlViewer/XmlViewerViewModel.cs b/src/SharpFM.Plugin.XmlViewer/XmlViewerViewModel.cs new file mode 100644 index 0000000..a4e477a --- /dev/null +++ b/src/SharpFM.Plugin.XmlViewer/XmlViewerViewModel.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using AvaloniaEdit.Document; +using SharpFM.Plugin; + +namespace SharpFM.Plugin.XmlViewer; + +public class XmlViewerViewModel : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + private void Notify([CallerMemberName] string name = "") => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + private readonly IPluginHost _host; + private readonly string _pluginId; + private bool _isSyncing; + + public TextDocument Document { get; } = new(); + + private bool _hasClip; + public bool HasClip { get => _hasClip; private set { _hasClip = value; Notify(); } } + + private string _clipLabel = "No clip selected"; + public string ClipLabel { get => _clipLabel; private set { _clipLabel = value; Notify(); } } + + public XmlViewerViewModel(IPluginHost host, string pluginId) + { + _host = host; + _pluginId = pluginId; + Document.TextChanged += OnDocumentTextChanged; + } + + public void RefreshFromHost() + { + var clip = _host.RefreshSelectedClip(); + LoadClip(clip); + } + + public void LoadClip(ClipInfo? clip) + { + _isSyncing = true; + try + { + if (clip is null) + { + Document.Text = ""; + HasClip = false; + ClipLabel = "No clip selected"; + } + else + { + Document.Text = clip.Xml ?? ""; + HasClip = true; + ClipLabel = $"{clip.Name} ({clip.ClipType})"; + } + } + finally + { + _isSyncing = false; + } + } + + public void SyncToHost() + { + if (!HasClip) return; + _isSyncing = true; + try + { + _host.UpdateSelectedClipXml(Document.Text, _pluginId); + } + finally + { + _isSyncing = false; + } + } + + private void OnDocumentTextChanged(object? sender, System.EventArgs e) + { + if (!_isSyncing && HasClip) + { + _isSyncing = true; + try + { + _host.UpdateSelectedClipXml(Document.Text, _pluginId); + } + finally + { + _isSyncing = false; + } + } + } +} diff --git a/src/SharpFM.Plugin/ClipContentChangedArgs.cs b/src/SharpFM.Plugin/ClipContentChangedArgs.cs new file mode 100644 index 0000000..45a018a --- /dev/null +++ b/src/SharpFM.Plugin/ClipContentChangedArgs.cs @@ -0,0 +1,14 @@ +// This file is part of SharpFM and is licensed under the GNU General Public License v3. +// +// Plugin Exception: You may create plugins that implement these interfaces without those +// plugins being subject to the GPL. Such plugins may use any license, including proprietary. + +namespace SharpFM.Plugin; + +/// +/// Event arguments for . +/// +/// Fresh snapshot of the clip with synced XML. +/// "editor" for user edits, or the originating plugin's . +/// True if the XML was produced from an incomplete parse (e.g. mid-typing). +public record ClipContentChangedArgs(ClipInfo Clip, string Origin, bool IsPartial); diff --git a/src/SharpFM.Plugin/ClipInfo.cs b/src/SharpFM.Plugin/ClipInfo.cs new file mode 100644 index 0000000..fcc3c19 --- /dev/null +++ b/src/SharpFM.Plugin/ClipInfo.cs @@ -0,0 +1,11 @@ +// This file is part of SharpFM and is licensed under the GNU General Public License v3. +// +// Plugin Exception: You may create plugins that implement these interfaces without those +// plugins being subject to the GPL. Such plugins may use any license, including proprietary. + +namespace SharpFM.Plugin; + +/// +/// Read-only snapshot of a clip's metadata and content, provided to plugins by the host. +/// +public record ClipInfo(string Name, string ClipType, string Xml); diff --git a/src/SharpFM.Plugin/IPanelPlugin.cs b/src/SharpFM.Plugin/IPanelPlugin.cs new file mode 100644 index 0000000..4117567 --- /dev/null +++ b/src/SharpFM.Plugin/IPanelPlugin.cs @@ -0,0 +1,44 @@ +// This file is part of SharpFM and is licensed under the GNU General Public License v3. +// +// Plugin Exception: You may create plugins that implement these interfaces without those +// plugins being subject to the GPL. Such plugins may use any license, including proprietary. + +using System; +using System.Collections.Generic; +using Avalonia.Controls; + +namespace SharpFM.Plugin; + +/// +/// A plugin that provides a dockable side panel in the SharpFM UI. +/// +public interface IPanelPlugin : IDisposable +{ + /// + /// Unique identifier for this plugin (e.g. "clip-inspector", "ai-assistant"). + /// + string Id { get; } + + /// + /// Display name shown in the View menu (e.g. "Clip Inspector"). + /// + string DisplayName { get; } + + /// + /// Create the panel control to be hosted in the main window sidebar. + /// Called once after . + /// + Control CreatePanel(); + + /// + /// Initialize the plugin with access to host services. + /// Called once at startup before . + /// + void Initialize(IPluginHost host); + + /// + /// Keyboard shortcuts this plugin wants registered in the host window. + /// The host registers these when the plugin is loaded. Return empty for no shortcuts. + /// + IReadOnlyList KeyBindings { get; } +} diff --git a/src/SharpFM.Plugin/IPluginHost.cs b/src/SharpFM.Plugin/IPluginHost.cs new file mode 100644 index 0000000..d3cf2fd --- /dev/null +++ b/src/SharpFM.Plugin/IPluginHost.cs @@ -0,0 +1,48 @@ +// This file is part of SharpFM and is licensed under the GNU General Public License v3. +// +// Plugin Exception: You may create plugins that implement these interfaces without those +// plugins being subject to the GPL. Such plugins may use any license, including proprietary. + +using System; + +namespace SharpFM.Plugin; + +/// +/// Services provided by the SharpFM host application to plugins. +/// +public interface IPluginHost +{ + /// + /// The currently selected clip, or null if nothing is selected. + /// + ClipInfo? SelectedClip { get; } + + /// + /// Raised when the selected clip changes (user selects a different clip in the list). + /// + event EventHandler SelectedClipChanged; + + /// + /// Replace the XML content of the currently selected clip. + /// The host syncs the new XML back to the structured editor automatically. + /// + /// The new XML content. + /// The Id of the plugin making the change, + /// used for origin tagging so the plugin can skip its own updates. + void UpdateSelectedClipXml(string xml, string originPluginId); + + /// + /// Sync the current editor state to XML and return a fresh snapshot. + /// Call this before reading if you need up-to-date XML + /// that reflects any in-progress edits in the structured editors. + /// + ClipInfo? RefreshSelectedClip(); + + /// + /// Raised when clip content changes — either from a user edit in the structured editor + /// or from a plugin pushing XML. The field + /// indicates who caused the change ("editor" for user edits, or a plugin Id). + /// Debounced for editor edits; immediate for plugin pushes. + /// + event EventHandler ClipContentChanged; +} diff --git a/src/SharpFM.Plugin/PluginKeyBinding.cs b/src/SharpFM.Plugin/PluginKeyBinding.cs new file mode 100644 index 0000000..e659236 --- /dev/null +++ b/src/SharpFM.Plugin/PluginKeyBinding.cs @@ -0,0 +1,16 @@ +// This file is part of SharpFM and is licensed under the GNU General Public License v3. +// +// Plugin Exception: You may create plugins that implement these interfaces without those +// plugins being subject to the GPL. Such plugins may use any license, including proprietary. + +using System; + +namespace SharpFM.Plugin; + +/// +/// A keyboard shortcut that a plugin wants registered in the host window. +/// +/// Key gesture string (e.g. "Ctrl+Shift+X"). Uses Avalonia gesture format. +/// Human-readable description shown in menus. +/// Action invoked when the shortcut is triggered. +public record PluginKeyBinding(string Gesture, string Description, Action Callback); diff --git a/src/SharpFM.Plugin/SharpFM.Plugin.csproj b/src/SharpFM.Plugin/SharpFM.Plugin.csproj new file mode 100644 index 0000000..44c5770 --- /dev/null +++ b/src/SharpFM.Plugin/SharpFM.Plugin.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + latest + + + + + + diff --git a/src/SharpFM/App.axaml.cs b/src/SharpFM/App.axaml.cs index d6250d5..9502732 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -30,10 +30,22 @@ public override void OnFrameworkInitializationCompleted() services.AddSingleton(x => new ClipboardService(desktop.MainWindow)); Services = services.BuildServiceProvider(); - desktop.MainWindow.DataContext = new MainWindowViewModel( + var viewModel = new MainWindowViewModel( logger, Services.GetRequiredService(), Services.GetRequiredService()); + + // Load plugins + var pluginHost = new PluginHost(viewModel); + var pluginService = new PluginService(logger); + pluginService.LoadPlugins(pluginHost); + viewModel.PanelPlugins = pluginService.LoadedPlugins; + + // Give the window access to plugin services for the manager dialog + if (desktop.MainWindow is MainWindow mainWindow) + mainWindow.SetPluginServices(pluginService, pluginHost); + + desktop.MainWindow.DataContext = viewModel; } base.OnFrameworkInitializationCompleted(); diff --git a/src/SharpFM/Editors/FallbackXmlEditor.cs b/src/SharpFM/Editors/FallbackXmlEditor.cs new file mode 100644 index 0000000..76b0548 --- /dev/null +++ b/src/SharpFM/Editors/FallbackXmlEditor.cs @@ -0,0 +1,46 @@ +using System; +using Avalonia.Threading; +using AvaloniaEdit.Document; + +namespace SharpFM.Editors; + +/// +/// Editor for clips with no specialized editor (layouts, unknown formats). +/// The user edits the raw XML directly via a TextDocument. +/// +public class FallbackXmlEditor : IClipEditor +{ + private readonly DispatcherTimer _debounceTimer; + + public event EventHandler? ContentChanged; + + /// The TextDocument bound to the AvaloniaEdit XML editor. + public TextDocument Document { get; } + + public bool IsPartial => false; + + public FallbackXmlEditor(string? xml) + { + Document = new TextDocument(xml ?? ""); + + _debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _debounceTimer.Tick += (_, _) => + { + _debounceTimer.Stop(); + ContentChanged?.Invoke(this, EventArgs.Empty); + }; + + Document.TextChanged += (_, _) => + { + _debounceTimer.Stop(); + _debounceTimer.Start(); + }; + } + + public string ToXml() => Document.Text; + + public void FromXml(string xml) + { + Document.Text = xml; + } +} diff --git a/src/SharpFM/Editors/IClipEditor.cs b/src/SharpFM/Editors/IClipEditor.cs new file mode 100644 index 0000000..e968c6b --- /dev/null +++ b/src/SharpFM/Editors/IClipEditor.cs @@ -0,0 +1,34 @@ +using System; + +namespace SharpFM.Editors; + +/// +/// Abstraction for clip-type-specific editing. Each clip type provides an implementation +/// that handles change detection, XML serialization, and reverse sync. ClipViewModel holds +/// one IClipEditor and delegates all sync operations to it — no clip-type branching needed. +/// +public interface IClipEditor +{ + /// + /// Fires when the user edits content in the structured editor. + /// Implementations should debounce this (e.g. 500ms) to avoid excessive events. + /// + event EventHandler? ContentChanged; + + /// + /// Serialize the current editor state to XML. + /// + string ToXml(); + + /// + /// Load XML into the editor (reverse sync from an external source like a plugin). + /// Implementations should diff/patch when possible to preserve UI state. + /// + void FromXml(string xml); + + /// + /// True if the last produced output from an incomplete or errored parse. + /// For example, a half-typed script step that can't fully round-trip. + /// + bool IsPartial { get; } +} diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs new file mode 100644 index 0000000..d4cfe50 --- /dev/null +++ b/src/SharpFM/Editors/ScriptClipEditor.cs @@ -0,0 +1,64 @@ +using System; +using Avalonia.Threading; +using AvaloniaEdit.Document; +using SharpFM.Scripting; + +namespace SharpFM.Editors; + +/// +/// Editor for script clips (Mac-XMSS, Mac-XMSC). Wraps a TextDocument containing the +/// plain-text script representation and handles FmScript model round-tripping. +/// +public class ScriptClipEditor : IClipEditor +{ + private readonly DispatcherTimer _debounceTimer; + private FmScript _script; + + public event EventHandler? ContentChanged; + + /// The TextDocument bound to the AvaloniaEdit script editor. + public TextDocument Document { get; } + + public bool IsPartial { get; private set; } + + public ScriptClipEditor(string? xml) + { + _script = FmScript.FromXml(xml ?? ""); + Document = new TextDocument(_script.ToDisplayText()); + + _debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _debounceTimer.Tick += (_, _) => + { + _debounceTimer.Stop(); + ContentChanged?.Invoke(this, EventArgs.Empty); + }; + + Document.TextChanged += (_, _) => + { + _debounceTimer.Stop(); + _debounceTimer.Start(); + }; + } + + public string ToXml() + { + try + { + _script = FmScript.FromDisplayText(Document.Text); + IsPartial = false; + return _script.ToXml(); + } + catch + { + IsPartial = true; + // Return best-effort XML from the last known good parse + return _script.ToXml(); + } + } + + public void FromXml(string xml) + { + _script = FmScript.FromXml(xml); + Document.Text = _script.ToDisplayText(); + } +} diff --git a/src/SharpFM/Editors/TableClipEditor.cs b/src/SharpFM/Editors/TableClipEditor.cs new file mode 100644 index 0000000..59d42ee --- /dev/null +++ b/src/SharpFM/Editors/TableClipEditor.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using Avalonia.Threading; +using SharpFM.Schema.Editor; +using SharpFM.Schema.Model; + +namespace SharpFM.Editors; + +/// +/// Editor for table/field clips (Mac-XMTB, Mac-XMFD). Wraps a TableEditorViewModel +/// and tracks field collection and property changes for live sync. +/// +public class TableClipEditor : IClipEditor +{ + private readonly DispatcherTimer _debounceTimer; + + public event EventHandler? ContentChanged; + + /// The TableEditorViewModel bound to the DataGrid. + public TableEditorViewModel ViewModel { get; private set; } + + public bool IsPartial => false; + + public TableClipEditor(string? xml) + { + var table = FmTable.FromXml(xml ?? ""); + ViewModel = new TableEditorViewModel(table); + + _debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _debounceTimer.Tick += (_, _) => + { + _debounceTimer.Stop(); + ContentChanged?.Invoke(this, EventArgs.Empty); + }; + + SubscribeToViewModel(ViewModel); + } + + public string ToXml() + { + ViewModel.SyncToModel(); + return ViewModel.Table.ToXml(); + } + + public void FromXml(string xml) + { + var incoming = FmTable.FromXml(xml); + PatchViewModel(incoming); + } + + private void SubscribeToViewModel(TableEditorViewModel vm) + { + vm.Fields.CollectionChanged += OnCollectionChanged; + vm.PropertyChanged += OnViewModelPropertyChanged; + + foreach (var field in vm.Fields) + field.PropertyChanged += OnFieldPropertyChanged; + } + + private void UnsubscribeFromViewModel(TableEditorViewModel vm) + { + vm.Fields.CollectionChanged -= OnCollectionChanged; + vm.PropertyChanged -= OnViewModelPropertyChanged; + + foreach (var field in vm.Fields) + field.PropertyChanged -= OnFieldPropertyChanged; + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // Subscribe to new fields, unsubscribe from removed ones + if (e.NewItems is not null) + foreach (FmField field in e.NewItems) + field.PropertyChanged += OnFieldPropertyChanged; + + if (e.OldItems is not null) + foreach (FmField field in e.OldItems) + field.PropertyChanged -= OnFieldPropertyChanged; + + RestartDebounce(); + } + + private void OnFieldPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + RestartDebounce(); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(TableEditorViewModel.TableName)) + RestartDebounce(); + } + + private void RestartDebounce() + { + _debounceTimer.Stop(); + _debounceTimer.Start(); + } + + /// + /// Diff and patch the existing ViewModel fields from incoming XML. + /// Preserves UI state (selection, scroll) when possible. + /// Falls back to full rebuild if the table identity changed. + /// + private void PatchViewModel(FmTable incoming) + { + var current = ViewModel; + + // If the table name/identity changed entirely, full rebuild + if (current.Table.Name != incoming.Name && current.Table.Id != incoming.Id) + { + UnsubscribeFromViewModel(current); + var table = incoming; + ViewModel = new TableEditorViewModel(table); + SubscribeToViewModel(ViewModel); + return; + } + + // Update table name + if (current.TableName != incoming.Name) + current.TableName = incoming.Name; + + // Build lookup of incoming fields by Id + var incomingById = incoming.Fields.ToDictionary(f => f.Id); + var currentById = current.Fields.ToDictionary(f => f.Id); + + // Remove fields not in incoming + var toRemove = current.Fields.Where(f => !incomingById.ContainsKey(f.Id)).ToList(); + foreach (var field in toRemove) + { + field.PropertyChanged -= OnFieldPropertyChanged; + current.Fields.Remove(field); + } + + // Update existing fields and add new ones + for (int i = 0; i < incoming.Fields.Count; i++) + { + var inField = incoming.Fields[i]; + + if (currentById.TryGetValue(inField.Id, out var existing)) + { + // Update properties on the existing field in-place + PatchField(existing, inField); + + // Move to correct position if needed + var currentIdx = current.Fields.IndexOf(existing); + if (currentIdx != i && currentIdx >= 0 && i < current.Fields.Count) + current.Fields.Move(currentIdx, i); + } + else + { + // New field — insert at the correct position + inField.PropertyChanged += OnFieldPropertyChanged; + if (i < current.Fields.Count) + current.Fields.Insert(i, inField); + else + current.Fields.Add(inField); + } + } + + // Sync the underlying model + current.SyncToModel(); + } + + private static void PatchField(FmField target, FmField source) + { + if (target.Name != source.Name) target.Name = source.Name; + if (target.DataType != source.DataType) target.DataType = source.DataType; + if (target.Kind != source.Kind) target.Kind = source.Kind; + if (target.Repetitions != source.Repetitions) target.Repetitions = source.Repetitions; + if (target.Comment != source.Comment) target.Comment = source.Comment; + if (target.NotEmpty != source.NotEmpty) target.NotEmpty = source.NotEmpty; + if (target.Unique != source.Unique) target.Unique = source.Unique; + if (target.Existing != source.Existing) target.Existing = source.Existing; + if (target.MaxDataLength != source.MaxDataLength) target.MaxDataLength = source.MaxDataLength; + if (target.ValidationCalculation != source.ValidationCalculation) target.ValidationCalculation = source.ValidationCalculation; + if (target.ErrorMessage != source.ErrorMessage) target.ErrorMessage = source.ErrorMessage; + if (target.RangeMin != source.RangeMin) target.RangeMin = source.RangeMin; + if (target.RangeMax != source.RangeMax) target.RangeMax = source.RangeMax; + if (target.AutoEnter != source.AutoEnter) target.AutoEnter = source.AutoEnter; + if (target.AllowEditing != source.AllowEditing) target.AllowEditing = source.AllowEditing; + if (target.AutoEnterValue != source.AutoEnterValue) target.AutoEnterValue = source.AutoEnterValue; + if (target.Calculation != source.Calculation) target.Calculation = source.Calculation; + if (target.AlwaysEvaluate != source.AlwaysEvaluate) target.AlwaysEvaluate = source.AlwaysEvaluate; + if (target.CalculationContext != source.CalculationContext) target.CalculationContext = source.CalculationContext; + if (target.SummaryOp != source.SummaryOp) target.SummaryOp = source.SummaryOp; + if (target.SummaryTargetField != source.SummaryTargetField) target.SummaryTargetField = source.SummaryTargetField; + if (target.IsGlobal != source.IsGlobal) target.IsGlobal = source.IsGlobal; + if (target.Indexing != source.Indexing) target.Indexing = source.Indexing; + } +} diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index ef4d800..ee382d9 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -46,7 +46,10 @@ - + + + + @@ -167,34 +170,61 @@ ResizeDirection="Columns" Width="16" /> - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs index 5e549ae..fa4b73f 100644 --- a/src/SharpFM/MainWindow.axaml.cs +++ b/src/SharpFM/MainWindow.axaml.cs @@ -1,8 +1,15 @@ using System; +using System.ComponentModel; +using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using AvaloniaEdit; using AvaloniaEdit.TextMate; +using SharpFM.Plugin; +using SharpFM.PluginManager; using SharpFM.Scripting; +using SharpFM.Services; +using SharpFM.ViewModels; using TextMateSharp.Grammars; namespace SharpFM; @@ -11,9 +18,9 @@ public partial class MainWindow : Window { private readonly RegistryOptions _registryOptions; private ScriptEditorController? _scriptController; - private TextMate.Installation? _xmlTextMateInstallation; private TextMate.Installation? _scriptTextMateInstallation; - private Window? _xmlWindow; + private PluginService? _pluginService; + private IPluginHost? _pluginHost; public MainWindow() { @@ -31,84 +38,133 @@ public MainWindow() _scriptController = new ScriptEditorController(scriptEditor); } - // "View XML" menu item - var viewXmlItem = this.FindControl("viewXmlMenuItem"); - if (viewXmlItem != null) - { - viewXmlItem.Click += (_, _) => ShowXmlWindow(); - } + // "Manage Plugins..." menu item + var managePlugins = this.FindControl("managePluginsMenuItem"); + if (managePlugins != null) + managePlugins.Click += (_, _) => ShowPluginManager(); + + // Wire up plugin UI when DataContext is set + DataContextChanged += OnDataContextChanged; } - private void ShowXmlWindow() + public void SetPluginServices(PluginService pluginService, IPluginHost pluginHost) { - var vm = (DataContext as SharpFM.ViewModels.MainWindowViewModel)?.SelectedClip; - if (vm == null) - return; + _pluginService = pluginService; + _pluginHost = pluginHost; + } - // Sync model to XML before showing - vm.SyncModelFromEditor(); + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (DataContext is not MainWindowViewModel vm) return; - // Reuse or create the XML window - if (_xmlWindow == null || !_xmlWindow.IsVisible) + BuildPluginMenuItems(vm); + vm.PropertyChanged += OnViewModelPropertyChanged; + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MainWindowViewModel.IsPluginPanelVisible)) + UpdatePluginPanelVisibility(); + else if (e.PropertyName == nameof(MainWindowViewModel.PluginPanelControl)) + UpdatePluginPanelContent(); + } + + private void BuildPluginMenuItems(MainWindowViewModel vm) + { + var separator = this.FindControl("pluginMenuSeparator"); + if (separator is null) return; + + var viewMenu = separator.Parent as MenuItem; + if (viewMenu is null || vm.PanelPlugins.Count == 0) return; + + separator.IsVisible = true; + + foreach (var plugin in vm.PanelPlugins) { - var xmlEditor = new TextEditor + var item = new MenuItem { Header = plugin.DisplayName, Tag = plugin }; + item.Click += (_, _) => { - FontFamily = new Avalonia.Media.FontFamily("Cascadia Code,Consolas,Menlo,Monospace"), - ShowLineNumbers = true, - WordWrap = false, + if (item.Tag is IPanelPlugin p) + vm.TogglePluginPanel(p); }; - // Lazy-load XML TextMate only when first needed - if (_xmlTextMateInstallation == null) - { - _xmlTextMateInstallation = xmlEditor.InstallTextMate(_registryOptions); - var xmlLang = _registryOptions.GetLanguageByExtension(".xml"); - _xmlTextMateInstallation.SetGrammar(_registryOptions.GetScopeByLanguageId(xmlLang.Id)); - } + // Show the first keybinding gesture as an InputGesture hint + if (plugin.KeyBindings.Count > 0) + item.InputGesture = KeyGesture.Parse(plugin.KeyBindings[0].Gesture); - xmlEditor.Document = new AvaloniaEdit.Document.TextDocument(vm.ClipXml ?? ""); + viewMenu.Items.Add(item); + } - _xmlWindow = new Window - { - Title = $"XML — {vm.Name}", - Width = 600, - Height = 500, - Content = xmlEditor, - }; + RegisterPluginKeyBindings(vm); + } - // Sync XML edits back to the model when the window closes - _xmlWindow.Closing += (_, _) => + private void RegisterPluginKeyBindings(MainWindowViewModel vm) + { + foreach (var plugin in vm.PanelPlugins) + { + foreach (var binding in plugin.KeyBindings) { - if (_xmlWindow.Content is TextEditor editor) + var gesture = KeyGesture.Parse(binding.Gesture); + var pluginRef = plugin; + KeyBindings.Add(new KeyBinding { - var currentVm = (DataContext as SharpFM.ViewModels.MainWindowViewModel)?.SelectedClip; - if (currentVm != null) + Gesture = gesture, + Command = new PluginKeyCommand(() => { - currentVm.ClipXml = editor.Document.Text; - currentVm.SyncEditorFromXml(); - } - } - }; - } - else - { - // Update existing window content - if (_xmlWindow.Content is TextEditor existing) - existing.Document = new AvaloniaEdit.Document.TextDocument(vm.ClipXml ?? ""); - _xmlWindow.Title = $"XML — {vm.Name}"; + vm.TogglePluginPanel(pluginRef); + binding.Callback(); + }) + }); + } } + } + + /// + /// Simple ICommand wrapper for plugin key binding callbacks. + /// + private class PluginKeyCommand(Action callback) : System.Windows.Input.ICommand + { +#pragma warning disable CS0067 // Required by ICommand interface + public event EventHandler? CanExecuteChanged; +#pragma warning restore CS0067 + public bool CanExecute(object? parameter) => true; + public void Execute(object? parameter) => callback(); + } + + private void UpdatePluginPanelVisibility() + { + if (DataContext is not MainWindowViewModel vm) return; + + var visible = vm.IsPluginPanelVisible; + pluginSplitter.IsVisible = visible; + pluginPanelBorder.IsVisible = visible; + editorPluginGrid.ColumnDefinitions[1].Width = visible ? new GridLength(16) : new GridLength(0); + editorPluginGrid.ColumnDefinitions[2].Width = visible ? new GridLength(350) : new GridLength(0); + } + + private void UpdatePluginPanelContent() + { + if (DataContext is not MainWindowViewModel vm) return; - _xmlWindow.Show(); - _xmlWindow.Activate(); + var host = this.FindControl("pluginPanelHost"); + if (host is not null) + host.Content = vm.PluginPanelControl; + } + + private void ShowPluginManager() + { + if (_pluginService is null || _pluginHost is null) return; + if (DataContext is not MainWindowViewModel vm) return; + + var window = new PluginManagerWindow(); + window.Configure(_pluginService, _pluginHost, vm); + window.ShowDialog(this); } protected override void OnClosed(EventArgs e) { base.OnClosed(e); - - _xmlWindow?.Close(); _scriptController?.Dispose(); - _xmlTextMateInstallation?.Dispose(); _scriptTextMateInstallation?.Dispose(); } } diff --git a/src/SharpFM/PluginManager/PluginManagerViewModel.cs b/src/SharpFM/PluginManager/PluginManagerViewModel.cs new file mode 100644 index 0000000..02723a0 --- /dev/null +++ b/src/SharpFM/PluginManager/PluginManagerViewModel.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using SharpFM.Plugin; + +namespace SharpFM.PluginManager; + +public class PluginEntry : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + private void Notify([CallerMemberName] string name = "") => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + public IPanelPlugin Plugin { get; } + public string Id => Plugin.Id; + public string DisplayName => Plugin.DisplayName; + public string AssemblyName => Plugin.GetType().Assembly.GetName().Name ?? "(unknown)"; + + private bool _isActive; + public bool IsActive + { + get => _isActive; + set { _isActive = value; Notify(); } + } + + public PluginEntry(IPanelPlugin plugin, bool isActive) + { + Plugin = plugin; + _isActive = isActive; + } +} + +public class PluginManagerViewModel : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + private void Notify([CallerMemberName] string name = "") => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + public ObservableCollection Plugins { get; } = []; + + private PluginEntry? _selectedPlugin; + public PluginEntry? SelectedPlugin + { + get => _selectedPlugin; + set { _selectedPlugin = value; Notify(); Notify(nameof(HasSelection)); } + } + + public bool HasSelection => _selectedPlugin is not null; + + public void Refresh(IReadOnlyList loadedPlugins, IPanelPlugin? activePlugin) + { + Plugins.Clear(); + foreach (var plugin in loadedPlugins) + { + Plugins.Add(new PluginEntry(plugin, plugin.Id == activePlugin?.Id)); + } + } +} diff --git a/src/SharpFM/PluginManager/PluginManagerWindow.axaml b/src/SharpFM/PluginManager/PluginManagerWindow.axaml new file mode 100644 index 0000000..152e4db --- /dev/null +++ b/src/SharpFM/PluginManager/PluginManagerWindow.axaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +