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
13 changes: 12 additions & 1 deletion src/SharpFM.Model/ClipData.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace SharpFM.Model;

/// <summary>
Expand All @@ -7,4 +9,13 @@ namespace SharpFM.Model;
/// <param name="Name">Clip display name (filename without extension for file-based storage).</param>
/// <param name="ClipType">Clipboard format identifier (e.g. "Mac-XMSS").</param>
/// <param name="Xml">Raw XML content of the clip.</param>
public record ClipData(string Name, string ClipType, string Xml);
public record ClipData(string Name, string ClipType, string Xml)
{
/// <summary>
/// Hierarchy the clip lives under, as an ordered list of folder segment
/// names. Empty = root. Segments are plain strings (no slashes); each
/// repository implementation decides how to map them to its storage
/// (subdirectories, record columns, URL path, etc.).
/// </summary>
public IReadOnlyList<string> FolderPath { get; init; } = [];
}
46 changes: 46 additions & 0 deletions src/SharpFM/Editors/ClipEditorViewFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Media;
using AvaloniaEdit;
using AvaloniaEdit.Highlighting;
using SharpFM.Schema.Editor;

namespace SharpFM.Editors;

/// <summary>
/// Builds the Avalonia control used to edit a given clip. Kept centralised so
/// the clip view-model can cache a single control instance per clip — tab
/// switches reparent that control rather than reconstructing it, which
/// matters because <see cref="ScriptTextEditor"/> installs TextMate on
/// construction and that is expensive.
/// </summary>
[ExcludeFromCodeCoverage]
public static class ClipEditorViewFactory
{
private static readonly FontFamily MonoFont =
new("Cascadia Code,Consolas,Menlo,Monospace");

public static Control Create(IClipEditor editor) => editor switch
{
ScriptClipEditor s => new ScriptTextEditor
{
FontFamily = MonoFont,
ShowLineNumbers = true,
WordWrap = false,
DataContext = s,
},
TableClipEditor t => new TableEditorControl
{
DataContext = t.ViewModel,
},
FallbackXmlEditor f => new TextEditor
{
FontFamily = MonoFont,
ShowLineNumbers = true,
WordWrap = false,
Document = f.Document,
SyntaxHighlighting = HighlightingManager.Instance.GetDefinition("Xml"),
},
_ => new TextBlock { Text = "No editor available for this clip type." },
};
}
28 changes: 21 additions & 7 deletions src/SharpFM/Editors/ScriptTextEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,28 @@ namespace SharpFM.Editors;
/// DataContext is expected to be a <see cref="ScriptClipEditor"/>.
/// </summary>
[ExcludeFromCodeCoverage]
public class ScriptTextEditor : TextEditor
public class ScriptTextEditor : TextEditor, IDisposable
{
// Avalonia 12 matches styles by exact type. Without this, a subclass of
// TextEditor gets no control template and renders blank — the editor's
// TextArea/TextView visual tree is never built.
protected override Type StyleKeyOverride => typeof(TextEditor);

// Shared across every script editor in the process. Building RegistryOptions
// pulls bundled themes into memory; doing that once (and reusing the result)
// measurably improves the hitch on first tab realisation for new clips.
private static readonly RegistryOptions SharedRegistry =
new((ThemeName)(int)ThemeName.DarkPlus);

private static readonly FmScriptRegistryOptions SharedFmRegistry =
new(SharedRegistry);

private readonly TextMate.Installation _textMate;
private readonly ScriptEditorController _controller;

public ScriptTextEditor()
{
var registry = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus);
var fmScriptRegistry = new FmScriptRegistryOptions(registry);
_textMate = this.InstallTextMate(fmScriptRegistry);
_textMate = this.InstallTextMate(SharedFmRegistry);
_textMate.SetGrammar(FmScriptRegistryOptions.ScopeName);

_controller = new ScriptEditorController(this);
Expand All @@ -50,10 +57,17 @@ protected override void OnDataContextChanged(EventArgs e)
_controller.AttachClipEditor(clipEditor);
}

protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
// Intentionally no dispose-on-detach. Tab switches reparent this control
// many times; tearing down TextMate on each detach would force a full
// grammar/theme reinstall on every reattach. Lifetime is owned by the
// ClipViewModel, which calls <see cref="Dispose"/> when the clip is
// removed or its editor is replaced.

private bool _disposed;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_controller.StatusMessageRaised -= OnStatusMessageRaised;
_controller.Dispose();
_textMate.Dispose();
Expand Down
180 changes: 124 additions & 56 deletions src/SharpFM/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,20 @@
Text="{Binding SearchText}" />
</StackPanel>

