attempt to improve syntax highlighting UX?

This commit is contained in:
Matt Parker
2025-10-17 18:04:36 +10:00
parent 85eaec30c7
commit dd3678d50c
4 changed files with 135 additions and 17 deletions

View File

@@ -457,10 +457,9 @@ public static class RoslynAnalysis
var syntaxTree = await document.GetSyntaxTreeAsync(cancellationToken);
var root = await syntaxTree!.GetRootAsync(cancellationToken);
var classifiedSpans = await Classifier.GetClassifiedSpansAsync(document, root.FullSpan, cancellationToken);
var result = classifiedSpans.Select(s => (syntaxTree.GetMappedLineSpan(s.TextSpan), s));
var classifiedSpans = await ClassifierHelper.GetClassifiedSpansAsync(document, root.FullSpan, ClassificationOptions.Default, false, cancellationToken);
var result = classifiedSpans.Select(s => (syntaxTree.GetMappedLineSpan(s.TextSpan), s)).ToList();
return result;
}

View File

@@ -3,6 +3,7 @@ using Godot;
using Godot.Collections;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Classification;
using SharpIDE.Godot.Features.CodeEditor;
using SharpIDE.RazorAccess;
namespace SharpIDE.Godot;
@@ -30,6 +31,67 @@ public partial class CustomHighlighter : SyntaxHighlighter
.ToList();
_classifiedSpansByLine = spansGroupedByFileSpan.ToDictionary(g => g.Key, g => g.ToImmutableArray());
}
// Indicates that lines were removed or added, and the overall result of that is that a line (wasLineNumber), is now (becameLineNumber)
// So if you added a line above line 10, then wasLineNumber=10, becameLineNumber=11
// If you removed a line above line 10, then wasLineNumber=10, becameLineNumber=9
//
// This is all a very dodgy workaround to move highlighting up and down, while we wait for the workspace to return us highlighting for the updated file
public void LinesChanged(long wasLineNumber, long becameLineNumber, SharpIdeCodeEdit.LineEditOrigin origin)
{
var difference = (int)(becameLineNumber - wasLineNumber);
if (difference is 0) return;
if (difference > 0)
{
LinesAdded(wasLineNumber, difference, origin);
}
else
{
LinesRemoved(wasLineNumber, -difference);
}
}
private void LinesAdded(long fromLine, int difference, SharpIdeCodeEdit.LineEditOrigin origin)
{
var newRazorDict = new System.Collections.Generic.Dictionary<int, ImmutableArray<SharpIdeRazorClassifiedSpan>>();
foreach (var kvp in _razorClassifiedSpansByLine)
{
bool shouldShift =
kvp.Key > fromLine || // always shift lines after the insertion point
(origin == SharpIdeCodeEdit.LineEditOrigin.StartOfLine && kvp.Key == fromLine); // shift current line if origin is Start
int newKey = shouldShift ? kvp.Key + difference : kvp.Key;
newRazorDict[newKey] = kvp.Value;
}
_razorClassifiedSpansByLine = newRazorDict;
}
private void LinesRemoved(long fromLine, int numberOfLinesRemoved)
{
// everything from 'fromLine' onwards needs to be shifted up by numberOfLinesRemoved
var newRazorDict = new System.Collections.Generic.Dictionary<int, ImmutableArray<SharpIdeRazorClassifiedSpan>>();
foreach (var kvp in _razorClassifiedSpansByLine)
{
if (kvp.Key < fromLine)
{
newRazorDict[kvp.Key] = kvp.Value;
}
else if (kvp.Key == fromLine)
{
newRazorDict[kvp.Key - numberOfLinesRemoved] = kvp.Value;
}
else if (kvp.Key >= fromLine + numberOfLinesRemoved)
{
newRazorDict[kvp.Key - numberOfLinesRemoved] = kvp.Value;
}
}
_razorClassifiedSpansByLine = newRazorDict;
}
public override Dictionary _GetLineSyntaxHighlighting(int line)
{
var highlights = (_classifiedSpansByLine, _razorClassifiedSpansByLine) switch

View File

@@ -15,6 +15,7 @@ using Timer = Godot.Timer;
namespace SharpIDE.Godot.Features.CodeEditor;
#pragma warning disable VSTHRD101
public partial class SharpIdeCodeEdit : CodeEdit
{
[Signal]
@@ -48,6 +49,41 @@ public partial class SharpIdeCodeEdit : CodeEdit
SymbolHovered += OnSymbolHovered;
SymbolValidate += OnSymbolValidate;
SymbolLookup += OnSymbolLookup;
LinesEditedFrom += OnLinesEditedFrom;
}
public enum LineEditOrigin
{
StartOfLine,
EndOfLine,
Unknown
}
// Line removed - fromLine 55, toLine 54
// Line added - fromLine 54, toLine 55
// Multi cursor gets a single line event for each
// problem is 10 to 11 gets returned for 'Enter' at the start of line 10, as well as 'Enter' at the end of line 10
// This means that the line that moves down needs to be based on whether the new line was from the start or end of the line
private void OnLinesEditedFrom(long fromLine, long toLine)
{
if (fromLine == toLine) return;
var fromLineText = GetLine((int)fromLine);
var caretPosition = this.GetCaretPosition();
var textFrom0ToCaret = fromLineText[..caretPosition.col];
var caretPositionEnum = LineEditOrigin.Unknown;
if (string.IsNullOrWhiteSpace(textFrom0ToCaret))
{
caretPositionEnum = LineEditOrigin.StartOfLine;
}
else
{
var textfromCaretToEnd = fromLineText[caretPosition.col..];
if (string.IsNullOrWhiteSpace(textfromCaretToEnd))
{
caretPositionEnum = LineEditOrigin.EndOfLine;
}
}
//GD.Print($"Lines edited from {fromLine} to {toLine}, origin: {caretPositionEnum}, current caret position: {caretPosition}");
_syntaxHighlighter.LinesChanged(fromLine, toLine, caretPositionEnum);
}
public override void _ExitTree()
@@ -208,19 +244,31 @@ public partial class SharpIdeCodeEdit : CodeEdit
// GD.Print($"Selection changed to line {_currentLine}, start {_selectionStartCol}, end {_selectionEndCol}");
}
// Ideally this method completes in < 35ms, to ~ handle 28 char/s spam typing
private async void OnTextChanged()
private CancellationTokenSource _textChangedCts = new();
private void OnTextChanged()
{
var __ = SharpIdeOtel.Source.StartActivity($"{nameof(SharpIdeCodeEdit)}.{nameof(OnTextChanged)}");
// Note that we are currently on the UI thread, so be very conscious of what we do here
// If we update the documents syntax highlighting here, we won't get any flashes of incorrect highlighting, most noticeable when inserting new lines
_currentFile.IsDirty.Value = true;
await Singletons.FileManager.UpdateFileTextInMemory(_currentFile, Text);
var syntaxHighlighting = RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile);
var razorSyntaxHighlighting = RoslynAnalysis.GetRazorDocumentSyntaxHighlighting(_currentFile);
SetSyntaxHighlightingModel(await syntaxHighlighting, await razorSyntaxHighlighting);
__?.Dispose();
_ = Task.GodotRun(async () => SetDiagnosticsModel(await RoslynAnalysis.GetDocumentDiagnostics(_currentFile)));
_ = Task.GodotRun(async () =>
{
_currentFile.IsDirty.Value = true;
await Singletons.FileManager.UpdateFileTextInMemory(_currentFile, Text);
await _textChangedCts.CancelAsync(); // Currently the below methods throw, TODO Fix with suppress throwing, and handle
_textChangedCts.Dispose();
_textChangedCts = new CancellationTokenSource();
_ = Task.GodotRun(async () =>
{
var syntaxHighlighting = RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile, _textChangedCts.Token);
var razorSyntaxHighlighting = RoslynAnalysis.GetRazorDocumentSyntaxHighlighting(_currentFile, _textChangedCts.Token);
await Task.WhenAll(syntaxHighlighting, razorSyntaxHighlighting);
await this.InvokeAsync(async () => SetSyntaxHighlightingModel(await syntaxHighlighting, await razorSyntaxHighlighting));
__?.Dispose();
});
_ = Task.GodotRun(async () =>
{
var documentDiagnostics = await RoslynAnalysis.GetDocumentDiagnostics(_currentFile, _textChangedCts.Token);
await this.InvokeAsync(() => SetDiagnosticsModel(documentDiagnostics));
});
});
}
// TODO: This is now significantly slower, invoke -> text updated in editor
@@ -296,7 +344,8 @@ public partial class SharpIdeCodeEdit : CodeEdit
});
await Task.WhenAll(syntaxHighlighting, razorSyntaxHighlighting, setTextTask); // Text must be set before setting syntax highlighting
await this.InvokeAsync(async () => SetSyntaxHighlightingModel(await syntaxHighlighting, await razorSyntaxHighlighting));
SetDiagnosticsModel(await diagnostics);
await diagnostics;
await this.InvokeAsync(async () => SetDiagnosticsModel(await diagnostics));
}
private async Task OnFileChangedExternallyFromDisk()
@@ -400,17 +449,19 @@ public partial class SharpIdeCodeEdit : CodeEdit
SetLineBackgroundColor(line, lineColour);
}
[RequiresGodotUiThread]
private void SetDiagnosticsModel(ImmutableArray<(FileLinePositionSpan fileSpan, Diagnostic diagnostic)> diagnostics)
{
_diagnostics = diagnostics;
Callable.From(QueueRedraw).CallDeferred();
QueueRedraw();
}
[RequiresGodotUiThread]
private void SetSyntaxHighlightingModel(IEnumerable<(FileLinePositionSpan fileSpan, ClassifiedSpan classifiedSpan)> classifiedSpans, IEnumerable<SharpIdeRazorClassifiedSpan> razorClassifiedSpans)
{
_syntaxHighlighter.SetHighlightingData(classifiedSpans, razorClassifiedSpans);
//_syntaxHighlighter.ClearHighlightingCache();
_syntaxHighlighter.UpdateCache();
_syntaxHighlighter.UpdateCache(); // I don't think this does anything, it will call _UpdateCache which we have not implemented
SyntaxHighlighter = null;
SyntaxHighlighter = _syntaxHighlighter; // Reassign to trigger redraw
}

View File

@@ -32,6 +32,12 @@ public static class ControlExtensions
// }
}
/// Has no functionality, just used as a reminder to indicate that a method must be called on the Godot UI thread.
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class RequiresGodotUiThreadAttribute : Attribute
{
}
public static class NodeExtensions
{
extension(TreeItem treeItem)