Skip to content

Commit cf39f6d

Browse files
Autocomplete Feature (#282)
### Description Adds the first iteration of the autocomplete feature. ### Related Issues ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots (Khan) From the demo completion provider: <img width="678" height="250" alt="Screenshot 2025-07-24 at 2 29 06 PM" src="https://github.com/user-attachments/assets/389c5517-b38c-4d40-9f12-fd128a0e8384" /> <img width="916" height="568" alt="Screenshot 2025-07-24 at 2 28 58 PM" src="https://github.com/user-attachments/assets/ae8dae44-1f0d-4274-80d0-1ea39482116c" /> --------- Co-authored-by: Khan Winter <[email protected]>
1 parent e9136b4 commit cf39f6d

22 files changed

+1085
-18
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */; };
2121
6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */; };
2222
6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */; };
23+
6C8B564C2E3018CC00DC3F29 /* MockCompletionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */; };
2324
6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */; };
2425
/* End PBXBuildFile section */
2526

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

@@ -116,6 +118,7 @@
116118
6C13654A2B8A7FD2004A1D18 /* Views */ = {
117119
isa = PBXGroup;
118120
children = (
121+
6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */,
119122
6C1365312B8A7B94004A1D18 /* ContentView.swift */,
120123
6CF31D4D2DB6A252006A77FD /* StatusBar.swift */,
121124
6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */,
@@ -215,6 +218,7 @@
215218
6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */,
216219
6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */,
217220
6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */,
221+
6C8B564C2E3018CC00DC3F29 /* MockCompletionDelegate.swift in Sources */,
218222
1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */,
219223
6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */,
220224
6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */,

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ struct ContentView: View {
2222
@State private var editorState = SourceEditorState(
2323
cursorPositions: [CursorPosition(line: 1, column: 1)]
2424
)
25+
@StateObject private var suggestions: MockCompletionDelegate = MockCompletionDelegate()
2526

2627
@State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
2728
@AppStorage("wrapLines") private var wrapLines: Bool = true
@@ -71,7 +72,8 @@ struct ContentView: View {
7172
warningCharacters: warningCharacters
7273
)
7374
),
74-
state: $editorState
75+
state: $editorState,
76+
completionDelegate: suggestions
7577
)
7678
.overlay(alignment: .bottom) {
7779
StatusBar(
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//
2+
// MockCompletionDelegate.swift
3+
// CodeEditSourceEditorExample
4+
//
5+
// Created by Khan Winter on 7/22/25.
6+
//
7+
8+
import SwiftUI
9+
import CodeEditSourceEditor
10+
import CodeEditTextView
11+
12+
private let text = [
13+
"Lorem",
14+
"ipsum",
15+
"dolor",
16+
"sit",
17+
"amet,",
18+
"consectetur",
19+
"adipiscing",
20+
"elit.",
21+
"Ut",
22+
"condimentum",
23+
"dictum",
24+
"malesuada.",
25+
"Praesent",
26+
"ut",
27+
"imperdiet",
28+
"nulla.",
29+
"Vivamus",
30+
"feugiat,",
31+
"ante",
32+
"non",
33+
"sagittis",
34+
"pellentesque,",
35+
"dui",
36+
"massa",
37+
"consequat",
38+
"odio,",
39+
"ac",
40+
"vestibulum",
41+
"augue",
42+
"erat",
43+
"et",
44+
"nunc."
45+
]
46+
47+
class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject {
48+
class Suggestion: CodeSuggestionEntry {
49+
var label: String
50+
var detail: String?
51+
var pathComponents: [String]? { nil }
52+
var targetPosition: CursorPosition? { nil }
53+
var sourcePreview: String? { nil }
54+
var image: Image = Image(systemName: "dot.square.fill")
55+
var imageColor: Color = .gray
56+
var deprecated: Bool = false
57+
58+
init(text: String) {
59+
self.label = text
60+
}
61+
}
62+
63+
private func randomSuggestions(_ count: Int? = nil) -> [Suggestion] {
64+
let count = count ?? Int.random(in: 0..<20)
65+
var suggestions: [Suggestion] = []
66+
for _ in 0..<count {
67+
let randomString = (0..<Int.random(in: 1..<text.count)).map {
68+
text[$0]
69+
}.shuffled().joined(separator: " ")
70+
suggestions.append(Suggestion(text: randomString))
71+
}
72+
return suggestions
73+
}
74+
75+
var moveCount = 0
76+
77+
func completionSuggestionsRequested(
78+
textView: TextViewController,
79+
cursorPosition: CursorPosition
80+
) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? {
81+
try? await Task.sleep(for: .seconds(0.2))
82+
return (cursorPosition, randomSuggestions())
83+
}
84+
85+
func completionOnCursorMove(
86+
textView: TextViewController,
87+
cursorPosition: CursorPosition
88+
) -> [CodeSuggestionEntry]? {
89+
moveCount += 1
90+
switch moveCount {
91+
case 1:
92+
return randomSuggestions(2)
93+
case 2:
94+
return randomSuggestions(20)
95+
default:
96+
moveCount = 0
97+
return nil
98+
}
99+
}
100+
101+
func completionWindowApplyCompletion(
102+
item: CodeSuggestionEntry,
103+
textView: TextViewController,
104+
cursorPosition: CursorPosition?
105+
) {
106+
guard let suggestion = item as? Suggestion else {
107+
return
108+
}
109+
textView.textView.undoManager?.beginUndoGrouping()
110+
textView.textView.insertText(suggestion.label)
111+
textView.textView.undoManager?.endUndoGrouping()
112+
}
113+
}

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ struct StatusBar: View {
104104
}
105105
}
106106
scrollPosition
107-
Text(getLabel(state.cursorPositions))
107+
Text(getLabel(state.cursorPositions ?? []))
108108
}
109109
.foregroundStyle(.secondary)
110110

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// CodeSuggestionDelegate.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Abe Malla on 12/26/24.
6+
//
7+
8+
public protocol CodeSuggestionDelegate: AnyObject {
9+
func completionTriggerCharacters() -> Set<String>
10+
11+
func completionSuggestionsRequested(
12+
textView: TextViewController,
13+
cursorPosition: CursorPosition
14+
) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])?
15+
16+
// This can't be async, we need it to be snappy. At most, it should just be filtering completion items
17+
func completionOnCursorMove(
18+
textView: TextViewController,
19+
cursorPosition: CursorPosition
20+
) -> [CodeSuggestionEntry]?
21+
22+
// Optional
23+
func completionWindowDidClose()
24+
25+
func completionWindowApplyCompletion(
26+
item: CodeSuggestionEntry,
27+
textView: TextViewController,
28+
cursorPosition: CursorPosition?
29+
)
30+
// Optional
31+
func completionWindowDidSelect(item: CodeSuggestionEntry)
32+
}
33+
34+
public extension CodeSuggestionDelegate {
35+
func completionTriggerCharacters() -> Set<String> { [] }
36+
func completionWindowDidClose() { }
37+
func completionWindowDidSelect(item: CodeSuggestionEntry) { }
38+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// CodeSuggestionEntry.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/22/25.
6+
//
7+
8+
import AppKit
9+
import SwiftUI
10+
11+
/// Represents an item that can be displayed in the code suggestion view
12+
public protocol CodeSuggestionEntry {
13+
var label: String { get }
14+
var detail: String? { get }
15+
16+
/// Leave as `nil` if the link is in the same document.
17+
var pathComponents: [String]? { get }
18+
var targetPosition: CursorPosition? { get }
19+
var sourcePreview: String? { get }
20+
21+
var image: Image { get }
22+
var imageColor: Color { get }
23+
24+
var deprecated: Bool { get }
25+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//
2+
// SuggestionViewModel.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/22/25.
6+
//
7+
8+
import AppKit
9+
10+
final class SuggestionViewModel: ObservableObject {
11+
/// The items to be displayed in the window
12+
@Published var items: [CodeSuggestionEntry] = []
13+
var itemsRequestTask: Task<Void, Never>?
14+
weak var activeTextView: TextViewController?
15+
16+
var delegate: CodeSuggestionDelegate? {
17+
activeTextView?.completionDelegate
18+
}
19+
20+
func showCompletions(
21+
textView: TextViewController,
22+
delegate: CodeSuggestionDelegate,
23+
cursorPosition: CursorPosition,
24+
showWindowOnParent: @escaping @MainActor (NSWindow, NSRect) -> Void
25+
) {
26+
self.activeTextView = nil
27+
itemsRequestTask?.cancel()
28+
29+
guard let targetParentWindow = textView.view.window else { return }
30+
31+
self.activeTextView = textView
32+
itemsRequestTask = Task {
33+
defer { itemsRequestTask = nil }
34+
35+
do {
36+
guard let completionItems = await delegate.completionSuggestionsRequested(
37+
textView: textView,
38+
cursorPosition: cursorPosition
39+
) else {
40+
return
41+
}
42+
43+
try Task.checkCancellation()
44+
try await MainActor.run {
45+
try Task.checkCancellation()
46+
47+
guard let cursorPosition = textView.resolveCursorPosition(completionItems.windowPosition),
48+
let cursorRect = textView.textView.layoutManager.rectForOffset(
49+
cursorPosition.range.location
50+
),
51+
let cursorRect = textView.view.window?.convertToScreen(
52+
textView.textView.convert(cursorRect, to: nil)
53+
) else {
54+
return
55+
}
56+
57+
self.items = completionItems.items
58+
showWindowOnParent(targetParentWindow, cursorRect)
59+
}
60+
} catch {
61+
return
62+
}
63+
}
64+
}
65+
66+
func cursorsUpdated(
67+
textView: TextViewController,
68+
delegate: CodeSuggestionDelegate,
69+
position: CursorPosition,
70+
close: () -> Void
71+
) {
72+
guard itemsRequestTask == nil else { return }
73+
74+
if activeTextView !== textView {
75+
close()
76+
return
77+
}
78+
79+
guard let newItems = delegate.completionOnCursorMove(
80+
textView: textView,
81+
cursorPosition: position
82+
),
83+
!newItems.isEmpty else {
84+
close()
85+
return
86+
}
87+
88+
items = newItems
89+
}
90+
91+
func didSelect(item: CodeSuggestionEntry) {
92+
delegate?.completionWindowDidSelect(item: item)
93+
}
94+
95+
func applySelectedItem(item: CodeSuggestionEntry, window: NSWindow?) {
96+
guard let activeTextView,
97+
let cursorPosition = activeTextView.cursorPositions.first else {
98+
return
99+
}
100+
self.delegate?.completionWindowApplyCompletion(
101+
item: item,
102+
textView: activeTextView,
103+
cursorPosition: cursorPosition
104+
)
105+
window?.close()
106+
}
107+
108+
func willClose() {
109+
items.removeAll()
110+
activeTextView = nil
111+
}
112+
}

0 commit comments

Comments
 (0)