diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d312fc00e..6b45b7e63 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,15 @@ "version" : "0.2.3" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "d7ac3f11f22ec2e820187acce8f3a3fb7aa8ddec", + "version" : "0.12.1" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift index fbc71cd19..0a4d4bd99 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -45,9 +45,12 @@ private let text = [ ] class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { + var lastPosition: CursorPosition? + class Suggestion: CodeSuggestionEntry { var label: String var detail: String? + var documentation: String? var pathComponents: [String]? var targetPosition: CursorPosition? = CursorPosition(line: 10, column: 20) var sourcePreview: String? @@ -89,6 +92,7 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { cursorPosition: CursorPosition ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { try? await Task.sleep(for: .seconds(0.2)) + lastPosition = cursorPosition return (cursorPosition, randomSuggestions()) } @@ -96,12 +100,24 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { textView: TextViewController, cursorPosition: CursorPosition ) -> [CodeSuggestionEntry]? { + // Check if we're typing all in a row. + guard (lastPosition?.range.location ?? 0) + 1 == cursorPosition.range.location else { + lastPosition = nil + moveCount = 0 + return nil + } + + lastPosition = cursorPosition moveCount += 1 switch moveCount { case 1: return randomSuggestions(2) case 2: return randomSuggestions(20) + case 3: + return randomSuggestions(4) + case 4: + return randomSuggestions(1) default: moveCount = 0 return nil diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift index 3ccb104bc..4b57989a2 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift @@ -15,13 +15,16 @@ final class MockJumpToDefinitionDelegate: JumpToDefinitionDelegate, ObservableOb url: nil, targetRange: CursorPosition(line: 0, column: 10), typeName: "Start of Document", - sourcePreview: "// Comment at start" + sourcePreview: "// Comment at start", + documentation: "Jumps to the comment at the start of the document. Useful?" ), JumpToDefinitionLink( url: URL(string: "https://codeedit.app/"), targetRange: CursorPosition(line: 1024, column: 10), typeName: "CodeEdit Website", - sourcePreview: "https://codeedit.app/" + sourcePreview: "https://codeedit.app/", + documentation: "Opens CodeEdit's homepage! You can customize how links are handled, this one opens a " + + "URL." ) ] } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionTriggerCharacterModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionTriggerCharacterModel.swift new file mode 100644 index 000000000..b82ad7c07 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionTriggerCharacterModel.swift @@ -0,0 +1,70 @@ +// +// SuggestionTriggerCharacterModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 8/25/25. +// + +import AppKit +import CodeEditTextView +import TextStory + +/// Triggers the suggestion window when trigger characters are typed. +/// Designed to be called in the ``TextViewDelegate``'s didReplaceCharacters method. +/// +/// Was originally a `TextFilter` model, however those are called before text is changed and cursors are updated. +/// The suggestion model expects up-to-date cursor positions as well as complete text contents. This being +/// essentially a textview delegate ensures both of those promises are upheld. +final class SuggestionTriggerCharacterModel { + weak var controller: TextViewController? + private var lastPosition: NSRange? + + var triggerCharacters: Set? { + controller?.configuration.peripherals.codeSuggestionTriggerCharacters + } + + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { + guard let controller, let completionDelegate = controller.completionDelegate, let triggerCharacters else { + return + } + + let mutation = TextMutation( + string: string, + range: range, + limit: textView.textStorage.length + ) + guard mutation.delta >= 0, + let lastChar = mutation.string.last else { + lastPosition = nil + return + } + + guard triggerCharacters.contains(String(lastChar)) || lastChar.isNumber || lastChar.isLetter else { + lastPosition = nil + return + } + + let range = NSRange(location: mutation.postApplyRange.max, length: 0) + lastPosition = range + SuggestionController.shared.cursorsUpdated( + textView: controller, + delegate: completionDelegate, + position: CursorPosition(range: range), + presentIfNot: true + ) + } + + func selectionUpdated(_ position: CursorPosition) { + guard let controller, let completionDelegate = controller.completionDelegate else { + return + } + + if lastPosition != position.range { + SuggestionController.shared.cursorsUpdated( + textView: controller, + delegate: completionDelegate, + position: position + ) + } + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 2b66bf3ae..34e8f51d5 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -16,6 +16,7 @@ final class SuggestionViewModel: ObservableObject { weak var delegate: CodeSuggestionDelegate? + private var cursorPosition: CursorPosition? private var syntaxHighlightedCache: [Int: NSAttributedString] = [:] func showCompletions( diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift index 748856a38..7637bff54 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift @@ -9,6 +9,8 @@ import AppKit import SwiftUI struct CodeSuggestionLabelView: View { + static let HORIZONTAL_PADDING: CGFloat = 13 + let suggestion: CodeSuggestionEntry let labelColor: NSColor let secondaryLabelColor: NSColor @@ -45,7 +47,7 @@ struct CodeSuggestionLabelView: View { } } .padding(.vertical, 3) - .padding(.horizontal, 13) + .padding(.horizontal, Self.HORIZONTAL_PADDING) .buttonStyle(PlainButtonStyle()) } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index 66a321888..48edf993c 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -9,7 +9,7 @@ import AppKit extension SuggestionController { /// Will constrain the window's frame to be within the visible screen - public func constrainWindowToScreenEdges(cursorRect: NSRect) { + public func constrainWindowToScreenEdges(cursorRect: NSRect, font: NSFont) { guard let window = self.window, let screenFrame = window.screen?.visibleFrame else { return @@ -18,7 +18,8 @@ extension SuggestionController { let windowSize = window.frame.size let padding: CGFloat = 22 var newWindowOrigin = NSPoint( - x: cursorRect.origin.x - Self.WINDOW_PADDING, + x: cursorRect.origin.x - Self.WINDOW_PADDING + - CodeSuggestionLabelView.HORIZONTAL_PADDING - font.pointSize, y: cursorRect.origin.y ) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index 8dd1a5b42..d8c32a51b 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -91,7 +91,7 @@ public final class SuggestionController: NSWindowController { self.popover = popover } else { self.showWindow(attachedTo: parentWindow) - self.constrainWindowToScreenEdges(cursorRect: cursorRect) + self.constrainWindowToScreenEdges(cursorRect: cursorRect, font: textView.font) if let controller = self.contentViewController as? SuggestionViewController { controller.styleView(using: textView) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index c157f027c..ed2791d27 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -82,8 +82,8 @@ extension TextViewController { } isPostingCursorNotification = false - if let completionDelegate = completionDelegate, let position = cursorPositions.first { - SuggestionController.shared.cursorsUpdated(textView: self, delegate: completionDelegate, position: position) + if let position = cursorPositions.first { + suggestionTriggerModel.selectionUpdated(position) } } @@ -96,7 +96,7 @@ extension TextViewController { let linePosition = textView.layoutManager.textLineForIndex(position.start.line - 1) else { return nil } - if let end = position.end, let endPosition = textView.layoutManager.textLineForIndex(end.line - 1) { + if position.end != nil { range = NSRange( location: linePosition.range.location + position.start.column, length: linePosition.range.max diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 1338a1a33..b98ad44f4 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -24,7 +24,6 @@ extension TextViewController { setUpNewlineTabFilters(indentOption: configuration.behavior.indentOption) setUpDeletePairFilters(pairs: BracketPairs.allValues) setUpDeleteWhitespaceFilter(indentOption: configuration.behavior.indentOption) - setUpSuggestionsFilter() } /// Returns a `TextualIndenter` based on available language configuration. @@ -121,24 +120,4 @@ extension TextViewController { return true } - - func setUpSuggestionsFilter() { - textFilters.append( - CodeSuggestionTriggerFilter( - triggerCharacters: configuration.peripherals.codeSuggestionTriggerCharacters, - didTrigger: { [weak self] in - guard let self else { return } - if let completionDelegate = self.completionDelegate, - let position = self.cursorPositions.first { - SuggestionController.shared.cursorsUpdated( - textView: self, - delegate: completionDelegate, - position: position, - presentIfNot: true - ) - } - } - ) - ) - } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift index ee94477fe..acca4a56c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift @@ -27,6 +27,8 @@ extension TextViewController: TextViewDelegate { coordinator.textViewDidChangeText(controller: self) } } + + suggestionTriggerModel.textView(textView, didReplaceContentsIn: range, with: string) } public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 3f662b41e..2bf2d962e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -51,6 +51,8 @@ public class TextViewController: NSViewController { /// A default `NSParagraphStyle` with a set `lineHeight` lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + var suggestionTriggerModel = SuggestionTriggerCharacterModel() + // MARK: - Public Variables /// Passthrough value for the `textView`s string @@ -224,6 +226,8 @@ public class TextViewController: NSViewController { super.init(nibName: nil, bundle: nil) + suggestionTriggerModel.controller = self + if let idx = highlightProviders.firstIndex(where: { $0 is TreeSitterClient }), let client = highlightProviders[idx] as? TreeSitterClient { self.treeSitterClient = client diff --git a/Sources/CodeEditSourceEditor/Filters/CodeSuggestionTriggerFilter.swift b/Sources/CodeEditSourceEditor/Filters/CodeSuggestionTriggerFilter.swift deleted file mode 100644 index c66a23bdd..000000000 --- a/Sources/CodeEditSourceEditor/Filters/CodeSuggestionTriggerFilter.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// CodeSuggestionTriggerFilter.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 7/22/25. -// - -import Foundation -import TextFormation -import TextStory - -struct CodeSuggestionTriggerFilter: Filter { - let triggerCharacters: Set - let didTrigger: () -> Void - - func processMutation( - _ mutation: TextMutation, - in interface: TextInterface, - with providers: WhitespaceProviders - ) -> FilterAction { - guard mutation.delta >= 0, - let lastChar = mutation.string.last else { - return .none - } - - if triggerCharacters.contains(String(lastChar)) || lastChar.isNumber || lastChar.isLetter { - didTrigger() - } - - return .none - } -} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift index 5bc271fb6..a7e5acc9e 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift @@ -51,7 +51,7 @@ extension TreeSitterClient { return HighlightRange(range: range, capture: captureName) } - var string = NSMutableAttributedString(string: string) + let string = NSMutableAttributedString(string: string) for highlight in highlights { string.setAttributes(