Skip to content

Autocomplete Feature #282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
36b86c4
Updates
FastestMolasses Oct 20, 2024
99a839a
Merge branch 'main' into itembox
FastestMolasses Dec 7, 2024
a143144
ItemBox updates
FastestMolasses Dec 18, 2024
32a7756
Small update
FastestMolasses Dec 22, 2024
924d86f
Small update
FastestMolasses Dec 22, 2024
9947256
Moved code from TextView, added more functionality to delegate
FastestMolasses Dec 27, 2024
afc302e
Small updates
FastestMolasses Dec 29, 2024
93de69a
Merge branch 'main' into itembox
FastestMolasses Dec 29, 2024
d1a4604
Replaced CompletionItem type
FastestMolasses Dec 30, 2024
92f1216
Merge branch 'main' into itembox
FastestMolasses Apr 7, 2025
e307e0d
Merge branch 'main' into itembox
thecoolwinter Jun 18, 2025
843303e
Fix Typo & Warnings
thecoolwinter Jun 18, 2025
bca0e02
Merge branch 'main' into itembox
FastestMolasses Jul 19, 2025
3ee6962
Merge branch 'main' into itembox
thecoolwinter Jul 21, 2025
46a7d67
AutoCompleteCoordinator
FastestMolasses Jul 23, 2025
c9f1d9e
Remove comment
FastestMolasses Jul 23, 2025
a5bcf89
Fix error
FastestMolasses Jul 23, 2025
fefc805
Refactor Suggestion Window
thecoolwinter Jul 23, 2025
f1df981
Resolve Cursors Method, Show Completions On CMD
thecoolwinter Jul 23, 2025
af114f9
Add `codeSuggestionTriggerCharacters`
thecoolwinter Jul 23, 2025
933c7a2
Add Mock Completion Delegate To Example
thecoolwinter Jul 23, 2025
af0059e
Remove Unused Variables
thecoolwinter Jul 23, 2025
76a0206
Theme the window
thecoolwinter Jul 23, 2025
1033aef
Merge branch 'main' into itembox
thecoolwinter Jul 24, 2025
0a0b10a
Move Suggestion UI Into CodeEditSourceEditor
thecoolwinter Jul 24, 2025
058e165
Remove Unused Method
thecoolwinter Jul 24, 2025
64115de
Use Default Window Background Color
thecoolwinter Jul 24, 2025
e00a49d
Round Window Corners, Adjust Origin When Above Cursor
thecoolwinter Jul 25, 2025
3135931
Hide Suggestion Window When Escaped
thecoolwinter Jul 25, 2025
a678f18
Ignore Cursor Change When Request In Progress
thecoolwinter Jul 25, 2025
ac1b50c
Update Conformances
thecoolwinter Jul 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */; };
6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */; };
6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */; };
6C8B564C2E3018CC00DC3F29 /* MockCompletionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */; };
6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */; };
/* End PBXBuildFile section */

