diff --git a/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs b/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs index 880578c80..5898e6054 100644 --- a/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs +++ b/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs @@ -198,7 +198,7 @@ private void InitTypeAssistance() if (TypeAssistance != null) { - Editor.SetEnableBreakpoints(TypeAssistance.CanAddBreakPoints, FullPath); + Editor.SetEnableBreakpoints(TypeAssistance, FullPath); if (TypeAssistance.FoldingStrategy != null) { diff --git a/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs b/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs new file mode 100644 index 000000000..3955d37c4 --- /dev/null +++ b/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs @@ -0,0 +1,189 @@ +using System.Collections.Specialized; +using System.Globalization; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.LanguageService; +using OneWare.Essentials.Services; + +namespace OneWare.Essentials.EditorExtensions; + +// Replaces the separate breakpoint column: breakpoints live on the line number margin, and a +// line that carries one shows the dot in place of its number, as Rider and VS Code do. +// MeasureOverride stays inherited, so the column is exactly as wide as it would be without +// breakpoints. +public class BreakPointLineNumberMargin : LineNumberMargin +{ + // Colours taken unchanged from BreakPointMargin. + private static readonly IBrush BreakPointBrush = new SolidColorBrush(Color.Parse("#FF3737")); + private static readonly IBrush PreviewBrush = new SolidColorBrush(Color.Parse("#E67466")); + + private readonly TextEditor _editor; + private readonly string _filePath; + private readonly BreakpointStore _store; + + // From the file type, fixed for the lifetime of the margin. + // null means no restriction, so a language without a rule notices nothing of this. + private readonly Regex? _breakPointableLines; + + // -1 = pointer is not over the margin + private int _previewLine = -1; + + public BreakPointLineNumberMargin(TextEditor editor, string filePath, BreakpointStore store, + ITypeAssistance? typeAssistance = null) + { + _editor = editor; + _filePath = filePath; + _store = store; + _breakPointableLines = CompilePattern(typeAssistance?.BreakPointLinePattern); + Cursor = new Cursor(StandardCursorType.Hand); + } + + // The pattern comes from a plugin, so an invalid expression must not disable the whole + // margin. Report it once, then behave as if no pattern had been given. + private static Regex? CompilePattern(string? pattern) + { + if (string.IsNullOrEmpty(pattern)) return null; + + try + { + return new Regex(pattern, RegexOptions.Compiled); + } + catch (ArgumentException exception) + { + ContainerLocator.Container?.Resolve() + .Error($"Invalid BreakPointLinePattern '{pattern}': {exception.Message}", exception); + return null; + } + } + + // Without a pattern every line carries a breakpoint. With one the line text decides, not the + // number, so the rule stays with the file type and need not be known here. + private bool IsBreakPointable(int lineNumber) + { + if (_breakPointableLines == null) return true; + + var document = _editor.Document; + if (document == null || lineNumber < 1 || lineNumber > document.LineCount) return false; + + var line = document.GetLineByNumber(lineNumber); + + return _breakPointableLines.IsMatch(document.GetText(line.Offset, line.Length)); + } + + public override void Render(DrawingContext context) + { + var textView = TextView; + if (textView is not { VisualLinesValid: true }) return; + + // Colour straight from the editor: AvaloniaEdit binds LineNumbersForeground only on the + // margin it creates itself, not on one inserted in its place. + var foreground = _editor.LineNumbersForeground ?? GetValue(TemplatedControl.ForegroundProperty); + + foreach (var line in textView.VisualLines) + { + var lineNumber = line.FirstDocumentLine.LineNumber; + + var brush = HasBreakPoint(lineNumber) ? BreakPointBrush + : lineNumber == _previewLine ? PreviewBrush + : null; + + if (brush != null) + { + // If the dot does not fit the column it shrinks, so the column never grows wider + // than the numbers alone would make it. + var diameter = Math.Min(Bounds.Width, line.Height * 0.75); + var centerY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - + textView.VerticalOffset; + context.DrawEllipse(brush, null, new Point(Bounds.Width / 2, centerY), diameter / 2, diameter / 2); + } + else + { + var text = new FormattedText(lineNumber.ToString(CultureInfo.CurrentCulture), + CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface, EmSize, foreground); + context.DrawText(text, + new Point(Bounds.Width - text.Width, + line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - + textView.VerticalOffset)); + } + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + // Deliberately without the base call: here a click means breakpoint and nothing else, + // so the line selection of the base class does not happen. Same as Rider and VS Code. + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; + + var lineNumber = GetLineNumberAtPointer(e); + if (lineNumber > 0 && !string.IsNullOrWhiteSpace(_filePath)) + { + var existing = _store.Breakpoints.FirstOrDefault(bp => bp.File == _filePath && bp.Line == lineNumber); + + // Removing always stays possible: a breakpoint that predates a rule change, or whose + // line has since been edited, must still be removable. + if (existing != null) _store.Remove(existing); + else if (IsBreakPointable(lineNumber)) _store.Add(new BreakPoint { File = _filePath, Line = lineNumber }); + } + + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + var lineNumber = GetLineNumberAtPointer(e); + + // Preview only where the click would take effect: a dot that does not stay once the + // button is released would be the most misleading feedback of all. + if (!IsBreakPointable(lineNumber)) lineNumber = -1; + + if (lineNumber == _previewLine) return; + + _previewLine = lineNumber; + InvalidateVisual(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + _previewLine = -1; + InvalidateVisual(); + } + + // Subscribing here rather than in the constructor: the subscription then lasts exactly as + // long as the margin is attached, and a store that outlives the margin cannot keep a closed + // editor and its document alive through it. + protected override void OnTextViewChanged(TextView oldTextView, TextView newTextView) + { + if (oldTextView != null) _store.Breakpoints.CollectionChanged -= OnBreakpointsChanged; + + base.OnTextViewChanged(oldTextView, newTextView); + + if (newTextView != null) _store.Breakpoints.CollectionChanged += OnBreakpointsChanged; + } + + private void OnBreakpointsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + InvalidateVisual(); + } + + private bool HasBreakPoint(int lineNumber) + { + return _store.Breakpoints.Any(bp => bp.File == _filePath && bp.Line == lineNumber); + } + + // Determining the line through the text view rather than through editor coordinates keeps + // this independent of where among the left margins this one sits; below the last line, -1. + private int GetLineNumberAtPointer(PointerEventArgs e) + { + var textView = TextView; + if (textView == null) return -1; + var visualLine = textView.GetVisualLineFromVisualTop(e.GetPosition(this).Y + textView.VerticalOffset); + return visualLine?.FirstDocumentLine.LineNumber ?? -1; + } +} diff --git a/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs b/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs index 2d9a3f380..3f1cead75 100644 --- a/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs +++ b/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs @@ -1,4 +1,5 @@ -using Avalonia; +using System.Collections.Specialized; +using Avalonia; using Avalonia.Input; using Avalonia.Media; using AvaloniaEdit; @@ -8,6 +9,9 @@ namespace OneWare.Essentials.EditorExtensions; +[Obsolete("Superseded by BreakPointLineNumberMargin, which puts the breakpoint on the line " + + "number instead of adding a column of its own. Kept so that anyone using this margin " + + "directly keeps working.")] public class BreakPointMargin : AbstractMargin { private readonly string _filePath; @@ -28,7 +32,23 @@ public BreakPointMargin(TextEditor editor, string filePath, BreakpointStore mana _editor = editor; _filePath = filePath; - _manager.Breakpoints.CollectionChanged += (o, i) => { InvalidateVisual(); }; + } + + // Subscribing here rather than in the constructor: the store is shared and outlives this + // margin, so a subscription that is never released would keep every margin of every closed + // editor alive and redraw it on each change. + protected override void OnTextViewChanged(TextView oldTextView, TextView newTextView) + { + if (oldTextView != null) _manager.Breakpoints.CollectionChanged -= OnBreakpointsChanged; + + base.OnTextViewChanged(oldTextView, newTextView); + + if (newTextView != null) _manager.Breakpoints.CollectionChanged += OnBreakpointsChanged; + } + + private void OnBreakpointsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + InvalidateVisual(); } public override void Render(DrawingContext context) diff --git a/src/OneWare.Essentials/EditorExtensions/BreakpointStore.cs b/src/OneWare.Essentials/EditorExtensions/BreakpointStore.cs index 168c10a21..c7c4ca938 100644 --- a/src/OneWare.Essentials/EditorExtensions/BreakpointStore.cs +++ b/src/OneWare.Essentials/EditorExtensions/BreakpointStore.cs @@ -5,6 +5,13 @@ namespace OneWare.Essentials.EditorExtensions; public class BreakpointStore : ObservableObject { + /// + /// The store every editor and every debug session works against. Shared rather than one per + /// editor, so that a breakpoint survives closing and reopening its file, a file opened twice + /// shows the same breakpoints in both views, and whoever debugs can reach them at all. + /// + public static BreakpointStore Instance { get; } = new(); + private BreakPoint? _currentBreakPoint; public ObservableCollection Breakpoints { get; } = new(); diff --git a/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs b/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs index 80c528b58..24c1f1346 100644 --- a/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs +++ b/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs @@ -3,8 +3,10 @@ using Avalonia.Media; using AvaloniaEdit; using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; using AvaloniaEdit.Folding; using AvaloniaEdit.TextMate; +using OneWare.Essentials.LanguageService; using DynamicData; using TextMateSharp.Registry; @@ -85,11 +87,35 @@ public void RemoveTextmate() TextMateInstallation = null; } - public void SetEnableBreakpoints(bool enable, string? filePath = null) + // Takes the ITypeAssistance rather than a flag: besides CanAddBreakPoints it also carries + // the pattern of breakpointable lines, and the margin needs both as a unit. + public void SetEnableBreakpoints(ITypeAssistance? typeAssistance, string? filePath = null) { - TextArea.LeftMargins.RemoveMany(TextArea.LeftMargins.Where(x => x is BreakPointMargin)); - if (enable && !string.IsNullOrWhiteSpace(filePath)) - TextArea.LeftMargins.Add(new BreakPointMargin(this, filePath, new BreakpointStore())); + if (TextArea.LeftMargins.Any(x => x is BreakPointLineNumberMargin)) + { + // The toggle also clears our own margin - AvaloniaEdit tests for "is + // LineNumberMargin" - and recreates the standard one with its colour binding. + ShowLineNumbers = false; + ShowLineNumbers = true; + } + + if (typeAssistance is not { CanAddBreakPoints: true } || string.IsNullOrWhiteSpace(filePath)) return; + + // A local value beats the style setter, so the line number margin exists afterwards even + // if the editor is not attached to the visual tree yet. + ShowLineNumbers = true; + + for (var i = 0; i < TextArea.LeftMargins.Count; i++) + { + if (TextArea.LeftMargins[i] is not LineNumberMargin) continue; + + // Remove and insert rather than assign by index: ComparisonControl relies on this + // sequence, and whether TextArea detaches cleanly on a replace is not established. + TextArea.LeftMargins.RemoveAt(i); + TextArea.LeftMargins.Insert(i, + new BreakPointLineNumberMargin(this, filePath, BreakpointStore.Instance, typeAssistance)); + break; + } } public void SetEnableFolding(bool enable) diff --git a/src/OneWare.Essentials/LanguageService/ITypeAssistance.cs b/src/OneWare.Essentials/LanguageService/ITypeAssistance.cs index 3e5d33e43..3371cd75a 100644 --- a/src/OneWare.Essentials/LanguageService/ITypeAssistance.cs +++ b/src/OneWare.Essentials/LanguageService/ITypeAssistance.cs @@ -8,6 +8,14 @@ namespace OneWare.Essentials.LanguageService; public interface ITypeAssistance { bool CanAddBreakPoints { get; } + + /// + /// Regular expression matching the lines that can carry a breakpoint; + /// means every line qualifies. A language whose lines are not all executable reports the + /// executable ones here - without it the margin accepts a breakpoint the debugger cannot put + /// on that line, and the backend silently moves it to the next line that has code. + /// + string? BreakPointLinePattern => null; string? LineCommentSequence { get; } IFoldingStrategy? FoldingStrategy { get; } event EventHandler AssistanceActivated; diff --git a/src/OneWare.Essentials/LanguageService/TypeAssistanceBase.cs b/src/OneWare.Essentials/LanguageService/TypeAssistanceBase.cs index 91aac7c65..02084e710 100644 --- a/src/OneWare.Essentials/LanguageService/TypeAssistanceBase.cs +++ b/src/OneWare.Essentials/LanguageService/TypeAssistanceBase.cs @@ -34,6 +34,7 @@ protected TypeAssistanceBase(IEditor editor) protected bool IsOpen { get; private set; } protected bool IsAttached { get; private set; } public virtual bool CanAddBreakPoints => false; + public string? BreakPointLinePattern { get; protected init; } public string? LineCommentSequence { get; protected init; } public IFoldingStrategy? FoldingStrategy { get; protected init; }