<!-- Clips list section -->
<!-- Clips tree section. Mirrors the repository's folder
hierarchy (ClipViewModel.FolderPath). Single-tap a
clip for a preview tab, double-tap for a permanent
tab. -->
<StackPanel Spacing="8">
<TextBlock
<TextBlock
Classes="Fluent2Subtitle"
Text="Available Clips" />
<ListBox
Classes="Fluent2"
ItemsSource="{Binding FilteredClips}"
SelectedItem="{Binding SelectedClip}">
<ListBox.ContextMenu>
<TreeView
x:Name="clipsTreeView"
ItemsSource="{Binding RootNodes}"
Tapped="ClipsTree_Tapped"
DoubleTapped="ClipsTree_DoubleTapped">
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem
Command="{Binding CopySelectedToClip}"
Expand All @@ -166,27 +170,28 @@
Command="{Binding DeleteSelectedClip}"
Header="Delete Clip" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate>
<Border
CornerRadius="4"
Padding="12">
<StackPanel Spacing="4">
<TextBox
Classes="Fluent2"
Text="{Binding Clip.Name, Mode=TwoWay}"
Watermark="Clip name" />
</TreeView.ContextMenu>
<TreeView.DataTemplates>
<TreeDataTemplate DataType="vm:ClipTreeNodeViewModel" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock
FontWeight="SemiBold"
IsVisible="{Binding IsFolder}"
Text="{Binding Name}" />
<StackPanel
Orientation="Horizontal"
Spacing="6"
IsVisible="{Binding IsClip}">
<TextBlock Text="{Binding Clip.Clip.Name}" />
<TextBlock
Classes="Fluent2Caption"
MaxLines="1"
Opacity="0.7"
Text="{Binding ClipTypeDisplay}" />
Opacity="0.6"
Text="{Binding Clip.ClipTypeDisplay}" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</TreeDataTemplate>
</TreeView.DataTemplates>
</TreeView>
</StackPanel>
</StackPanel>
</ScrollViewer>
Expand All @@ -207,40 +212,103 @@
<ColumnDefinition Width="0" />
</Grid.ColumnDefinitions>

<!-- Code editor with Fluent 2 surface treatment. The editor is
selected by the runtime type of ClipViewModel.Editor: no
binding resolves through a null SelectedClip, and no editor
instance exists when no clip is selected. -->
<!-- Open clips area: VS Code-style tab strip. Each tab wraps
a ClipViewModel; the tab header italicizes for preview
tabs and shows a dirty dot when the clip has unsaved
edits. The tab body reuses the existing per-editor
DataTemplates. -->
<Border
Grid.Column="0"
Classes="Fluent2SurfaceElevated">
<ContentControl Content="{Binding SelectedClip}">
<ContentControl.DataTemplates>
<DataTemplate DataType="vm:ClipViewModel">
<ContentControl Content="{Binding Editor}">
<ContentControl.DataTemplates>
<DataTemplate DataType="editors:ScriptClipEditor">
<editors:ScriptTextEditor
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="False" />
</DataTemplate>
<DataTemplate DataType="editors:TableClipEditor">
<schema:TableEditorControl DataContext="{Binding ViewModel}" />
</DataTemplate>
<DataTemplate DataType="editors:FallbackXmlEditor">
<AvaloniaEdit:TextEditor
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
SyntaxHighlighting="Xml"
WordWrap="False"
Document="{Binding Document}" />
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
<Grid>
<TextBlock
Classes="Fluent2Caption"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Opacity="0.6"
IsVisible="{Binding !OpenTabs.Tabs.Count}"
Text="Single-tap a clip to preview, double-tap to open." />

<!-- TabStrip for headers; a separate ItemsControl
realises every open editor into the visual tree
once and toggles IsVisible per tab. This avoids
reparenting AvaloniaEdit on each switch, which
triggers costly layout/tokenise passes. -->
<DockPanel IsVisible="{Binding OpenTabs.Tabs.Count}">
<TabStrip
x:Name="openTabStrip"
DockPanel.Dock="Top"
ItemsSource="{Binding OpenTabs.Tabs}"
SelectedItem="{Binding OpenTabs.ActiveTab, Mode=TwoWay}">
<TabStrip.Styles>
<Style Selector="TabStripItem">
<Setter Property="FontSize" Value="14" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="12,6" />
<Setter Property="Margin" Value="0,0,2,0" />
<Setter Property="CornerRadius" Value="4,4,0,0" />
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}" />
<Setter Property="BorderThickness" Value="1,1,1,0" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlBackgroundChromeMediumBrush}" />
<Setter Property="Opacity" Value="0.75" />
</Style>
<Style Selector="TabStripItem:pointerover">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundChromeMediumBrush}" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="TabStripItem:selected">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAltHighBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorBrush}" />
<Setter Property="BorderThickness" Value="1,2,1,0" />
<Setter Property="Opacity" Value="1" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
</TabStrip.Styles>
<TabStrip.ItemTemplate>
<DataTemplate DataType="vm:OpenTabViewModel">
<StackPanel Orientation="Horizontal" Spacing="8" DoubleTapped="TabHeader_DoubleTapped">
<TextBlock
VerticalAlignment="Center"
FontSize="14"
MaxWidth="200"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}"
FontStyle="{Binding TitleFontStyle}"
Text="{Binding Title}" />
<Ellipse
Width="6" Height="6"
VerticalAlignment="Center"
Fill="{DynamicResource SystemAccentColorBrush}"
IsVisible="{Binding IsDirty}" />
<Button
Padding="4,0"
FontSize="12"
Background="Transparent"
BorderThickness="0"
Content="✕"
Click="CloseTab_Click"
Tag="{Binding}" />
</StackPanel>
</DataTemplate>
</TabStrip.ItemTemplate>
</TabStrip>
<ItemsControl
ItemsSource="{Binding OpenTabs.Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Grid />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:OpenTabViewModel">
<ContentControl
Content="{Binding Clip.EditorView}"
IsVisible="{Binding IsActive}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Grid>
</Border>

<!-- Plugin panel splitter -->
Expand Down
Loading
Loading