diff --git a/src/SharpFM/Editors/ClipEditorViewFactory.cs b/src/SharpFM/Editors/ClipEditorViewFactory.cs index 2352efa..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,15 +19,62 @@ namespace SharpFM.Editors; [ExcludeFromCodeCoverage] public static class ClipEditorViewFactory { - private static readonly FontFamily MonoFont = - new("Cascadia Code,Consolas,Menlo,Monospace"); + // Resolve a real installed face name at startup. Avalonia's font + // cache (SystemFontCollection._glyphTypefaceCache) is keyed by the + // family-name string the caller asks for, but populated under the + // platform's RESOLVED face name — so requesting an alias like + // "Monospace" or "Cascadia Code" on a system that doesn't carry + // that face misses the cache forever. The trace showed every one + // of 1283 typeface lookups falling through to the slow path. By + // detecting an actually-installed face name once at startup and + // using it as the editor font, every subsequent typeface request + // hits the cache after the first. + private static readonly FontFamily MonoFont = ResolveMonospaceFont(); + + private static FontFamily ResolveMonospaceFont() + { + // Per-platform preference order. First entries are the "good" + // monospace fonts we'd pick if available; tail entries are + // safer last-resorts known to ship with the OS. + string[] preferred = + OperatingSystem.IsWindows() ? new[] { "Cascadia Code", "Cascadia Mono", "Consolas", "Lucida Console", "Courier New" } : + OperatingSystem.IsMacOS() ? new[] { "Menlo", "Monaco", "Courier New" } : + new[] { "JetBrains Mono", "DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "Ubuntu Mono" }; + + try + { + var installed = FontManager.Current.SystemFonts + .Select(f => f.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var name in preferred) + { + if (installed.Contains(name)) + return new FontFamily(name); + } + } + catch + { + // FontManager not yet available during certain init orderings. + // Fall through to the default below. + } + + // No known monospace face installed — accept the alias path. We + // pay the cache-miss cost but the editor still works. + return new FontFamily("Monospace"); + } public static Control Create(IClipEditor editor) => editor switch { ScriptClipEditor s => new ScriptTextEditor { FontFamily = MonoFont, - ShowLineNumbers = true, + // Step-index margin is installed by ScriptEditorController; + // skipping the built-in line-number margin avoids adding a + // margin we'll just remove (and that AvaloniaEdit can re-add + // if the editor's template is re-applied later — in the + // post-pipeline trace it was still rendering at 5.8s/30s). + ShowLineNumbers = false, WordWrap = false, DataContext = s, }, diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs index ee8e8aa..b4836d6 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,9 +67,72 @@ public ScriptClipEditor(string? xml) BuildSealedAnchors(); _debouncer = new DebouncedEventRaiser(500, () => ContentChanged?.Invoke(this, EventArgs.Empty)); - Document.TextChanged += (_, _) => _debouncer.Trigger(); + Document.TextChanged += (_, _) => + { + _sealedCacheDirty = true; + _debouncer.Trigger(); + }; + } + + /// + /// 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; } + /// + /// Force-invalidate the sealed-line cache. Called from paths that + /// mutate outside a TextChanged event + /// (BuildSealedAnchors, UpdateSealedXml, RebuildFromDocument). + /// + private void InvalidateSealedCache() => _sealedCacheDirty = true; + /// /// Live sealed anchors (entries whose line text still matches the /// signature captured at creation). Exposed for the editor's @@ -69,6 +144,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 @@ -111,6 +197,7 @@ internal bool UpdateSealedXml(TextAnchor anchor, XElement newXml) // Update the cache entry with the fresh XML + new signature. _sealedAnchors[anchor] = (new XElement(newXml), newDisplay); + InvalidateSealedCache(); return true; } @@ -154,8 +241,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(); @@ -210,6 +296,7 @@ private void RebuildFromDocument() .Select(kv => kv.Key) .ToList(); foreach (var d in dead) _sealedAnchors.Remove(d); + if (dead.Count > 0) InvalidateSealedCache(); _script = new FmScript(newSteps); } @@ -223,8 +310,7 @@ private void BuildSealedAnchors() { _sealedAnchors.Clear(); - var text = Document.Text; - var ranges = MultiLineStatementRanges.Compute(text); + var ranges = SharpFM.Scripting.Editor.CachedMultiLineRanges.Compute(Document); int stepIdx = 0; foreach (var (startLine, endLine) in ranges) @@ -248,5 +334,6 @@ private void BuildSealedAnchors() anchor.SurviveDeletion = false; _sealedAnchors[anchor] = (step.ToXml(), lineText); } + InvalidateSealedCache(); } } 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); } diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs b/src/SharpFM/Editors/SealedSteps/SealedStepCogGenerator.cs index 5249202..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,53 +30,64 @@ public SealedStepCogGenerator(ScriptClipEditor editor) public override int GetFirstInterestedOffset(int startOffset) { - // The cog sits at the end of each sealed line. Return the - // earliest end-of-line offset that belongs to a sealed anchor - // and is >= startOffset; -1 when no more. + if (!_editor.HasSealedAnchors) return -1; + + var endOffsets = _editor.SealedLineEndOffsets; + if (endOffsets.Count == 0) return -1; + int best = int.MaxValue; - var doc = CurrentContext.Document; - foreach (var anchor in _editor.SealedAnchors) + foreach (var end in endOffsets.Values) { - if (anchor.IsDeleted) continue; - // Defensive bounds check: a stale anchor from a previously - // attached clip could point past the end of the current doc. - if (anchor.Offset < 0 || anchor.Offset > doc.TextLength) continue; - var line = doc.GetLineByOffset(anchor.Offset); - if (line.EndOffset >= startOffset && line.EndOffset < best) - best = line.EndOffset; + if (end >= startOffset && end < best) + best = end; } return best == int.MaxValue ? -1 : best; } public override VisualLineElement? ConstructElement(int offset) { + if (!_editor.HasSealedAnchors) return null; + var endOffsets = _editor.SealedLineEndOffsets; + + // Verify offset matches a sealed line's end. Don't allocate a + // button for non-sealed line ends. + var matched = false; + foreach (var end in endOffsets.Values) + { + if (end == offset) { matched = true; break; } + } + if (!matched) return null; + + // Resolve which anchor lives on this line so the click handler + // can map back to the sealed step's XML. Iterating SealedAnchors + // is acceptable here (called once per sealed line construction, + // not per visible line per layout). Use a stable strategy: pick + // the anchor whose line contains the offset. var doc = CurrentContext.Document; + TextAnchor? hit = null; foreach (var anchor in _editor.SealedAnchors) { if (anchor.IsDeleted) continue; if (anchor.Offset < 0 || anchor.Offset > doc.TextLength) continue; var line = doc.GetLineByOffset(anchor.Offset); - if (line.EndOffset != offset) continue; - - var button = new Button - { - Content = "⚙", - Padding = new Thickness(4, 0), - Margin = new Thickness(6, 0, 0, 0), - FontSize = 12, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand), - }; + if (line.EndOffset == offset) { hit = anchor; break; } + } + if (hit == null) return null; - // Capture the anchor in the click handler so the subscriber - // can map back to the sealed step's XML. - var capturedAnchor = anchor; - button.Click += (_, _) => CogClicked?.Invoke(this, capturedAnchor); + var button = new Button + { + Content = "⚙", + Padding = new Thickness(4, 0), + Margin = new Thickness(6, 0, 0, 0), + FontSize = 12, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand), + }; - return new InlineObjectElement(0, button); - } + var capturedAnchor = hit; + button.Click += (_, _) => CogClicked?.Invoke(this, capturedAnchor); - return null; + return new InlineObjectElement(0, button); } } 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 eaeba91..0000000 --- a/src/SharpFM/Editors/SealedSteps/SealedStepItalicColorizer.cs +++ /dev/null @@ -1,40 +0,0 @@ -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 -{ - private readonly ScriptClipEditor _editor; - - public SealedStepItalicColorizer(ScriptClipEditor editor) - { - _editor = editor; - } - - protected override void ColorizeLine(DocumentLine line) - { - 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 => - { - element.TextRunProperties.SetTypeface( - new Typeface(element.TextRunProperties.Typeface.FontFamily, FontStyle.Italic)); - }); - return; - } - } -} diff --git a/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs b/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs deleted file mode 100644 index 2a029e0..0000000 --- a/src/SharpFM/Editors/SealedSteps/SealedStepSquiggleRenderer.cs +++ /dev/null @@ -1,76 +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; - - 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/CachedMultiLineRanges.cs b/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs new file mode 100644 index 0000000..e509a34 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/CachedMultiLineRanges.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +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. +/// +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/ErrorMarkerRenderer.cs b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs deleted file mode 100644 index 421d928..0000000 --- a/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs +++ /dev/null @@ -1,114 +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; - - public void UpdateDiagnostics(List diagnostics) - { - _diagnostics = diagnostics; - } - - 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 86f7224..9e8cf2d 100644 --- a/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs +++ b/src/SharpFM/Scripting/Editor/Pipeline/BracketMatchLayer.cs @@ -1,47 +1,48 @@ -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 - var text = doc.Text; + // 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 != ']') + return oldOpen != -1 || oldClose != -1; + + var text = doc.Text; + if (charBefore == '[') { var match = BracketMatcher.FindMatchingClose(text, offset - 1); @@ -63,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 cb7ef37..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 = MultiLineStatementRanges.Compute(doc.Text); + 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..ea765f3 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/ErrorMarkerLayer.cs @@ -0,0 +1,101 @@ +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; + // Diagnostics list is pushed in via the pipeline's UpdateDiagnostics + // path (the validator runs on its own debounce). Caret moves don't + // affect the squiggle. + 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..d2cf793 --- /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, +/// 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 +{ + /// + /// 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..e2fd000 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/RenderContext.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using SharpFM.Editors; +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)>(); + + /// + /// 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(); + + /// + /// 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..99d0784 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/ScriptEditorRenderPipeline.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using SharpFM.Editors; +using SharpFM.Model.Scripting; + +namespace SharpFM.Scripting.Editor.Pipeline; + +/// +/// Owns the script editor's render layers and consolidates their event +/// handling + invalidation. Replaces the standalone +/// IBackgroundRenderer implementations (bracket, statement, +/// continuation, error, sealed-step) 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 SealedStepLayer _sealedLayer; + private readonly LayeredBackgroundRenderer _bgRenderer; + private readonly LayeredBackgroundRenderer _selRenderer; + private readonly IRenderLayer[] _allLayers; + 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(); + _sealedLayer = new SealedStepLayer(); + + _allLayers = new IRenderLayer[] + { + _statementLayer, _continuationLayer, + _bracketLayer, _errorLayer, _sealedLayer, + }; + + _bgRenderer = new LayeredBackgroundRenderer( + _context, + KnownLayer.Background, + new IRenderLayer[] { _statementLayer, _continuationLayer }); + + _selRenderer = new LayeredBackgroundRenderer( + _context, + KnownLayer.Selection, + new IRenderLayer[] { _sealedLayer, _bracketLayer, _errorLayer }); + + textArea.TextView.BackgroundRenderers.Add(_bgRenderer); + textArea.TextView.BackgroundRenderers.Add(_selRenderer); + + textArea.Caret.PositionChanged += OnCaretChanged; + } + + /// + /// Wire the pipeline to a clip editor so layers (sealed-step layer + /// today) can read its cached snapshot through + /// . Pass null + /// to detach. + /// + public void AttachClipEditor(ScriptClipEditor? clipEditor) + { + _context.SetClipEditor(clipEditor); + // 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); + } + + /// + /// 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; + + foreach (var layer in _allLayers) + { + if (!layer.OnCaretChanged(_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); + } + + 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/SealedStepLayer.cs b/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs new file mode 100644 index 0000000..e28eb99 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/Pipeline/SealedStepLayer.cs @@ -0,0 +1,78 @@ +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) so each +/// paint is an O(visible-sealed-lines) loop. +/// +internal sealed class SealedStepLayer : IRenderLayer +{ + public KnownLayer TargetLayer => KnownLayer.Selection; + + public bool OnCaretChanged(RenderContext ctx) => false; + + 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 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 46085bb..62d8aed 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; @@ -30,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; /// @@ -52,17 +55,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 +88,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 +99,6 @@ private void AttachToDocument(TextDocument document) private async void RunValidation() { - if (_errorRenderer == null) return; - var text = _editor.Document.Text; try @@ -116,8 +106,10 @@ private async void RunValidation() var diagnostics = await System.Threading.Tasks.Task.Run( () => ScriptValidator.Validate(text)); - _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 { @@ -127,8 +119,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) { @@ -137,7 +127,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) { @@ -220,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); @@ -240,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) { @@ -323,5 +308,6 @@ public void Dispose() _editor.TextArea.TextEntered -= OnTextEntered; _editor.PointerMoved -= OnPointerMoved; _editor.KeyDown -= OnKeyDownGuardSealed; + _renderPipeline.Dispose(); } } 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; } diff --git a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs deleted file mode 100644 index 47211f7..0000000 --- a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs +++ /dev/null @@ -1,84 +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; - - public StatementHighlightRenderer(TextArea textArea) - { - _textArea = textArea; - _textArea.Caret.PositionChanged += (_, _) => UpdateHighlight(); - } - - public KnownLayer Layer => KnownLayer.Background; - - public void UpdateHighlight() - { - 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); - - // 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); - } - } - -} diff --git a/src/SharpFM/Scripting/Editor/StepIndexMargin.cs b/src/SharpFM/Scripting/Editor/StepIndexMargin.cs index c1ccc8f..5bdcb23 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,10 +23,25 @@ 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; + // 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(); + + // 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. @@ -46,6 +59,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); @@ -54,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(); } @@ -86,14 +134,18 @@ 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) => - 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; + } } 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 + } +}