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