rework completions v1

This commit is contained in:
Matt Parker
2026-01-23 22:09:48 +10:00
parent 19f9d07bf6
commit e766197ef8
12 changed files with 659 additions and 148 deletions

View File

@@ -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<RoslynAnalysis> 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<IdeCompletionListResult> GetCodeCompletionsForDocumentAtPosition(SharpIdeFile fileModel, LinePosition linePosition)
public record IdeCompletionListResult(Document Document, CompletionList CompletionList, LinePosition LinePosition);
public async Task<IdeCompletionListResult> 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<RoslynAnalysis> logger, BuildService
return codeActions.ToImmutableArray();
}
private static async Task<CompletionList> 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<bool> ShouldTriggerCompletionAsync(SharpIdeFile file, LinePosition linePosition, CompletionTrigger completionTrigger, CancellationToken cancellationToken = default)
public async Task<bool> 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<SharpIdeCompletionItem> 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<SharpIdeCompletionItem> 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<MatchResult>.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();

View File

@@ -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<TextSpan>? MatchedSpans)
{
public readonly CompletionItem CompletionItem = CompletionItem;
public readonly ImmutableArray<TextSpan>? MatchedSpans = MatchedSpans;
}

View File

@@ -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<string> _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<string> _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<PopupMenu>("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<Dictionary> _FilterCodeCompletionCandidates(Array<Dictionary> 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<GodotObjectContainer<IdeCompletionItem>>().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<IdeCompletionItem> 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<TypeKind>(typeKindString, out var tk) ? tk : null;
Accessibility? accessibilityModifier = Enum.TryParse<Accessibility>(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<IdeCompletionItem>(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)
{

View File

@@ -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

View File

@@ -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<Texture2D>("uid://8mdxo65qepqv");
private readonly Texture2D _delegateIcon = ResourceLoader.Load<Texture2D>("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<TypeKind>(typeKindString, out var tk) ? tk : null;
Accessibility? accessibilityModifier = Enum.TryParse<Accessibility>(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;
}
}
private EventWrapper<CompletionTrigger, Task> CustomCodeCompletionRequested { get; } = new(_ => Task.CompletedTask);
private CompletionList? completionList;
private Document? completionResultDocument;
private CompletionTrigger? completionTrigger;
private CompletionTrigger? pendingCompletionTrigger;
private CompletionFilterReason? pendingCompletionFilterReason;
private readonly List<string> _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<SharpIdeCompletionItem>.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();
}
}

View File

@@ -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<SharpIdeCompletionItem> _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
);
}
}
}

View File

@@ -0,0 +1 @@
uid://bjxy1d78vrp6o

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
uid://c5pllbl8kxp0q

View File

@@ -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";
}

View File

@@ -23,6 +23,7 @@
<ItemGroup>
<Publicize Include="Microsoft.CodeAnalysis.Workspaces" />
<Publicize Include="Microsoft.CodeAnalysis.Features" />
<DoNotPublicize Include="Microsoft.CodeAnalysis.Workspaces:System.Linq.RoslynEnumerableExtensions" />
</ItemGroup>

View File

@@ -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]