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
55 changes: 52 additions & 3 deletions src/SharpFM/Editors/ClipEditorViewFactory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Media;
using AvaloniaEdit;
Expand All @@ -17,15 +19,62 @@ namespace SharpFM.Editors;
[ExcludeFromCodeCoverage]
public static class ClipEditorViewFactory
{
private static readonly FontFamily MonoFont =
new("Cascadia Code,Consolas,Menlo,Monospace");
// Resolve a real installed face name at startup. Avalonia's font
// cache (SystemFontCollection._glyphTypefaceCache) is keyed by the
// family-name string the caller asks for, but populated under the
// platform's RESOLVED face name — so requesting an alias like
// "Monospace" or "Cascadia Code" on a system that doesn't carry
// that face misses the cache forever. The trace showed every one
// of 1283 typeface lookups falling through to the slow path. By
// detecting an actually-installed face name once at startup and
// using it as the editor font, every subsequent typeface request
// hits the cache after the first.
private static readonly FontFamily MonoFont = ResolveMonospaceFont();

private static FontFamily ResolveMonospaceFont()
{
// Per-platform preference order. First entries are the "good"
// monospace fonts we'd pick if available; tail entries are
// safer last-resorts known to ship with the OS.
string[] preferred =
OperatingSystem.IsWindows() ? new[] { "Cascadia Code", "Cascadia Mono", "Consolas", "Lucida Console", "Courier New" } :
OperatingSystem.IsMacOS() ? new[] { "Menlo", "Monaco", "Courier New" } :
new[] { "JetBrains Mono", "DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "Ubuntu Mono" };

try
{
var installed = FontManager.Current.SystemFonts
.Select(f => f.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

foreach (var name in preferred)
{
if (installed.Contains(name))
return new FontFamily(name);
}
}
catch
{
// FontManager not yet available during certain init orderings.
// Fall through to the default below.
}

// No known monospace face installed — accept the alias path. We
// pay the cache-miss cost but the editor still works.
return new FontFamily("Monospace");
}

public static Control Create(IClipEditor editor) => editor switch
{
ScriptClipEditor s => new ScriptTextEditor
{
FontFamily = MonoFont,
ShowLineNumbers = true,
// Step-index margin is installed by ScriptEditorController;
// skipping the built-in line-number margin avoids adding a
// margin we'll just remove (and that AvaloniaEdit can re-add
// if the editor's template is re-applied later — in the
// post-pipeline trace it was still rendering at 5.8s/30s).
ShowLineNumbers = false,
WordWrap = false,
DataContext = s,
},
Expand Down
97 changes: 92 additions & 5 deletions src/SharpFM/Editors/ScriptClipEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ public class ScriptClipEditor : IClipEditor
// the anchor is considered stale and ignored.
private readonly Dictionary<TextAnchor, (XElement Xml, string Signature)> _sealedAnchors = new();

// Hot-path cache: sealed line numbers + end-offsets, recomputed
// lazily on next read after Document.TextChanged. Visual components
// (italic colorizer, cog generator, squiggle renderer) fire per
// visible line per layout — without this, each fired SealedAnchors
// enumerator walks every anchor and allocates a fresh string per
// anchor through SignatureMatches/GetText. With N visible lines and
// M anchors that's N*M strings per paint. The cache collapses this
// to a single pass per text change.
private HashSet<int>? _sealedLineNumbersCache;
private Dictionary<int, int>? _sealedEndOffsetsCache; // line# -> end offset
private bool _sealedCacheDirty = true;

public event EventHandler? ContentChanged;

/// <summary>The TextDocument bound to the AvaloniaEdit script editor.</summary>
Expand All @@ -55,9 +67,72 @@ public ScriptClipEditor(string? xml)
BuildSealedAnchors();

_debouncer = new DebouncedEventRaiser(500, () => ContentChanged?.Invoke(this, EventArgs.Empty));
Document.TextChanged += (_, _) => _debouncer.Trigger();
Document.TextChanged += (_, _) =>
{
_sealedCacheDirty = true;
_debouncer.Trigger();
};
}

/// <summary>
/// Document line numbers (1-based) currently occupied by a sealed step.
/// Built lazily on first read after a text change; the underlying set
/// is reused across reads so render-pipeline consumers (colorizer,
/// cog generator, sealed-step layer) get O(1) lookup. Skips the
/// SignatureMatches string allocations that the live SealedAnchors
/// enumerator does — for visual rendering, anchor liveness + bounds
/// is sufficient; signature checks belong on the model-rebuild path.
/// </summary>
internal IReadOnlySet<int> SealedLineNumbers
{
get
{
EnsureSealedCache();
return _sealedLineNumbersCache!;
}
}

/// <summary>
/// Map from sealed-line number (1-based) to the document offset of
/// that line's end. Used by the cog generator to decide which visual
/// line ends carry a cog button.
/// </summary>
internal IReadOnlyDictionary<int, int> SealedLineEndOffsets
{
get
{
EnsureSealedCache();
return _sealedEndOffsetsCache!;
}
}

private void EnsureSealedCache()
{
if (!_sealedCacheDirty && _sealedLineNumbersCache != null) return;

var lineNumbers = new HashSet<int>();
var endOffsets = new Dictionary<int, int>();
foreach (var kv in _sealedAnchors)
{
var anchor = kv.Key;
if (anchor.IsDeleted) continue;
if (anchor.Offset < 0 || anchor.Offset > Document.TextLength) continue;
var line = Document.GetLineByOffset(anchor.Offset);
lineNumbers.Add(line.LineNumber);
endOffsets[line.LineNumber] = line.EndOffset;
}
_sealedLineNumbersCache = lineNumbers;
_sealedEndOffsetsCache = endOffsets;
_sealedCacheDirty = false;
}

/// <summary>
/// Force-invalidate the sealed-line cache. Called from paths that
/// mutate <see cref="_sealedAnchors"/> outside a TextChanged event
/// (BuildSealedAnchors, UpdateSealedXml, RebuildFromDocument).
/// </summary>
private void InvalidateSealedCache() => _sealedCacheDirty = true;

/// <summary>
/// Live sealed anchors (entries whose line text still matches the
/// signature captured at creation). Exposed for the editor's
Expand All @@ -69,6 +144,17 @@ public ScriptClipEditor(string? xml)
.Where(kv => !kv.Key.IsDeleted && SignatureMatches(kv.Key, kv.Value.Signature))
.Select(kv => kv.Key);

/// <summary>
/// Cheap predicate for sealed-step renderers / colorizers to fast-exit
/// when no sealed anchors exist. The full <see cref="SealedAnchors"/>
/// enumerator is a Where+Select chain that calls <c>Document.GetText</c>
/// per anchor — fine when the loop body runs, expensive when it never
/// does because the script has zero sealed steps. Sealed-step
/// components fire per visible line per layout, so even an empty
/// iteration adds up.
/// </summary>
internal bool HasSealedAnchors => _sealedAnchors.Count > 0;

/// <summary>
/// Retrieve the cached XML for a sealed anchor. Returns false if the
/// anchor is dead, evicted, or whose underlying line no longer matches
Expand Down Expand Up @@ -111,6 +197,7 @@ internal bool UpdateSealedXml(TextAnchor anchor, XElement newXml)

// Update the cache entry with the fresh XML + new signature.
_sealedAnchors[anchor] = (new XElement(newXml), newDisplay);
InvalidateSealedCache();
return true;
}

Expand Down Expand Up @@ -154,8 +241,7 @@ public void FromXml(string xml)
/// </summary>
private void RebuildFromDocument()
{
var text = Document.Text;
var ranges = MultiLineStatementRanges.Compute(text);
var ranges = SharpFM.Scripting.Editor.CachedMultiLineRanges.Compute(Document);

var newSteps = new List<ScriptStep>();
var consumedAnchors = new HashSet<TextAnchor>();
Expand Down Expand Up @@ -210,6 +296,7 @@ private void RebuildFromDocument()
.Select(kv => kv.Key)
.ToList();
foreach (var d in dead) _sealedAnchors.Remove(d);
if (dead.Count > 0) InvalidateSealedCache();

_script = new FmScript(newSteps);
}
Expand All @@ -223,8 +310,7 @@ private void BuildSealedAnchors()
{
_sealedAnchors.Clear();

var text = Document.Text;
var ranges = MultiLineStatementRanges.Compute(text);
var ranges = SharpFM.Scripting.Editor.CachedMultiLineRanges.Compute(Document);
int stepIdx = 0;

foreach (var (startLine, endLine) in ranges)
Expand All @@ -248,5 +334,6 @@ private void BuildSealedAnchors()
anchor.SurviveDeletion = false;
_sealedAnchors[anchor] = (step.ToXml(), lineText);
}
InvalidateSealedCache();
}
}
10 changes: 10 additions & 0 deletions src/SharpFM/Editors/ScriptTextEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class ScriptTextEditor : TextEditor, IDisposable

private readonly TextMate.Installation _textMate;
private readonly ScriptEditorController _controller;
private ScriptClipEditor? _attachedEditor;

public ScriptTextEditor()
{
Expand All @@ -53,6 +54,15 @@ protected override void OnDataContextChanged(EventArgs e)

if (DataContext is not ScriptClipEditor clipEditor) return;

// Avalonia fires DataContextChanged multiple times during template
// instantiation and visual-tree reparenting even when the resolved
// value is the same instance. Skip the attach work when nothing
// actually changed — the trace showed this firing 9× for a single
// tab open, each time tearing down + reinstalling sealed-step
// renderers.
if (ReferenceEquals(_attachedEditor, clipEditor)) return;
_attachedEditor = clipEditor;

Document = clipEditor.Document;
_controller.AttachClipEditor(clipEditor);
}
Expand Down
82 changes: 47 additions & 35 deletions src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
Expand All @@ -10,10 +11,10 @@ namespace SharpFM.Editors.SealedSteps;

/// <summary>
/// Inline element generator that places a small clickable cog button
/// at the end of every sealed-step line. Clicking the cog raises the
/// <see cref="CogClicked"/> event with the sealed step's line anchor;
/// the subscriber (ScriptEditorController) opens the raw-XML editor
/// dialog and writes the result back to the sealed-step cache.
/// at the end of every sealed-step line. Reads the cached
/// <see cref="ScriptClipEditor.SealedLineEndOffsets"/> map for O(1)
/// lookup — no per-call iteration over the anchor dictionary, no
/// per-anchor <c>Document.GetText</c> string allocations.
/// </summary>
[ExcludeFromCodeCoverage]
public class SealedStepCogGenerator : VisualLineElementGenerator
Expand All @@ -29,53 +30,64 @@ public SealedStepCogGenerator(ScriptClipEditor editor)

