Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions SharpFM.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
40 changes: 40 additions & 0 deletions src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:SharpFM.Plugin.Sample"
x:Class="SharpFM.Plugin.Sample.ClipInspectorPanel"
x:DataType="local:ClipInspectorViewModel">

<StackPanel Margin="16" Spacing="12">
<TextBlock Classes="Fluent2Subtitle" Text="Clip Inspector" />

<StackPanel Spacing="8" IsVisible="{Binding HasClip}">
<StackPanel Spacing="2">
<TextBlock Classes="Fluent2Caption" Opacity="0.7" Text="Name" />
<TextBlock Classes="Fluent2Body" Text="{Binding ClipName}" />
</StackPanel>

<StackPanel Spacing="2">
<TextBlock Classes="Fluent2Caption" Opacity="0.7" Text="Type" />
<TextBlock Classes="Fluent2Body" Text="{Binding ClipType}" />
</StackPanel>

<StackPanel Spacing="2">
<TextBlock Classes="Fluent2Caption" Opacity="0.7" Text="XML Elements" />
<TextBlock Classes="Fluent2Body" Text="{Binding ElementCount}" />
</StackPanel>

<StackPanel Spacing="2">
<TextBlock Classes="Fluent2Caption" Opacity="0.7" Text="Approx. Size" />
<TextBlock Classes="Fluent2Body" Text="{Binding XmlSize}" />
</StackPanel>
</StackPanel>

<TextBlock
Classes="Fluent2Body"
Opacity="0.5"
IsVisible="{Binding !HasClip}"
Text="Select a clip to inspect its metadata." />
</StackPanel>

</UserControl>
12 changes: 12 additions & 0 deletions src/SharpFM.Plugin.Sample/ClipInspectorPanel.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace SharpFM.Plugin.Sample;

public partial class ClipInspectorPanel : UserControl
{
public ClipInspectorPanel()
{
InitializeComponent();
}
}
48 changes: 48 additions & 0 deletions src/SharpFM.Plugin.Sample/ClipInspectorPlugin.cs
Original file line number Diff line number Diff line change
@@ -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<PluginKeyBinding> KeyBindings => [];
public IReadOnlyList<PluginMenuAction> 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;
}
}
67 changes: 67 additions & 0 deletions src/SharpFM.Plugin.Sample/ClipInspectorViewModel.cs
Original file line number Diff line number Diff line change
@@ -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"
};
}
17 changes: 17 additions & 0 deletions src/SharpFM.Plugin.Sample/SharpFM.Plugin.Sample.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\SharpFM.Plugin\SharpFM.Plugin.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.4" />
<PackageReference Include="FluentAvaloniaUI" Version="2.2.0" />
</ItemGroup>
</Project>
20 changes: 20 additions & 0 deletions src/SharpFM.Plugin.XmlViewer/SharpFM.Plugin.XmlViewer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\SharpFM.Plugin\SharpFM.Plugin.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.4" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.1.0" />
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.66" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.1.0" />
<PackageReference Include="FluentAvaloniaUI" Version="2.2.0" />
</ItemGroup>
</Project>
36 changes: 36 additions & 0 deletions src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:local="using:SharpFM.Plugin.XmlViewer"
x:Class="SharpFM.Plugin.XmlViewer.XmlViewerPanel"
x:DataType="local:XmlViewerViewModel">

<Grid RowDefinitions="Auto,*">
<!-- Header -->
<Border Grid.Row="0" Padding="12,8" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
<TextBlock Classes="Fluent2Caption" Text="{Binding ClipLabel}" Opacity="0.8" />
</Border>

<!-- XML Editor -->
<AvaloniaEdit:TextEditor
x:Name="xmlEditor"
Grid.Row="1"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="False"
IsVisible="{Binding HasClip}"
Document="{Binding Document}" />

<!-- Empty state -->
<TextBlock
Grid.Row="1"
Classes="Fluent2Body"
Opacity="0.5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsVisible="{Binding !HasClip}"
Text="Select a clip to view its XML." />
</Grid>

</UserControl>
30 changes: 30 additions & 0 deletions src/SharpFM.Plugin.XmlViewer/XmlViewerPanel.axaml.cs
Original file line number Diff line number Diff line change
@@ -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<TextEditor>("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();
}
}
Loading