diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionDelegate.swift new file mode 100644 index 000000000..8f5721d63 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionDelegate.swift @@ -0,0 +1,32 @@ +// +// InlineCompletionDelegate.swift +// CodeEditSourceEditor +// +// Created by anxkhn on 6/29/26. +// + +@MainActor +public protocol InlineCompletionDelegate: AnyObject { + /// Requests inline completion items for the given cursor position. + /// + /// Called after the user types, debounced by the controller. Return an empty array to display nothing. + func inlineCompletionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> [InlineCompletionItem] + + /// Called when an item begins displaying as ghost text. + func inlineCompletionDidShow(item: InlineCompletionItem) + + /// Called when an item is accepted and inserted into the document. + func inlineCompletionDidAccept(item: InlineCompletionItem) + + /// Called when a displayed item is dismissed without being accepted. + func inlineCompletionDidDismiss(item: InlineCompletionItem) +} + +public extension InlineCompletionDelegate { + func inlineCompletionDidShow(item: InlineCompletionItem) { } + func inlineCompletionDidAccept(item: InlineCompletionItem) { } + func inlineCompletionDidDismiss(item: InlineCompletionItem) { } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionItem.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionItem.swift new file mode 100644 index 000000000..21a96a5b4 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionItem.swift @@ -0,0 +1,34 @@ +// +// InlineCompletionItem.swift +// CodeEditSourceEditor +// +// Created by anxkhn on 6/29/26. +// + +import Foundation + +/// Represents a single inline completion (ghost text) suggestion. +/// +/// An item is rendered as ghost text anchored at the caret. When accepted, the ``insertText`` replaces the characters +/// in ``range``. An empty ``range`` represents a pure insertion at the cursor. +public struct InlineCompletionItem: Sendable, Identifiable { + /// A stable identifier for the item. + public let id: UUID + + /// The text to insert when the item is accepted. This is also the text rendered as ghost text. + public let insertText: String + + /// The range the ``insertText`` replaces when accepted. An empty range is a pure insertion at the cursor. + public let range: NSRange + + /// Create an inline completion item. + /// - Parameters: + /// - id: A stable identifier for the item. Defaults to a new `UUID`. + /// - insertText: The text to insert when accepted, and the text rendered as ghost text. + /// - range: The range replaced on acceptance. An empty range is a pure insertion at the cursor. + public init(id: UUID = UUID(), insertText: String, range: NSRange) { + self.id = id + self.insertText = insertText + self.range = range + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionTriggerModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionTriggerModel.swift new file mode 100644 index 000000000..04ff13c40 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/InlineCompletionTriggerModel.swift @@ -0,0 +1,49 @@ +// +// InlineCompletionTriggerModel.swift +// CodeEditSourceEditor +// +// Created by anxkhn on 6/29/26. +// + +import AppKit +import CodeEditTextView +import TextStory + +/// Triggers an inline completion (ghost text) request when the user edits text. +/// Designed to be called in the ``TextViewDelegate``'s didReplaceCharacters method. +/// +/// Mirrors ``SuggestionTriggerCharacterModel``: it lives as effectively a text view delegate so that both the text +/// contents and the caret are up-to-date when it runs. Any mutation clears the current ghost text before a new +/// request is debounced. Ghost text and the existing suggestion popup are mutually exclusive, so requests are skipped +/// while ``SuggestionController`` is visible. +@MainActor +final class InlineCompletionTriggerModel { + weak var controller: TextViewController? + + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { + guard let controller, controller.inlineCompletionDelegate != nil else { + return + } + + // Any edit invalidates the currently displayed ghost text. + controller.dismissInlineSuggestion() + + // Ghost text and the existing suggestion popup are mutually exclusive. + guard !SuggestionController.shared.isVisible else { + return + } + + let mutation = TextMutation( + string: string, + range: range, + limit: textView.textStorage.length + ) + + // Only request after an insertion (typing), not after a deletion. + guard mutation.delta > 0 else { + return + } + + controller.requestInlineSuggestion() + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index ed2791d27..557084d04 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -85,6 +85,13 @@ extension TextViewController { if let position = cursorPositions.first { suggestionTriggerModel.selectionUpdated(position) } + + // Dismiss ghost text when the cursor moves off the suggestion's anchor. + if activeInlineSuggestion != nil, + let anchor = inlineSuggestionAnchor, + cursorPositions.first?.range != NSRange(location: anchor, length: 0) { + dismissInlineSuggestion() + } } /// Fills out all properties on the given cursor position if it's missing either the range or line/column diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+InlineCompletion.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+InlineCompletion.swift new file mode 100644 index 000000000..0eea61c11 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+InlineCompletion.swift @@ -0,0 +1,139 @@ +// +// TextViewController+InlineCompletion.swift +// CodeEditSourceEditor +// +// Created by anxkhn on 6/29/26. +// + +import AppKit +import CodeEditTextView + +public extension TextViewController { + /// The debounce interval applied before an inline completion request is sent to the delegate. + private static var inlineCompletionDebounce: Duration { .milliseconds(200) } + + /// Requests inline completion items from the ``inlineCompletionDelegate``. + /// + /// Debounced with a single in-flight cancellable task. Each call cancels any pending request. Requests are skipped + /// while the suggestion popup is visible, so ghost text and the popup never appear at the same time. + func requestInlineSuggestion() { + inlineCompletionTask?.cancel() + inlineCompletionTask = nil + + guard let delegate = inlineCompletionDelegate, + !SuggestionController.shared.isVisible, + let selectedRange = textView.selectionManager.textSelections.first?.range, + let cursorPosition = resolveCursorPosition(CursorPosition(range: selectedRange)) else { + return + } + + inlineCompletionTask = Task { [weak self] in + do { + try await Task.sleep(for: Self.inlineCompletionDebounce) + try Task.checkCancellation() + + guard let self else { return } + + let items = await delegate.inlineCompletionsRequested( + textView: self, + cursorPosition: cursorPosition + ) + + try Task.checkCancellation() + guard !items.isEmpty, !SuggestionController.shared.isVisible else { + return + } + + self.setInlineSuggestions(items) + } catch { + return + } + } + } + + /// Renders the given inline completion items as ghost text, displaying the item at `selectedIndex`. + /// + /// Stores the items and renders the selected item's ``InlineCompletionItem/insertText`` as ghost text anchored at + /// the caret. Notifies the delegate via ``InlineCompletionDelegate/inlineCompletionDidShow(item:)``. + /// - Parameters: + /// - items: The items to make available. Passing an empty array is a no-op. + /// - selectedIndex: The index of the item to display. Clamped to a valid index. + func setInlineSuggestions(_ items: [InlineCompletionItem], selectedIndex: Int = 0) { + guard !items.isEmpty, !SuggestionController.shared.isVisible else { + return + } + + inlineSuggestionItems = items + inlineSuggestionSelectedIndex = min(max(0, selectedIndex), items.count - 1) + let item = items[inlineSuggestionSelectedIndex] + activeInlineSuggestion = item + + let offset = textView.selectionManager.textSelections.first?.range.location ?? item.range.location + inlineSuggestionAnchor = offset + textView.setInlineSuggestion(item.insertText, at: offset) + + inlineCompletionDelegate?.inlineCompletionDidShow(item: item) + } + + /// Accepts the active inline suggestion, inserting its text into the document. + /// + /// Replaces the characters in the item's range with its ``InlineCompletionItem/insertText``, clears the ghost + /// text, and notifies the delegate. + /// - Returns: `true` if a suggestion was accepted, otherwise `false`. + @discardableResult + func acceptInlineSuggestion() -> Bool { + guard let item = activeInlineSuggestion else { + return false + } + + // Clear state before mutating so the edit doesn't re-enter this suggestion's lifecycle. + clearInlineSuggestionState() + textView.clearInlineSuggestion() + textView.replaceCharacters(in: item.range, with: item.insertText) + + inlineCompletionDelegate?.inlineCompletionDidAccept(item: item) + return true + } + + /// Cycles to the next inline suggestion and re-renders it. + func selectNextInlineSuggestion() { + guard !inlineSuggestionItems.isEmpty else { return } + let next = (inlineSuggestionSelectedIndex + 1) % inlineSuggestionItems.count + setInlineSuggestions(inlineSuggestionItems, selectedIndex: next) + } + + /// Cycles to the previous inline suggestion and re-renders it. + func selectPreviousInlineSuggestion() { + guard !inlineSuggestionItems.isEmpty else { return } + let count = inlineSuggestionItems.count + let previous = (inlineSuggestionSelectedIndex - 1 + count) % count + setInlineSuggestions(inlineSuggestionItems, selectedIndex: previous) + } + + /// Dismisses the active inline suggestion without inserting it. + /// + /// Clears the ghost text and notifies the delegate. + /// - Returns: `true` if a suggestion was dismissed, otherwise `false`. + @discardableResult + func dismissInlineSuggestion() -> Bool { + guard let item = activeInlineSuggestion else { + return false + } + + clearInlineSuggestionState() + textView.clearInlineSuggestion() + + inlineCompletionDelegate?.inlineCompletionDidDismiss(item: item) + return true + } + + /// Resets all stored inline suggestion state and cancels any in-flight request. + private func clearInlineSuggestionState() { + inlineCompletionTask?.cancel() + inlineCompletionTask = nil + activeInlineSuggestion = nil + inlineSuggestionItems = [] + inlineSuggestionSelectedIndex = 0 + inlineSuggestionAnchor = nil + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index caca6be0c..e0476417a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -247,6 +247,11 @@ extension TextViewController { let commandKey = NSEvent.ModifierFlags.command let controlKey = NSEvent.ModifierFlags.control + // Inline completion (ghost text) keys take precedence while a suggestion is active. + if handleInlineCompletionKeyDown(event: event, modifierFlags: modifierFlags) { + return nil + } + switch (modifierFlags, event.charactersIgnoringModifiers) { case (commandKey, "/"): handleCommandSlash() @@ -262,12 +267,7 @@ extension TextViewController { self.findViewController?.showFindPanel() return nil case (.init(rawValue: 0), "\u{1b}"): // Escape key - if findViewController?.viewModel.isShowingFindPanel == true { - self.findViewController?.hideFindPanel() - return nil - } - // Attempt to show completions otherwise - return handleShowCompletions(event) + return handleEscapeKey(event) case (controlKey, " "): return handleShowCompletions(event) case ([NSEvent.ModifierFlags.command, NSEvent.ModifierFlags.control], "j"): @@ -281,6 +281,40 @@ extension TextViewController { } } + /// Handles the Escape key when no inline completion is active: hides the find panel if it is showing, otherwise + /// toggles the completion suggestions. + private func handleEscapeKey(_ event: NSEvent) -> NSEvent? { + if findViewController?.viewModel.isShowingFindPanel == true { + self.findViewController?.hideFindPanel() + return nil + } + // Attempt to show completions otherwise + return handleShowCompletions(event) + } + + /// Handles key events that control an active inline completion (ghost text). + /// + /// While a suggestion is active, Escape dismisses it and Option+`]` / Option+`[` cycle through the available items. + /// When no suggestion is active this is a no-op so the keys keep their normal behavior. + /// - Returns: `true` if the event was consumed by the inline completion. + private func handleInlineCompletionKeyDown(event: NSEvent, modifierFlags: NSEvent.ModifierFlags) -> Bool { + guard activeInlineSuggestion != nil else { return false } + + switch (modifierFlags, event.charactersIgnoringModifiers) { + case (.option, "]"): + selectNextInlineSuggestion() + return true + case (.option, "["): + selectPreviousInlineSuggestion() + return true + case (.init(rawValue: 0), "\u{1b}"): // Escape key + dismissInlineSuggestion() + return true + default: + return false + } + } + /// Handles the tab key event. /// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines /// are highlighted and handles indenting accordingly. @@ -289,6 +323,12 @@ extension TextViewController { func handleTab(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let shiftKey = NSEvent.ModifierFlags.shift.rawValue + // Accept ghost text with an unmodified Tab, but only when the suggestion popup isn't also showing. + if modifierFlags == 0, activeInlineSuggestion != nil, !SuggestionController.shared.isVisible { + acceptInlineSuggestion() + return nil + } + if modifierFlags == shiftKey { handleIndent(inwards: true) } else { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift index acca4a56c..47a3efe04 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift @@ -29,6 +29,7 @@ extension TextViewController: TextViewDelegate { } suggestionTriggerModel.textView(textView, didReplaceContentsIn: range, with: string) + inlineCompletionTriggerModel.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 fb95c81c1..4c5961547 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -50,6 +50,19 @@ public class TextViewController: NSViewController { lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() var suggestionTriggerModel = SuggestionTriggerCharacterModel() + var inlineCompletionTriggerModel = InlineCompletionTriggerModel() + + /// The inline completion items currently available, ordered as provided by the delegate. + var inlineSuggestionItems: [InlineCompletionItem] = [] + + /// The index of the displayed inline suggestion within ``inlineSuggestionItems``. + var inlineSuggestionSelectedIndex: Int = 0 + + /// The document offset the current ghost text is anchored to, used to dismiss it when the cursor moves away. + var inlineSuggestionAnchor: Int? + + /// The single in-flight task used to request inline completions. + var inlineCompletionTask: Task? // MARK: - Public Variables @@ -91,6 +104,15 @@ public class TextViewController: NSViewController { /// strong reference to the delegate is kept *outside* of this variable. public weak var completionDelegate: CodeSuggestionDelegate? + /// A delegate object that can respond to requests for inline completion items (ghost text), and to their + /// lifecycle. See ``InlineCompletionDelegate``. + /// - Note: The ``TextViewController`` keeps only a `weak` reference to this object. To function properly, ensure a + /// strong reference to the delegate is kept *outside* of this variable. + public weak var inlineCompletionDelegate: InlineCompletionDelegate? + + /// The inline completion item currently displayed as ghost text, if any. + internal(set) public var activeInlineSuggestion: InlineCompletionItem? + /// A delegate object that responds to requests for jump to definition actions. see ``JumpToDefinitionDelegate``. /// - Note: The ``TextViewController`` keeps only a `weak` reference to this object. To function properly, ensure a /// strong reference to the delegate is kept *outside* of this variable. @@ -230,6 +252,7 @@ public class TextViewController: NSViewController { undoManager: CEUndoManager? = nil, coordinators: [TextViewCoordinator] = [], completionDelegate: CodeSuggestionDelegate? = nil, + inlineCompletionDelegate: InlineCompletionDelegate? = nil, jumpToDefinitionDelegate: JumpToDefinitionDelegate? = nil ) { self.language = language @@ -240,6 +263,7 @@ public class TextViewController: NSViewController { self._undoManager = undoManager self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator(configuration: configuration) self.completionDelegate = completionDelegate + self.inlineCompletionDelegate = inlineCompletionDelegate self.jumpToDefinitionModel = JumpToDefinitionModel( controller: nil, treeSitterClient: treeSitterClient, @@ -250,6 +274,7 @@ public class TextViewController: NSViewController { jumpToDefinitionModel.controller = self suggestionTriggerModel.controller = self + inlineCompletionTriggerModel.controller = self if let idx = highlightProviders.firstIndex(where: { $0 is TreeSitterClient }), let client = highlightProviders[idx] as? TreeSitterClient { @@ -296,6 +321,7 @@ public class TextViewController: NSViewController { } highlighter = nil highlightProviders.removeAll() + inlineCompletionTask?.cancel() textCoordinators.values().forEach { $0.destroy() } diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index ed3ee508b..a83caa588 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -37,6 +37,7 @@ public struct SourceEditor: NSViewControllerRepresentable { undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [], completionDelegate: CodeSuggestionDelegate? = nil, + inlineCompletionDelegate: InlineCompletionDelegate? = nil, jumpToDefinitionDelegate: JumpToDefinitionDelegate? = nil ) { self.text = .binding(text) @@ -47,6 +48,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.undoManager = undoManager self.coordinators = coordinators self.completionDelegate = completionDelegate + self.inlineCompletionDelegate = inlineCompletionDelegate self.jumpToDefinitionDelegate = jumpToDefinitionDelegate } @@ -70,6 +72,7 @@ public struct SourceEditor: NSViewControllerRepresentable { undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [], completionDelegate: CodeSuggestionDelegate? = nil, + inlineCompletionDelegate: InlineCompletionDelegate? = nil, jumpToDefinitionDelegate: JumpToDefinitionDelegate? = nil ) { self.text = .storage(text) @@ -80,6 +83,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.undoManager = undoManager self.coordinators = coordinators self.completionDelegate = completionDelegate + self.inlineCompletionDelegate = inlineCompletionDelegate self.jumpToDefinitionDelegate = jumpToDefinitionDelegate } @@ -91,6 +95,7 @@ public struct SourceEditor: NSViewControllerRepresentable { var undoManager: CEUndoManager? var coordinators: [any TextViewCoordinator] weak var completionDelegate: CodeSuggestionDelegate? + weak var inlineCompletionDelegate: InlineCompletionDelegate? weak var jumpToDefinitionDelegate: JumpToDefinitionDelegate? public typealias NSViewControllerType = TextViewController @@ -105,6 +110,7 @@ public struct SourceEditor: NSViewControllerRepresentable { undoManager: undoManager, coordinators: coordinators, completionDelegate: completionDelegate, + inlineCompletionDelegate: inlineCompletionDelegate, jumpToDefinitionDelegate: jumpToDefinitionDelegate ) switch text { @@ -130,6 +136,7 @@ public struct SourceEditor: NSViewControllerRepresentable { public func updateNSViewController(_ controller: TextViewController, context: Context) { controller.completionDelegate = completionDelegate + controller.inlineCompletionDelegate = inlineCompletionDelegate controller.jumpToDefinitionDelegate = jumpToDefinitionDelegate context.coordinator.updateHighlightProviders(highlightProviders) diff --git a/Tests/CodeEditSourceEditorTests/InlineCompletionTests.swift b/Tests/CodeEditSourceEditorTests/InlineCompletionTests.swift new file mode 100644 index 000000000..ffe3c1293 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/InlineCompletionTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import CodeEditSourceEditor +import CodeEditTextView +import AppKit + +@MainActor +final class MockInlineCompletionDelegate: InlineCompletionDelegate { + let items: [InlineCompletionItem] + private(set) var requestCount = 0 + private(set) var shownItems: [InlineCompletionItem] = [] + private(set) var acceptedItems: [InlineCompletionItem] = [] + private(set) var dismissedItems: [InlineCompletionItem] = [] + + init(items: [InlineCompletionItem]) { + self.items = items + } + + func inlineCompletionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> [InlineCompletionItem] { + requestCount += 1 + return items + } + + func inlineCompletionDidShow(item: InlineCompletionItem) { + shownItems.append(item) + } + + func inlineCompletionDidAccept(item: InlineCompletionItem) { + acceptedItems.append(item) + } + + func inlineCompletionDidDismiss(item: InlineCompletionItem) { + dismissedItems.append(item) + } +} + +@MainActor +final class InlineCompletionTests: XCTestCase { + + var controller: TextViewController! + var theme: EditorTheme! + var delegate: MockInlineCompletionDelegate! + + override func setUpWithError() throws { + theme = Mock.theme() + controller = Mock.textViewController(theme: theme) + + controller.loadView() + controller.view.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + controller.view.layoutSubtreeIfNeeded() + + delegate = MockInlineCompletionDelegate( + items: [InlineCompletionItem(insertText: "bar", range: NSRange(location: 3, length: 0))] + ) + controller.inlineCompletionDelegate = delegate + } + + override func tearDownWithError() throws { + controller = nil + theme = nil + delegate = nil + } + + private func prepare(text: String, cursorLocation: Int) { + controller.setText(text) + controller.textView.selectionManager.setSelectedRange(NSRange(location: cursorLocation, length: 0)) + controller.textView.layoutManager.layoutLines() + controller.updateCursorPosition() + } + + private func tabEvent() -> NSEvent { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\t", + charactersIgnoringModifiers: "\t", + isARepeat: false, + keyCode: 0x30 + )! + } + + // MARK: Request + + func test_requestInlineSuggestion_setsActiveAndRendersGhostText() async throws { + prepare(text: "foo", cursorLocation: 3) + + controller.requestInlineSuggestion() + await controller.inlineCompletionTask?.value + + XCTAssertEqual(delegate.requestCount, 1) + XCTAssertEqual(controller.activeInlineSuggestion?.insertText, "bar") + XCTAssertNotNil(controller.textView.inlineSuggestionManager?.current) + XCTAssertEqual(controller.textView.inlineSuggestionManager?.current?.text, "bar") + XCTAssertEqual(delegate.shownItems.count, 1) + // Ghost text must never mutate the document. + XCTAssertEqual(controller.textView.string, "foo") + } + + // MARK: Set + + func test_setInlineSuggestions_rendersAndNotifies() throws { + prepare(text: "foo", cursorLocation: 3) + let item = InlineCompletionItem(insertText: "bar", range: NSRange(location: 3, length: 0)) + + controller.setInlineSuggestions([item]) + + XCTAssertEqual(controller.activeInlineSuggestion?.id, item.id) + XCTAssertNotNil(controller.textView.inlineSuggestionManager?.current) + XCTAssertEqual(delegate.shownItems.map(\.id), [item.id]) + } + + // MARK: Accept + + func test_acceptInlineSuggestion_insertsTextAndClearsGhost() throws { + prepare(text: "foo", cursorLocation: 3) + let item = InlineCompletionItem(insertText: "bar", range: NSRange(location: 3, length: 0)) + controller.setInlineSuggestions([item]) + XCTAssertNotNil(controller.activeInlineSuggestion) + + let accepted = controller.acceptInlineSuggestion() + + XCTAssertTrue(accepted) + XCTAssertEqual(controller.textView.string, "foobar") + XCTAssertNil(controller.activeInlineSuggestion) + XCTAssertNil(controller.textView.inlineSuggestionManager?.current) + XCTAssertEqual(delegate.acceptedItems.map(\.id), [item.id]) + } + + func test_acceptInlineSuggestion_returnsFalseWhenInactive() throws { + prepare(text: "foo", cursorLocation: 3) + + XCTAssertFalse(controller.acceptInlineSuggestion()) + XCTAssertEqual(controller.textView.string, "foo") + XCTAssertTrue(delegate.acceptedItems.isEmpty) + } + + // MARK: Dismiss + + func test_dismissInlineSuggestion_clearsWithoutInserting() throws { + prepare(text: "foo", cursorLocation: 3) + let item = InlineCompletionItem(insertText: "bar", range: NSRange(location: 3, length: 0)) + controller.setInlineSuggestions([item]) + + let dismissed = controller.dismissInlineSuggestion() + + XCTAssertTrue(dismissed) + XCTAssertEqual(controller.textView.string, "foo") + XCTAssertNil(controller.activeInlineSuggestion) + XCTAssertNil(controller.textView.inlineSuggestionManager?.current) + XCTAssertEqual(delegate.dismissedItems.map(\.id), [item.id]) + XCTAssertTrue(delegate.acceptedItems.isEmpty) + } + + // MARK: Cycling + + func test_cycleInlineSuggestions_reRendersSelectedItem() throws { + prepare(text: "foo", cursorLocation: 3) + let first = InlineCompletionItem(insertText: "one", range: NSRange(location: 3, length: 0)) + let second = InlineCompletionItem(insertText: "two", range: NSRange(location: 3, length: 0)) + controller.setInlineSuggestions([first, second]) + XCTAssertEqual(controller.activeInlineSuggestion?.id, first.id) + + controller.selectNextInlineSuggestion() + XCTAssertEqual(controller.activeInlineSuggestion?.id, second.id) + XCTAssertEqual(controller.textView.inlineSuggestionManager?.current?.text, "two") + + controller.selectNextInlineSuggestion() + XCTAssertEqual(controller.activeInlineSuggestion?.id, first.id, "Cycling should wrap around.") + + controller.selectPreviousInlineSuggestion() + XCTAssertEqual(controller.activeInlineSuggestion?.id, second.id, "Previous should wrap backwards.") + } + + // MARK: Clearing + + func test_cursorMoveClearsGhostText() throws { + prepare(text: "foo", cursorLocation: 3) + let item = InlineCompletionItem(insertText: "bar", range: NSRange(location: 3, length: 0)) + controller.setInlineSuggestions([item]) + XCTAssertNotNil(controller.activeInlineSuggestion) + + controller.textView.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) + controller.updateCursorPosition() + + XCTAssertNil(controller.activeInlineSuggestion) + XCTAssertNil(controller.textView.inlineSuggestionManager?.current) + } + + func test_editClearsGhostText() throws { + prepare(text: "foo", cursorLocation: 3) + let item = InlineCompletionItem(insertText: "bar", range: NSRange(location: 3, length: 0)) + controller.setInlineSuggestions([item]) + XCTAssertNotNil(controller.activeInlineSuggestion) + + controller.textView.replaceCharacters(in: NSRange(location: 3, length: 0), with: "x") + + XCTAssertNil(controller.activeInlineSuggestion) + XCTAssertNil(controller.textView.inlineSuggestionManager?.current) + } + + // MARK: Tab + + func test_tabAcceptsOnlyWhenActive() throws { + prepare(text: "foo", cursorLocation: 3) + + // Inactive: Tab is passed through and the document is unchanged. + let passthrough = controller.handleTab(event: tabEvent(), modifierFlags: 0) + XCTAssertNotNil(passthrough, "Tab should be passed through when no suggestion is active.") + XCTAssertEqual(controller.textView.string, "foo") + + // Active: Tab is consumed and the suggestion is accepted. + let item = InlineCompletionItem(insertText: "bar", range: NSRange(location: 3, length: 0)) + controller.setInlineSuggestions([item]) + let consumed = controller.handleTab(event: tabEvent(), modifierFlags: 0) + XCTAssertNil(consumed, "Tab should be consumed when a suggestion is active.") + XCTAssertEqual(controller.textView.string, "foobar") + XCTAssertEqual(delegate.acceptedItems.map(\.id), [item.id]) + } +}