From a0e4bbd78a8ef3e5ebed1ae9e4a4fb4ffe75a304 Mon Sep 17 00:00:00 2001 From: Daniel Pour Bakhsh Date: Sat, 29 Aug 2026 00:23:05 +0200 Subject: [PATCH 1/3] Put breakpoints on the line numbers The breakpoint margin occupied a column of its own between the line numbers and the text. Rider and VS Code instead put the breakpoint on the line number: the number gives way to the dot when one is set, and the gutter keeps the width the numbers give it. BreakPointLineNumberMargin derives from LineNumberMargin and takes over its slot in TextArea.LeftMargins, so MeasureOverride stays inherited and the column is exactly as wide as it was. A click there now means "breakpoint" and nothing else - the base class's line selection is deliberately skipped, matching what those editors do. BreakPointMargin is marked obsolete rather than removed, so anyone using it directly keeps working. Two details that are not obvious from the diff: the foreground colour is read from the editor because AvaloniaEdit binds LineNumbersForeground only on the margin it creates itself, and the dot shrinks when it would not fit, so the column never grows wider than the numbers alone would make it. --- .../BreakPointLineNumberMargin.cs | 140 ++++++++++++++++++ .../EditorExtensions/BreakPointMargin.cs | 3 + .../EditorExtensions/ExtendedTextEditor.cs | 28 +++- 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs diff --git a/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs b/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs new file mode 100644 index 000000000..21d0b0528 --- /dev/null +++ b/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs @@ -0,0 +1,140 @@ +using System.Collections.Specialized; +using System.Globalization; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +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; + + // -1 = pointer is not over the margin + private int _previewLine = -1; + + public BreakPointLineNumberMargin(TextEditor editor, string filePath, BreakpointStore store) + { + _editor = editor; + _filePath = filePath; + _store = store; + Cursor = new Cursor(StandardCursorType.Hand); + } + + 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); + + if (existing != null) _store.Remove(existing); + else _store.Add(new BreakPoint { File = _filePath, Line = lineNumber }); + } + + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + var lineNumber = GetLineNumberAtPointer(e); + 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..3a0693627 100644 --- a/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs +++ b/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs @@ -8,6 +8,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; diff --git a/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs b/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs index 80c528b58..0d4638f7a 100644 --- a/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs +++ b/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs @@ -3,6 +3,7 @@ using Avalonia.Media; using AvaloniaEdit; using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; using AvaloniaEdit.Folding; using AvaloniaEdit.TextMate; using DynamicData; @@ -87,9 +88,30 @@ public void RemoveTextmate() public void SetEnableBreakpoints(bool enable, 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 (!enable || 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, new BreakpointStore())); + break; + } } public void SetEnableFolding(bool enable) From 149884145a1d707af45c987ce85b6de188c5749d Mon Sep 17 00:00:00 2001 From: Daniel Pour Bakhsh Date: Sat, 29 Aug 2026 00:23:42 +0200 Subject: [PATCH 2/3] Share one breakpoint store across editors SetEnableBreakpoints gave every editor its own BreakpointStore, so a breakpoint reached nothing beyond the margin that drew it: closing and reopening the file lost it, the same file open in two views held two unrelated sets, and no debugger could read any of them. BreakpointStore.Instance is now that one store, following ExplorerNameComparer and TypeAssistanceIconStore in the same assembly. A shared store outlives the margins that use it, so both margins now subscribe for as long as they are attached instead of from their constructor on. Subscribing once and never detaching would let the store hold every margin of every closed editor alive and redraw them on each change - harmless while the store died with the margin, a leak once it does not. --- .../EditorExtensions/BreakPointMargin.cs | 21 +++++++++++++++++-- .../EditorExtensions/BreakpointStore.cs | 7 +++++++ .../EditorExtensions/ExtendedTextEditor.cs | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs b/src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs index 3a0693627..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; @@ -31,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 0d4638f7a..1e046018a 100644 --- a/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs +++ b/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs @@ -109,7 +109,7 @@ public void SetEnableBreakpoints(bool enable, string? filePath = null) // 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, new BreakpointStore())); + TextArea.LeftMargins.Insert(i, new BreakPointLineNumberMargin(this, filePath, BreakpointStore.Instance)); break; } } From 21280bc95bc4ce236535d4be35bf27adb4baaecd Mon Sep 17 00:00:00 2001 From: Daniel Pour Bakhsh Date: Sat, 29 Aug 2026 00:24:33 +0200 Subject: [PATCH 3/3] Let a file type declare which lines can carry a breakpoint The margin accepts a breakpoint on every line of a file whose type supports them. A language whose lines are not all executable has no way to say so, and a breakpoint on such a line does not fail visibly: the backend moves it to the next line that has code, silently, while the dot stays where the user put it. ITypeAssistance gains BreakPointLinePattern, a default interface member returning null, so every existing language keeps its current behaviour and no implementer has to change. The rule stays with the file type; the margin only applies it. Checking the line text rather than the line number keeps any one language's syntax out of the core. An invalid pattern from a plugin is logged once and then treated as no restriction, and removing a breakpoint always stays possible, so one that predates a rule change can still be taken away. The hover preview follows the same rule, since a dot that disappears on release is the most misleading feedback available. SetEnableBreakpoints now takes the ITypeAssistance rather than a flag, since the margin needs both values as a unit. The margin's new parameter is optional, so existing callers keep working unchanged. Not included: an upper limit per file. A target's breakpoint capacity is a property of the target, not of the file type, and it is a per-target resource that cannot be counted per file. IDebugSession.SetBreakpointAsync already reports whether the target took the breakpoint, which is where that belongs. --- .../ViewModels/DockViews/EditViewModel.cs | 2 +- .../BreakPointLineNumberMargin.cs | 53 ++++++++++++++++++- .../EditorExtensions/ExtendedTextEditor.cs | 10 ++-- .../LanguageService/ITypeAssistance.cs | 8 +++ .../LanguageService/TypeAssistanceBase.cs | 1 + 5 files changed, 68 insertions(+), 6 deletions(-) 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 index 21d0b0528..3955d37c4 100644 --- a/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs +++ b/src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs @@ -1,5 +1,6 @@ using System.Collections.Specialized; using System.Globalization; +using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls.Primitives; using Avalonia.Input; @@ -7,6 +8,9 @@ using AvaloniaEdit; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.LanguageService; +using OneWare.Essentials.Services; namespace OneWare.Essentials.EditorExtensions; @@ -24,17 +28,55 @@ public class BreakPointLineNumberMargin : LineNumberMargin 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) + 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; @@ -84,8 +126,10 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) { 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 _store.Add(new BreakPoint { File = _filePath, Line = lineNumber }); + else if (IsBreakPointable(lineNumber)) _store.Add(new BreakPoint { File = _filePath, Line = lineNumber }); } e.Handled = true; @@ -94,6 +138,11 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) 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; diff --git a/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs b/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs index 1e046018a..24c1f1346 100644 --- a/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs +++ b/src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs @@ -6,6 +6,7 @@ using AvaloniaEdit.Editing; using AvaloniaEdit.Folding; using AvaloniaEdit.TextMate; +using OneWare.Essentials.LanguageService; using DynamicData; using TextMateSharp.Registry; @@ -86,7 +87,9 @@ 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) { if (TextArea.LeftMargins.Any(x => x is BreakPointLineNumberMargin)) { @@ -96,7 +99,7 @@ public void SetEnableBreakpoints(bool enable, string? filePath = null) ShowLineNumbers = true; } - if (!enable || string.IsNullOrWhiteSpace(filePath)) return; + 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. @@ -109,7 +112,8 @@ public void SetEnableBreakpoints(bool enable, string? filePath = null) // 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)); + TextArea.LeftMargins.Insert(i, + new BreakPointLineNumberMargin(this, filePath, BreakpointStore.Instance, typeAssistance)); break; } } 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; }