Skip to content

Commit 424453d

Browse files
Suggestion Window Fixes (#350)
### Description Generally improves a few UX bugs with the suggestion window. Specifically the window would flash often even if the controller did not re-request new items, and would sometimes move the window when it shouldn't have. Also adjusts the window's x position to align the completion labels with the text. - Centralizes suggestion presentation logic into a single class. - Moves the trigger character logic out of a filter and into a textview delegate-like method that checks if the last typed character was a trigger character. - Ensures the textview and cursor positions are up-to-date when the notification is sent. - Helps remove duplicate cursor update notifications sent to the suggestion controller by checking if an update is a duplicate in the centralized logic controller. - Adjusts the suggestion window's x position to align the text in the completion labels with the text being typed. Also includes a few changes fixing some build warnings. ### Related Issues * #282 ### 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 https://github.com/user-attachments/assets/14662210-0c15-422d-8dea-a5ae55b5d836
1 parent ee0c00a commit 424453d

File tree

14 files changed

+118
-63
lines changed

14 files changed

+118
-63
lines changed

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

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

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ private let text = [
4545
]
4646

4747
class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject {
48+
var lastPosition: CursorPosition?
49+
4850
class Suggestion: CodeSuggestionEntry {
4951
var label: String
5052
var detail: String?
53+
var documentation: String?
5154
var pathComponents: [String]?
5255
var targetPosition: CursorPosition? = CursorPosition(line: 10, column: 20)
5356
var sourcePreview: String?
@@ -89,19 +92,32 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject {
8992
cursorPosition: CursorPosition
9093
) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? {
9194
try? await Task.sleep(for: .seconds(0.2))
95+
lastPosition = cursorPosition
9296
return (cursorPosition, randomSuggestions())
9397
}
9498

9599
func completionOnCursorMove(
96100
textView: TextViewController,
97101
cursorPosition: CursorPosition
98102
) -> [CodeSuggestionEntry]? {
103+
// Check if we're typing all in a row.
104+
guard (lastPosition?.range.location ?? 0) + 1 == cursorPosition.range.location else {
105+
lastPosition = nil
106+
moveCount = 0
107+
return nil
108+
}
109+
110+
lastPosition = cursorPosition
99111
moveCount += 1
100112
switch moveCount {
101113
case 1:
102114
return randomSuggestions(2)
103115
case 2:
104116
return randomSuggestions(20)
117+
case 3:
118+
return randomSuggestions(4)
119+
case 4:
120+
return randomSuggestions(1)
105121
default:
106122
moveCount = 0
107123
return nil

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ final class MockJumpToDefinitionDelegate: JumpToDefinitionDelegate, ObservableOb
1515
url: nil,
1616
targetRange: CursorPosition(line: 0, column: 10),
1717
typeName: "Start of Document",
18-
sourcePreview: "// Comment at start"
18+
sourcePreview: "// Comment at start",
19+
documentation: "Jumps to the comment at the start of the document. Useful?"
1920
),
2021
JumpToDefinitionLink(
2122
url: URL(string: "https://codeedit.app/"),
2223
targetRange: CursorPosition(line: 1024, column: 10),
2324
typeName: "CodeEdit Website",
24-
sourcePreview: "https://codeedit.app/"
25+
sourcePreview: "https://codeedit.app/",
26+
documentation: "Opens CodeEdit's homepage! You can customize how links are handled, this one opens a "
27+
+ "URL."
2528
)
2629
]
2730
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// SuggestionTriggerCharacterModel.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 8/25/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
import TextStory
11+
12+
/// Triggers the suggestion window when trigger characters are typed.
13+
/// Designed to be called in the ``TextViewDelegate``'s didReplaceCharacters method.
14+
///
15+
/// Was originally a `TextFilter` model, however those are called before text is changed and cursors are updated.
16+
/// The suggestion model expects up-to-date cursor positions as well as complete text contents. This being
17+
/// essentially a textview delegate ensures both of those promises are upheld.
18+
final class SuggestionTriggerCharacterModel {
19+
weak var controller: TextViewController?
20+
private var lastPosition: NSRange?
21+
22+
var triggerCharacters: Set<String>? {
23+
controller?.configuration.peripherals.codeSuggestionTriggerCharacters
24+
}
25+
26+
func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) {
27+
guard let controller, let completionDelegate = controller.completionDelegate, let triggerCharacters else {
28+
return
29+
}
30+
31+
let mutation = TextMutation(
32+
string: string,
33+
range: range,
34+
limit: textView.textStorage.length
35+
)
36+
guard mutation.delta >= 0,
37+
let lastChar = mutation.string.last else {
38+
lastPosition = nil
39+
return
40+
}
41+
42+
guard triggerCharacters.contains(String(lastChar)) || lastChar.isNumber || lastChar.isLetter else {
43+
lastPosition = nil
44+
return
45+
}
46+
47+
let range = NSRange(location: mutation.postApplyRange.max, length: 0)
48+
lastPosition = range
49+
SuggestionController.shared.cursorsUpdated(
50+
textView: controller,
51+
delegate: completionDelegate,
52+
position: CursorPosition(range: range),
53+
presentIfNot: true
54+
)
55+
}
56+
57+
func selectionUpdated(_ position: CursorPosition) {
58+
guard let controller, let completionDelegate = controller.completionDelegate else {
59+
return
60+
}
61+
62+
if lastPosition != position.range {
63+
SuggestionController.shared.cursorsUpdated(
64+
textView: controller,
65+
delegate: completionDelegate,
66+
position: position
67+
)
68+
}
69+
}
70+
}

Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ final class SuggestionViewModel: ObservableObject {
1616

1717
weak var delegate: CodeSuggestionDelegate?
1818

19+
private var cursorPosition: CursorPosition?
1920
private var syntaxHighlightedCache: [Int: NSAttributedString] = [:]
2021

2122
func showCompletions(

Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import AppKit
99
import SwiftUI
1010

1111
struct CodeSuggestionLabelView: View {
12+
static let HORIZONTAL_PADDING: CGFloat = 13
13+
1214
let suggestion: CodeSuggestionEntry
1315
let labelColor: NSColor
1416
let secondaryLabelColor: NSColor
@@ -45,7 +47,7 @@ struct CodeSuggestionLabelView: View {
4547
}
4648
}
4749
.padding(.vertical, 3)
48-
.padding(.horizontal, 13)
50+
.padding(.horizontal, Self.HORIZONTAL_PADDING)
4951
.buttonStyle(PlainButtonStyle())
5052
}
5153
}

Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import AppKit
99

1010
extension SuggestionController {
1111
/// Will constrain the window's frame to be within the visible screen
12-
public func constrainWindowToScreenEdges(cursorRect: NSRect) {
12+
public func constrainWindowToScreenEdges(cursorRect: NSRect, font: NSFont) {
1313
guard let window = self.window,
1414
let screenFrame = window.screen?.visibleFrame else {
1515
return
@@ -18,7 +18,8 @@ extension SuggestionController {
1818
let windowSize = window.frame.size
1919
let padding: CGFloat = 22
2020
var newWindowOrigin = NSPoint(
21-
x: cursorRect.origin.x - Self.WINDOW_PADDING,
21+
x: cursorRect.origin.x - Self.WINDOW_PADDING
22+
- CodeSuggestionLabelView.HORIZONTAL_PADDING - font.pointSize,
2223
y: cursorRect.origin.y
2324
)
2425

Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public final class SuggestionController: NSWindowController {
9191
self.popover = popover
9292
} else {
9393
self.showWindow(attachedTo: parentWindow)
94-
self.constrainWindowToScreenEdges(cursorRect: cursorRect)
94+
self.constrainWindowToScreenEdges(cursorRect: cursorRect, font: textView.font)
9595

9696
if let controller = self.contentViewController as? SuggestionViewController {
9797
controller.styleView(using: textView)

Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ extension TextViewController {
8282
}
8383
isPostingCursorNotification = false
8484

85-
if let completionDelegate = completionDelegate, let position = cursorPositions.first {
86-
SuggestionController.shared.cursorsUpdated(textView: self, delegate: completionDelegate, position: position)
85+
if let position = cursorPositions.first {
86+
suggestionTriggerModel.selectionUpdated(position)
8787
}
8888
}
8989

@@ -96,7 +96,7 @@ extension TextViewController {
9696
let linePosition = textView.layoutManager.textLineForIndex(position.start.line - 1) else {
9797
return nil
9898
}
99-
if let end = position.end, let endPosition = textView.layoutManager.textLineForIndex(end.line - 1) {
99+
if position.end != nil {
100100
range = NSRange(
101101
location: linePosition.range.location + position.start.column,
102102
length: linePosition.range.max

Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ extension TextViewController {
2424
setUpNewlineTabFilters(indentOption: configuration.behavior.indentOption)
2525
setUpDeletePairFilters(pairs: BracketPairs.allValues)
2626
setUpDeleteWhitespaceFilter(indentOption: configuration.behavior.indentOption)
27-
setUpSuggestionsFilter()
2827
}
2928

3029
/// Returns a `TextualIndenter` based on available language configuration.
@@ -121,24 +120,4 @@ extension TextViewController {
121120

122121
return true
123122
}
124-
125-
func setUpSuggestionsFilter() {
126-
textFilters.append(
127-
CodeSuggestionTriggerFilter(
128-
triggerCharacters: configuration.peripherals.codeSuggestionTriggerCharacters,
129-
didTrigger: { [weak self] in
130-
guard let self else { return }
131-
if let completionDelegate = self.completionDelegate,
132-
let position = self.cursorPositions.first {
133-
SuggestionController.shared.cursorsUpdated(
134-
textView: self,
135-
delegate: completionDelegate,
136-
position: position,
137-
presentIfNot: true
138-
)
139-
}
140-
}
141-
)
142-
)
143-
}
144123
}

0 commit comments

Comments
 (0)