diff --git a/README.md b/README.md index 229160a..2fe7d70 100644 --- a/README.md +++ b/README.md @@ -8,34 +8,48 @@ Note: SharpFM and FileMaker must be running on the same computer. In order to sh - Head over to [Releases](https://github.com/fuzzzerd/SharpFM/releases), grab the latest version (binaries for Windows, Mac, Linux are all available there). -### Clipping from FileMaker +### Importing Clips from FileMaker - Open SharpFM. - Switch over to FileMaker. -- Copy something to the clipboard. +- Copy something to the clipboard (scripts, tables, layouts, etc). - Switch back to SharpFM. -- Use the Edit menu to "Paste from FileMaker Blob". -- See your object(s) in the clips list with the Xml editor on the side. +- Use **File > New > From Clipboard** (`Ctrl+V`) to import the clip. +- The clip appears in the left panel with the appropriate editor on the right. -### Clipping from SharpFM to FileMaker +### Exporting Clips to FileMaker -- Ensure you have a clip in SharpFM -- Select the clip in the list -- Use the Edit menu to "Copy As FileMaker Blob" -- Switch to FileMaker: based on the clip type, open Database manger, Script manager, layout mode, etc. -- Paste into FileMaker as you normally would. +- Select a clip in the left panel. +- Use **File > Save > Selected clip to Clipboard** (`Ctrl+Shift+C`). +- Switch to FileMaker and open the appropriate destination (Database Manager, Script Workspace, Layout mode, etc). +- Paste as you normally would. -### Saving / Sharing XML Clips +### Editing Scripts -This is an area we can improve, with interoperability with some other similar tools. More to come? Contributions welcome. +- Select a script clip or create one with **File > New > Script** (`Ctrl+N`). +- The script editor shows a plain-text representation of the script steps with FmScript syntax highlighting. +- Edit the script text directly; changes are synced back to the underlying XML. -SharpFM has the option to persist clips between sessions by using the File menu to "Save to Db". +### Editing Tables -- Save the XML for a given clip as a separate file (copy/paste to Notepad, Nano, email body, etc) -- Share the resulting XML file. -- Use the File menu to create a New clip. -- Select the appropriate clip type (Table, Script, Layout, etc) -- Paste the raw XML into the code editor. +- Select a table clip or create one with **File > New > Table** (`Ctrl+Shift+N`). +- The table editor shows a DataGrid with columns for Field Name, Type, Kind, Required, Unique, and Comment. +- Click **+ Add Field** to add a new field, then edit its properties inline. +- Select a field and click **Remove** or press `Delete` to remove it. +- Change a field's Kind to Calculated or Summary, then click **Edit Calculation...** to open the calculation editor. + +### Viewing Raw XML + +- Select any clip and use **View > Show XML** (`Ctrl+Shift+X`) to open the raw XML in a separate window. +- Edits made in the XML window are synced back to the clip when the window is closed. + +### Saving and Sharing Clips + +SharpFM persists clips between sessions as XML files in a local folder. + +- Use **File > Save > Save All To Folder** (`Ctrl+S`) to save all clips. +- Use **File > Open Folder** to load clips from a different folder. +- The clip files are plain XML and can be shared via git, email, or any text-based tool. ## Features @@ -43,7 +57,28 @@ SharpFM has the option to persist clips between sessions by using the File menu - [x] Store FileMaker Scripts, Tables, and Layouts to xml files that can be shared via git, email or other text based tools. - [x] Edit raw FileMaker XML code (scripts, layouts, tables) with ability to paste changes back into FileMaker. - [x] Use AvaloniaEdit for XML editing with XML syntax highlighting. -- [ ] Better UI tools to mutate the Raw XML. +- [x] Plain-text script editor with FmScript syntax highlighting. +- [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 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..82b96c3 --- /dev/null +++ b/src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs @@ -0,0 +1,48 @@ +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 => []; + public IReadOnlyList MenuActions => []; + + 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..f2d7012 --- /dev/null +++ b/src/SharpFM.Plugin.XmlViewer/XmlViewerPlugin.cs @@ -0,0 +1,52 @@ +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 IReadOnlyList MenuActions => []; + + 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..a623c04 --- /dev/null +++ b/src/SharpFM.Plugin/IPanelPlugin.cs @@ -0,0 +1,51 @@ +// 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; } + + /// + /// Custom menu actions shown under this plugin's entry in the Plugins menu. + /// If empty, the plugin shows as a simple toggle item. If non-empty, it shows + /// as a submenu with "Toggle Panel" plus these custom actions. + /// + IReadOnlyList MenuActions { 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/PluginMenuAction.cs b/src/SharpFM.Plugin/PluginMenuAction.cs new file mode 100644 index 0000000..6b910aa --- /dev/null +++ b/src/SharpFM.Plugin/PluginMenuAction.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 custom menu action that a plugin registers in the host's Plugins menu. +/// +/// Display text for the menu item. +/// Action invoked when the menu item is clicked. +/// Optional keyboard gesture string (e.g. "Ctrl+Shift+X"). +public record PluginMenuAction(string Label, Action Callback, string? Gesture = null); 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 b/src/SharpFM/App.axaml index b1025c1..34c3564 100644 --- a/src/SharpFM/App.axaml +++ b/src/SharpFM/App.axaml @@ -6,6 +6,7 @@ + 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 2d8045b..d49db26 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -6,6 +6,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SharpFM.ViewModels" + xmlns:schema="using:SharpFM.Schema.Editor" Icon="/Assets/noun-sharp-teeth-monster-4226695.small.png" Title="SharpFM" Width="700" @@ -17,45 +18,51 @@ - + + - - - - + + - - - - - - + + - + + + + - - + + + - - + - + Padding="16,6"> + + + + @@ -136,21 +143,16 @@ - - + - - + MaxLines="1" + Opacity="0.7" + Text="{Binding ClipTypeDisplay}" /> @@ -168,31 +170,61 @@ ResizeDirection="Columns" Width="16" /> - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs index e8d2b29..c54c05e 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() { @@ -21,7 +28,7 @@ public MainWindow() _registryOptions = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); - // Script editor: setup on first load (deferred until control is available) + // Script editor var scriptEditor = this.FindControl("scriptEditor"); if (scriptEditor != null) { @@ -31,87 +38,166 @@ public MainWindow() _scriptController = new ScriptEditorController(scriptEditor); } - // Fallback XML editor for non-script clips (lightweight — no TextMate needed, - // built-in SyntaxHighlighting="Xml" in the XAML handles it) + // "Manage Plugins..." menu item + var managePlugins = this.FindControl("managePluginsMenuItem"); + if (managePlugins != null) + managePlugins.Click += (_, _) => ShowPluginManager(); - // "View XML" menu item — opens XML in a separate window on demand - var viewXmlItem = this.FindControl("viewXmlMenuItem"); - if (viewXmlItem != null) - { - viewXmlItem.Click += (_, _) => ShowXmlWindow(); - } + // Wire up plugin UI when DataContext is set + DataContextChanged += OnDataContextChanged; + } + + public void SetPluginServices(PluginService pluginService, IPluginHost pluginHost) + { + _pluginService = pluginService; + _pluginHost = pluginHost; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (DataContext is not MainWindowViewModel vm) return; + + 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 ShowXmlWindow() + private void BuildPluginMenuItems(MainWindowViewModel vm) { - var vm = (DataContext as SharpFM.ViewModels.MainWindowViewModel)?.SelectedClip; - if (vm == null) + var pluginsMenu = this.FindControl("pluginsMenu"); + var manageItem = this.FindControl("managePluginsMenuItem"); + if (pluginsMenu is null || manageItem is null || vm.PanelPlugins.Count == 0) + { + RegisterPluginKeyBindings(vm); return; + } - // Sync model to XML before showing - vm.SyncModelFromEditor(); + // Insert plugin items before the Manage Plugins item + var insertIndex = pluginsMenu.Items.IndexOf(manageItem); - // Reuse or create the XML window - if (_xmlWindow == null || !_xmlWindow.IsVisible) + foreach (var plugin in vm.PanelPlugins) { - var xmlEditor = new TextEditor + MenuItem pluginItem; + + if (plugin.MenuActions.Count > 0) { - FontFamily = new Avalonia.Media.FontFamily("Cascadia Code,Consolas,Menlo,Monospace"), - ShowLineNumbers = true, - WordWrap = false, - }; + // Plugin has custom actions — create a submenu + pluginItem = new MenuItem { Header = plugin.DisplayName }; - // Lazy-load XML TextMate only when first needed - if (_xmlTextMateInstallation == null) + var toggleItem = new MenuItem { Header = "Toggle Panel", Tag = plugin }; + if (plugin.KeyBindings.Count > 0) + toggleItem.InputGesture = KeyGesture.Parse(plugin.KeyBindings[0].Gesture); + toggleItem.Click += (_, _) => + { + if (toggleItem.Tag is IPanelPlugin p) vm.TogglePluginPanel(p); + }; + pluginItem.Items.Add(toggleItem); + + foreach (var action in plugin.MenuActions) + { + var actionItem = new MenuItem { Header = action.Label }; + if (action.Gesture is not null) + actionItem.InputGesture = KeyGesture.Parse(action.Gesture); + var cb = action.Callback; + actionItem.Click += (_, _) => cb(); + pluginItem.Items.Add(actionItem); + } + } + else { - _xmlTextMateInstallation = xmlEditor.InstallTextMate(_registryOptions); - var xmlLang = _registryOptions.GetLanguageByExtension(".xml"); - _xmlTextMateInstallation.SetGrammar(_registryOptions.GetScopeByLanguageId(xmlLang.Id)); + // No custom actions — flat item that toggles the panel + pluginItem = new MenuItem { Header = plugin.DisplayName, Tag = plugin }; + if (plugin.KeyBindings.Count > 0) + pluginItem.InputGesture = KeyGesture.Parse(plugin.KeyBindings[0].Gesture); + pluginItem.Click += (_, _) => + { + if (pluginItem.Tag is IPanelPlugin p) vm.TogglePluginPanel(p); + }; } - xmlEditor.Document = new AvaloniaEdit.Document.TextDocument(vm.ClipXml ?? ""); + pluginsMenu.Items.Insert(insertIndex++, pluginItem); + } - _xmlWindow = new Window - { - Title = $"XML — {vm.Name}", - Width = 600, - Height = 500, - Content = xmlEditor, - }; - - // Sync XML edits back to the model when the window closes - _xmlWindow.Closing += (_, _) => + // Add separator before Manage Plugins + pluginsMenu.Items.Insert(insertIndex, new Separator()); + + RegisterPluginKeyBindings(vm); + } + + 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(); + }) + }); + } } + } - _xmlWindow.Show(); - _xmlWindow.Activate(); + /// + /// 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; + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +