From 6643b90a8e89331497a3c0d610095116ad59946b Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 14:36:47 -0500 Subject: [PATCH 01/13] perf: cache MultiLineStatementRanges by document version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MultiLineStatementRanges.Compute is pure on document text, but every renderer + caret handler called it independently — at least four full-document scans per edit cycle (StatementHighlightRenderer.Draw, ContinuationLineRenderer.Draw, StatementHighlightRenderer.UpdateHighlight on every caret move, StepIndexMargin via BuildStepIndex). CachedMultiLineRanges wraps the compute with a per-document version cache backed by ConditionalWeakTable. First caller in an edit cycle runs the compute; subsequent callers reuse the result. Step-index lookup is cached jointly so StepIndexMargin no longer rewalks the ranges either. Plus a caret-line skip in StatementHighlightRenderer.UpdateHighlight: PositionChanged fires on every keystroke and arrow key, but the highlighted *step* only changes when the caret moves to a different physical line. Skipping in-line caret movement eliminates the most common path that previously triggered Compute. Combined effect on the trace's hot frames: 4× redundant Compute calls collapse to 1, and the per-keystroke caret handler no longer hits Compute at all. Renderers that cannot use the cached doc-based overload (ScriptValidator on a background snapshot string, ContinuationIndentStrategy on a single Enter event) keep using the existing string overload — neither is hot. --- src/SharpFM/Editors/ScriptClipEditor.cs | 6 +- .../Scripting/Editor/CachedMultiLineRanges.cs | 89 +++++++++++++++++++ .../Editor/ContinuationLineRenderer.cs | 2 +- .../Editor/StatementHighlightRenderer.cs | 19 ++-- .../Scripting/Editor/StepIndexMargin.cs | 11 +-- 5 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs index ee8e8aa..92f7690 100644 --- a/src/SharpFM/Editors/ScriptClipEditor.cs +++ b/src/SharpFM/Editors/ScriptClipEditor.cs @@ -154,8 +154,7 @@ public void FromXml(string xml) /// private void RebuildFromDocument() { - var text = Document.Text; - var ranges = MultiLineStatementRanges.Compute(text); + var ranges = SharpFM.Scripting.Editor.CachedMultiLineRanges.Compute(Document); var newSteps = new List(); var consumedAnchors = new HashSet(); @@ -223,8 +222,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) diff --git a/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs b/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs new file mode 100644 index 0000000..8631726 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using AvaloniaEdit.Document; +using SharpFM.Model.Scripting; + +namespace SharpFM.Scripting.Editor; + +/// +/// Per-document cache for +/// results. The compute is pure on document text — every renderer + +/// caret handler calling it independently used to do the same full-doc +/// scan multiple times per edit. With this wrapper the work runs once +/// per document version and all callers reuse the result. +/// +/// Cache lifetime is tied to the via +/// , so closing a +/// document drops its cache entry automatically. +/// +[ExcludeFromCodeCoverage] +public static class CachedMultiLineRanges +{ + private sealed class CacheEntry + { + public ITextSourceVersion? Version; + public List<(int StartLine, int EndLine)>? Ranges; + public IReadOnlyDictionary? StepIndex; + } + + private static readonly ConditionalWeakTable Cache = new(); + + /// + /// Statement ranges for the document. Cached by version — repeated + /// calls within the same edit cycle return the previously computed + /// list with no recomputation. + /// + public static List<(int StartLine, int EndLine)> Compute(TextDocument document) + { + var entry = Cache.GetValue(document, static _ => new CacheEntry()); + var version = document.Version; + + if (entry.Ranges != null && SameVersion(entry.Version, version)) + return entry.Ranges; + + var ranges = MultiLineStatementRanges.Compute(document.Text); + entry.Version = version; + entry.Ranges = ranges; + // Step-index recomputes lazily on first call after a version change. + entry.StepIndex = null; + return ranges; + } + + /// + /// Step-index lookup (first-line → 1-based step number) for the + /// document. Cached jointly with the statement ranges. + /// + public static IReadOnlyDictionary GetStepIndex(TextDocument document) + { + var entry = Cache.GetValue(document, static _ => new CacheEntry()); + var version = document.Version; + + if (entry.StepIndex != null && SameVersion(entry.Version, version)) + return entry.StepIndex; + + // Reuse the cached ranges if they match this version, otherwise + // Compute will refresh them. + var ranges = (entry.Ranges != null && SameVersion(entry.Version, version)) + ? entry.Ranges + : Compute(document); + + var lookup = new Dictionary(capacity: ranges.Count); + int stepIndex = 0; + foreach (var (start, _) in ranges) + { + stepIndex++; + lookup[start] = stepIndex; + } + + entry.StepIndex = lookup; + return lookup; + } + + private static bool SameVersion(ITextSourceVersion? a, ITextSourceVersion? b) + { + if (a is null || b is null) return false; + if (!a.BelongsToSameDocumentAs(b)) return false; + return a.CompareAge(b) == 0; + } +} diff --git a/src/SharpFM/Scripting/Editor/ContinuationLineRenderer.cs b/src/SharpFM/Scripting/Editor/ContinuationLineRenderer.cs index cb7ef37..cdcd7c9 100644 --- a/src/SharpFM/Scripting/Editor/ContinuationLineRenderer.cs +++ b/src/SharpFM/Scripting/Editor/ContinuationLineRenderer.cs @@ -31,7 +31,7 @@ public void Draw(TextView textView, DrawingContext drawingContext) var doc = _textArea.Document; if (doc == null) return; - var ranges = MultiLineStatementRanges.Compute(doc.Text); + var ranges = CachedMultiLineRanges.Compute(doc); var charWidth = textView.WideSpaceWidth; foreach (var (startLine, endLine) in ranges) diff --git a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs index 47211f7..98ece05 100644 --- a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs +++ b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs @@ -17,6 +17,7 @@ public class StatementHighlightRenderer : IBackgroundRenderer private readonly TextArea _textArea; private int _highlightStartLine = -1; private int _highlightEndLine = -1; + private int _lastCaretLine = -1; public StatementHighlightRenderer(TextArea textArea) { @@ -28,16 +29,24 @@ public StatementHighlightRenderer(TextArea textArea) public void UpdateHighlight() { + var doc = _textArea.Document; + if (doc == null) return; + + var caretLine = _textArea.Caret.Line; // 1-indexed + + // PositionChanged fires on every keystroke and every arrow key — + // but the highlighted *step* only changes when the caret moves + // to a different physical line. Skip the recompute for in-line + // movement (which is the vast majority of caret events). + if (caretLine == _lastCaretLine) return; + _lastCaretLine = caretLine; + var oldStart = _highlightStartLine; var oldEnd = _highlightEndLine; _highlightStartLine = -1; _highlightEndLine = -1; - var doc = _textArea.Document; - if (doc == null) return; - - var caretLine = _textArea.Caret.Line; // 1-indexed - var ranges = MultiLineStatementRanges.Compute(doc.Text); + var ranges = CachedMultiLineRanges.Compute(doc); // Highlight the step the caret is in — single-line OR multi-line. // Skip blank-line "ranges" so the highlight doesn't appear on diff --git a/src/SharpFM/Scripting/Editor/StepIndexMargin.cs b/src/SharpFM/Scripting/Editor/StepIndexMargin.cs index c1ccc8f..aad274c 100644 --- a/src/SharpFM/Scripting/Editor/StepIndexMargin.cs +++ b/src/SharpFM/Scripting/Editor/StepIndexMargin.cs @@ -7,8 +7,6 @@ using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; -using SharpFM.Model.Scripting; - namespace SharpFM.Scripting.Editor; /// @@ -25,7 +23,6 @@ namespace SharpFM.Scripting.Editor; public class StepIndexMargin : AbstractMargin { private IReadOnlyDictionary _stepIndex = new Dictionary(); - private int _cachedTextVersion = -1; private Typeface _typeface = new(FontFamily.Default); private double _emSize = 12; @@ -86,11 +83,9 @@ public override void Render(DrawingContext context) private void EnsureStepIndexFresh(TextDocument? document) { if (document == null) return; - if (document.Version is { } version && version.GetHashCode() == _cachedTextVersion) - return; - - _stepIndex = MultiLineStatementRanges.BuildStepIndex(document.Text); - _cachedTextVersion = document.Version?.GetHashCode() ?? -1; + // Defer to the shared cache so multiple renderers reading the + // same document version reuse one Compute pass. + _stepIndex = CachedMultiLineRanges.GetStepIndex(document); } private FormattedText MakeFormattedText(string text) => From ade6d4f660c7d6cd84e71a412a4b5ec911db4bfc Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 14:43:51 -0500 Subject: [PATCH 02/13] perf: cache FormattedText and skip bracket-match alloc when off-bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the MultiLineStatementRanges cache landed, the next-tier trace shows StepIndexMargin.Render at 127ms inclusive over 30s, with 16ms in MakeFormattedText alone — Avalonia's FormattedText construction does font shaping + glyph layout per call. Step numbers are bounded ("1", "2", "3", …) so cache by string and clear when the typeface or em size changes. Separately, BracketMatchRenderer.UpdateBracketMatch reads doc.Text (full-document string allocation) on every caret move even when the caret isn't on a bracket — which is most caret positions. Cheaply check the char-before / char-at offsets first and defer the doc.Text read until we know we're actually near a bracket. --- .../Scripting/Editor/BracketMatchRenderer.cs | 15 +++++++++++++-- .../Scripting/Editor/StepIndexMargin.cs | 19 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs b/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs index 86f7224..756ebc0 100644 --- a/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs +++ b/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs @@ -37,11 +37,22 @@ public void UpdateBracketMatch() var offset = _textArea.Caret.Offset; if (offset <= 0 || offset > doc.TextLength) return; - // Check character before caret and at caret - var text = doc.Text; + // Check character before caret and at caret. Defer reading doc.Text + // (full-document allocation) until we know we're actually on a + // bracket — most caret positions aren't, and this fires per + // keystroke / per arrow key. var charBefore = offset > 0 ? doc.GetCharAt(offset - 1) : '\0'; var charAt = offset < doc.TextLength ? doc.GetCharAt(offset) : '\0'; + if (charBefore != '[' && charBefore != ']' && charAt != '[' && charAt != ']') + { + if (_openOffset != oldOpen || _closeOffset != oldClose) + _textArea.TextView.InvalidateLayer(Layer); + return; + } + + var text = doc.Text; + if (charBefore == '[') { var match = BracketMatcher.FindMatchingClose(text, offset - 1); diff --git a/src/SharpFM/Scripting/Editor/StepIndexMargin.cs b/src/SharpFM/Scripting/Editor/StepIndexMargin.cs index aad274c..1a09eaa 100644 --- a/src/SharpFM/Scripting/Editor/StepIndexMargin.cs +++ b/src/SharpFM/Scripting/Editor/StepIndexMargin.cs @@ -26,6 +26,12 @@ public class StepIndexMargin : AbstractMargin private Typeface _typeface = new(FontFamily.Default); private double _emSize = 12; + // FormattedText is heavy to construct (font shaping + glyph layout). + // Step numbers are bounded — for the visible range, they're a tiny + // recurring set ("1", "2", "3", …) — so cache by string. Reset when + // the typeface or em size changes (handled in OnTextViewChanged). + private readonly Dictionary _formattedCache = new(); + public StepIndexMargin() { // Match the editor's default font; will be re-pulled on attach. @@ -43,6 +49,7 @@ protected override void OnTextViewChanged(TextView? oldTextView, TextView? newTe newTextView.VisualLinesChanged += OnVisualLinesChanged; _typeface = new Typeface(newTextView.GetValue(TextElement.FontFamilyProperty)); _emSize = newTextView.GetValue(TextElement.FontSizeProperty); + _formattedCache.Clear(); } base.OnTextViewChanged(oldTextView, newTextView); @@ -88,7 +95,13 @@ private void EnsureStepIndexFresh(TextDocument? document) _stepIndex = CachedMultiLineRanges.GetStepIndex(document); } - private FormattedText MakeFormattedText(string text) => - new(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, - _typeface, _emSize, Brushes.Gray); + private FormattedText MakeFormattedText(string text) + { + if (_formattedCache.TryGetValue(text, out var cached)) + return cached; + var formatted = new FormattedText(text, CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, _typeface, _emSize, Brushes.Gray); + _formattedCache[text] = formatted; + return formatted; + } } From 958e8cfe78aa7744232ac76a6c25972cf5f19830 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 14:51:37 -0500 Subject: [PATCH 03/13] perf: skip ErrorMarker invalidation when diagnostics unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateDiagnostics now returns a bool indicating whether the new list actually differs from the previous one. The validation RunValidation path only calls InvalidateLayer when the answer is yes — steady typing inside a line that's still good (or bad in the same way) returns identical lists every cycle, and the invalidation forced a full TextView render pass each time. --- .../Scripting/Editor/ErrorMarkerRenderer.cs | 25 ++++++++++++++++++- .../Editor/ScriptEditorController.cs | 8 ++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs index 421d928..7d11104 100644 --- a/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs +++ b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs @@ -22,9 +22,32 @@ public ErrorMarkerRenderer(TextDocument document) public KnownLayer Layer => KnownLayer.Selection; - public void UpdateDiagnostics(List diagnostics) + /// + /// Replace the renderer's diagnostics list. Returns true if the list + /// actually changed (different count or any element differs) — caller + /// can use this to skip InvalidateLayer when the validator returned + /// the same diagnostics it returned last time, which is the common + /// case during steady typing inside a single problematic line. + /// + public bool UpdateDiagnostics(List diagnostics) { + if (DiagnosticsEquivalent(_diagnostics, diagnostics)) return false; _diagnostics = diagnostics; + return true; + } + + private static bool DiagnosticsEquivalent(List a, List b) + { + if (a.Count != b.Count) return false; + for (int i = 0; i < a.Count; i++) + { + var x = a[i]; + var y = b[i]; + if (x.Line != y.Line || x.StartCol != y.StartCol || x.EndCol != y.EndCol + || x.Severity != y.Severity || x.Message != y.Message) + return false; + } + return true; } public ScriptDiagnostic? GetDiagnosticAtOffset(int offset) diff --git a/src/SharpFM/Scripting/Editor/ScriptEditorController.cs b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs index 46085bb..db0399f 100644 --- a/src/SharpFM/Scripting/Editor/ScriptEditorController.cs +++ b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs @@ -116,8 +116,12 @@ private async void RunValidation() var diagnostics = await System.Threading.Tasks.Task.Run( () => ScriptValidator.Validate(text)); - _errorRenderer.UpdateDiagnostics(diagnostics); - _editor.TextArea.TextView.InvalidateLayer(_errorRenderer.Layer); + // Only invalidate when the diagnostics actually changed — + // typing inside a line that's still good (or still bad in + // the same way) returns identical lists every cycle, and an + // invalidation here forces a full TextView render pass. + if (_errorRenderer.UpdateDiagnostics(diagnostics)) + _editor.TextArea.TextView.InvalidateLayer(_errorRenderer.Layer); } catch { From 6301e756efbb707be45dedb3bddcf1abdecb7b89 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 15:05:19 -0500 Subject: [PATCH 04/13] fix: dedupe OnDataContextChanged in ScriptTextEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avalonia fires DataContextChanged multiple times during template instantiation and visual-tree reparenting even when the resolved value is the same instance. The trace showed it firing 9× for a single open tab, each time tearing down and reinstalling sealed- step renderers (squiggle + italic + cog) plus reattaching the read-only provider. Track the currently attached ScriptClipEditor and skip the work when the resolved DataContext is the same instance as before. --- src/SharpFM/Editors/ScriptTextEditor.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/SharpFM/Editors/ScriptTextEditor.cs b/src/SharpFM/Editors/ScriptTextEditor.cs index 4ee6c71..a07ad51 100644 --- a/src/SharpFM/Editors/ScriptTextEditor.cs +++ b/src/SharpFM/Editors/ScriptTextEditor.cs @@ -37,6 +37,7 @@ public class ScriptTextEditor : TextEditor, IDisposable private readonly TextMate.Installation _textMate; private readonly ScriptEditorController _controller; + private ScriptClipEditor? _attachedEditor; public ScriptTextEditor() { @@ -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); } From df67c942d982b2ae52fd94fd1e2f448a92763822 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 15:21:31 -0500 Subject: [PATCH 05/13] perf: stop sealed-step components from forcing per-line work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sealed-step components fire per visible line per layout (SealedStepItalicColorizer.ColorizeLine) or per paint (SealedStepSquiggleRenderer.Draw, SealedStepCogGenerator. GetFirstInterestedOffset). Each iterates _editor.SealedAnchors, which allocates a Where+Select enumerator and runs SignatureMatches (Document.GetText) per anchor — fine when something actually needs processing, but pure overhead when the script has zero sealed steps. With ~10 visible lines and hundreds of layouts in a 30- second window, the empty-loop iterations add up. Add a cheap HasSealedAnchors getter on ScriptClipEditor (one dictionary Count check) and short-circuit at the top of each sealed-step component when it returns false. While in SealedStepItalicColorizer, also fix two amplifiers that trigger Avalonia text-shaping work redundantly: - Cache the italic Typeface struct per FontFamily. The original code allocated a fresh Typeface(family, FontStyle.Italic) per text run per visible line per layout — each fresh struct drives a glyph-typeface lookup, defeating Avalonia's font cache. - Skip SetTypeface entirely when the run is already italic. Avalonia treats every SetTypeface call as a state change that re-runs ShapeTextRuns + glyph-typeface resolution, even when the value is unchanged. The trace showed Avalonia spending ~15s of CPU on font/typeface resolution during a 30-second typing window — the chief amplifier of "we're doing nothing visible but the editor still feels slow". --- src/SharpFM/Editors/ScriptClipEditor.cs | 11 +++++++++ .../SealedSteps/SealedStepCogGenerator.cs | 5 ++++ .../SealedSteps/SealedStepItalicColorizer.cs | 24 +++++++++++++++++-- .../SealedSteps/SealedStepSquiggleRenderer.cs | 3 +++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs index 92f7690..0c74e75 100644 --- a/src/SharpFM/Editors/ScriptClipEditor.cs +++ b/src/SharpFM/Editors/ScriptClipEditor.cs @@ -69,6 +69,17 @@ public ScriptClipEditor(string? xml) .Where(kv => !kv.Key.IsDeleted && SignatureMatches(kv.Key, kv.Value.Signature)) .Select(kv => kv.Key); + /// + /// Cheap predicate for sealed-step renderers / colorizers to fast-exit + /// when no sealed anchors exist. The full + /// enumerator is a Where+Select chain that calls Document.GetText + /// 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. + /// + internal bool HasSealedAnchors => _sealedAnchors.Count > 0; + /// /// Retrieve the cached XML for a sealed anchor. Returns false if the /// anchor is dead, evicted, or whose underlying line no longer matches diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs b/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs index 5249202..6cd8ad5 100644 --- a/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs +++ b/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs @@ -29,6 +29,11 @@ public SealedStepCogGenerator(ScriptClipEditor editor) public override int GetFirstInterestedOffset(int startOffset) { + // Fast exit: skip the SealedAnchors enumerator when no sealed + // steps exist. AvaloniaEdit calls this per visual line during + // line construction. + if (!_editor.HasSealedAnchors) return -1; + // 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. diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs b/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs index eaeba91..042eecc 100644 --- a/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs +++ b/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Avalonia.Media; using AvaloniaEdit.Document; @@ -12,6 +13,12 @@ namespace SharpFM.Editors.SealedSteps; [ExcludeFromCodeCoverage] public class SealedStepItalicColorizer : DocumentColorizingTransformer { + // Italic Typeface struct keyed by FontFamily — reused across every + // SetTypeface call so we don't allocate a fresh struct per text run + // per visible line per layout. Avalonia's font cache eventually + // hits, but each fresh Typeface instance still drives a lookup. + private static readonly ConcurrentDictionary ItalicCache = new(); + private readonly ScriptClipEditor _editor; public SealedStepItalicColorizer(ScriptClipEditor editor) @@ -21,6 +28,11 @@ public SealedStepItalicColorizer(ScriptClipEditor editor) protected override void ColorizeLine(DocumentLine line) { + // Fast exit: skip the SealedAnchors enumerator allocation entirely + // when there are no sealed anchors. ColorizeLine fires per visible + // line per layout — a hot path even when the body would no-op. + if (!_editor.HasSealedAnchors) return; + var doc = CurrentContext.Document; foreach (var anchor in _editor.SealedAnchors) { @@ -31,10 +43,18 @@ protected override void ColorizeLine(DocumentLine line) ChangeLinePart(line.Offset, line.EndOffset, element => { - element.TextRunProperties.SetTypeface( - new Typeface(element.TextRunProperties.Typeface.FontFamily, FontStyle.Italic)); + var current = element.TextRunProperties.Typeface; + // Skip the SetTypeface call entirely when the run is + // already italic — Avalonia treats every SetTypeface + // as a state change that drives ShapeTextRuns and + // glyph-typeface resolution. + if (current.Style == FontStyle.Italic) return; + element.TextRunProperties.SetTypeface(GetItalic(current.FontFamily)); }); return; } } + + private static Typeface GetItalic(FontFamily family) => + ItalicCache.GetOrAdd(family, static f => new Typeface(f, FontStyle.Italic)); } diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs b/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs index 2a029e0..73a754c 100644 --- a/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs +++ b/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs @@ -32,6 +32,9 @@ public void Draw(TextView textView, DrawingContext drawingContext) { var doc = _textArea.Document; if (doc == null) return; + // Fast exit: avoid the SealedAnchors enumerator allocation when + // the script has zero sealed steps. Draw fires per paint. + if (!_editor.HasSealedAnchors) return; foreach (var anchor in _editor.SealedAnchors) { From 81706789243740c6f58bb6f6bd753d0146ff85a8 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 15:23:04 -0500 Subject: [PATCH 06/13] perf: skip StepIndexMargin invalidation when paint would be identical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VisualLinesChanged fires on every layout pass — including ones where neither the visible line range nor the document's step structure changed (e.g. typing a character within a step, toggling focus). Each unconditional InvalidateVisual schedules a render frame; the render itself is now cheap (FormattedText is cached) but scheduling still cascades through the layout system. Track the last (first, last) visible line numbers we rendered plus the step-index dict identity (CachedMultiLineRanges returns the same instance across version-matched calls, so reference equality reads as "doc structure didn't change"). Skip InvalidateVisual when all three match the previous paint — the next paint would be pixel-identical anyway. --- .../Scripting/Editor/StepIndexMargin.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/SharpFM/Scripting/Editor/StepIndexMargin.cs b/src/SharpFM/Scripting/Editor/StepIndexMargin.cs index 1a09eaa..5bdcb23 100644 --- a/src/SharpFM/Scripting/Editor/StepIndexMargin.cs +++ b/src/SharpFM/Scripting/Editor/StepIndexMargin.cs @@ -32,6 +32,16 @@ public class StepIndexMargin : AbstractMargin // the typeface or em size changes (handled in OnTextViewChanged). private readonly Dictionary _formattedCache = new(); + // Track the last visible line range + step-index identity we + // actually rendered. VisualLinesChanged fires for many reasons + // (every layout pass, scroll attempts, font property changes, etc.) + // — when neither the visible-line range nor the step-index dict + // identity has changed since our last paint, the next paint would + // be pixel-identical, so InvalidateVisual is wasted work. + private int _lastFirstVisibleLine = -1; + private int _lastLastVisibleLine = -1; + private object? _lastStepIndexKey; + public StepIndexMargin() { // Match the editor's default font; will be re-pulled on attach. @@ -58,6 +68,40 @@ protected override void OnTextViewChanged(TextView? oldTextView, TextView? newTe private void OnVisualLinesChanged(object? sender, System.EventArgs e) { + var tv = TextView; + if (tv == null || !tv.VisualLinesValid) + { + InvalidateVisual(); + return; + } + + var visualLines = tv.VisualLines; + if (visualLines.Count == 0) + { + InvalidateVisual(); + return; + } + + var first = visualLines[0].FirstDocumentLine.LineNumber; + var last = visualLines[visualLines.Count - 1].FirstDocumentLine.LineNumber; + var doc = tv.Document; + // Reference-identity on the cached step-index dict — same + // instance means the document version hasn't changed since + // CachedMultiLineRanges last computed. + object? stepIndexKey = doc != null + ? CachedMultiLineRanges.GetStepIndex(doc) + : null; + + if (first == _lastFirstVisibleLine + && last == _lastLastVisibleLine + && ReferenceEquals(stepIndexKey, _lastStepIndexKey)) + { + return; + } + + _lastFirstVisibleLine = first; + _lastLastVisibleLine = last; + _lastStepIndexKey = stepIndexKey; InvalidateVisual(); } From 4e4a64b664f0a4e40744da27c281bb040f6f2043 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 15:28:47 -0500 Subject: [PATCH 07/13] refactor: collapse 4 background renderers into a shared pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace BracketMatchRenderer, StatementHighlightRenderer, ContinuationLineRenderer, and ErrorMarkerRenderer with a unified ScriptEditorRenderPipeline that owns: - One RenderContext (TextArea, Caret, cached statement ranges, diagnostics) shared across all feature layers. - Four IRenderLayer implementations — StatementHighlightLayer, ContinuationRailLayer, BracketMatchLayer, ErrorMarkerLayer — each owning only its own dirty state. - Two LayeredBackgroundRenderer wrappers, one per AvaloniaEdit KnownLayer (Background + Selection), each hosting the layers that target it. Visual stacking order preserved. - One Caret.PositionChanged subscription (was four). Layers are dispatched once per event and report whether their draw output would differ; the pipeline accumulates dirty KnownLayers and issues at most one InvalidateLayer call per affected target. Beyond the cleaner architecture, the practical perf win is that multiple feature layers reporting dirty on the same KnownLayer no longer force separate InvalidateLayer / repaint cycles — they collapse into a single render pass. ScriptEditorController.RunValidation hands diagnostics through ScriptEditorRenderPipeline.UpdateDiagnostics, which tracks list identity and only invalidates the Selection layer on a real change. The pointer-hover tooltip path queries diagnostics via GetDiagnosticAtOffset instead of the deleted ErrorMarkerRenderer. The four old renderer files are removed; their logic now lives in the per-layer classes under Scripting/Editor/Pipeline/. --- .../Scripting/Editor/ErrorMarkerRenderer.cs | 137 ------------------ .../BracketMatchLayer.cs} | 65 ++++----- .../ContinuationRailLayer.cs} | 34 ++--- .../Editor/Pipeline/ErrorMarkerLayer.cs | 99 +++++++++++++ .../Scripting/Editor/Pipeline/IRenderLayer.cs | 38 +++++ .../Pipeline/LayeredBackgroundRenderer.cs | 33 +++++ .../Editor/Pipeline/RenderContext.cs | 63 ++++++++ .../Pipeline/ScriptEditorRenderPipeline.cs | 130 +++++++++++++++++ .../Pipeline/StatementHighlightLayer.cs | 72 +++++++++ .../Editor/ScriptEditorController.cs | 43 ++---- .../Editor/StatementHighlightRenderer.cs | 93 ------------ 11 files changed, 485 insertions(+), 322 deletions(-) delete mode 100644 src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs rename src/SharpFM/Scripting/Editor/{BracketMatchRenderer.cs => Pipeline/BracketMatchLayer.cs} (52%) rename src/SharpFM/Scripting/Editor/{ContinuationLineRenderer.cs => Pipeline/ContinuationRailLayer.cs} (53%) create mode 100644 src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs create mode 100644 src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs create mode 100644 src/SharpFM/Scripting/Editor/Pipeline/LayeredBackgroundRenderer.cs create mode 100644 src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs create mode 100644 src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs create mode 100644 src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs delete mode 100644 src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs diff --git a/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs deleted file mode 100644 index 7d11104..0000000 --- a/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Avalonia; -using Avalonia.Media; -using AvaloniaEdit.Document; -using AvaloniaEdit.Rendering; - -namespace SharpFM.Scripting.Editor; - -[ExcludeFromCodeCoverage] -public class ErrorMarkerRenderer : IBackgroundRenderer -{ - private readonly TextDocument _document; - private List _diagnostics = new(); - - - public ErrorMarkerRenderer(TextDocument document) - { - _document = document; - } - - public KnownLayer Layer => KnownLayer.Selection; - - /// - /// Replace the renderer's diagnostics list. Returns true if the list - /// actually changed (different count or any element differs) — caller - /// can use this to skip InvalidateLayer when the validator returned - /// the same diagnostics it returned last time, which is the common - /// case during steady typing inside a single problematic line. - /// - public bool UpdateDiagnostics(List diagnostics) - { - if (DiagnosticsEquivalent(_diagnostics, diagnostics)) return false; - _diagnostics = diagnostics; - return true; - } - - private static bool DiagnosticsEquivalent(List a, List b) - { - if (a.Count != b.Count) return false; - for (int i = 0; i < a.Count; i++) - { - var x = a[i]; - var y = b[i]; - if (x.Line != y.Line || x.StartCol != y.StartCol || x.EndCol != y.EndCol - || x.Severity != y.Severity || x.Message != y.Message) - return false; - } - return true; - } - - public ScriptDiagnostic? GetDiagnosticAtOffset(int offset) - { - if (_diagnostics.Count == 0 || offset < 0 || offset >= _document.TextLength) - return null; - - var location = _document.GetLocation(offset); - var lineIndex = location.Line - 1; // 1-indexed to 0-indexed - - foreach (var diag in _diagnostics) - { - if (diag.Line != lineIndex) continue; - - var col = location.Column - 1; // 1-indexed to 0-indexed - var startCol = diag.StartCol; - var endCol = diag.EndCol; - - // If no specific span, the whole line is the target - if (startCol >= endCol) - { - return diag; - } - - if (col >= startCol && col <= endCol) - return diag; - } - - return null; - } - - public void Draw(TextView textView, DrawingContext drawingContext) - { - if (_diagnostics.Count == 0) return; - - foreach (var diag in _diagnostics) - { - if (diag.Line < 0 || diag.Line >= _document.LineCount) - continue; - - var docLine = _document.GetLineByNumber(diag.Line + 1); // 0-indexed to 1-indexed - var startOffset = docLine.Offset + Math.Min(diag.StartCol, docLine.Length); - var endOffset = docLine.Offset + Math.Min(diag.EndCol, docLine.Length); - - if (startOffset >= endOffset) - { - // If no span, underline the whole line content - startOffset = docLine.Offset; - endOffset = docLine.EndOffset; - } - - var segment = new TextSegment { StartOffset = startOffset, EndOffset = endOffset }; - var pen = diag.Severity == DiagnosticSeverity.Error - ? ScriptEditorTheme.ErrorPen - : ScriptEditorTheme.WarningPen; - - foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) - { - DrawZigzag(drawingContext, pen, rect); - } - } - } - - private static void DrawZigzag(DrawingContext context, IPen pen, Rect rect) - { - const double zigLength = 3; - const double zigHeight = 2; - - var y = rect.Bottom; - var startX = rect.Left; - var endX = rect.Right; - - var geometry = new StreamGeometry(); - using (var ctx = geometry.Open()) - { - ctx.BeginFigure(new Point(startX, y), false); - bool up = true; - for (double x = startX + zigLength; x <= endX; x += zigLength) - { - ctx.LineTo(new Point(x, up ? y - zigHeight : y)); - up = !up; - } - } - - context.DrawGeometry(null, pen, geometry); - } -} diff --git a/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs b/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs similarity index 52% rename from src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs rename to src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs index 756ebc0..9e8cf2d 100644 --- a/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs @@ -1,55 +1,45 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using Avalonia; using Avalonia.Media; using AvaloniaEdit.Document; -using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; +using SharpFM.Model.Scripting; -namespace SharpFM.Scripting.Editor; +namespace SharpFM.Scripting.Editor.Pipeline; -[ExcludeFromCodeCoverage] -public class BracketMatchRenderer : IBackgroundRenderer +/// +/// Highlights the matching [/] pair when the caret sits +/// adjacent to one of them. Updates only when the caret is near a +/// bracket — for all other caret moves the layer reports clean and +/// no Avalonia work is triggered. +/// +internal sealed class BracketMatchLayer : IRenderLayer { + public KnownLayer TargetLayer => KnownLayer.Selection; - private readonly TextArea _textArea; private int _openOffset = -1; private int _closeOffset = -1; - public BracketMatchRenderer(TextArea textArea) - { - _textArea = textArea; - _textArea.Caret.PositionChanged += (_, _) => UpdateBracketMatch(); - } - - public KnownLayer Layer => KnownLayer.Selection; - - public void UpdateBracketMatch() + public bool OnCaretChanged(RenderContext ctx) { var oldOpen = _openOffset; var oldClose = _closeOffset; _openOffset = -1; _closeOffset = -1; - var doc = _textArea.Document; - if (doc == null) return; + var doc = ctx.Document; + if (doc == null) return oldOpen != -1 || oldClose != -1; - var offset = _textArea.Caret.Offset; - if (offset <= 0 || offset > doc.TextLength) return; + var offset = ctx.CaretOffset; + if (offset <= 0 || offset > doc.TextLength) + return oldOpen != -1 || oldClose != -1; - // Check character before caret and at caret. Defer reading doc.Text - // (full-document allocation) until we know we're actually on a - // bracket — most caret positions aren't, and this fires per - // keystroke / per arrow key. + // Defer reading doc.Text (full-document allocation) until we know + // we're actually adjacent to a bracket — most caret positions + // aren't, and this fires per keystroke + arrow key. var charBefore = offset > 0 ? doc.GetCharAt(offset - 1) : '\0'; var charAt = offset < doc.TextLength ? doc.GetCharAt(offset) : '\0'; if (charBefore != '[' && charBefore != ']' && charAt != '[' && charAt != ']') - { - if (_openOffset != oldOpen || _closeOffset != oldClose) - _textArea.TextView.InvalidateLayer(Layer); - return; - } + return oldOpen != -1 || oldClose != -1; var text = doc.Text; @@ -74,25 +64,22 @@ public void UpdateBracketMatch() if (match >= 0) { _openOffset = match; _closeOffset = offset; } } - if (_openOffset != oldOpen || _closeOffset != oldClose) - _textArea.TextView.InvalidateLayer(Layer); + return _openOffset != oldOpen || _closeOffset != oldClose; } - public void Draw(TextView textView, DrawingContext drawingContext) + public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) { if (_openOffset < 0 || _closeOffset < 0) return; - - DrawBracketHighlight(textView, drawingContext, _openOffset); - DrawBracketHighlight(textView, drawingContext, _closeOffset); + DrawBracketHighlight(textView, dc, _openOffset); + DrawBracketHighlight(textView, dc, _closeOffset); } - private static void DrawBracketHighlight(TextView textView, DrawingContext context, int offset) + private static void DrawBracketHighlight(TextView textView, DrawingContext dc, int offset) { var segment = new TextSegment { StartOffset = offset, EndOffset = offset + 1 }; foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) { - context.DrawRectangle(ScriptEditorTheme.BracketMatchBrush, ScriptEditorTheme.BracketMatchPen, rect); + dc.DrawRectangle(ScriptEditorTheme.BracketMatchBrush, ScriptEditorTheme.BracketMatchPen, rect); } } - } diff --git a/src/SharpFM/Scripting/Editor/ContinuationLineRenderer.cs b/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs similarity index 53% rename from src/SharpFM/Scripting/Editor/ContinuationLineRenderer.cs rename to src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs index cdcd7c9..5809b32 100644 --- a/src/SharpFM/Scripting/Editor/ContinuationLineRenderer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs @@ -1,37 +1,28 @@ -using System.Diagnostics.CodeAnalysis; using Avalonia; using Avalonia.Media; -using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; using SharpFM.Model.Scripting; -namespace SharpFM.Scripting.Editor; +namespace SharpFM.Scripting.Editor.Pipeline; /// /// Draws a thin vertical rail in the gutter of each multi-line script /// step, anchored to the column just after the step's opening [. -/// Visually links the continuation lines of a multi-line calculation -/// back to their parent step, giving the editor a "this is one logical -/// thing" cue without modifying document text. +/// Has no caret-driven state — depends only on the document's +/// statement ranges (cached on ). /// -[ExcludeFromCodeCoverage] -public class ContinuationLineRenderer : IBackgroundRenderer +internal sealed class ContinuationRailLayer : IRenderLayer { - private readonly TextArea _textArea; + public KnownLayer TargetLayer => KnownLayer.Background; - public ContinuationLineRenderer(TextArea textArea) - { - _textArea = textArea; - } - - public KnownLayer Layer => KnownLayer.Background; + public bool OnCaretChanged(RenderContext ctx) => false; - public void Draw(TextView textView, DrawingContext drawingContext) + public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) { - var doc = _textArea.Document; + var doc = ctx.Document; if (doc == null) return; - var ranges = CachedMultiLineRanges.Compute(doc); + var ranges = ctx.StatementRanges; var charWidth = textView.WideSpaceWidth; foreach (var (startLine, endLine) in ranges) @@ -43,13 +34,8 @@ public void Draw(TextView textView, DrawingContext drawingContext) var col = MultiLineStatementRanges.FindContinuationColumn(firstLineText); if (col < 0) continue; - // Monospace font is used for the script editor (Cascadia Code, - // Consolas, Menlo) so column × char-width is correct. Subtract - // horizontal offset for scrolling. Drawing context is already - // in text-view coordinates. var x = col * charWidth - textView.HorizontalOffset; - // Draw the rail across continuation lines (startLine+1 .. endLine). for (int lineNum = startLine + 1; lineNum <= endLine; lineNum++) { var visualLine = textView.GetVisualLine(lineNum); @@ -58,7 +44,7 @@ public void Draw(TextView textView, DrawingContext drawingContext) var y1 = visualLine.VisualTop - textView.VerticalOffset; var y2 = y1 + visualLine.Height; - drawingContext.DrawLine( + dc.DrawLine( ScriptEditorTheme.ContinuationRailPen, new Point(x, y1), new Point(x, y2)); diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs new file mode 100644 index 0000000..c12aa9e --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs @@ -0,0 +1,99 @@ +using System; +using Avalonia; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; +using SharpFM.Model.Scripting; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// Draws zigzag underlines for validator diagnostics. Diagnostics live +/// on the shared ; the validator pushes them +/// in via the pipeline's UpdateDiagnostics method. +/// +internal sealed class ErrorMarkerLayer : IRenderLayer +{ + public KnownLayer TargetLayer => KnownLayer.Selection; + + public bool OnCaretChanged(RenderContext ctx) => false; + + /// + /// Find the diagnostic at , or null. Used + /// by the controller to populate hover tooltips. Reads the + /// canonical diagnostic list from the context. + /// + public ScriptDiagnostic? GetDiagnosticAtOffset(RenderContext ctx, int offset) + { + var doc = ctx.Document; + var diagnostics = ctx.Diagnostics; + if (doc == null || diagnostics.Count == 0) return null; + if (offset < 0 || offset >= doc.TextLength) return null; + + var location = doc.GetLocation(offset); + var lineIndex = location.Line - 1; + + foreach (var diag in diagnostics) + { + if (diag.Line != lineIndex) continue; + var col = location.Column - 1; + if (diag.StartCol >= diag.EndCol) return diag; + if (col >= diag.StartCol && col <= diag.EndCol) return diag; + } + return null; + } + + public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) + { + var doc = ctx.Document; + var diagnostics = ctx.Diagnostics; + if (doc == null || diagnostics.Count == 0) return; + + foreach (var diag in diagnostics) + { + if (diag.Line < 0 || diag.Line >= doc.LineCount) continue; + + var docLine = doc.GetLineByNumber(diag.Line + 1); + var startOffset = docLine.Offset + Math.Min(diag.StartCol, docLine.Length); + var endOffset = docLine.Offset + Math.Min(diag.EndCol, docLine.Length); + + if (startOffset >= endOffset) + { + startOffset = docLine.Offset; + endOffset = docLine.EndOffset; + } + + var segment = new TextSegment { StartOffset = startOffset, EndOffset = endOffset }; + var pen = diag.Severity == DiagnosticSeverity.Error + ? ScriptEditorTheme.ErrorPen + : ScriptEditorTheme.WarningPen; + + foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) + DrawZigzag(dc, pen, rect); + } + } + + private static void DrawZigzag(DrawingContext dc, IPen pen, Rect rect) + { + const double zigLength = 3; + const double zigHeight = 2; + + var y = rect.Bottom; + var startX = rect.Left; + var endX = rect.Right; + + var geometry = new StreamGeometry(); + using (var ctx = geometry.Open()) + { + ctx.BeginFigure(new Point(startX, y), false); + bool up = true; + for (double x = startX + zigLength; x <= endX; x += zigLength) + { + ctx.LineTo(new Point(x, up ? y - zigHeight : y)); + up = !up; + } + } + + dc.DrawGeometry(null, pen, geometry); + } +} diff --git a/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs new file mode 100644 index 0000000..b5f388c --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs @@ -0,0 +1,38 @@ +using Avalonia.Media; +using AvaloniaEdit.Rendering; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// One feature in the script editor's render pipeline (statement +/// highlight, bracket match, continuation rail, error squiggle). +/// Layers own only their own dirty state; everything else they read +/// from the shared . The pipeline owner +/// dispatches lifecycle events to every layer and consolidates +/// invalidations into at most one InvalidateLayer call per +/// affected . +/// +internal interface IRenderLayer +{ + /// + /// Which AvaloniaEdit layer this draws on. Determines which + /// IBackgroundRenderer wrapper hosts the layer and which + /// InvalidateLayer target the pipeline calls when the + /// layer reports dirty. + /// + KnownLayer TargetLayer { get; } + + /// + /// Caret position changed. Layer should update its internal state + /// and return true if its draw output would differ from + /// the previous paint. + /// + bool OnCaretChanged(RenderContext ctx); + + /// + /// Render the layer's contribution to the given drawing context. + /// Called by AvaloniaEdit during paint via the IBackgroundRenderer + /// wrapper. + /// + void Draw(RenderContext ctx, TextView textView, DrawingContext drawingContext); +} diff --git a/src/SharpFM/Scripting/Editor/Pipeline/LayeredBackgroundRenderer.cs b/src/SharpFM/Scripting/Editor/Pipeline/LayeredBackgroundRenderer.cs new file mode 100644 index 0000000..f1e1aef --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/LayeredBackgroundRenderer.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Avalonia.Media; +using AvaloniaEdit.Rendering; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// Hosts a fixed set of instances on a single +/// AvaloniaEdit . Per paint, dispatches Draw to +/// each layer in registration order. AvaloniaEdit only knows about this +/// wrapper — the four feature-specific layers it contains are invisible +/// to the editor's layering system. +/// +internal sealed class LayeredBackgroundRenderer : IBackgroundRenderer +{ + private readonly RenderContext _context; + private readonly IReadOnlyList _layers; + + public LayeredBackgroundRenderer(RenderContext context, KnownLayer layer, IReadOnlyList layers) + { + _context = context; + Layer = layer; + _layers = layers; + } + + public KnownLayer Layer { get; } + + public void Draw(TextView textView, DrawingContext drawingContext) + { + foreach (var layer in _layers) + layer.Draw(_context, textView, drawingContext); + } +} diff --git a/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs b/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs new file mode 100644 index 0000000..545940c --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using SharpFM.Model.Scripting; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// Shared state surface for every in the +/// script editor's render pipeline. Layers read everything they need +/// through this object instead of subscribing to the editor's events +/// individually — the pipeline owns the subscriptions and dispatches +/// once. Cached data (statement ranges, diagnostics) lives here so the +/// layers all share one Compute pass. +/// +internal sealed class RenderContext +{ + public RenderContext(TextArea textArea) + { + TextArea = textArea; + } + + public TextArea TextArea { get; } + public TextView TextView => TextArea.TextView; + public TextDocument? Document => TextArea.Document; + public Caret Caret => TextArea.Caret; + public int CaretLine => Caret.Line; + public int CaretOffset => Caret.Offset; + + public IReadOnlyList<(int StartLine, int EndLine)> StatementRanges => + Document is { } d ? CachedMultiLineRanges.Compute(d) : Array.Empty<(int, int)>(); + + public List Diagnostics { get; private set; } = new(); + + /// + /// Replace the diagnostics list. Returns true if the new list + /// differs from the previous (count or any element field), so the + /// pipeline can skip an InvalidateLayer when nothing visible + /// changed. + /// + public bool SetDiagnostics(List next) + { + if (DiagnosticsEquivalent(Diagnostics, next)) return false; + Diagnostics = next; + return true; + } + + private static bool DiagnosticsEquivalent(List a, List b) + { + if (a.Count != b.Count) return false; + for (int i = 0; i < a.Count; i++) + { + var x = a[i]; + var y = b[i]; + if (x.Line != y.Line || x.StartCol != y.StartCol || x.EndCol != y.EndCol + || x.Severity != y.Severity || x.Message != y.Message) + return false; + } + return true; + } +} diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs b/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs new file mode 100644 index 0000000..a9f28c6 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using SharpFM.Model.Scripting; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// Owns the script editor's background render layers and consolidates +/// their event handling + invalidation. Replaces the four standalone +/// IBackgroundRenderer implementations (bracket, statement, +/// continuation, error) with one shared pipeline: +/// +/// +/// One subscription to ; +/// layers are dispatched in order and each reports whether its +/// draw output changed. +/// Two wrappers — one per +/// (Background + Selection) — host the +/// feature layers in their correct visual stacking order. +/// One call per affected +/// per event, instead of one per dirty +/// layer. +/// +/// +/// The pipeline is owned by and +/// disposed alongside it. +/// +[ExcludeFromCodeCoverage] +public sealed class ScriptEditorRenderPipeline : IDisposable +{ + private readonly TextArea _textArea; + private readonly RenderContext _context; + private readonly StatementHighlightLayer _statementLayer; + private readonly ContinuationRailLayer _continuationLayer; + private readonly BracketMatchLayer _bracketLayer; + private readonly ErrorMarkerLayer _errorLayer; + private readonly LayeredBackgroundRenderer _bgRenderer; + private readonly LayeredBackgroundRenderer _selRenderer; + private bool _disposed; + + public ScriptEditorRenderPipeline(TextArea textArea) + { + _textArea = textArea; + _context = new RenderContext(textArea); + + _statementLayer = new StatementHighlightLayer(); + _continuationLayer = new ContinuationRailLayer(); + _bracketLayer = new BracketMatchLayer(); + _errorLayer = new ErrorMarkerLayer(); + + _bgRenderer = new LayeredBackgroundRenderer( + _context, + KnownLayer.Background, + new IRenderLayer[] { _statementLayer, _continuationLayer }); + + _selRenderer = new LayeredBackgroundRenderer( + _context, + KnownLayer.Selection, + new IRenderLayer[] { _bracketLayer, _errorLayer }); + + textArea.TextView.BackgroundRenderers.Add(_bgRenderer); + textArea.TextView.BackgroundRenderers.Add(_selRenderer); + + textArea.Caret.PositionChanged += OnCaretChanged; + } + + /// + /// Replace the validator's diagnostic list. Returns whether the + /// list changed; callers (the controller's RunValidation path) + /// only need to invalidate when this returns true. + /// + public bool UpdateDiagnostics(List diagnostics) + { + if (!_context.SetDiagnostics(diagnostics)) return false; + _textArea.TextView.InvalidateLayer(_errorLayer.TargetLayer); + return true; + } + + /// + /// Look up the diagnostic at a document offset. Used by the + /// controller's pointer-hover path to populate tooltips. + /// + public ScriptDiagnostic? GetDiagnosticAtOffset(int offset) => + _errorLayer.GetDiagnosticAtOffset(_context, offset); + + private void OnCaretChanged(object? sender, EventArgs e) + { + var bgDirty = false; + var selDirty = false; + + if (_statementLayer.OnCaretChanged(_context)) + { + if (_statementLayer.TargetLayer == KnownLayer.Background) bgDirty = true; + else selDirty = true; + } + if (_continuationLayer.OnCaretChanged(_context)) + { + if (_continuationLayer.TargetLayer == KnownLayer.Background) bgDirty = true; + else selDirty = true; + } + if (_bracketLayer.OnCaretChanged(_context)) + { + if (_bracketLayer.TargetLayer == KnownLayer.Background) bgDirty = true; + else selDirty = true; + } + if (_errorLayer.OnCaretChanged(_context)) + { + if (_errorLayer.TargetLayer == KnownLayer.Background) bgDirty = true; + else selDirty = true; + } + + // One InvalidateLayer call per dirty layer, max one per known + // target. Multiple feature layers reporting dirty on the same + // KnownLayer collapse into a single repaint. + if (bgDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Background); + if (selDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Selection); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _textArea.Caret.PositionChanged -= OnCaretChanged; + _textArea.TextView.BackgroundRenderers.Remove(_bgRenderer); + _textArea.TextView.BackgroundRenderers.Remove(_selRenderer); + } +} diff --git a/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs new file mode 100644 index 0000000..64c8182 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs @@ -0,0 +1,72 @@ +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; +using SharpFM.Model.Scripting; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// Highlights the step containing the caret with a subtle background +/// rectangle. Reads cached statement ranges from the +/// ; only updates when the caret crosses +/// a physical line, since the highlighted step doesn't change for +/// in-line caret movement. +/// +internal sealed class StatementHighlightLayer : IRenderLayer +{ + public KnownLayer TargetLayer => KnownLayer.Background; + + private int _highlightStartLine = -1; + private int _highlightEndLine = -1; + private int _lastCaretLine = -1; + + public bool OnCaretChanged(RenderContext ctx) + { + var caretLine = ctx.CaretLine; + if (caretLine == _lastCaretLine) return false; + _lastCaretLine = caretLine; + + var oldStart = _highlightStartLine; + var oldEnd = _highlightEndLine; + _highlightStartLine = -1; + _highlightEndLine = -1; + + var doc = ctx.Document; + if (doc == null) return oldStart != -1 || oldEnd != -1; + + var ranges = ctx.StatementRanges; + var hit = MultiLineStatementRanges.FindRangeContainingLine(ranges, caretLine); + if (hit is { } range) + { + var firstLineText = doc.GetText(doc.GetLineByNumber(range.StartLine)); + if (!string.IsNullOrWhiteSpace(firstLineText)) + { + _highlightStartLine = range.StartLine; + _highlightEndLine = range.EndLine; + } + } + + return _highlightStartLine != oldStart || _highlightEndLine != oldEnd; + } + + public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) + { + if (_highlightStartLine < 0 || _highlightEndLine < 0) return; + var doc = ctx.Document; + if (doc == null) return; + + var startLine = doc.GetLineByNumber(_highlightStartLine); + var endLine = doc.GetLineByNumber(_highlightEndLine); + var segment = new TextSegment + { + StartOffset = startLine.Offset, + EndOffset = endLine.EndOffset, + }; + + var builder = new BackgroundGeometryBuilder { CornerRadius = 2 }; + builder.AddSegment(textView, segment); + var geometry = builder.CreateGeometry(); + if (geometry != null) + dc.DrawGeometry(ScriptEditorTheme.StatementHighlightBrush, null, geometry); + } +} diff --git a/src/SharpFM/Scripting/Editor/ScriptEditorController.cs b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs index db0399f..5f0c8d4 100644 --- a/src/SharpFM/Scripting/Editor/ScriptEditorController.cs +++ b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs @@ -10,6 +10,7 @@ using SharpFM.Diagnostics; using SharpFM.Editors; using SharpFM.Editors.SealedSteps; +using SharpFM.Scripting.Editor.Pipeline; namespace SharpFM.Scripting.Editor; @@ -21,7 +22,7 @@ public class ScriptEditorController : IDisposable { private readonly TextEditor _editor; private readonly DispatcherTimer _validationTimer; - private ErrorMarkerRenderer? _errorRenderer; + private readonly ScriptEditorRenderPipeline _renderPipeline; private CompletionWindow? _completionWindow; private ScriptClipEditor? _clipEditor; @@ -52,17 +53,12 @@ public ScriptEditorController(TextEditor editor) RunValidation(); }; - // Bracket matching - var bracketRenderer = new BracketMatchRenderer(_editor.TextArea); - _editor.TextArea.TextView.BackgroundRenderers.Add(bracketRenderer); - - // Multi-line statement highlighting - var statementRenderer = new StatementHighlightRenderer(_editor.TextArea); - _editor.TextArea.TextView.BackgroundRenderers.Add(statementRenderer); - - // Continuation rail for multi-line calc steps - var continuationRenderer = new ContinuationLineRenderer(_editor.TextArea); - _editor.TextArea.TextView.BackgroundRenderers.Add(continuationRenderer); + // Single shared render pipeline. Hosts bracket-match, + // statement-highlight, continuation-rail, and error-marker + // layers on two IBackgroundRenderer instances (one per + // KnownLayer), with one Caret.PositionChanged subscription + // and at most one InvalidateLayer call per layer per event. + _renderPipeline = new ScriptEditorRenderPipeline(_editor.TextArea); // Replace AvaloniaEdit's built-in line-number margin with a step-index // margin (FileMaker-style: one number per script step, regardless of @@ -90,12 +86,6 @@ public ScriptEditorController(TextEditor editor) private void AttachToDocument(TextDocument document) { - if (_errorRenderer != null) - _editor.TextArea.TextView.BackgroundRenderers.Remove(_errorRenderer); - - _errorRenderer = new ErrorMarkerRenderer(document); - _editor.TextArea.TextView.BackgroundRenderers.Add(_errorRenderer); - document.TextChanged += (_, _) => { _validationTimer.Stop(); @@ -107,8 +97,6 @@ private void AttachToDocument(TextDocument document) private async void RunValidation() { - if (_errorRenderer == null) return; - var text = _editor.Document.Text; try @@ -116,12 +104,10 @@ private async void RunValidation() var diagnostics = await System.Threading.Tasks.Task.Run( () => ScriptValidator.Validate(text)); - // Only invalidate when the diagnostics actually changed — - // typing inside a line that's still good (or still bad in - // the same way) returns identical lists every cycle, and an - // invalidation here forces a full TextView render pass. - if (_errorRenderer.UpdateDiagnostics(diagnostics)) - _editor.TextArea.TextView.InvalidateLayer(_errorRenderer.Layer); + // Pipeline.UpdateDiagnostics returns true only when the + // list actually changed; the pipeline performs the + // single InvalidateLayer call internally. + _renderPipeline.UpdateDiagnostics(diagnostics); } catch { @@ -131,8 +117,6 @@ private async void RunValidation() private void OnPointerMoved(object? sender, PointerEventArgs e) { - if (_errorRenderer == null) return; - var pos = _editor.GetPositionFromPoint(e.GetPosition(_editor)); if (pos == null) { @@ -141,7 +125,7 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) } var offset = _editor.Document.GetOffset(pos.Value.Location); - var diag = _errorRenderer.GetDiagnosticAtOffset(offset); + var diag = _renderPipeline.GetDiagnosticAtOffset(offset); if (diag != null) { @@ -327,5 +311,6 @@ public void Dispose() _editor.TextArea.TextEntered -= OnTextEntered; _editor.PointerMoved -= OnPointerMoved; _editor.KeyDown -= OnKeyDownGuardSealed; + _renderPipeline.Dispose(); } } diff --git a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs deleted file mode 100644 index 98ece05..0000000 --- a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Avalonia; -using Avalonia.Media; -using AvaloniaEdit.Document; -using AvaloniaEdit.Editing; -using AvaloniaEdit.Rendering; -using SharpFM.Model.Scripting; - -namespace SharpFM.Scripting.Editor; - -[ExcludeFromCodeCoverage] -public class StatementHighlightRenderer : IBackgroundRenderer -{ - - private readonly TextArea _textArea; - private int _highlightStartLine = -1; - private int _highlightEndLine = -1; - private int _lastCaretLine = -1; - - public StatementHighlightRenderer(TextArea textArea) - { - _textArea = textArea; - _textArea.Caret.PositionChanged += (_, _) => UpdateHighlight(); - } - - public KnownLayer Layer => KnownLayer.Background; - - public void UpdateHighlight() - { - var doc = _textArea.Document; - if (doc == null) return; - - var caretLine = _textArea.Caret.Line; // 1-indexed - - // PositionChanged fires on every keystroke and every arrow key — - // but the highlighted *step* only changes when the caret moves - // to a different physical line. Skip the recompute for in-line - // movement (which is the vast majority of caret events). - if (caretLine == _lastCaretLine) return; - _lastCaretLine = caretLine; - - var oldStart = _highlightStartLine; - var oldEnd = _highlightEndLine; - _highlightStartLine = -1; - _highlightEndLine = -1; - - var ranges = CachedMultiLineRanges.Compute(doc); - - // Highlight the step the caret is in — single-line OR multi-line. - // Skip blank-line "ranges" so the highlight doesn't appear on - // empty lines between steps. - var hit = MultiLineStatementRanges.FindRangeContainingLine(ranges, caretLine); - if (hit is { } range) - { - var lineText = doc.GetText(doc.GetLineByNumber(range.StartLine)); - if (!string.IsNullOrWhiteSpace(lineText)) - { - _highlightStartLine = range.StartLine; - _highlightEndLine = range.EndLine; - } - } - - if (_highlightStartLine != oldStart || _highlightEndLine != oldEnd) - _textArea.TextView.InvalidateLayer(Layer); - } - - public void Draw(TextView textView, DrawingContext drawingContext) - { - if (_highlightStartLine < 0 || _highlightEndLine < 0) return; - - var doc = _textArea.Document; - if (doc == null) return; - - var startLine = doc.GetLineByNumber(_highlightStartLine); - var endLine = doc.GetLineByNumber(_highlightEndLine); - var segment = new TextSegment - { - StartOffset = startLine.Offset, - EndOffset = endLine.EndOffset - }; - - var builder = new BackgroundGeometryBuilder { CornerRadius = 2 }; - builder.AddSegment(textView, segment); - var geometry = builder.CreateGeometry(); - if (geometry != null) - { - drawingContext.DrawGeometry(ScriptEditorTheme.StatementHighlightBrush, null, geometry); - } - } - -} From 24077d4bffe82f1a4ceac5bdf9ca013f72777a8d Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 20:23:30 -0500 Subject: [PATCH 08/13] perf: extend pipeline with idle cadence + sealed-step layer Sealed-step rendering was the dominant per-keystroke cost. The italic colorizer mutated TextRunProperties.Typeface per text run per visible line per layout, driving a ShapeTextRuns + font-resolution cascade. The squiggle renderer and cog generator both walked the SealedAnchors LINQ chain per paint/per visual-line, allocating a fresh string per anchor through SignatureMatches. Changes: - ScriptClipEditor caches HashSet SealedLineNumbers and a Dictionary SealedLineEndOffsets, invalidated on Document.TextChanged. Renderers read O(1) from the cache instead of iterating anchors and allocating strings per call. - IRenderLayer gains a RenderCadence (Realtime|Idle) and an OnTextChanged hook. The pipeline subscribes to Document.TextChanged on a 150ms debounce and dispatches idle layers; realtime layers keep using the existing caret path. - New SealedStepLayer (Idle, Selection) replaces the standalone SealedStepSquiggleRenderer. Draws the existing zigzag plus a left- edge accent stripe. - SealedStepDimmingColorizer (renamed from SealedStepItalicColorizer) applies a dimmed foreground brush instead of flipping typeface. Foreground brush changes are paint-time only; no shape/font work. - SealedStepCogGenerator reads end offsets from the cached dict. - RenderContext exposes the sealed snapshot to layers so they read the same data via the same surface as StatementRanges/Diagnostics. --- src/SharpFM/Editors/ScriptClipEditor.cs | 91 +++++++++++- .../SealedSteps/SealedStepCogGenerator.cs | 83 ++++++----- .../SealedSteps/SealedStepDimmingColorizer.cs | 46 ++++++ .../SealedSteps/SealedStepItalicColorizer.cs | 60 -------- .../SealedSteps/SealedStepSquiggleRenderer.cs | 79 ----------- .../Editor/Pipeline/BracketMatchLayer.cs | 2 + .../Editor/Pipeline/ContinuationRailLayer.cs | 2 + .../Editor/Pipeline/ErrorMarkerLayer.cs | 5 + .../Scripting/Editor/Pipeline/IRenderLayer.cs | 51 +++++-- .../Editor/Pipeline/RenderContext.cs | 22 +++ .../Pipeline/ScriptEditorRenderPipeline.cs | 132 +++++++++++++++--- .../Editor/Pipeline/SealedStepLayer.cs | 108 ++++++++++++++ .../Pipeline/StatementHighlightLayer.cs | 2 + .../Editor/ScriptEditorController.cs | 29 ++-- .../Scripting/Editor/ScriptEditorTheme.cs | 18 +++ 15 files changed, 505 insertions(+), 225 deletions(-) create mode 100644 src/SharpFM/Editors/SealedSteps/SealedStepDimmingColorizer.cs delete mode 100644 src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs delete mode 100644 src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs create mode 100644 src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs index 0c74e75..b6c93c3 100644 --- a/src/SharpFM/Editors/ScriptClipEditor.cs +++ b/src/SharpFM/Editors/ScriptClipEditor.cs @@ -40,6 +40,18 @@ public class ScriptClipEditor : IClipEditor // the anchor is considered stale and ignored. private readonly Dictionary _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? _sealedLineNumbersCache; + private Dictionary? _sealedEndOffsetsCache; // line# -> end offset + private bool _sealedCacheDirty = true; + public event EventHandler? ContentChanged; /// The TextDocument bound to the AvaloniaEdit script editor. @@ -55,7 +67,81 @@ public ScriptClipEditor(string? xml) BuildSealedAnchors(); _debouncer = new DebouncedEventRaiser(500, () => ContentChanged?.Invoke(this, EventArgs.Empty)); - Document.TextChanged += (_, _) => _debouncer.Trigger(); + Document.TextChanged += (_, _) => + { + _sealedCacheDirty = true; + _debouncer.Trigger(); + }; + } + + /// + /// 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. + /// + internal IReadOnlySet SealedLineNumbers + { + get + { + EnsureSealedCache(); + return _sealedLineNumbersCache!; + } + } + + /// + /// 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. + /// + internal IReadOnlyDictionary SealedLineEndOffsets + { + get + { + EnsureSealedCache(); + return _sealedEndOffsetsCache!; + } + } + + private void EnsureSealedCache() + { + if (!_sealedCacheDirty && _sealedLineNumbersCache != null) return; + + var lineNumbers = new HashSet(); + var endOffsets = new Dictionary(); + 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; + } + + /// + /// Raised after the sealed-line cache has been invalidated and the + /// next read rebuilt it. Pipeline consumers subscribe to refresh + /// their snapshot views and trigger a debounced repaint. + /// + internal event EventHandler? SealedCacheChanged; + + /// + /// Force-invalidate the sealed-line cache. Called from paths that + /// mutate outside a TextChanged event + /// (BuildSealedAnchors, UpdateSealedXml, RebuildFromDocument). + /// + private void InvalidateSealedCache() + { + _sealedCacheDirty = true; + SealedCacheChanged?.Invoke(this, EventArgs.Empty); } /// @@ -122,6 +208,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; } @@ -220,6 +307,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); } @@ -257,5 +345,6 @@ private void BuildSealedAnchors() anchor.SurviveDeletion = false; _sealedAnchors[anchor] = (step.ToXml(), lineText); } + InvalidateSealedCache(); } } diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs b/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs index 6cd8ad5..0593622 100644 --- a/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs +++ b/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Avalonia; using Avalonia.Controls; @@ -10,10 +11,10 @@ namespace SharpFM.Editors.SealedSteps; /// /// Inline element generator that places a small clickable cog button -/// at the end of every sealed-step line. Clicking the cog raises the -/// 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 +/// map for O(1) +/// lookup — no per-call iteration over the anchor dictionary, no +/// per-anchor Document.GetText string allocations. /// [ExcludeFromCodeCoverage] public class SealedStepCogGenerator : VisualLineElementGenerator @@ -29,58 +30,64 @@ public SealedStepCogGenerator(ScriptClipEditor editor) public override int GetFirstInterestedOffset(int startOffset) { - // Fast exit: skip the SealedAnchors enumerator when no sealed - // steps exist. AvaloniaEdit calls this per visual line during - // line construction. if (!_editor.HasSealedAnchors) return -1; - // 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. + 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); } } diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepDimmingColorizer.cs b/src/SharpFM/Editors/SealedSteps/SealedStepDimmingColorizer.cs new file mode 100644 index 0000000..cf50241 --- /dev/null +++ b/src/SharpFM/Editors/SealedSteps/SealedStepDimmingColorizer.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; +using SharpFM.Scripting.Editor; + +namespace SharpFM.Editors.SealedSteps; + +/// +/// Applies a dimmed foreground brush to sealed-step lines so they read +/// as "annotation / read-only summary" rather than as first-class code. +/// +/// Earlier versions flipped the typeface to italic — that drove a font +/// lookup and a ShapeTextRuns pass per text run per layout, and +/// became the dominant cost of every keystroke. A foreground-brush +/// change is paint-time only; it does not invalidate shape state, so +/// the colorizer's per-line cost is now an O(1) hashset lookup plus +/// one SetForegroundBrush call per sealed line. +/// +/// +/// Backed by — a +/// per-document cache invalidated on Document.TextChanged — +/// so the colorizer never iterates anchors and never allocates the +/// per-anchor signature strings the legacy implementation did. +/// +/// +[ExcludeFromCodeCoverage] +public class SealedStepDimmingColorizer : DocumentColorizingTransformer +{ + private readonly ScriptClipEditor _editor; + + public SealedStepDimmingColorizer(ScriptClipEditor editor) + { + _editor = editor; + } + + protected override void ColorizeLine(DocumentLine line) + { + if (!_editor.HasSealedAnchors) return; + if (!_editor.SealedLineNumbers.Contains(line.LineNumber)) return; + + ChangeLinePart(line.Offset, line.EndOffset, element => + { + element.TextRunProperties.SetForegroundBrush(ScriptEditorTheme.SealedTextBrush); + }); + } +} diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs b/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs deleted file mode 100644 index 042eecc..0000000 --- a/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using Avalonia.Media; -using AvaloniaEdit.Document; -using AvaloniaEdit.Rendering; - -namespace SharpFM.Editors.SealedSteps; - -/// -/// Applies italic font style to sealed-step lines so they read as -/// "annotation / read-only summary" rather than as first-class code. -/// -[ExcludeFromCodeCoverage] -public class SealedStepItalicColorizer : DocumentColorizingTransformer -{ - // Italic Typeface struct keyed by FontFamily — reused across every - // SetTypeface call so we don't allocate a fresh struct per text run - // per visible line per layout. Avalonia's font cache eventually - // hits, but each fresh Typeface instance still drives a lookup. - private static readonly ConcurrentDictionary ItalicCache = new(); - - private readonly ScriptClipEditor _editor; - - public SealedStepItalicColorizer(ScriptClipEditor editor) - { - _editor = editor; - } - - protected override void ColorizeLine(DocumentLine line) - { - // Fast exit: skip the SealedAnchors enumerator allocation entirely - // when there are no sealed anchors. ColorizeLine fires per visible - // line per layout — a hot path even when the body would no-op. - if (!_editor.HasSealedAnchors) return; - - var doc = CurrentContext.Document; - foreach (var anchor in _editor.SealedAnchors) - { - if (anchor.IsDeleted) continue; - if (anchor.Offset < 0 || anchor.Offset > doc.TextLength) continue; - var anchorLine = doc.GetLineByOffset(anchor.Offset); - if (anchorLine.LineNumber != line.LineNumber) continue; - - ChangeLinePart(line.Offset, line.EndOffset, element => - { - var current = element.TextRunProperties.Typeface; - // Skip the SetTypeface call entirely when the run is - // already italic — Avalonia treats every SetTypeface - // as a state change that drives ShapeTextRuns and - // glyph-typeface resolution. - if (current.Style == FontStyle.Italic) return; - element.TextRunProperties.SetTypeface(GetItalic(current.FontFamily)); - }); - return; - } - } - - private static Typeface GetItalic(FontFamily family) => - ItalicCache.GetOrAdd(family, static f => new Typeface(f, FontStyle.Italic)); -} diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs b/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs deleted file mode 100644 index 73a754c..0000000 --- a/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Avalonia; -using Avalonia.Media; -using AvaloniaEdit.Document; -using AvaloniaEdit.Editing; -using AvaloniaEdit.Rendering; -using SharpFM.Scripting.Editor; - -namespace SharpFM.Editors.SealedSteps; - -/// -/// Draws a yellow zigzag underline across sealed-step lines — visual -/// cue that the line is read-only and editable only through the raw -/// XML popup. Uses the same geometry pattern as -/// but in a warning-gold brush. -/// -[ExcludeFromCodeCoverage] -public class SealedStepSquiggleRenderer : IBackgroundRenderer -{ - private readonly TextArea _textArea; - private readonly ScriptClipEditor _editor; - - public SealedStepSquiggleRenderer(TextArea textArea, ScriptClipEditor editor) - { - _textArea = textArea; - _editor = editor; - } - - public KnownLayer Layer => KnownLayer.Selection; - - public void Draw(TextView textView, DrawingContext drawingContext) - { - var doc = _textArea.Document; - if (doc == null) return; - // Fast exit: avoid the SealedAnchors enumerator allocation when - // the script has zero sealed steps. Draw fires per paint. - if (!_editor.HasSealedAnchors) return; - - 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); - var segment = new TextSegment - { - StartOffset = line.Offset, - EndOffset = line.EndOffset - }; - - foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) - { - DrawZigzag(drawingContext, rect.BottomLeft, rect.BottomRight); - } - } - } - - private static void DrawZigzag(DrawingContext ctx, Point left, Point right) - { - var geometry = new StreamGeometry(); - using (var g = geometry.Open()) - { - const double amp = 1.5; - const double period = 4.0; - double x = left.X; - double baseY = left.Y - 1; - g.BeginFigure(new Point(x, baseY), false); - bool up = true; - while (x < right.X) - { - x += period / 2; - var y = up ? baseY - amp : baseY + amp; - g.LineTo(new Point(x, y)); - up = !up; - } - } - ctx.DrawGeometry(null, ScriptEditorTheme.SealedSquigglePen, geometry); - } -} diff --git a/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs index 9e8cf2d..2462299 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs @@ -14,6 +14,8 @@ namespace SharpFM.Scripting.Editor.Pipeline; internal sealed class BracketMatchLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Selection; + public RenderCadence Cadence => RenderCadence.Realtime; + public bool OnTextChanged(RenderContext ctx) => false; private int _openOffset = -1; private int _closeOffset = -1; diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs index 5809b32..bf464de 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs @@ -14,8 +14,10 @@ namespace SharpFM.Scripting.Editor.Pipeline; internal sealed class ContinuationRailLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Background; + public RenderCadence Cadence => RenderCadence.Realtime; public bool OnCaretChanged(RenderContext ctx) => false; + public bool OnTextChanged(RenderContext ctx) => false; public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) { diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs index c12aa9e..b6e4027 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs @@ -15,8 +15,13 @@ namespace SharpFM.Scripting.Editor.Pipeline; internal sealed class ErrorMarkerLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Selection; + // Diagnostics list is pushed in via the pipeline's UpdateDiagnostics + // path (the validator runs on its own debounce). Neither caret moves + // nor raw TextChanged need to recompute anything here. + public RenderCadence Cadence => RenderCadence.Realtime; public bool OnCaretChanged(RenderContext ctx) => false; + public bool OnTextChanged(RenderContext ctx) => false; /// /// Find the diagnostic at , or null. Used diff --git a/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs index b5f388c..b4d3735 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs @@ -3,14 +3,35 @@ namespace SharpFM.Scripting.Editor.Pipeline; +/// +/// When the pipeline asks the layer to recompute its state. +/// +/// Realtime: recomputed inside the pipeline's +/// handler — used when the +/// layer's draw output tracks the caret (bracket match, +/// statement highlight). +/// Idle: recomputed inside a debounced +/// handler — used when the +/// layer's draw output only changes with the document content, +/// not with the caret (sealed-step squiggle, error markers). +/// Skipping these out of the per-keystroke path keeps the hot +/// path tight without making the visual lag perceptible. +/// +/// +internal enum RenderCadence +{ + Realtime, + Idle, +} + /// /// One feature in the script editor's render pipeline (statement -/// highlight, bracket match, continuation rail, error squiggle). -/// Layers own only their own dirty state; everything else they read -/// from the shared . The pipeline owner -/// dispatches lifecycle events to every layer and consolidates -/// invalidations into at most one InvalidateLayer call per -/// affected . +/// highlight, bracket match, continuation rail, error squiggle, +/// sealed-step layer). Layers own only their own dirty state; +/// everything else they read from the shared . +/// The pipeline owner dispatches lifecycle events to every layer and +/// consolidates invalidations into at most one InvalidateLayer +/// call per affected . /// internal interface IRenderLayer { @@ -23,12 +44,24 @@ internal interface IRenderLayer KnownLayer TargetLayer { get; } /// - /// Caret position changed. Layer should update its internal state - /// and return true if its draw output would differ from - /// the previous paint. + /// When the pipeline should drive this layer's recompute step. + /// + RenderCadence Cadence { get; } + + /// + /// Caret position changed. Realtime layers do their work here. + /// Idle layers should return false. Returns true if the layer's + /// draw output would differ from the previous paint. /// bool OnCaretChanged(RenderContext ctx); + /// + /// Document text changed (debounced). Idle layers do their work + /// here. Realtime layers should return false. Returns true if + /// the layer's draw output would differ from the previous paint. + /// + bool OnTextChanged(RenderContext ctx); + /// /// Render the layer's contribution to the given drawing context. /// Called by AvaloniaEdit during paint via the IBackgroundRenderer diff --git a/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs b/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs index 545940c..e2fd000 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs @@ -3,6 +3,7 @@ using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; +using SharpFM.Editors; using SharpFM.Model.Scripting; namespace SharpFM.Scripting.Editor.Pipeline; @@ -32,6 +33,27 @@ public RenderContext(TextArea textArea) public IReadOnlyList<(int StartLine, int EndLine)> StatementRanges => Document is { } d ? CachedMultiLineRanges.Compute(d) : Array.Empty<(int, int)>(); + /// + /// Optional sealed-step source. Set by the pipeline when a + /// is attached so layers can read + /// the cached sealed-line snapshot through the context rather than + /// holding their own reference. + /// + public ScriptClipEditor? ClipEditor { get; private set; } + + public void SetClipEditor(ScriptClipEditor? clipEditor) => ClipEditor = clipEditor; + + private static readonly HashSet EmptyLineSet = new(); + private static readonly Dictionary EmptyEndOffsets = new(); + + /// Sealed line numbers (1-based), O(1) lookup. + public IReadOnlySet SealedLineNumbers => + ClipEditor?.SealedLineNumbers ?? EmptyLineSet; + + /// Sealed-line end offsets keyed by line number. + public IReadOnlyDictionary SealedLineEndOffsets => + ClipEditor?.SealedLineEndOffsets ?? EmptyEndOffsets; + public List Diagnostics { get; private set; } = new(); /// diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs b/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs index a9f28c6..40a97df 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs @@ -1,22 +1,28 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using Avalonia.Threading; +using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; +using SharpFM.Editors; using SharpFM.Model.Scripting; namespace SharpFM.Scripting.Editor.Pipeline; /// -/// Owns the script editor's background render layers and consolidates -/// their event handling + invalidation. Replaces the four standalone +/// Owns the script editor's render layers and consolidates their event +/// handling + invalidation. Replaces the four standalone /// IBackgroundRenderer implementations (bracket, statement, /// continuation, error) with one shared pipeline: /// /// /// One subscription to ; -/// layers are dispatched in order and each reports whether its -/// draw output changed. +/// realtime layers are dispatched in order and each reports whether +/// its draw output changed. +/// One debounced subscription to Document.TextChanged; +/// idle layers are recomputed once per quiet window so per-keystroke +/// work stays out of the hot path. /// Two wrappers — one per /// (Background + Selection) — host the /// feature layers in their correct visual stacking order. @@ -31,14 +37,21 @@ namespace SharpFM.Scripting.Editor.Pipeline; [ExcludeFromCodeCoverage] public sealed class ScriptEditorRenderPipeline : IDisposable { + private const int IdleDebounceMs = 150; + private readonly TextArea _textArea; private readonly RenderContext _context; private readonly StatementHighlightLayer _statementLayer; private readonly ContinuationRailLayer _continuationLayer; private readonly BracketMatchLayer _bracketLayer; private readonly ErrorMarkerLayer _errorLayer; + private readonly SealedStepLayer _sealedLayer; private readonly LayeredBackgroundRenderer _bgRenderer; private readonly LayeredBackgroundRenderer _selRenderer; + private readonly DispatcherTimer _idleTimer; + private readonly IRenderLayer[] _allLayers; + private TextDocument? _attachedDocument; + private ScriptClipEditor? _clipEditor; private bool _disposed; public ScriptEditorRenderPipeline(TextArea textArea) @@ -50,6 +63,13 @@ public ScriptEditorRenderPipeline(TextArea textArea) _continuationLayer = new ContinuationRailLayer(); _bracketLayer = new BracketMatchLayer(); _errorLayer = new ErrorMarkerLayer(); + _sealedLayer = new SealedStepLayer(); + + _allLayers = new IRenderLayer[] + { + _statementLayer, _continuationLayer, + _bracketLayer, _errorLayer, _sealedLayer, + }; _bgRenderer = new LayeredBackgroundRenderer( _context, @@ -59,12 +79,40 @@ public ScriptEditorRenderPipeline(TextArea textArea) _selRenderer = new LayeredBackgroundRenderer( _context, KnownLayer.Selection, - new IRenderLayer[] { _bracketLayer, _errorLayer }); + new IRenderLayer[] { _sealedLayer, _bracketLayer, _errorLayer }); textArea.TextView.BackgroundRenderers.Add(_bgRenderer); textArea.TextView.BackgroundRenderers.Add(_selRenderer); textArea.Caret.PositionChanged += OnCaretChanged; + + _idleTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(IdleDebounceMs) }; + _idleTimer.Tick += OnIdleTick; + + AttachDocument(textArea.Document); + textArea.DocumentChanged += OnTextAreaDocumentChanged; + } + + /// + /// Wire the pipeline to a clip editor so layers (sealed-step layer + /// today; possibly more later) can read its cached snapshot through + /// . Pass null + /// to detach. + /// + public void AttachClipEditor(ScriptClipEditor? clipEditor) + { + if (_clipEditor != null) + _clipEditor.SealedCacheChanged -= OnSealedCacheChanged; + + _clipEditor = clipEditor; + _context.SetClipEditor(clipEditor); + + if (clipEditor != null) + clipEditor.SealedCacheChanged += OnSealedCacheChanged; + + // Force a fresh idle pass so the sealed layer recomputes + // immediately under the new clip's snapshot. + ScheduleIdleRecompute(); } /// @@ -91,39 +139,79 @@ private void OnCaretChanged(object? sender, EventArgs e) var bgDirty = false; var selDirty = false; - if (_statementLayer.OnCaretChanged(_context)) - { - if (_statementLayer.TargetLayer == KnownLayer.Background) bgDirty = true; - else selDirty = true; - } - if (_continuationLayer.OnCaretChanged(_context)) + foreach (var layer in _allLayers) { - if (_continuationLayer.TargetLayer == KnownLayer.Background) bgDirty = true; + if (layer.Cadence != RenderCadence.Realtime) continue; + if (!layer.OnCaretChanged(_context)) continue; + if (layer.TargetLayer == KnownLayer.Background) bgDirty = true; else selDirty = true; } - if (_bracketLayer.OnCaretChanged(_context)) - { - if (_bracketLayer.TargetLayer == KnownLayer.Background) bgDirty = true; - else selDirty = true; - } - if (_errorLayer.OnCaretChanged(_context)) + + if (bgDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Background); + if (selDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Selection); + } + + private void OnIdleTick(object? sender, EventArgs e) + { + _idleTimer.Stop(); + + var bgDirty = false; + var selDirty = false; + + foreach (var layer in _allLayers) { - if (_errorLayer.TargetLayer == KnownLayer.Background) bgDirty = true; + if (layer.Cadence != RenderCadence.Idle) continue; + if (!layer.OnTextChanged(_context)) continue; + if (layer.TargetLayer == KnownLayer.Background) bgDirty = true; else selDirty = true; } - // One InvalidateLayer call per dirty layer, max one per known - // target. Multiple feature layers reporting dirty on the same - // KnownLayer collapse into a single repaint. if (bgDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Background); if (selDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Selection); } + private void ScheduleIdleRecompute() + { + _idleTimer.Stop(); + _idleTimer.Start(); + } + + private void OnDocumentTextChanged(object? sender, EventArgs e) => + ScheduleIdleRecompute(); + + private void OnSealedCacheChanged(object? sender, EventArgs e) => + ScheduleIdleRecompute(); + + private void OnTextAreaDocumentChanged(object? sender, EventArgs e) + { + AttachDocument(_textArea.Document); + // New document means the cached snapshot the layers hold may + // not match the doc that's about to paint — recompute soon. + ScheduleIdleRecompute(); + } + + private void AttachDocument(TextDocument? document) + { + if (ReferenceEquals(_attachedDocument, document)) return; + if (_attachedDocument != null) + _attachedDocument.TextChanged -= OnDocumentTextChanged; + _attachedDocument = document; + if (document != null) + document.TextChanged += OnDocumentTextChanged; + } + public void Dispose() { if (_disposed) return; _disposed = true; + _idleTimer.Stop(); + _idleTimer.Tick -= OnIdleTick; _textArea.Caret.PositionChanged -= OnCaretChanged; + _textArea.DocumentChanged -= OnTextAreaDocumentChanged; + if (_attachedDocument != null) + _attachedDocument.TextChanged -= OnDocumentTextChanged; + if (_clipEditor != null) + _clipEditor.SealedCacheChanged -= OnSealedCacheChanged; _textArea.TextView.BackgroundRenderers.Remove(_bgRenderer); _textArea.TextView.BackgroundRenderers.Remove(_selRenderer); } diff --git a/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs new file mode 100644 index 0000000..18af2af --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs @@ -0,0 +1,108 @@ +using Avalonia; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// Draws sealed-step background cues: a left-edge accent stripe across +/// every sealed line and a gold zigzag underline beneath. Reads sealed +/// line numbers from the shared snapshot +/// (no per-line anchor walks, no per-paint string allocations) and +/// recomputes only on idle ticks driven by Document.TextChanged. +/// +/// Replaces the standalone SealedStepSquiggleRenderer and lifts +/// its work into the pipeline so a single InvalidateLayer call +/// can collapse multiple feature changes into one repaint. +/// +internal sealed class SealedStepLayer : IRenderLayer +{ + public KnownLayer TargetLayer => KnownLayer.Selection; + public RenderCadence Cadence => RenderCadence.Idle; + + // Snapshot of the sealed-line set captured at the last idle tick. + // Compared on the next tick to decide whether the layer needs to + // repaint at all. + private int[] _lastLineSet = System.Array.Empty(); + + public bool OnCaretChanged(RenderContext ctx) => false; + + public bool OnTextChanged(RenderContext ctx) + { + var current = ctx.SealedLineNumbers; + if (current.Count == _lastLineSet.Length) + { + var allSame = true; + foreach (var n in _lastLineSet) + { + if (!current.Contains(n)) { allSame = false; break; } + } + if (allSame) return false; + } + + var snapshot = new int[current.Count]; + var i = 0; + foreach (var n in current) snapshot[i++] = n; + _lastLineSet = snapshot; + return true; + } + + public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) + { + var doc = ctx.Document; + if (doc == null) return; + + var sealedLines = ctx.SealedLineNumbers; + if (sealedLines.Count == 0) return; + + foreach (var lineNumber in sealedLines) + { + if (lineNumber < 1 || lineNumber > doc.LineCount) continue; + var line = doc.GetLineByNumber(lineNumber); + var segment = new TextSegment + { + StartOffset = line.Offset, + EndOffset = line.EndOffset, + }; + + foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) + { + DrawLeftStripe(dc, rect); + DrawZigzag(dc, rect.BottomLeft, rect.BottomRight); + } + } + } + + private static void DrawLeftStripe(DrawingContext dc, Rect rect) + { + var stripe = new Rect( + rect.X, + rect.Y, + ScriptEditorTheme.SealedLeftStripeWidth, + rect.Height); + dc.DrawRectangle(ScriptEditorTheme.SealedLeftStripeBrush, null, stripe); + } + + private static void DrawZigzag(DrawingContext dc, Point left, Point right) + { + var geometry = new StreamGeometry(); + using (var g = geometry.Open()) + { + const double amp = 1.5; + const double period = 4.0; + double x = left.X; + double baseY = left.Y - 1; + g.BeginFigure(new Point(x, baseY), false); + bool up = true; + while (x < right.X) + { + x += period / 2; + var y = up ? baseY - amp : baseY + amp; + g.LineTo(new Point(x, y)); + up = !up; + } + } + dc.DrawGeometry(null, ScriptEditorTheme.SealedSquigglePen, geometry); + } +} diff --git a/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs index 64c8182..dabdfdb 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs @@ -15,6 +15,8 @@ namespace SharpFM.Scripting.Editor.Pipeline; internal sealed class StatementHighlightLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Background; + public RenderCadence Cadence => RenderCadence.Realtime; + public bool OnTextChanged(RenderContext ctx) => false; private int _highlightStartLine = -1; private int _highlightEndLine = -1; diff --git a/src/SharpFM/Scripting/Editor/ScriptEditorController.cs b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs index 5f0c8d4..62d8aed 100644 --- a/src/SharpFM/Scripting/Editor/ScriptEditorController.cs +++ b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs @@ -31,8 +31,10 @@ public class ScriptEditorController : IDisposable // references against their original document, and AvaloniaEdit will // keep invoking them against whatever document the shared TextView // is now displaying — offsets go out of range and throw. - private SealedStepSquiggleRenderer? _sealedSquiggleRenderer; - private SealedStepItalicColorizer? _sealedItalicColorizer; + // (The sealed-step squiggle now lives in the render pipeline as + // SealedStepLayer; only the colorizer and cog generator are still + // hosted directly on the editor.) + private SealedStepDimmingColorizer? _sealedDimmingColorizer; private SealedStepCogGenerator? _cogGenerator; /// @@ -208,13 +210,12 @@ public void AttachClipEditor(ScriptClipEditor clipEditor) DetachSealedStepRenderers(); _clipEditor = clipEditor; + // Pipeline gains access to the clip's sealed-line snapshot so + // SealedStepLayer can paint without owning its own subscription. + _renderPipeline.AttachClipEditor(clipEditor); - // Squiggle + italic renderers read from clipEditor.SealedAnchors. - _sealedSquiggleRenderer = new SealedStepSquiggleRenderer(_editor.TextArea, clipEditor); - _editor.TextArea.TextView.BackgroundRenderers.Add(_sealedSquiggleRenderer); - - _sealedItalicColorizer = new SealedStepItalicColorizer(clipEditor); - _editor.TextArea.TextView.LineTransformers.Add(_sealedItalicColorizer); + _sealedDimmingColorizer = new SealedStepDimmingColorizer(clipEditor); + _editor.TextArea.TextView.LineTransformers.Add(_sealedDimmingColorizer); // Cog-button inline element + click handler. _cogGenerator = new SealedStepCogGenerator(clipEditor); @@ -228,15 +229,11 @@ public void AttachClipEditor(ScriptClipEditor clipEditor) private void DetachSealedStepRenderers() { - if (_sealedSquiggleRenderer != null) - { - _editor.TextArea.TextView.BackgroundRenderers.Remove(_sealedSquiggleRenderer); - _sealedSquiggleRenderer = null; - } - if (_sealedItalicColorizer != null) + _renderPipeline.AttachClipEditor(null); + if (_sealedDimmingColorizer != null) { - _editor.TextArea.TextView.LineTransformers.Remove(_sealedItalicColorizer); - _sealedItalicColorizer = null; + _editor.TextArea.TextView.LineTransformers.Remove(_sealedDimmingColorizer); + _sealedDimmingColorizer = null; } if (_cogGenerator != null) { diff --git a/src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs b/src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs index 8376936..da419e5 100644 --- a/src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs +++ b/src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs @@ -17,4 +17,22 @@ internal static class ScriptEditorTheme internal static readonly IBrush StatementHighlightBrush = new SolidColorBrush(Color.FromArgb(20, 100, 180, 255)); internal static readonly IPen ContinuationRailPen = new Pen(new SolidColorBrush(Color.FromArgb(100, 100, 180, 255)), 1.0); internal static readonly IPen SealedSquigglePen = new Pen(new SolidColorBrush(Color.FromArgb(220, 220, 170, 40)), 1.0); + + /// + /// Foreground brush applied to sealed-step text. Same hue as the + /// default editor text but at reduced alpha — reads as "muted / + /// not first-class" without flipping typeface (which would force + /// AvaloniaEdit to re-shape the run on every layout). + /// + internal static readonly IBrush SealedTextBrush = new SolidColorBrush(Color.FromArgb(150, 200, 200, 200)); + + /// + /// Brush filling the thin accent stripe drawn at the left edge of + /// every sealed-step line. Same gold family as the squiggle so the + /// two cues read as one feature. + /// + internal static readonly IBrush SealedLeftStripeBrush = new SolidColorBrush(Color.FromArgb(180, 220, 170, 40)); + + /// Width of the left-edge sealed-step accent stripe. + internal const double SealedLeftStripeWidth = 3.0; } From bcb23a9be38a1354940ffa041b7c21fe408d0bb6 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 20:29:30 -0500 Subject: [PATCH 09/13] perf: skip built-in line-number margin on script editor Trace showed LineNumberMargin.Render consuming 5.8s/30s. Factory was setting ShowLineNumbers=true and the controller was flipping it off, but the margin can re-attach when the editor template re-applies. The controller installs StepIndexMargin regardless, so set it to false up front. --- src/SharpFM/Editors/ClipEditorViewFactory.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/SharpFM/Editors/ClipEditorViewFactory.cs b/src/SharpFM/Editors/ClipEditorViewFactory.cs index 2352efa..fa1f569 100644 --- a/src/SharpFM/Editors/ClipEditorViewFactory.cs +++ b/src/SharpFM/Editors/ClipEditorViewFactory.cs @@ -25,7 +25,12 @@ public static class ClipEditorViewFactory 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, }, From ada21f681c8ab115da5bbcc23ade771a09a3c8dd Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 20:37:09 -0500 Subject: [PATCH 10/13] perf: collapse editor font family to single name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trace showed 2321 native font lookups in 30s — about 8.7 per visual- line build. Cause: the editor's font family was a comma-separated fallback chain "Cascadia Code,Consolas,Menlo,Monospace". Three of those four names don't exist on this Linux box (fc-match resolves Cascadia/Menlo to Noto Sans, a proportional face); every text-run shape pass was walking the chain looking for missing fonts before landing on the resolved face. Fontconfig's generic "Monospace" alias resolves directly to the platform's default monospace face on each OS, so a single name replaces the chain without losing portability. --- src/SharpFM/Editors/ClipEditorViewFactory.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/SharpFM/Editors/ClipEditorViewFactory.cs b/src/SharpFM/Editors/ClipEditorViewFactory.cs index fa1f569..2db1786 100644 --- a/src/SharpFM/Editors/ClipEditorViewFactory.cs +++ b/src/SharpFM/Editors/ClipEditorViewFactory.cs @@ -17,8 +17,17 @@ namespace SharpFM.Editors; [ExcludeFromCodeCoverage] public static class ClipEditorViewFactory { - private static readonly FontFamily MonoFont = - new("Cascadia Code,Consolas,Menlo,Monospace"); + // Use a single font name so Avalonia/Skia resolves through the + // platform font manager once per Typeface request. The previous + // comma-separated fallback chain ("Cascadia Code,Consolas,Menlo, + // Monospace") forced every text-run shape pass to walk the chain + // looking for missing fonts on platforms where most of those + // names don't resolve — on Linux, three of the four miss, and the + // trace showed 2321 native font lookups in a 30s window with + // ~8.7 lookups per visual-line build. "Monospace" is the standard + // generic alias resolved to the platform's default monospace face + // (DejaVu Sans Mono on Linux, Menlo on macOS, Consolas on Windows). + private static readonly FontFamily MonoFont = new("Monospace"); public static Control Create(IClipEditor editor) => editor switch { From c6c8563ea7f6776fc755ee03be0897aee46f3184 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 20:50:11 -0500 Subject: [PATCH 11/13] perf: resolve editor font to a real installed face name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trace showed 1283 typeface lookups per 30s, ~100% of which missed Avalonia's font cache. Cause: SystemFontCollection's cache is keyed by the literal family-name string we ask for, but populated under the RESOLVED face name. Asking for the fontconfig alias "Monospace" never hits a cache entry stored under "DejaVu Sans Mono" — every text-run shape pass re-runs the full system-font lookup. Probe FontManager.Current.SystemFonts at startup and pick the first entry from a platform-appropriate preference list (Cascadia/Consolas on Windows, Menlo/Monaco on macOS, JetBrains Mono / DejaVu Sans Mono on Linux). With a real face name in the editor's FontFamily, the second and every subsequent typeface lookup is a hashtable hit. A user-visible font picker is a follow-up; this lands the perf fix. --- src/SharpFM/Editors/ClipEditorViewFactory.cs | 57 ++++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/src/SharpFM/Editors/ClipEditorViewFactory.cs b/src/SharpFM/Editors/ClipEditorViewFactory.cs index 2db1786..12d6b86 100644 --- a/src/SharpFM/Editors/ClipEditorViewFactory.cs +++ b/src/SharpFM/Editors/ClipEditorViewFactory.cs @@ -1,4 +1,6 @@ +using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Avalonia.Controls; using Avalonia.Media; using AvaloniaEdit; @@ -17,17 +19,50 @@ namespace SharpFM.Editors; [ExcludeFromCodeCoverage] public static class ClipEditorViewFactory { - // Use a single font name so Avalonia/Skia resolves through the - // platform font manager once per Typeface request. The previous - // comma-separated fallback chain ("Cascadia Code,Consolas,Menlo, - // Monospace") forced every text-run shape pass to walk the chain - // looking for missing fonts on platforms where most of those - // names don't resolve — on Linux, three of the four miss, and the - // trace showed 2321 native font lookups in a 30s window with - // ~8.7 lookups per visual-line build. "Monospace" is the standard - // generic alias resolved to the platform's default monospace face - // (DejaVu Sans Mono on Linux, Menlo on macOS, Consolas on Windows). - private static readonly FontFamily MonoFont = new("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 { From 7bfdbf4c1983d8943813aa6497254d176bb6d1aa Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 21:00:43 -0500 Subject: [PATCH 12/13] refactor: drop idle cadence apparatus from render pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the editor font was resolved to a real installed face name, typeface lookups dropped 99% and every "expensive" path on the caret/text-change side became cheap enough that idle scheduling saves nothing measurable. Strip the complexity: - Remove RenderCadence enum, OnTextChanged from IRenderLayer, and the per-layer Cadence/OnTextChanged overrides. - Remove the DispatcherTimer, AttachDocument/OnDocumentTextChanged, OnIdleTick, OnTextAreaDocumentChanged, ScheduleIdleRecompute, and SealedCacheChanged event plumbing. - SealedStepLayer reads the sealed-line snapshot directly during Draw — no _lastLineSet, no diffing, no idle dispatch. - AttachClipEditor still wires the snapshot into RenderContext and invalidates the Selection layer once so the new clip's stripe + squiggle paint immediately. The shared sealed-line cache on ScriptClipEditor stays — it still saves per-paint string allocations through SignatureMatches. The event raise on cache invalidation is gone since nothing subscribes. Net: -162 lines. --- src/SharpFM/Editors/ScriptClipEditor.cs | 13 +-- .../Editor/Pipeline/BracketMatchLayer.cs | 2 - .../Editor/Pipeline/ContinuationRailLayer.cs | 2 - .../Editor/Pipeline/ErrorMarkerLayer.cs | 7 +- .../Scripting/Editor/Pipeline/IRenderLayer.cs | 39 +------- .../Pipeline/ScriptEditorRenderPipeline.cs | 99 ++----------------- .../Editor/Pipeline/SealedStepLayer.cs | 34 +------ .../Pipeline/StatementHighlightLayer.cs | 2 - 8 files changed, 18 insertions(+), 180 deletions(-) diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs index b6c93c3..b4836d6 100644 --- a/src/SharpFM/Editors/ScriptClipEditor.cs +++ b/src/SharpFM/Editors/ScriptClipEditor.cs @@ -126,23 +126,12 @@ private void EnsureSealedCache() _sealedCacheDirty = false; } - /// - /// Raised after the sealed-line cache has been invalidated and the - /// next read rebuilt it. Pipeline consumers subscribe to refresh - /// their snapshot views and trigger a debounced repaint. - /// - internal event EventHandler? SealedCacheChanged; - /// /// Force-invalidate the sealed-line cache. Called from paths that /// mutate outside a TextChanged event /// (BuildSealedAnchors, UpdateSealedXml, RebuildFromDocument). /// - private void InvalidateSealedCache() - { - _sealedCacheDirty = true; - SealedCacheChanged?.Invoke(this, EventArgs.Empty); - } + private void InvalidateSealedCache() => _sealedCacheDirty = true; /// /// Live sealed anchors (entries whose line text still matches the diff --git a/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs index 2462299..9e8cf2d 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs @@ -14,8 +14,6 @@ namespace SharpFM.Scripting.Editor.Pipeline; internal sealed class BracketMatchLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Selection; - public RenderCadence Cadence => RenderCadence.Realtime; - public bool OnTextChanged(RenderContext ctx) => false; private int _openOffset = -1; private int _closeOffset = -1; diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs index bf464de..5809b32 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/ContinuationRailLayer.cs @@ -14,10 +14,8 @@ namespace SharpFM.Scripting.Editor.Pipeline; internal sealed class ContinuationRailLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Background; - public RenderCadence Cadence => RenderCadence.Realtime; public bool OnCaretChanged(RenderContext ctx) => false; - public bool OnTextChanged(RenderContext ctx) => false; public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) { diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs index b6e4027..ea765f3 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs @@ -16,12 +16,9 @@ internal sealed class ErrorMarkerLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Selection; // Diagnostics list is pushed in via the pipeline's UpdateDiagnostics - // path (the validator runs on its own debounce). Neither caret moves - // nor raw TextChanged need to recompute anything here. - public RenderCadence Cadence => RenderCadence.Realtime; - + // path (the validator runs on its own debounce). Caret moves don't + // affect the squiggle. public bool OnCaretChanged(RenderContext ctx) => false; - public bool OnTextChanged(RenderContext ctx) => false; /// /// Find the diagnostic at , or null. Used diff --git a/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs index b4d3735..d2cf793 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/IRenderLayer.cs @@ -3,27 +3,6 @@ namespace SharpFM.Scripting.Editor.Pipeline; -/// -/// When the pipeline asks the layer to recompute its state. -/// -/// Realtime: recomputed inside the pipeline's -/// handler — used when the -/// layer's draw output tracks the caret (bracket match, -/// statement highlight). -/// Idle: recomputed inside a debounced -/// handler — used when the -/// layer's draw output only changes with the document content, -/// not with the caret (sealed-step squiggle, error markers). -/// Skipping these out of the per-keystroke path keeps the hot -/// path tight without making the visual lag perceptible. -/// -/// -internal enum RenderCadence -{ - Realtime, - Idle, -} - /// /// One feature in the script editor's render pipeline (statement /// highlight, bracket match, continuation rail, error squiggle, @@ -44,24 +23,12 @@ internal interface IRenderLayer KnownLayer TargetLayer { get; } /// - /// When the pipeline should drive this layer's recompute step. - /// - RenderCadence Cadence { get; } - - /// - /// Caret position changed. Realtime layers do their work here. - /// Idle layers should return false. Returns true if the layer's - /// draw output would differ from the previous paint. + /// Caret position changed. Layer should update its internal state + /// and return true if its draw output would differ from + /// the previous paint. /// bool OnCaretChanged(RenderContext ctx); - /// - /// Document text changed (debounced). Idle layers do their work - /// here. Realtime layers should return false. Returns true if - /// the layer's draw output would differ from the previous paint. - /// - bool OnTextChanged(RenderContext ctx); - /// /// Render the layer's contribution to the given drawing context. /// Called by AvaloniaEdit during paint via the IBackgroundRenderer diff --git a/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs b/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs index 40a97df..99d0784 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using Avalonia.Threading; -using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; using SharpFM.Editors; @@ -12,17 +10,14 @@ namespace SharpFM.Scripting.Editor.Pipeline; /// /// Owns the script editor's render layers and consolidates their event -/// handling + invalidation. Replaces the four standalone +/// handling + invalidation. Replaces the standalone /// IBackgroundRenderer implementations (bracket, statement, -/// continuation, error) with one shared pipeline: +/// continuation, error, sealed-step) with one shared pipeline: /// /// /// One subscription to ; -/// realtime layers are dispatched in order and each reports whether -/// its draw output changed. -/// One debounced subscription to Document.TextChanged; -/// idle layers are recomputed once per quiet window so per-keystroke -/// work stays out of the hot path. +/// layers are dispatched in order and each reports whether its draw +/// output changed. /// Two wrappers — one per /// (Background + Selection) — host the /// feature layers in their correct visual stacking order. @@ -37,8 +32,6 @@ namespace SharpFM.Scripting.Editor.Pipeline; [ExcludeFromCodeCoverage] public sealed class ScriptEditorRenderPipeline : IDisposable { - private const int IdleDebounceMs = 150; - private readonly TextArea _textArea; private readonly RenderContext _context; private readonly StatementHighlightLayer _statementLayer; @@ -48,10 +41,7 @@ public sealed class ScriptEditorRenderPipeline : IDisposable private readonly SealedStepLayer _sealedLayer; private readonly LayeredBackgroundRenderer _bgRenderer; private readonly LayeredBackgroundRenderer _selRenderer; - private readonly DispatcherTimer _idleTimer; private readonly IRenderLayer[] _allLayers; - private TextDocument? _attachedDocument; - private ScriptClipEditor? _clipEditor; private bool _disposed; public ScriptEditorRenderPipeline(TextArea textArea) @@ -85,34 +75,22 @@ public ScriptEditorRenderPipeline(TextArea textArea) textArea.TextView.BackgroundRenderers.Add(_selRenderer); textArea.Caret.PositionChanged += OnCaretChanged; - - _idleTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(IdleDebounceMs) }; - _idleTimer.Tick += OnIdleTick; - - AttachDocument(textArea.Document); - textArea.DocumentChanged += OnTextAreaDocumentChanged; } /// /// Wire the pipeline to a clip editor so layers (sealed-step layer - /// today; possibly more later) can read its cached snapshot through + /// today) can read its cached snapshot through /// . Pass null /// to detach. /// public void AttachClipEditor(ScriptClipEditor? clipEditor) { - if (_clipEditor != null) - _clipEditor.SealedCacheChanged -= OnSealedCacheChanged; - - _clipEditor = clipEditor; _context.SetClipEditor(clipEditor); - - if (clipEditor != null) - clipEditor.SealedCacheChanged += OnSealedCacheChanged; - - // Force a fresh idle pass so the sealed layer recomputes - // immediately under the new clip's snapshot. - ScheduleIdleRecompute(); + // The Selection layer hosts the sealed-step layer; force a + // repaint so the new clip's stripe + squiggle render against + // the freshly-attached snapshot rather than whatever the + // previous clip left on screen. + _textArea.TextView.InvalidateLayer(KnownLayer.Selection); } /// @@ -141,7 +119,6 @@ private void OnCaretChanged(object? sender, EventArgs e) foreach (var layer in _allLayers) { - if (layer.Cadence != RenderCadence.Realtime) continue; if (!layer.OnCaretChanged(_context)) continue; if (layer.TargetLayer == KnownLayer.Background) bgDirty = true; else selDirty = true; @@ -151,67 +128,11 @@ private void OnCaretChanged(object? sender, EventArgs e) if (selDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Selection); } - private void OnIdleTick(object? sender, EventArgs e) - { - _idleTimer.Stop(); - - var bgDirty = false; - var selDirty = false; - - foreach (var layer in _allLayers) - { - if (layer.Cadence != RenderCadence.Idle) continue; - if (!layer.OnTextChanged(_context)) continue; - if (layer.TargetLayer == KnownLayer.Background) bgDirty = true; - else selDirty = true; - } - - if (bgDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Background); - if (selDirty) _textArea.TextView.InvalidateLayer(KnownLayer.Selection); - } - - private void ScheduleIdleRecompute() - { - _idleTimer.Stop(); - _idleTimer.Start(); - } - - private void OnDocumentTextChanged(object? sender, EventArgs e) => - ScheduleIdleRecompute(); - - private void OnSealedCacheChanged(object? sender, EventArgs e) => - ScheduleIdleRecompute(); - - private void OnTextAreaDocumentChanged(object? sender, EventArgs e) - { - AttachDocument(_textArea.Document); - // New document means the cached snapshot the layers hold may - // not match the doc that's about to paint — recompute soon. - ScheduleIdleRecompute(); - } - - private void AttachDocument(TextDocument? document) - { - if (ReferenceEquals(_attachedDocument, document)) return; - if (_attachedDocument != null) - _attachedDocument.TextChanged -= OnDocumentTextChanged; - _attachedDocument = document; - if (document != null) - document.TextChanged += OnDocumentTextChanged; - } - public void Dispose() { if (_disposed) return; _disposed = true; - _idleTimer.Stop(); - _idleTimer.Tick -= OnIdleTick; _textArea.Caret.PositionChanged -= OnCaretChanged; - _textArea.DocumentChanged -= OnTextAreaDocumentChanged; - if (_attachedDocument != null) - _attachedDocument.TextChanged -= OnDocumentTextChanged; - if (_clipEditor != null) - _clipEditor.SealedCacheChanged -= OnSealedCacheChanged; _textArea.TextView.BackgroundRenderers.Remove(_bgRenderer); _textArea.TextView.BackgroundRenderers.Remove(_selRenderer); } diff --git a/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs index 18af2af..e28eb99 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs @@ -9,45 +9,15 @@ namespace SharpFM.Scripting.Editor.Pipeline; /// Draws sealed-step background cues: a left-edge accent stripe across /// every sealed line and a gold zigzag underline beneath. Reads sealed /// line numbers from the shared snapshot -/// (no per-line anchor walks, no per-paint string allocations) and -/// recomputes only on idle ticks driven by Document.TextChanged. -/// -/// Replaces the standalone SealedStepSquiggleRenderer and lifts -/// its work into the pipeline so a single InvalidateLayer call -/// can collapse multiple feature changes into one repaint. +/// (no per-line anchor walks, no per-paint string allocations) so each +/// paint is an O(visible-sealed-lines) loop. /// internal sealed class SealedStepLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Selection; - public RenderCadence Cadence => RenderCadence.Idle; - - // Snapshot of the sealed-line set captured at the last idle tick. - // Compared on the next tick to decide whether the layer needs to - // repaint at all. - private int[] _lastLineSet = System.Array.Empty(); public bool OnCaretChanged(RenderContext ctx) => false; - public bool OnTextChanged(RenderContext ctx) - { - var current = ctx.SealedLineNumbers; - if (current.Count == _lastLineSet.Length) - { - var allSame = true; - foreach (var n in _lastLineSet) - { - if (!current.Contains(n)) { allSame = false; break; } - } - if (allSame) return false; - } - - var snapshot = new int[current.Count]; - var i = 0; - foreach (var n in current) snapshot[i++] = n; - _lastLineSet = snapshot; - return true; - } - public void Draw(RenderContext ctx, TextView textView, DrawingContext dc) { var doc = ctx.Document; diff --git a/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs index dabdfdb..64c8182 100644 --- a/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/StatementHighlightLayer.cs @@ -15,8 +15,6 @@ namespace SharpFM.Scripting.Editor.Pipeline; internal sealed class StatementHighlightLayer : IRenderLayer { public KnownLayer TargetLayer => KnownLayer.Background; - public RenderCadence Cadence => RenderCadence.Realtime; - public bool OnTextChanged(RenderContext ctx) => false; private int _highlightStartLine = -1; private int _highlightEndLine = -1; From 1724e5d735e335dccb1661f1660ac827946b3098 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 25 Apr 2026 23:13:04 -0500 Subject: [PATCH 13/13] test: cover sealed-line cache and multi-line ranges cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bits of pure-logic infra that drive the script editor's render path with no UI of their own: - ScriptClipEditor.SealedLineNumbers / SealedLineEndOffsets — the hot-path snapshot read by the dimming colorizer, cog generator, and sealed-step layer. Tests cover initial population, the empty case, line-number tracking through inserts, and entry pruning on whole-line deletion. - CachedMultiLineRanges — the per-document range cache reused by the continuation rail, statement highlight, and step-index margin. Tests cover the reference-equality contract (same version → same instance) and joint invalidation of ranges + step-index on edits. Drop the [ExcludeFromCodeCoverage] from CachedMultiLineRanges now that it has direct coverage. --- .../Scripting/Editor/CachedMultiLineRanges.cs | 2 - .../Editors/ScriptClipEditorTests.cs | 73 +++++++++++++++ .../Editor/CachedMultiLineRangesTests.cs | 88 +++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/SharpFM.Tests/Scripting/Editor/CachedMultiLineRangesTests.cs diff --git a/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs b/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs index 8631726..e509a34 100644 --- a/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs +++ b/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using AvaloniaEdit.Document; using SharpFM.Model.Scripting; @@ -17,7 +16,6 @@ namespace SharpFM.Scripting.Editor; /// , so closing a /// document drops its cache entry automatically. /// -[ExcludeFromCodeCoverage] public static class CachedMultiLineRanges { private sealed class CacheEntry diff --git a/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs b/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs index 536f064..f086ddb 100644 --- a/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs +++ b/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using SharpFM.Editors; using Xunit; @@ -86,4 +87,76 @@ public void UnknownStep_IsSealedForXmlEditorOnly() Assert.Contains("bar", emitted); Assert.Contains("FutureStep", emitted); } + + private const string OneSealedAtLineTwoXml = + "" + + "before" + + "bar" + + "after" + + ""; + + [Fact] + public void SealedLineNumbers_PointsAtSealedStepLine() + { + // The sealed FutureStep is the second display line; the + // colorizer / cog generator / sealed-step layer all read this + // set per paint and must see line 2. + var editor = new ScriptClipEditor(OneSealedAtLineTwoXml); + + Assert.Single(editor.SealedLineNumbers); + Assert.Contains(2, editor.SealedLineNumbers); + } + + [Fact] + public void SealedLineEndOffsets_KeyedByLineNumber() + { + var editor = new ScriptClipEditor(OneSealedAtLineTwoXml); + var line2 = editor.Document.GetLineByNumber(2); + + Assert.Single(editor.SealedLineEndOffsets); + Assert.Equal(line2.EndOffset, editor.SealedLineEndOffsets[2]); + } + + [Fact] + public void SealedLineNumbers_Empty_WhenNoSealedSteps() + { + var editor = new ScriptClipEditor(SampleScriptXml); + + Assert.Empty(editor.SealedLineNumbers); + Assert.Empty(editor.SealedLineEndOffsets); + } + + [Fact] + public void SealedLineNumbers_RecomputesAfterDocumentEdit() + { + // Inserting a fresh line above the sealed step should slide its + // line number from 2 → 3. Without cache invalidation the + // dimming colorizer would highlight the wrong physical line. + var editor = new ScriptClipEditor(OneSealedAtLineTwoXml); + Assert.Contains(2, editor.SealedLineNumbers); + + editor.Document.Insert(0, "# new top line\n"); + + Assert.Contains(3, editor.SealedLineNumbers); + Assert.DoesNotContain(2, editor.SealedLineNumbers); + var line3 = editor.Document.GetLineByNumber(3); + Assert.Equal(line3.EndOffset, editor.SealedLineEndOffsets[3]); + } + + [Fact] + public void SealedLineNumbers_DropsEntryWhenLineDeleted() + { + // Deleting the entire sealed line drops the anchor (its + // SurviveDeletion flag is false), so the cache should empty + // out on next read. + var editor = new ScriptClipEditor(OneSealedAtLineTwoXml); + var sealedLine = editor.Document.GetLineByNumber(2); + Assert.Contains(2, editor.SealedLineNumbers); + + // Delete sealed line including its trailing newline + editor.Document.Remove(sealedLine.Offset, sealedLine.TotalLength); + editor.ToXml(); // forces RebuildFromDocument → prunes dead anchors + + Assert.Empty(editor.SealedLineNumbers); + } } diff --git a/tests/SharpFM.Tests/Scripting/Editor/CachedMultiLineRangesTests.cs b/tests/SharpFM.Tests/Scripting/Editor/CachedMultiLineRangesTests.cs new file mode 100644 index 0000000..82e10e6 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Editor/CachedMultiLineRangesTests.cs @@ -0,0 +1,88 @@ +using AvaloniaEdit.Document; +using SharpFM.Scripting.Editor; +using Xunit; + +namespace SharpFM.Tests.Scripting.Editor; + +/// +/// Cache-key behavior for the per-document range cache that backs the +/// continuation rail, statement highlight, and step-index margin. +/// Regressions here surface as stale ranges → wrong renders, so the +/// reference-equality and post-edit invalidation guarantees matter. +/// +public class CachedMultiLineRangesTests +{ + private const string SampleText = + "If [ $x > 0 ]\n" + + "Set Variable [ $y ; Value: 1 ]\n" + + "End If"; + + [Fact] + public void Compute_SameVersion_ReturnsSameInstance() + { + // Reference-equality is the contract: callers (StatementHighlight + // layer, ContinuationRail layer, StepIndexMargin) all hit Compute + // independently within a single paint pass. They reuse one list + // only if the cache returns the same instance. + var doc = new TextDocument(SampleText); + + var first = CachedMultiLineRanges.Compute(doc); + var second = CachedMultiLineRanges.Compute(doc); + + Assert.Same(first, second); + } + + [Fact] + public void Compute_AfterEdit_ReturnsFreshRanges() + { + var doc = new TextDocument(SampleText); + var before = CachedMultiLineRanges.Compute(doc); + Assert.Equal(3, before.Count); + + doc.Insert(0, "Beep\n"); + var after = CachedMultiLineRanges.Compute(doc); + + Assert.NotSame(before, after); + Assert.Equal(4, after.Count); + } + + [Fact] + public void GetStepIndex_MapsFirstLineToStepNumber() + { + var doc = new TextDocument(SampleText); + var index = CachedMultiLineRanges.GetStepIndex(doc); + + Assert.Equal(1, index[1]); // If + Assert.Equal(2, index[2]); // Set Variable + Assert.Equal(3, index[3]); // End If + } + + [Fact] + public void GetStepIndex_SameVersion_ReturnsSameInstance() + { + var doc = new TextDocument(SampleText); + + var first = CachedMultiLineRanges.GetStepIndex(doc); + var second = CachedMultiLineRanges.GetStepIndex(doc); + + Assert.Same(first, second); + } + + [Fact] + public void GetStepIndex_AfterEdit_RecomputesAlongsideRanges() + { + // Step-index shares the cache version with Compute. If only the + // ranges entry was bumped on edit, the step-index would go stale + // and StepIndexMargin would render wrong numbers. + var doc = new TextDocument(SampleText); + var before = CachedMultiLineRanges.GetStepIndex(doc); + Assert.Equal(3, before.Count); + + doc.Insert(0, "Beep\n"); + var after = CachedMultiLineRanges.GetStepIndex(doc); + + Assert.NotSame(before, after); + Assert.Equal(4, after.Count); + Assert.Equal(1, after[1]); // newly-inserted Beep + } +}