Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ private void InitTypeAssistance()

if (TypeAssistance != null)
{
Editor.SetEnableBreakpoints(TypeAssistance.CanAddBreakPoints, FullPath);
Editor.SetEnableBreakpoints(TypeAssistance, FullPath);

if (TypeAssistance.FoldingStrategy != null)
{
Expand Down
189 changes: 189 additions & 0 deletions src/OneWare.Essentials/EditorExtensions/BreakPointLineNumberMargin.cs
Original file line number Diff line number Diff line change
@@ -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<ILogger>()
.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;
}
}
24 changes: 22 additions & 2 deletions src/OneWare.Essentials/EditorExtensions/BreakPointMargin.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Avalonia;
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Input;
using Avalonia.Media;
using AvaloniaEdit;
Expand All @@ -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;
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/OneWare.Essentials/EditorExtensions/BreakpointStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ namespace OneWare.Essentials.EditorExtensions;

public class BreakpointStore : ObservableObject
{
/// <summary>
/// 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.
/// </summary>
public static BreakpointStore Instance { get; } = new();

private BreakPoint? _currentBreakPoint;
public ObservableCollection<BreakPoint> Breakpoints { get; } = new();

Expand Down
34 changes: 30 additions & 4 deletions src/OneWare.Essentials/EditorExtensions/ExtendedTextEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/OneWare.Essentials/LanguageService/ITypeAssistance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ namespace OneWare.Essentials.LanguageService;
public interface ITypeAssistance
{
bool CanAddBreakPoints { get; }

/// <summary>
/// Regular expression matching the lines that can carry a breakpoint; <see langword="null"/>
/// 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.
/// </summary>
string? BreakPointLinePattern => null;
string? LineCommentSequence { get; }
IFoldingStrategy? FoldingStrategy { get; }
event EventHandler AssistanceActivated;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading