diff --git a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs index 172c892..71e66c1 100644 --- a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs +++ b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs @@ -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; } diff --git a/src/SharpIDE.Godot/Features/CodeEditor/CustomSyntaxHighlighter.cs b/src/SharpIDE.Godot/Features/CodeEditor/CustomSyntaxHighlighter.cs index ed73a53..b955fc7 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/CustomSyntaxHighlighter.cs +++ b/src/SharpIDE.Godot/Features/CodeEditor/CustomSyntaxHighlighter.cs @@ -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>(); + + 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>(); + + 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 diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs index d62e869..5d45791 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs @@ -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 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 } diff --git a/src/SharpIDE.Godot/NodeExtensions.cs b/src/SharpIDE.Godot/NodeExtensions.cs index 9772d4f..5a4af87 100644 --- a/src/SharpIDE.Godot/NodeExtensions.cs +++ b/src/SharpIDE.Godot/NodeExtensions.cs @@ -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)