public override int GetFirstInterestedOffset(int startOffset)
{
// The cog sits at the end of each sealed line. Return the
// earliest end-of-line offset that belongs to a sealed anchor
// and is >= startOffset; -1 when no more.
if (!_editor.HasSealedAnchors) return -1;

var endOffsets = _editor.SealedLineEndOffsets;
if (endOffsets.Count == 0) return -1;

int best = int.MaxValue;
var doc = CurrentContext.Document;
foreach (var anchor in _editor.SealedAnchors)
foreach (var end in endOffsets.Values)
{
if (anchor.IsDeleted) continue;
// Defensive bounds check: a stale anchor from a previously
// attached clip could point past the end of the current doc.
if (anchor.Offset < 0 || anchor.Offset > doc.TextLength) continue;
var line = doc.GetLineByOffset(anchor.Offset);
if (line.EndOffset >= startOffset && line.EndOffset < best)
best = line.EndOffset;
if (end >= startOffset && end < best)
best = end;
}
return best == int.MaxValue ? -1 : best;
}

public override VisualLineElement? ConstructElement(int offset)
{
if (!_editor.HasSealedAnchors) return null;
var endOffsets = _editor.SealedLineEndOffsets;

// Verify offset matches a sealed line's end. Don't allocate a
// button for non-sealed line ends.
var matched = false;
foreach (var end in endOffsets.Values)
{
if (end == offset) { matched = true; break; }
}
if (!matched) return null;

// Resolve which anchor lives on this line so the click handler
// can map back to the sealed step's XML. Iterating SealedAnchors
// is acceptable here (called once per sealed line construction,
// not per visible line per layout). Use a stable strategy: pick
// the anchor whose line contains the offset.
var doc = CurrentContext.Document;
TextAnchor? hit = null;
foreach (var anchor in _editor.SealedAnchors)
{
if (anchor.IsDeleted) continue;
if (anchor.Offset < 0 || anchor.Offset > doc.TextLength) continue;
var line = doc.GetLineByOffset(anchor.Offset);
if (line.EndOffset != offset) continue;

var button = new Button
{
Content = "⚙",
Padding = new Thickness(4, 0),
Margin = new Thickness(6, 0, 0, 0),
FontSize = 12,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
};
if (line.EndOffset == offset) { hit = anchor; break; }
}
if (hit == null) return null;

// Capture the anchor in the click handler so the subscriber
// can map back to the sealed step's XML.
var capturedAnchor = anchor;
button.Click += (_, _) => CogClicked?.Invoke(this, capturedAnchor);
var button = new Button
{
Content = "⚙",
Padding = new Thickness(4, 0),
Margin = new Thickness(6, 0, 0, 0),
FontSize = 12,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
};

return new InlineObjectElement(0, button);
}
var capturedAnchor = hit;
button.Click += (_, _) => CogClicked?.Invoke(this, capturedAnchor);

return null;
return new InlineObjectElement(0, button);
}
}
Loading
Loading