Expand All @@ -38,6 +39,7 @@
6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePicker.swift; sourceTree = "<group>"; };
6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditorTheme+Default.swift"; sourceTree = "<group>"; };
6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Hex.swift"; sourceTree = "<group>"; };
6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCompletionDelegate.swift; sourceTree = "<group>"; };
6CF31D4D2DB6A252006A77FD /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -116,6 +118,7 @@
6C13654A2B8A7FD2004A1D18 /* Views */ = {
isa = PBXGroup;
children = (
6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */,
6C1365312B8A7B94004A1D18 /* ContentView.swift */,
6CF31D4D2DB6A252006A77FD /* StatusBar.swift */,
6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */,
Expand Down Expand Up @@ -215,6 +218,7 @@
6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */,
6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */,
6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */,
6C8B564C2E3018CC00DC3F29 /* MockCompletionDelegate.swift in Sources */,
1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */,
6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */,
6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct ContentView: View {
@State private var editorState = SourceEditorState(
cursorPositions: [CursorPosition(line: 1, column: 1)]
)
@StateObject private var suggestions: MockCompletionDelegate = MockCompletionDelegate()

@State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
@AppStorage("wrapLines") private var wrapLines: Bool = true
Expand Down Expand Up @@ -71,7 +72,8 @@ struct ContentView: View {
warningCharacters: warningCharacters
)
),
state: $editorState
state: $editorState,
completionDelegate: suggestions
)
.overlay(alignment: .bottom) {
StatusBar(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// MockCompletionDelegate.swift
// CodeEditSourceEditorExample
//
// Created by Khan Winter on 7/22/25.
//

import SwiftUI
import CodeEditSourceEditor
import CodeEditTextView

private let text = [
"Lorem",
"ipsum",
"dolor",
"sit",
"amet,",
"consectetur",
"adipiscing",
"elit.",
"Ut",
"condimentum",
"dictum",
"malesuada.",
"Praesent",
"ut",
"imperdiet",
"nulla.",
"Vivamus",
"feugiat,",
"ante",
"non",
"sagittis",
"pellentesque,",
"dui",
"massa",
"consequat",
"odio,",
"ac",
"vestibulum",
"augue",
"erat",
"et",
"nunc."
]

class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject {
class Suggestion: CodeSuggestionEntry {
var label: String
var detail: String?
var pathComponents: [String]? { nil }
var targetPosition: CursorPosition? { nil }
var sourcePreview: String? { nil }
var image: Image = Image(systemName: "dot.square.fill")
var imageColor: Color = .gray
var deprecated: Bool = false

init(text: String) {
self.label = text
}
}

private func randomSuggestions(_ count: Int? = nil) -> [Suggestion] {
let count = count ?? Int.random(in: 0..<20)
var suggestions: [Suggestion] = []
for _ in 0..<count {
let randomString = (0..<Int.random(in: 1..<text.count)).map {
text[$0]
}.shuffled().joined(separator: " ")
suggestions.append(Suggestion(text: randomString))
}
return suggestions
}

var moveCount = 0

func completionSuggestionsRequested(
textView: TextViewController,
cursorPosition: CursorPosition
) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? {
try? await Task.sleep(for: .seconds(0.2))
return (cursorPosition, randomSuggestions())
}

func completionOnCursorMove(
textView: TextViewController,
cursorPosition: CursorPosition
) -> [CodeSuggestionEntry]? {
moveCount += 1
switch moveCount {
case 1:
return randomSuggestions(2)
case 2:
return randomSuggestions(20)
default:
moveCount = 0
return nil
}
}

func completionWindowApplyCompletion(
item: CodeSuggestionEntry,
textView: TextViewController,
cursorPosition: CursorPosition?
) {
guard let suggestion = item as? Suggestion else {
return
}
textView.textView.undoManager?.beginUndoGrouping()
textView.textView.insertText(suggestion.label)
textView.textView.undoManager?.endUndoGrouping()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ struct StatusBar: View {
}
}
scrollPosition
Text(getLabel(state.cursorPositions))
Text(getLabel(state.cursorPositions ?? []))
}
.foregroundStyle(.secondary)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// CodeSuggestionDelegate.swift
// CodeEditSourceEditor
//
// Created by Abe Malla on 12/26/24.
//

public protocol CodeSuggestionDelegate: AnyObject {
func completionTriggerCharacters() -> Set<String>

func completionSuggestionsRequested(
textView: TextViewController,
cursorPosition: CursorPosition
) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])?

// This can't be async, we need it to be snappy. At most, it should just be filtering completion items
func completionOnCursorMove(
textView: TextViewController,
cursorPosition: CursorPosition
) -> [CodeSuggestionEntry]?

// Optional
func completionWindowDidClose()

func completionWindowApplyCompletion(
item: CodeSuggestionEntry,
textView: TextViewController,
cursorPosition: CursorPosition?
)
// Optional
func completionWindowDidSelect(item: CodeSuggestionEntry)
}

public extension CodeSuggestionDelegate {
func completionTriggerCharacters() -> Set<String> { [] }
func completionWindowDidClose() { }
func completionWindowDidSelect(item: CodeSuggestionEntry) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// CodeSuggestionEntry.swift
// CodeEditSourceEditor
//
// Created by Khan Winter on 7/22/25.
//

import AppKit
import SwiftUI

/// Represents an item that can be displayed in the code suggestion view
public protocol CodeSuggestionEntry {
var label: String { get }
var detail: String? { get }

/// Leave as `nil` if the link is in the same document.
var pathComponents: [String]? { get }
var targetPosition: CursorPosition? { get }
var sourcePreview: String? { get }

var image: Image { get }
var imageColor: Color { get }

var deprecated: Bool { get }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// SuggestionViewModel.swift
// CodeEditSourceEditor
//
// Created by Khan Winter on 7/22/25.
//

import AppKit

final class SuggestionViewModel: ObservableObject {
/// The items to be displayed in the window
@Published var items: [CodeSuggestionEntry] = []
var itemsRequestTask: Task<Void, Never>?
weak var activeTextView: TextViewController?

var delegate: CodeSuggestionDelegate? {
activeTextView?.completionDelegate
}

func showCompletions(
textView: TextViewController,
delegate: CodeSuggestionDelegate,
cursorPosition: CursorPosition,
showWindowOnParent: @escaping @MainActor (NSWindow, NSRect) -> Void
) {
self.activeTextView = nil
itemsRequestTask?.cancel()

guard let targetParentWindow = textView.view.window else { return }

self.activeTextView = textView
itemsRequestTask = Task {
defer { itemsRequestTask = nil }

do {
guard let completionItems = await delegate.completionSuggestionsRequested(
textView: textView,
cursorPosition: cursorPosition
) else {
return
}

try Task.checkCancellation()
try await MainActor.run {
try Task.checkCancellation()

guard let cursorPosition = textView.resolveCursorPosition(completionItems.windowPosition),
let cursorRect = textView.textView.layoutManager.rectForOffset(
cursorPosition.range.location
),
let cursorRect = textView.view.window?.convertToScreen(
textView.textView.convert(cursorRect, to: nil)
) else {
return
}

self.items = completionItems.items
showWindowOnParent(targetParentWindow, cursorRect)
}
} catch {
return
}
}
}

func cursorsUpdated(
textView: TextViewController,
delegate: CodeSuggestionDelegate,
position: CursorPosition,
close: () -> Void
) {
guard itemsRequestTask == nil else { return }

if activeTextView !== textView {
close()
return
}

guard let newItems = delegate.completionOnCursorMove(
textView: textView,
cursorPosition: position
),
!newItems.isEmpty else {
close()
return
}

items = newItems
}

func didSelect(item: CodeSuggestionEntry) {
delegate?.completionWindowDidSelect(item: item)
}

func applySelectedItem(item: CodeSuggestionEntry, window: NSWindow?) {
guard let activeTextView,
let cursorPosition = activeTextView.cursorPositions.first else {
return
}
self.delegate?.completionWindowApplyCompletion(
item: item,
textView: activeTextView,
cursorPosition: cursorPosition
)
window?.close()
}

func willClose() {
items.removeAll()
activeTextView = nil
}
}
Loading