Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -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) { }
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"):
Expand All @@ -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.
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading