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/BreakPoint.cs b/src/OneWare.Essentials/EditorExtensions/BreakPoint.cs index 83144940b..d05d3266c 100644 --- a/src/OneWare.Essentials/EditorExtensions/BreakPoint.cs +++ b/src/OneWare.Essentials/EditorExtensions/BreakPoint.cs @@ -2,7 +2,9 @@ public class BreakPoint { - public string File { get; set; } + public required string File { get; set; } public int Line { get; set; } + + public bool IsVerified { get; set; } = true; } \ No newline at end of file diff --git a/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs b/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs new file mode 100644 index 000000000..9138d586a --- /dev/null +++ b/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs @@ -0,0 +1,241 @@ +using System.Collections.Specialized; +using System.Globalization; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls.Notifications; +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")); + + // Not armed at the target: grey and hollow. Two differences instead of one, so that even + // someone who tells colours apart poorly sees from the ring that this one is not armed. + private static readonly IBrush UnverifiedBrush = new SolidColorBrush(Color.Parse("#9E9E9E")); + + 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; + } + } + + private static void NotifyTargetRunning() + { + ContainerLocator.Container?.Resolve().ShowNotification( + "Breakpoint not set", + "The target is running. Pause it before setting or removing breakpoints.", + NotificationType.Warning); + } + + // 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 breakPoint = FindBreakPoint(lineNumber); + + var brush = breakPoint != null ? 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; + var center = new Point(Bounds.Width / 2, centerY); + var radius = diameter / 2; + + if (breakPoint is { IsVerified: false }) + { + // Not armed at the target, so a grey ring. It sits inside the same diameter + // as the filled dot, so the column keeps its width and the lines do not jump. + var thickness = Math.Max(1.0, radius * 0.4); + var inner = radius - thickness / 2; + + context.DrawEllipse(null, new Pen(UnverifiedBrush, thickness), center, inner, inner); + } + else + { + context.DrawEllipse(brush, null, center, radius, radius); + } + } + 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; + + if (_store.IsTargetRunning) + { + NotifyTargetRunning(); + e.Handled = true; + 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 (_store.IsTargetRunning || !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; + _store.VerificationChanged -= OnVerificationChanged; + } + + base.OnTextViewChanged(oldTextView, newTextView); + + if (newTextView != null) + { + _store.Breakpoints.CollectionChanged += OnBreakpointsChanged; + _store.VerificationChanged += OnVerificationChanged; + } + } + + private void OnBreakpointsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + InvalidateVisual(); + } + + private void OnVerificationChanged(object? sender, EventArgs e) + { + InvalidateVisual(); + } + + // Returns the breakpoint rather than just yes/no: Render needs its state to tell an armed + // one from one that is only set. + private BreakPoint? FindBreakPoint(int lineNumber) + { + return _store.Breakpoints.FirstOrDefault(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..944dfe67b 100644 --- a/src/OneWare.Essentials/EditorExtensions/BreakpointStore.cs +++ b/src/OneWare.Essentials/EditorExtensions/BreakpointStore.cs @@ -5,7 +5,15 @@ namespace OneWare.Essentials.EditorExtensions; public class BreakpointStore : ObservableObject { + /// + /// Shared, application-wide breakpoint store. All editors and debug sessions + /// observe this same instance so that breakpoints set in any open file are + /// available to the active debugger and survive editor close/re-open. + /// + public static BreakpointStore Instance { get; } = new(); + private BreakPoint? _currentBreakPoint; + private bool _isTargetRunning; public ObservableCollection Breakpoints { get; } = new(); public BreakPoint? CurrentBreakPoint @@ -14,15 +22,50 @@ public BreakPoint? CurrentBreakPoint set => SetProperty(ref _currentBreakPoint, value); } + public bool IsTargetRunning + { + get => _isTargetRunning; + set => SetProperty(ref _isTargetRunning, value); + } + + // A breakpoint changed its state while the collection stayed the same. Needed because a + // margin listening to CollectionChanged alone would never see a refusal by the target. + public event EventHandler? VerificationChanged; + public void Add(BreakPoint bp) { - //if (!MainDock.Debugger.IsDebugging || MainDock.Debugger.InsertBreakpoint(bp)) Breakpoints.Add(bp); } public void Remove(BreakPoint bp) { - //if (!MainDock.Debugger.IsDebugging || MainDock.Debugger.RemoveBreakpoint(bp)) Breakpoints.Remove(bp); } + + // Report only a change that really happened: every editor currently open repaints its + // margin on this. + public void SetVerified(BreakPoint bp, bool verified) + { + if (bp.IsVerified == verified) return; + + bp.IsVerified = verified; + VerificationChanged?.Invoke(this, EventArgs.Empty); + } + + // Once a session has ended, no target says anything about the breakpoints any more. They + // stay, but from here on a hollow dot would be a claim with nothing behind it. + public void ResetVerification() + { + var changed = false; + + foreach (var bp in Breakpoints) + { + if (bp.IsVerified) continue; + + bp.IsVerified = true; + changed = true; + } + + if (changed) VerificationChanged?.Invoke(this, EventArgs.Empty); + } } \ No newline at end of file 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; }