diff --git a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs index 1aa05e8..dcf99fa 100644 --- a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs +++ b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs @@ -15,6 +15,8 @@ using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.FindSymbols; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Remote; using Microsoft.CodeAnalysis.Razor.SemanticTokens; @@ -26,7 +28,6 @@ using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; using NuGet.Frameworks; -using Roslyn.LanguageServer.Protocol; using SharpIDE.Application.Features.Analysis.FixLoaders; using SharpIDE.Application.Features.Analysis.ProjectLoader; using SharpIDE.Application.Features.Analysis.Razor; @@ -34,11 +35,14 @@ using SharpIDE.Application.Features.Build; using SharpIDE.Application.Features.FileWatching; using SharpIDE.Application.Features.SolutionDiscovery; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; +using static Roslyn.Utilities.EnumerableExtensions; using CodeAction = Microsoft.CodeAnalysis.CodeActions.CodeAction; using CompletionItem = Microsoft.CodeAnalysis.Completion.CompletionItem; using CompletionList = Microsoft.CodeAnalysis.Completion.CompletionList; +using CompletionOptions = Microsoft.CodeAnalysis.Completion.CompletionOptions; using Diagnostic = Microsoft.CodeAnalysis.Diagnostic; using DiagnosticSeverity = Microsoft.CodeAnalysis.DiagnosticSeverity; +using VSInternalClientCapabilities = Roslyn.LanguageServer.Protocol.VSInternalClientCapabilities; namespace SharpIDE.Application.Features.Analysis; @@ -598,15 +602,15 @@ public partial class RoslynAnalysis(ILogger logger, BuildService // We store the document here, so that we have the correct version of the document when we compute completions // This may not be the best way to do this, but it seems to work okay. It may only be a problem because I continue to update the doc in the workspace as the user continues typing, filtering the completion // I could possibly pause updating the document while the completion list is open, but that seems more complex - handling accepted vs cancelled completions etc - public record IdeCompletionListResult(Document Document, CompletionList CompletionList); - public async Task GetCodeCompletionsForDocumentAtPosition(SharpIdeFile fileModel, LinePosition linePosition) + public record IdeCompletionListResult(Document Document, CompletionList CompletionList, LinePosition LinePosition); + public async Task GetCodeCompletionsForDocumentAtPosition(SharpIdeFile fileModel, LinePosition linePosition, CompletionTrigger completionTrigger) { using var _ = SharpIdeOtel.Source.StartActivity($"{nameof(RoslynAnalysis)}.{nameof(GetCodeCompletionsForDocumentAtPosition)}"); await _solutionLoadedTcs.Task; var document = await GetDocumentForSharpIdeFile(fileModel); Guard.Against.Null(document, nameof(document)); - var completions = await GetCompletionsAsync(document, linePosition).ConfigureAwait(false); - return new IdeCompletionListResult(document, completions); + var (completions, triggerLinePosition) = await GetCompletionsAsync(document, linePosition, completionTrigger).ConfigureAwait(false); + return new IdeCompletionListResult(document, completions, triggerLinePosition); } // TODO: Pass in LinePositionSpan for refactorings that span multiple characters, e.g. extract method @@ -722,31 +726,82 @@ public partial class RoslynAnalysis(ILogger logger, BuildService return codeActions.ToImmutableArray(); } - private static async Task GetCompletionsAsync(Document document, LinePosition linePosition, CancellationToken cancellationToken = default) + private static async Task<(CompletionList completions, LinePosition triggerLinePosition)> GetCompletionsAsync(Document document, LinePosition linePosition, CompletionTrigger completionTrigger, CancellationToken cancellationToken = default) { var completionService = CompletionService.GetService(document); if (completionService is null) throw new InvalidOperationException("Completion service is not available for the document."); var sourceText = await document.GetTextAsync(cancellationToken); var position = sourceText.Lines.GetPosition(linePosition); - var completions = await completionService.GetCompletionsAsync(document, position, cancellationToken: cancellationToken); - //var filterItems = completionService.FilterItems(document, completions.ItemsList.AsImmutable(), "va"); - return completions; + var completions = await completionService.GetCompletionsAsync(document, position, completionTrigger, cancellationToken: cancellationToken); + var triggerLinePosition = sourceText.GetLinePosition(completions.Span.Start); + return (completions, triggerLinePosition); } // Currently unused - private async Task ShouldTriggerCompletionAsync(SharpIdeFile file, LinePosition linePosition, CompletionTrigger completionTrigger, CancellationToken cancellationToken = default) + public async Task ShouldTriggerCompletionAsync(SharpIdeFile file, string documentText, LinePosition linePosition, CompletionTrigger completionTrigger, CancellationToken cancellationToken = default) { + await _solutionLoadedTcs.Task; var document = await GetDocumentForSharpIdeFile(file, cancellationToken); var completionService = CompletionService.GetService(document); if (completionService is null) throw new InvalidOperationException("Completion service is not available for the document."); - var sourceText = await document.GetTextAsync(cancellationToken); + //var sourceText = await document.GetTextAsync(cancellationToken); + var sourceText = SourceText.From(documentText, Encoding.UTF8); var position = sourceText.Lines.GetPosition(linePosition); - var shouldTrigger = completionService.ShouldTriggerCompletion(sourceText, position, completionTrigger); + var shouldTrigger = completionService.ShouldTriggerCompletion(document.Project, document.Project.Services, sourceText, position, completionTrigger, CompletionOptions.Default, document.Project.Solution.Options ?? OptionSet.Empty); return shouldTrigger; } + public static ImmutableArray FilterCompletions(SharpIdeFile file, string documentText, LinePosition linePosition, CompletionList completionList, CompletionTrigger completionTrigger, CompletionFilterReason filterReason) + { + var sourceText = SourceText.From(documentText, Encoding.UTF8); + var position = sourceText.Lines.GetPosition(linePosition); + + var filterSpanLength = position - completionList.Span.Start; + // user has backspaced past the trigger span + if (filterSpanLength < 0) return []; + var filterSpan = new TextSpan(completionList.Span.Start, length: filterSpanLength); + completionList = completionList.WithSpan(filterSpan); + + var filteredCompletionItems = FilterCompletionList(completionList, completionTrigger, filterReason, sourceText); + return filteredCompletionItems; + } + + private static ImmutableArray FilterCompletionList(CompletionList completionList, CompletionTrigger completionTrigger, CompletionFilterReason filterReason, SourceText sourceText) + { + var filterText = sourceText.GetSubText(completionList.Span).ToString(); + Console.WriteLine($"Filter text: '{filterText}'"); + + // Use pattern matching to determine which items are most relevant out of the calculated items. + using var _ = ArrayBuilder.GetInstance(out var matchResultsBuilder); + var index = 0; + using var helper = new PatternMatchHelper(filterText); + foreach (var item in completionList.ItemsList) + { + if (helper.TryCreateMatchResult( + item, + completionTrigger.Kind, + filterReason, + recentItemIndex: -1, + includeMatchSpans: true, + index, + out var matchResult)) + { + matchResultsBuilder.Add(matchResult); + index++; + } + } + + // Next, we sort the list based on the pattern matching result. + matchResultsBuilder.Sort(MatchResult.SortingComparer); + + var filteredList = matchResultsBuilder.SelectAsArray(matchResult => + new SharpIdeCompletionItem(matchResult.CompletionItem, matchResult.PatternMatch?.MatchedSpans)); + + return filteredList; + } + public async Task<(string updatedText, SharpIdeFileLinePosition sharpIdeFileLinePosition)> GetCompletionApplyChanges(SharpIdeFile file, CompletionItem completionItem, Document document, CancellationToken cancellationToken = default) { //var documentId = _workspace!.CurrentSolution.GetDocumentIdsWithFilePath(file.Path).Single(); diff --git a/src/SharpIDE.Application/Features/Analysis/SharpIdeCompletionItem.cs b/src/SharpIDE.Application/Features/Analysis/SharpIdeCompletionItem.cs new file mode 100644 index 0000000..5c22fdf --- /dev/null +++ b/src/SharpIDE.Application/Features/Analysis/SharpIdeCompletionItem.cs @@ -0,0 +1,11 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.Text; + +namespace SharpIDE.Application.Features.Analysis; + +public readonly record struct SharpIdeCompletionItem(CompletionItem CompletionItem, ImmutableArray? MatchedSpans) +{ + public readonly CompletionItem CompletionItem = CompletionItem; + public readonly ImmutableArray? MatchedSpans = MatchedSpans; +} diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs index adbbd2e..c7307d0 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs @@ -2,9 +2,7 @@ using System.Collections.Immutable; using Godot; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Shared.TestHooks; -using Microsoft.CodeAnalysis.Tags; using Microsoft.CodeAnalysis.Text; using Microsoft.CodeAnalysis.Threading; using ObservableCollections; @@ -21,7 +19,6 @@ using SharpIDE.Application.Features.NavigationHistory; using SharpIDE.Application.Features.Run; using SharpIDE.Application.Features.SolutionDiscovery; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; -using SharpIDE.Godot.Features.Problems; using Task = System.Threading.Tasks.Task; namespace SharpIDE.Godot.Features.CodeEditor; @@ -57,18 +54,6 @@ public partial class SharpIdeCodeEdit : CodeEdit [Inject] private readonly IdeNavigationHistoryService _navigationHistoryService = null!; [Inject] private readonly EditorCaretPositionService _editorCaretPositionService = null!; - private readonly List _codeCompletionTriggers = - [ - "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", - "_", "<", ".", "#" - ]; - private readonly List _additionalCodeCompletionPrefixes = - [ - //"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", - //"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", - "(", ",", "=", "\t", ":" - ]; - public SharpIdeCodeEdit() { _selectionChangedQueue = new AsyncBatchingWorkQueue(TimeSpan.FromMilliseconds(150), ProcessSelectionChanged, IAsynchronousOperationListener.Instance, CancellationToken.None); @@ -76,14 +61,10 @@ public partial class SharpIdeCodeEdit : CodeEdit public override void _Ready() { - // _filter_code_completion_candidates_impl uses these prefixes to determine where the completions menu is allowed to show. - // It is quite annoying as we cannot override it via _FilterCodeCompletionCandidates, as we would lose the filtering as well. - // Currently, it is not possible to show completions on a new line at col 0 - CodeCompletionPrefixes = [.._codeCompletionTriggers, .._additionalCodeCompletionPrefixes]; SyntaxHighlighter = _syntaxHighlighter; _popupMenu = GetNode("CodeFixesMenu"); _popupMenu.IdPressed += OnCodeFixSelected; - CodeCompletionRequested += OnCodeCompletionRequested; + CustomCodeCompletionRequested.Subscribe(OnCodeCompletionRequested); CodeFixesRequested += OnCodeFixesRequested; BreakpointToggled += OnBreakpointToggled; CaretChanged += OnCaretChanged; @@ -233,6 +214,25 @@ public partial class SharpIdeCodeEdit : CodeEdit var __ = SharpIdeOtel.Source.StartActivity($"{nameof(SharpIdeCodeEdit)}.{nameof(OnTextChanged)}"); _currentFile.IsDirty.Value = true; await _fileChangedService.SharpIdeFileChanged(_currentFile, Text, FileChangeType.IdeUnsavedChange); + if (pendingCompletionTrigger is not null) + { + var cursorPosition = GetCaretPosition(); + var linePosition = new LinePosition(cursorPosition.line, cursorPosition.col); + completionTrigger = pendingCompletionTrigger; + pendingCompletionTrigger = null; + var shouldTriggerCompletion = await _roslynAnalysis.ShouldTriggerCompletionAsync(_currentFile, Text, linePosition, completionTrigger!.Value); + GD.Print($"Code completion trigger typed: '{completionTrigger.Value.Character}' at {linePosition.Line}:{linePosition.Character} should trigger: {shouldTriggerCompletion}"); + if (shouldTriggerCompletion) + { + await OnCodeCompletionRequested(completionTrigger.Value); + } + } + else if (pendingCompletionFilterReason is not null) + { + var filterReason = pendingCompletionFilterReason.Value; + pendingCompletionFilterReason = null; + await CustomFilterCodeCompletionCandidates(filterReason); + } __?.Dispose(); }); } @@ -382,17 +382,19 @@ public partial class SharpIdeCodeEdit : CodeEdit }; UnderlineRange(line, startCol, endCol, color); } + DrawCompletionsPopup(); } - // public override Array _FilterCodeCompletionCandidates(Array candidates) - // { - // return base._FilterCodeCompletionCandidates(candidates); - // } - // This only gets invoked if the Node is focused public override void _GuiInput(InputEvent @event) { - if (@event is InputEventMouseButton { Pressed: true } mouseEvent) + if (@event is InputEventMouseMotion) return; + if (CompletionsPopupTryConsumeGuiInput(@event)) + { + AcceptEvent(); + return; + } + if (@event is InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left or MouseButton.Right } mouseEvent) { var (col, line) = GetLineColumnAtPos((Vector2I)mouseEvent.Position); var current = _navigationHistoryService.Current.Value; @@ -402,44 +404,6 @@ public partial class SharpIdeCodeEdit : CodeEdit _navigationHistoryService.RecordNavigation(_currentFile, new SharpIdeFileLinePosition(line, col)); } } - else if (@event is InputEventKey { Pressed: true } keyEvent) - { - var codeCompletionSelectedIndex = GetCodeCompletionSelectedIndex(); - var isCodeCompletionPopupOpen = codeCompletionSelectedIndex is not -1; - if (keyEvent is { Keycode: Key.Backspace, CtrlPressed: false }) - { - - } - if (keyEvent is { Keycode: Key.Delete, CtrlPressed: false }) - { - - } - else if (keyEvent.Unicode != 0) - { - var unicodeString = char.ConvertFromUtf32((int)keyEvent.Unicode); - if (isCodeCompletionPopupOpen && unicodeString is " ") - { - Callable.From(() => CancelCodeCompletion()).CallDeferred(); - } - else if (isCodeCompletionPopupOpen is false && _codeCompletionTriggers.Contains(unicodeString, StringComparer.OrdinalIgnoreCase)) - { - void OnAction() - { - TextChanged -= OnAction; - Callable.From(() => RequestCodeCompletion(true)).CallDeferred(); - } - // TODO: This is flawed - we currently retrieve completions after TextChanged fires, but OnTextChange returns before the workspace is actually updated, so we may ask for completions for stale text. - TextChanged += OnAction; // We need to wait for the text to actually change before requesting completions - } - } - } - // else if (@event.IsActionPressed("ui_text_completion_query")) - // { - // GD.Print("Entering CompletionQueryBuiltin _GuiInput"); - // AcceptEvent(); - // //GetViewport().SetInputAsHandled(); - // Callable.From(() => RequestCodeCompletion(true)).CallDeferred(); - // } } public override void _UnhandledKeyInput(InputEvent @event) @@ -548,75 +512,6 @@ public partial class SharpIdeCodeEdit : CodeEdit }); }); } - - public override void _ConfirmCodeCompletion(bool replace) - { - var selectedIndex = GetCodeCompletionSelectedIndex(); - var selectedText = GetCodeCompletionOption(selectedIndex); - if (selectedText is null) return; - var completionItem = selectedText["default_value"].As>().Item; - _ = Task.GodotRun(async () => - { - await _ideApplyCompletionService.ApplyCompletion(_currentFile, completionItem.CompletionItem, completionItem.Document); - }); - CancelCodeCompletion(); - } - - private record struct IdeCompletionItem(CompletionItem CompletionItem, Document Document); - private void OnCodeCompletionRequested() - { - var (caretLine, caretColumn) = GetCaretPosition(); - - GD.Print($"Code completion requested at line {caretLine}, column {caretColumn}"); - _ = Task.GodotRun(async () => - { - var linePos = new LinePosition(caretLine, caretColumn); - - var completionsResult = await _roslynAnalysis.GetCodeCompletionsForDocumentAtPosition(_currentFile, linePos); - var completionOptions = new List<(CodeCompletionKind kind, string displayText, Texture2D? icon, GodotObjectContainer refCountedContainer)>(completionsResult.CompletionList.ItemsList.Count); - - foreach (var completionItem in completionsResult.CompletionList.ItemsList) - { - var symbolKindString = CollectionExtensions.GetValueOrDefault(completionItem.Properties, "SymbolKind"); - var symbolKind = symbolKindString is null ? null : (SymbolKind?)int.Parse(symbolKindString); - var wellKnownTags = completionItem.Tags; - var typeKindString = completionItem.Tags.ElementAtOrDefault(0); - var accessibilityModifierString = completionItem.Tags.Skip(1).FirstOrDefault(); // accessibility is not always supplied, and I don't think there's actually any guarantee on the order of tags. See WellKnownTags and WellKnownTagArrays - TypeKind? typeKind = Enum.TryParse(typeKindString, out var tk) ? tk : null; - Accessibility? accessibilityModifier = Enum.TryParse(accessibilityModifierString, out var am) ? am : null; - var godotCompletionType = symbolKind switch - { - SymbolKind.Method => CodeCompletionKind.Function, - SymbolKind.NamedType => CodeCompletionKind.Class, - SymbolKind.Local => CodeCompletionKind.Variable, - SymbolKind.Parameter => CodeCompletionKind.Variable, - SymbolKind.Property => CodeCompletionKind.Member, - SymbolKind.Field => CodeCompletionKind.Member, - _ => CodeCompletionKind.PlainText - }; - var isKeyword = wellKnownTags.Contains(WellKnownTags.Keyword); - var isExtensionMethod = wellKnownTags.Contains(WellKnownTags.ExtensionMethod); - var isMethod = wellKnownTags.Contains(WellKnownTags.Method); - if (symbolKind is null && (isMethod || isExtensionMethod)) symbolKind = SymbolKind.Method; - var icon = GetIconForCompletion(symbolKind, typeKind, accessibilityModifier, isKeyword); - var ideItem = new IdeCompletionItem(completionItem, completionsResult.Document); - // TODO: This is a GodotObjectContainer to avoid errors with the RefCountedContainer?? But the workaround 100% causes a memory leak as these are never freed, unlike RefCounted. Do this better - var refContainer = new GodotObjectContainer(ideItem); - - completionOptions.Add((godotCompletionType, completionItem.DisplayText, icon, refContainer)); - } - await this.InvokeAsync(() => - { - foreach (var (godotCompletionType, displayText, icon, refCountedContainer) in completionOptions) - { - AddCodeCompletionOption(godotCompletionType, displayText, displayText, icon: icon, value: refCountedContainer); - } - UpdateCodeCompletionOptions(true); - //RequestCodeCompletion(true); - }); - GD.Print($"Found {completionsResult.CompletionList.ItemsList.Count} completions, displaying menu"); - }); - } private (int line, int col) GetCaretPosition(bool startAt1 = false) { diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.tscn b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.tscn index f429237..626e9d0 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.tscn +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.tscn @@ -32,7 +32,6 @@ gutters_draw_breakpoints_gutter = true gutters_draw_executing_lines = true gutters_draw_line_numbers = true delimiter_strings = Array[String]([]) -code_completion_enabled = true code_completion_prefixes = Array[String]([".", " "]) indent_automatic = true auto_brace_completion_enabled = true diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions.cs b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions.cs index 77ec391..64d03a2 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions.cs +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions.cs @@ -1,5 +1,12 @@ -using Godot; +using System.Collections.Immutable; +using Ardalis.GuardClauses; +using Godot; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.Tags; +using Microsoft.CodeAnalysis.Text; +using SharpIDE.Application.Features.Analysis; +using SharpIDE.Application.Features.Events; namespace SharpIDE.Godot.Features.CodeEditor; @@ -18,6 +25,25 @@ public partial class SharpIdeCodeEdit private readonly Texture2D _enumIcon = ResourceLoader.Load("uid://8mdxo65qepqv"); private readonly Texture2D _delegateIcon = ResourceLoader.Load("uid://c83pv25rdescy"); + private Texture2D? GetIconForCompletion(SharpIdeCompletionItem sharpIdeCompletionItem) + { + var completionItem = sharpIdeCompletionItem.CompletionItem; + var symbolKindString = CollectionExtensions.GetValueOrDefault(completionItem.Properties, "SymbolKind"); + var symbolKind = symbolKindString is null ? null : (SymbolKind?)int.Parse(symbolKindString); + var wellKnownTags = completionItem.Tags; + var typeKindString = completionItem.Tags.ElementAtOrDefault(0); + var accessibilityModifierString = completionItem.Tags.Skip(1).FirstOrDefault(); // accessibility is not always supplied, and I don't think there's actually any guarantee on the order of tags. See WellKnownTags and WellKnownTagArrays + TypeKind? typeKind = Enum.TryParse(typeKindString, out var tk) ? tk : null; + Accessibility? accessibilityModifier = Enum.TryParse(accessibilityModifierString, out var am) ? am : null; + + var isKeyword = wellKnownTags.Contains(WellKnownTags.Keyword); + var isExtensionMethod = wellKnownTags.Contains(WellKnownTags.ExtensionMethod); + var isMethod = wellKnownTags.Contains(WellKnownTags.Method); + if (symbolKind is null && (isMethod || isExtensionMethod)) symbolKind = SymbolKind.Method; + var icon = GetIconForCompletion(symbolKind, typeKind, accessibilityModifier, isKeyword); + return icon; + } + private Texture2D? GetIconForCompletion(SymbolKind? symbolKind, TypeKind? typeKind, Accessibility? accessibility, bool isKeyword) { if (isKeyword) return _keywordIcon; @@ -40,4 +66,80 @@ public partial class SharpIdeCodeEdit }; return texture; } -} \ No newline at end of file + + private EventWrapper CustomCodeCompletionRequested { get; } = new(_ => Task.CompletedTask); + private CompletionList? completionList; + private Document? completionResultDocument; + private CompletionTrigger? completionTrigger; + private CompletionTrigger? pendingCompletionTrigger; + private CompletionFilterReason? pendingCompletionFilterReason; + + private readonly List _codeCompletionTriggers = + [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + "_", "<", ".", "#" + ]; + + private void ResetCompletionPopupState() + { + _codeCompletionOptions = ImmutableArray.Empty; + completionList = null; + completionResultDocument = null; + completionTrigger = null; + _completionTriggerPosition = null; + _codeCompletionCurrentSelected = 0; + _codeCompletionForceItemCenter = -1; + } + + private async Task CustomFilterCodeCompletionCandidates(CompletionFilterReason filterReason) + { + if (completionList is null || completionList.ItemsList.Count is 0) return; + var cursorPosition = GetCaretPosition(); + var linePosition = new LinePosition(cursorPosition.line, cursorPosition.col); + var filteredCompletions = RoslynAnalysis.FilterCompletions(_currentFile, Text, linePosition, completionList, completionTrigger!.Value, filterReason); + _codeCompletionOptions = filteredCompletions; + await this.InvokeAsync(QueueRedraw); + } + + private async Task OnCodeCompletionRequested(CompletionTrigger completionTrigger) + { + var (caretLine, caretColumn) = GetCaretPosition(); + + GD.Print($"Code completion requested at line {caretLine}, column {caretColumn}"); + _ = Task.GodotRun(async () => + { + var linePos = new LinePosition(caretLine, caretColumn); + + var completionsResult = await _roslynAnalysis.GetCodeCompletionsForDocumentAtPosition(_currentFile, linePos, completionTrigger); + + // We can't draw until we get this position + _completionTriggerPosition = await this.InvokeAsync(() => GetPosAtLineColumn(completionsResult.LinePosition.Line, completionsResult.LinePosition.Character)); + + completionList = completionsResult.CompletionList; + completionResultDocument = completionsResult.Document; + var filterReason = completionTrigger.Kind switch + { + CompletionTriggerKind.Insertion => CompletionFilterReason.Insertion, + CompletionTriggerKind.Deletion => CompletionFilterReason.Deletion, + CompletionTriggerKind.InvokeAndCommitIfUnique => CompletionFilterReason.Other, + _ => throw new ArgumentOutOfRangeException(nameof(completionTrigger.Kind), completionTrigger.Kind, null), + }; + await CustomFilterCodeCompletionCandidates(filterReason); + GD.Print($"Found {completionsResult.CompletionList.ItemsList.Count} completions, displaying menu"); + }); + } + + public void ApplySelectedCodeCompletion() + { + var selectedIndex = _codeCompletionCurrentSelected; + var completionItem = _codeCompletionOptions[selectedIndex]; + var document = completionResultDocument; + _ = Task.GodotRun(async () => + { + Guard.Against.Null(document); + await _ideApplyCompletionService.ApplyCompletion(_currentFile, completionItem.CompletionItem, document); + }); + ResetCompletionPopupState(); + QueueRedraw(); + } +} diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_Draw.cs b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_Draw.cs new file mode 100644 index 0000000..a666444 --- /dev/null +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_Draw.cs @@ -0,0 +1,311 @@ +using System.Collections.Immutable; +using Godot; +using SharpIDE.Application.Features.Analysis; + +namespace SharpIDE.Godot.Features.CodeEditor; + +public partial class SharpIdeCodeEdit +{ + private ImmutableArray _codeCompletionOptions = []; + + private Rect2I _codeCompletionRect = new Rect2I(); + private Rect2I _codeCompletionScrollRect = new Rect2I(); + private Vector2I _codeHintMinsize = new Vector2I(); + private Vector2I? _completionTriggerPosition; + private int _codeCompletionLineOfs = 0; + private int _codeCompletionForceItemCenter = -1; + private int _codeCompletionCurrentSelected = 0; + private bool _isCodeCompletionScrollHovered = false; + private bool _isCodeCompletionScrollPressed = false; + private const int MaxLines = 7; + + private int? GetCompletionOptionAtPoint(Vector2I point) + { + if (!_codeCompletionRect.HasPoint(point)) return null; + + int rowHeight = GetLineHeight(); + int relativeY = point.Y - _codeCompletionRect.Position.Y; + int lineIndex = relativeY / rowHeight + _codeCompletionLineOfs; + if (lineIndex < 0 || lineIndex >= _codeCompletionOptions.Length) return null; + + return lineIndex; + } + + private void DrawCompletionsPopup() + { + var drawCodeCompletion = _codeCompletionOptions.Length > 0; + var drawCodeHint = false; + var codeHintDrawBelow = false; + + if (!drawCodeCompletion) return; + + // originally from theme cache + const int codeCompletionIconSeparation = 4; + var codeCompletionMinimumSize = new Vector2I(50, 50); + var lineSpacing = 2; + var themeScrollWidth = 6; + // + + var font = GetThemeFont(ThemeStringNames.Font); + var fontSize = GetThemeFontSize(ThemeStringNames.FontSize); + var ci = GetCanvasItem(); + var availableCompletions = _codeCompletionOptions.Length; // TODO: Actually get from completions + var completionsToDisplay = Math.Min(availableCompletions, MaxLines); + var rowHeight = GetLineHeight(); + var iconAreaSize = new Vector2I(rowHeight, rowHeight); + + var completionMaxWidth = 200; + var codeCompletionLongestLine = Math.Min(completionMaxWidth, + _codeCompletionOptions.MaxBy(s => s.CompletionItem.DisplayText.Length)!.CompletionItem.DisplayText.Length * fontSize); + codeCompletionLongestLine = 500; + + _codeCompletionRect.Size = new Vector2I( + codeCompletionLongestLine + codeCompletionIconSeparation + iconAreaSize.X + 2, + completionsToDisplay * rowHeight + ); + + var caretPos = (Vector2I)GetCaretDrawPos(); + var totalHeight = codeCompletionMinimumSize.Y + _codeCompletionRect.Size.Y; + float minY = caretPos.Y - rowHeight; + float maxY = caretPos.Y + rowHeight + totalHeight; + + // if (drawCodeHint) + // { + // if (codeHintDrawBelow) + // { + // maxY += codeHintMinsize.Y; + // } + // else + // { + // minY -= codeHintMinsize. Y; + // } + // } + + bool canFitCompletionAbove = minY > totalHeight; + var sharpIdeCodeEditSize = GetSize(); + bool canFitCompletionBelow = maxY <= sharpIdeCodeEditSize.Y; + + bool shouldPlaceAbove = !canFitCompletionBelow && canFitCompletionAbove; + + if (!canFitCompletionBelow && !canFitCompletionAbove) + { + float spaceAbove = caretPos.Y - rowHeight; + float spaceBelow = sharpIdeCodeEditSize.Y - caretPos.Y; + shouldPlaceAbove = spaceAbove > spaceBelow; + + // Reduce the line count and recalculate heights to better fit the completion popup. + float spaceAvail; + if (shouldPlaceAbove) + { + spaceAvail = spaceAbove - codeCompletionMinimumSize.Y; + } + else + { + spaceAvail = spaceBelow - codeCompletionMinimumSize.Y; + } + + int maxLinesFit = Mathf.Max(1, (int)(spaceAvail / rowHeight)); + completionsToDisplay = Mathf.Min(completionsToDisplay, maxLinesFit); + _codeCompletionRect.Size = new Vector2I(_codeCompletionRect.Size.X, completionsToDisplay * rowHeight); + totalHeight = codeCompletionMinimumSize.Y + _codeCompletionRect.Size.Y; + } + + if (shouldPlaceAbove) + { + _codeCompletionRect.Position = new Vector2I( + _codeCompletionRect.Position.X, + (caretPos.Y - totalHeight - rowHeight) + lineSpacing + ); + if (drawCodeHint && !codeHintDrawBelow) + { + _codeCompletionRect.Position = new Vector2I( + _codeCompletionRect.Position.X, + _codeCompletionRect.Position.Y - _codeHintMinsize.Y + ); + } + } + else + { + _codeCompletionRect.Position = new Vector2I( + _codeCompletionRect.Position.X, + caretPos.Y + (lineSpacing / 2) + ); + if (drawCodeHint && codeHintDrawBelow) + { + _codeCompletionRect.Position = new Vector2I( + _codeCompletionRect.Position.X, + _codeCompletionRect.Position.Y + _codeHintMinsize.Y + ); + } + } + + var scrollWidth = availableCompletions > MaxLines ? themeScrollWidth : 0; + + // TODO: Fix + var codeCompletionBase = ""; + + const int iconOffset = 25; + // Desired X position for the popup to start at + int desiredX = _completionTriggerPosition!.Value.X - iconOffset; + + // Calculate the maximum X allowed so the popup stays inside the parent + int maxX = (int)sharpIdeCodeEditSize.X - _codeCompletionRect.Size.X - scrollWidth; + + // Clamp the X position so it never overflows to the right + int finalX = Math.Min(desiredX, maxX); + + _codeCompletionRect.Position = new Vector2I(finalX, _codeCompletionRect.Position.Y); + + //var completionStyle = GetThemeStylebox(ThemeStringNames.Completion); + // I don't know what this is used for, but it puts a weird block box around the completions + // completionStyle.Draw( + // ci, + // new Rect2( + // codeCompletionRect.Position - completionStyle.GetOffset(), + // codeCompletionRect.Size + codeCompletionMinimumSize + new Vector2I(scrollWidth, 0) + // ) + // ); + + var codeCompletionBackgroundColor = GetThemeColor(ThemeStringNames.CompletionBackgroundColor); + if (codeCompletionBackgroundColor.A > 0.01f) + { + RenderingServer.Singleton.CanvasItemAddRect( + ci, + new Rect2(_codeCompletionRect.Position, _codeCompletionRect.Size + new Vector2I(scrollWidth, 0)), + codeCompletionBackgroundColor + ); + } + + _codeCompletionScrollRect.Position = _codeCompletionRect.Position + new Vector2I(_codeCompletionRect.Size.X, 0); + _codeCompletionScrollRect.Size = new Vector2I(scrollWidth, _codeCompletionRect.Size.Y); + + _codeCompletionLineOfs = Mathf.Clamp( + (_codeCompletionForceItemCenter < 0 ? _codeCompletionCurrentSelected : _codeCompletionForceItemCenter) - + completionsToDisplay / 2, + 0, + availableCompletions - completionsToDisplay + ); + + var codeCompletionSelectedColor = GetThemeColor(ThemeStringNames.CompletionSelectedColor); + RenderingServer.Singleton.CanvasItemAddRect( + ci, + new Rect2( + new Vector2( + _codeCompletionRect.Position.X, + _codeCompletionRect.Position.Y + (_codeCompletionCurrentSelected - _codeCompletionLineOfs) * rowHeight + ), + new Vector2(_codeCompletionRect.Size.X, rowHeight) + ), + codeCompletionSelectedColor + ); + + // TODO: Cache + string lang = OS.GetLocale(); + for (int i = 0; i < completionsToDisplay; i++) + { + int l = _codeCompletionLineOfs + i; + if (l < 0 || l >= availableCompletions) + { + GD.PushError($"Invalid line index: {l}"); + continue; + } + + var sharpIdeCompletionItem = _codeCompletionOptions[l]; + var displayText = sharpIdeCompletionItem.CompletionItem.DisplayText; + TextLine tl = new TextLine(); + tl.AddString( + displayText, + font, + fontSize, + lang + ); + + float yofs = (rowHeight - tl.GetSize().Y) / 2; + Vector2 titlePos = new Vector2( + _codeCompletionRect.Position.X, + _codeCompletionRect.Position.Y + i * rowHeight + yofs + ); + + /* Draw completion icon if it is valid. */ + var icon = GetIconForCompletion(sharpIdeCompletionItem); + Rect2 iconArea = new Rect2( + new Vector2(_codeCompletionRect.Position.X, _codeCompletionRect.Position.Y + i * rowHeight), + iconAreaSize + ); + + if (icon != null) + { + Vector2 iconSize = iconArea.Size * 0.7f; + icon.DrawRect( + ci, + new Rect2( + iconArea.Position + (iconArea.Size - iconSize) / 2, + iconSize + ), + false + ); + } + + titlePos.X = iconArea.Position.X + iconArea.Size.X + codeCompletionIconSeparation; + + tl.Width = _codeCompletionRect.Size.X - (iconAreaSize.X + codeCompletionIconSeparation); + + tl.Alignment = HorizontalAlignment.Left; + + + Vector2 matchPos = new Vector2( + _codeCompletionRect.Position.X + iconAreaSize.X + codeCompletionIconSeparation, + _codeCompletionRect.Position.Y + i * rowHeight + ); + + foreach (var matchSegment in sharpIdeCompletionItem.MatchedSpans ?? []) + { + float matchOffset = font.GetStringSize( + displayText.Substr(0, matchSegment.Start), + HorizontalAlignment.Left, + -1, + fontSize + ).X; + + float matchLen = font.GetStringSize( + displayText.Substr(matchSegment.Start, matchSegment.Length), + HorizontalAlignment.Left, + -1, + fontSize + ).X; + + RenderingServer.Singleton.CanvasItemAddRect( + ci, + new Rect2(matchPos + new Vector2(matchOffset, 0), new Vector2(matchLen, rowHeight)), + GetThemeColor(ThemeStringNames.CompletionExistingColor) + ); + } + + var fontColour = Colors.White; + tl.Draw(ci, titlePos, fontColour); + } + + /* Draw a small scroll rectangle to show a position in the options. */ + if (scrollWidth > 0) + { + Color scrollColor = _isCodeCompletionScrollHovered || _isCodeCompletionScrollPressed + ? GetThemeColor(ThemeStringNames.CompletionScrollHoveredColor) + : GetThemeColor(ThemeStringNames.CompletionScrollColor); + + float r = (float)MaxLines / availableCompletions; + float o = (float)_codeCompletionLineOfs / availableCompletions; + + RenderingServer.Singleton.CanvasItemAddRect( + ci, + new Rect2( + new Vector2( + _codeCompletionRect.Position.X + _codeCompletionRect.Size.X, + _codeCompletionRect.Position.Y + o * _codeCompletionRect.Size.Y + ), + new Vector2(scrollWidth, _codeCompletionRect.Size.Y * r) + ), + scrollColor + ); + } + } +} \ No newline at end of file diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_Draw.cs.uid b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_Draw.cs.uid new file mode 100644 index 0000000..f1e9aac --- /dev/null +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_Draw.cs.uid @@ -0,0 +1 @@ +uid://bjxy1d78vrp6o diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_InputHandling.cs b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_InputHandling.cs new file mode 100644 index 0000000..206e447 --- /dev/null +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_InputHandling.cs @@ -0,0 +1,120 @@ +using Godot; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.Text; +using SharpIDE.Godot.Features.Problems; + +namespace SharpIDE.Godot.Features.CodeEditor; + +public partial class SharpIdeCodeEdit +{ + private bool CompletionsPopupTryConsumeGuiInput(InputEvent @event) + { + var isCodeCompletionPopupOpen = _codeCompletionOptions.Length is not 0; + if (isCodeCompletionPopupOpen is false) + { + if (@event.IsActionPressed(InputStringNames.CodeEditorRequestCompletions)) + { + completionTrigger = new CompletionTrigger(CompletionTriggerKind.InvokeAndCommitIfUnique); + CustomCodeCompletionRequested.InvokeParallelFireAndForget(completionTrigger!.Value); + return true; + } + } + if (isCodeCompletionPopupOpen) + { + if (@event is InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left or MouseButton.Right } mouseEvent) + { + var codeCompletionIndex = GetCompletionOptionAtPoint((Vector2I)mouseEvent.Position); + if (codeCompletionIndex is null) + { + // if the index is null, it means we clicked outside the completion popup, so close it + ResetCompletionPopupState(); + return false; + } + + // If no item is currently centered + if (_codeCompletionForceItemCenter is -1) + { + // center the current central item, as an anchor, before we update the selection + _codeCompletionForceItemCenter = _codeCompletionCurrentSelected; + } + + _codeCompletionCurrentSelected = codeCompletionIndex.Value; + if (mouseEvent is { DoubleClick: true }) + { + ApplySelectedCodeCompletion(); + return true; + } + + GD.Print($"Code completion option clicked: {codeCompletionIndex.Value}"); + QueueRedraw(); + return true; + } + + if (@event is InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.WheelDown or MouseButton.WheelUp } scrollEvent) + { + if (_codeCompletionRect.HasPoint((Vector2I)scrollEvent.Position)) + { + int scrollAmount = scrollEvent.ButtonIndex is MouseButton.WheelDown ? 1 : -1; + _codeCompletionCurrentSelected = Mathf.Clamp( + _codeCompletionCurrentSelected + scrollAmount, + 0, + _codeCompletionOptions.Length - 1 + ); + _codeCompletionForceItemCenter = -1; + QueueRedraw(); + return true; + } + } + else if (@event.IsActionPressed(InputStringNames.Backspace)) + { + pendingCompletionFilterReason = CompletionFilterReason.Deletion; + return false; + } + + if (@event is InputEventKey { Pressed: true, Keycode: Key.Up or Key.Down } inputEventKey) + { + var delta = inputEventKey.Keycode is Key.Up ? -1 : 1; + _codeCompletionCurrentSelected = Mathf.Clamp( + _codeCompletionCurrentSelected + delta, + 0, + _codeCompletionOptions.Length - 1 + ); + _codeCompletionForceItemCenter = -1; + QueueRedraw(); + return true; + } + + if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape }) + { + ResetCompletionPopupState(); + QueueRedraw(); + return true; + } + + if (@event is InputEventKey { Pressed: true, Keycode: Key.Enter or Key.Tab }) + { + ApplySelectedCodeCompletion(); + return true; + } + } + + if (@event is InputEventKey { Pressed: true, Unicode: not 0 } keyEvent) + { + + var unicodeString = char.ConvertFromUtf32((int)keyEvent.Unicode); + if (isCodeCompletionPopupOpen && keyEvent.Unicode >= 32) + { + pendingCompletionFilterReason = CompletionFilterReason.Insertion; + return false; // Let the text update happen + } + + if (isCodeCompletionPopupOpen is false && _codeCompletionTriggers.Contains(unicodeString, StringComparer.OrdinalIgnoreCase)) + { + pendingCompletionTrigger = CompletionTrigger.CreateInsertionTrigger(unicodeString[0]); + return false; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_InputHandling.cs.uid b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_InputHandling.cs.uid new file mode 100644 index 0000000..4b857ca --- /dev/null +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit_Completions_InputHandling.cs.uid @@ -0,0 +1 @@ +uid://c5pllbl8kxp0q diff --git a/src/SharpIDE.Godot/InputStringNames.cs b/src/SharpIDE.Godot/InputStringNames.cs index b8674e6..ca2fcb9 100644 --- a/src/SharpIDE.Godot/InputStringNames.cs +++ b/src/SharpIDE.Godot/InputStringNames.cs @@ -4,6 +4,7 @@ namespace SharpIDE.Godot; public static class InputStringNames { + public static readonly StringName Backspace = "ui_text_backspace"; public static readonly StringName RenameSymbol = nameof(RenameSymbol); public static readonly StringName CodeFixes = nameof(CodeFixes); public static readonly StringName StepOver = nameof(StepOver); @@ -16,6 +17,7 @@ public static class InputStringNames public static readonly StringName SaveAllFiles = nameof(SaveAllFiles); public static readonly StringName EditorFontSizeIncrease = nameof(EditorFontSizeIncrease); public static readonly StringName EditorFontSizeDecrease = nameof(EditorFontSizeDecrease); + public static readonly StringName CodeEditorRequestCompletions = nameof(CodeEditorRequestCompletions); } public static class ThemeStringNames @@ -29,4 +31,12 @@ public static class ThemeStringNames public static readonly StringName Panel = "panel"; public static readonly StringName Separation = "separation"; + + public static readonly StringName Completion = "completion"; + public static readonly StringName CompletionBackgroundColor = "completion_background_color"; + public static readonly StringName CompletionSelectedColor = "completion_selected_color"; + public static readonly StringName CompletionScrollHoveredColor = "completion_scroll_hovered_color"; + public static readonly StringName CompletionScrollColor = "completion_scroll_color"; + public static readonly StringName CompletionExistingColor = "completion_existing_color"; + public static readonly StringName CompletionColorBgIcon = "completion_color_bg"; } \ No newline at end of file diff --git a/src/SharpIDE.Godot/SharpIDE.Godot.csproj b/src/SharpIDE.Godot/SharpIDE.Godot.csproj index 36bf2b1..b16247f 100644 --- a/src/SharpIDE.Godot/SharpIDE.Godot.csproj +++ b/src/SharpIDE.Godot/SharpIDE.Godot.csproj @@ -23,6 +23,7 @@ + diff --git a/src/SharpIDE.Godot/project.godot b/src/SharpIDE.Godot/project.godot index 06c04bb..6071bf7 100644 --- a/src/SharpIDE.Godot/project.godot +++ b/src/SharpIDE.Godot/project.godot @@ -98,6 +98,11 @@ EditorFontSizeDecrease={ "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"button_mask":16,"position":Vector2(120, 12),"global_position":Vector2(129, 60),"factor":1.0,"button_index":5,"canceled":false,"pressed":true,"double_click":false,"script":null) ] } +CodeEditorRequestCompletions={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +] +} [rendering]