diff --git a/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestion.swift b/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestion.swift new file mode 100644 index 00000000..78c0a800 --- /dev/null +++ b/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestion.swift @@ -0,0 +1,30 @@ +// +// InlineSuggestion.swift +// CodeEditTextView +// +// Created by anxkhn on 6/29/26. +// + +import Foundation + +/// Represents an inline "ghost text" suggestion to display in a text view. +/// +/// A suggestion is purely a rendering hint. It is drawn as a sibling overlay anchored at the caret and never mutates +/// the text view's ``TextView/textStorage`` or shifts real text layout. This makes it suitable as the foundation for +/// inline completions, such as those provided by GitHub Copilot. +public struct InlineSuggestion: Equatable { + /// The document offset the suggestion is anchored to. This is typically the primary caret position. + public let offset: Int + + /// The text to display. May contain line breaks to render a multi-line suggestion. + public let text: String + + /// Create an inline suggestion. + /// - Parameters: + /// - offset: The document offset to anchor the suggestion to. + /// - text: The text to display. May contain line breaks for a multi-line suggestion. + public init(offset: Int, text: String) { + self.offset = offset + self.text = text + } +} diff --git a/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionManager.swift b/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionManager.swift new file mode 100644 index 00000000..5b5504dd --- /dev/null +++ b/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionManager.swift @@ -0,0 +1,202 @@ +// +// InlineSuggestionManager.swift +// CodeEditTextView +// +// Created by anxkhn on 6/29/26. +// + +import AppKit + +/// Manages a single inline "ghost text" suggestion within a ``TextView``. +/// +/// The suggestion is rendered by an ``InlineSuggestionView`` inserted as a sibling overlay below the text view's +/// content. The manager never mutates ``TextView/textStorage`` and never shifts real text layout. It only typesets the +/// suggestion text and positions the overlay at the caret using ``TextLayoutManager/rectForOffset(_:)``. +public final class InlineSuggestionManager { + weak var textView: TextView? + + /// The color used to draw the ghost text. Defaults to ``NSColor/placeholderTextColor``. + public var suggestionColor: NSColor = .placeholderTextColor + + /// The suggestion currently being displayed, if any. + public private(set) var current: InlineSuggestion? + + /// The overlay view drawing the current suggestion, if any. + private var suggestionView: InlineSuggestionView? + + init(textView: TextView) { + self.textView = textView + } + + // MARK: - Set, Clear + + /// Sets the suggestion text anchored at the given document offset. + /// + /// Passing `nil` or an empty string clears any existing suggestion. + /// - Parameters: + /// - text: The suggestion text. May contain line breaks for a multi-line suggestion. + /// - offset: The document offset to anchor the suggestion to. + public func setSuggestion(_ text: String?, at offset: Int) { + guard let text, !text.isEmpty else { + clearSuggestion() + return + } + setSuggestion(InlineSuggestion(offset: offset, text: text)) + } + + /// Sets the suggestion to display. + /// + /// Passing `nil` clears any existing suggestion. + /// - Parameter suggestion: The suggestion to display, or `nil` to clear. + public func setSuggestion(_ suggestion: InlineSuggestion?) { + guard let suggestion else { + clearSuggestion() + return + } + current = suggestion + render(suggestion, rebuildLines: true) + } + + /// Clears any displayed suggestion and removes the overlay view. + public func clearSuggestion() { + current = nil + suggestionView?.removeFromSuperview() + suggestionView = nil + } + + // MARK: - Layout + + /// Recomputes the suggestion's geometry and repositions the overlay. + /// + /// This is a cheap no-op when no suggestion is being displayed. If the anchored offset no longer fits within the + /// document (for instance after an edit shrinks the text), the suggestion is cleared safely. + public func updateLayout() { + guard let current else { return } + guard let textView, current.offset <= textView.textStorage.length else { + clearSuggestion() + return + } + render(current, rebuildLines: false) + } + + // MARK: - Rendering + + /// Renders the given suggestion, inserting or updating the overlay view. + /// - Parameters: + /// - suggestion: The suggestion to render. + /// - rebuildLines: Whether the typeset lines should be rebuilt. Pass `false` to only reposition. + private func render(_ suggestion: InlineSuggestion, rebuildLines: Bool) { + guard let textView else { return } + + let ctLines = rebuildLines || suggestionView == nil + ? makeCTLines(for: suggestion.text) + : suggestionView?.ctLines ?? makeCTLines(for: suggestion.text) + + guard let layout = makeLayout(for: suggestion, lineCount: ctLines.count) else { + clearSuggestion() + return + } + + if let suggestionView { + suggestionView.frame = layout.frame + if rebuildLines { + suggestionView.update(ctLines: ctLines, geometry: layout.geometry) + } else { + suggestionView.update(geometry: layout.geometry) + } + } else { + let view = InlineSuggestionView(ctLines: ctLines, geometry: layout.geometry) + view.frame = layout.frame + textView.addSubview(view, positioned: .below, relativeTo: nil) + suggestionView = view + } + } + + /// The drawing attributes for the ghost text, read from the text view's typing attributes. + private func suggestionAttributes() -> [NSAttributedString.Key: Any] { + guard let textView else { return [:] } + return [ + .font: textView.font, + .foregroundColor: suggestionColor, + .kern: textView.kern + ] + } + + /// Typesets the suggestion text into one `CTLine` per line break. + private func makeCTLines(for text: String) -> [CTLine] { + let attributes = suggestionAttributes() + return splitIntoLines(text).map { line in + CTLineCreateWithAttributedString(NSAttributedString(string: line, attributes: attributes)) + } + } + + /// Splits the text into lines on any ``LineEnding`` sequence, preserving empty trailing lines. + private func splitIntoLines(_ text: String) -> [String] { + let endings = LineEnding.allCases.sorted { $0.length > $1.length } + var lines: [String] = [] + var currentLine = "" + var index = text.startIndex + while index < text.endIndex { + let remaining = text[index...] + if let ending = endings.first(where: { remaining.hasPrefix($0.rawValue) }) { + lines.append(currentLine) + currentLine = "" + index = text.index(index, offsetBy: ending.length) + } else { + currentLine.append(text[index]) + index = text.index(after: index) + } + } + lines.append(currentLine) + return lines + } + + /// The computed frame and geometry for a suggestion. + private struct Layout { + let frame: CGRect + let geometry: InlineSuggestionView.Geometry + } + + /// Computes the overlay frame and drawing geometry for a suggestion. + private func makeLayout(for suggestion: InlineSuggestion, lineCount: Int) -> Layout? { + guard let textView, + let layoutManager = textView.layoutManager, + let caretRect = layoutManager.rectForOffset(suggestion.offset) else { + return nil + } + + let lineHeight = layoutManager.estimateLineHeight() + let (descent, heightDifference) = lineMetrics(multiplier: layoutManager.lineHeightMultiplier) + + let geometry = InlineSuggestionView.Geometry( + firstLineXPosition: caretRect.minX, + continuationXPosition: layoutManager.edgeInsets.left, + lineHeight: lineHeight, + descent: descent, + heightDifference: heightDifference + ) + + let frame = CGRect( + x: 0, + y: caretRect.minY, + width: textView.frame.width, + height: lineHeight * CGFloat(lineCount) + ) + + return Layout(frame: frame, geometry: geometry) + } + + /// Computes the descent and scaled height difference for the current suggestion attributes. + private func lineMetrics(multiplier: CGFloat) -> (descent: CGFloat, heightDifference: CGFloat) { + let referenceLine = CTLineCreateWithAttributedString( + NSAttributedString(string: "0", attributes: suggestionAttributes()) + ) + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + var leading: CGFloat = 0 + CTLineGetTypographicBounds(referenceLine, &ascent, &descent, &leading) + let unscaledHeight = ascent + descent + leading + let heightDifference = (unscaledHeight * multiplier) - unscaledHeight + return (descent, heightDifference) + } +} diff --git a/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionView.swift b/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionView.swift new file mode 100644 index 00000000..f4d0807d --- /dev/null +++ b/Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionView.swift @@ -0,0 +1,118 @@ +// +// InlineSuggestionView.swift +// CodeEditTextView +// +// Created by anxkhn on 6/29/26. +// + +import AppKit +import CodeEditTextViewObjC + +/// Draws inline "ghost text" as a sibling overlay of a ``TextView``. +/// +/// This view holds pre-typeset `CTLine`s and the geometry needed to position them. It draws them in a dimmed color +/// using the same Core Text drawing context setup as ``LineFragmentRenderer``, so the ghost text visually matches the +/// real text. The view never mutates text storage and ignores hit testing so it does not interfere with selection or +/// editing. +open class InlineSuggestionView: NSView { + /// The geometry needed to position the suggestion's typeset lines. + struct Geometry { + /// The x position of the first line, anchored to the caret. + let firstLineXPosition: CGFloat + /// The x position of wrapped or subsequent lines, anchored to the text view's leading edge inset. + let continuationXPosition: CGFloat + /// The height of each line. + let lineHeight: CGFloat + /// The descent of the typeset lines. + let descent: CGFloat + /// The difference between the scaled and unscaled line height. + let heightDifference: CGFloat + } + + /// The pre-typeset lines to draw. Line `0` is drawn at the caret, lines `1...` at the continuation x position. + private(set) var ctLines: [CTLine] + private var geometry: Geometry + + override open var isFlipped: Bool { + true + } + + override open var isOpaque: Bool { + false + } + + override open func hitTest(_ point: NSPoint) -> NSView? { nil } + + /// Create an inline suggestion view. + /// - Parameters: + /// - ctLines: The pre-typeset lines to draw. + /// - geometry: The geometry used to position the lines. + init(ctLines: [CTLine], geometry: Geometry) { + self.ctLines = ctLines + self.geometry = geometry + super.init(frame: .zero) + wantsLayer = true + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Update the typeset lines and geometry, then request a redraw. + /// - Parameters: + /// - ctLines: The new pre-typeset lines to draw. + /// - geometry: The new geometry used to position the lines. + func update(ctLines: [CTLine], geometry: Geometry) { + self.ctLines = ctLines + self.geometry = geometry + needsDisplay = true + } + + /// Update the geometry without re-typesetting the lines, then request a redraw. + /// - Parameter geometry: The new geometry used to position the lines. + func update(geometry: Geometry) { + self.geometry = geometry + needsDisplay = true + } + + /// The x position to draw the line at the given index. + private func xFor(_ index: Int) -> CGFloat { + index == 0 ? geometry.firstLineXPosition : geometry.continuationXPosition + } + + override open func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard let context = NSGraphicsContext.current?.cgContext else { return } + + context.saveGState() + // Removes jagged edges + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + + // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than + // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness + // in low-contrast settings. + context.setAllowsFontSubpixelPositioning(true) + context.setShouldSubpixelPositionFonts(true) + + // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher + // quality bitmaps and performance. + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + ContextSetHiddenSmoothingStyle(context, 16) + + context.textMatrix = .init(scaleX: 1, y: -1) + + for (index, ctLine) in ctLines.enumerated() { + context.textPosition = CGPoint( + x: xFor(index), + y: CGFloat(index) * geometry.lineHeight + + geometry.lineHeight - geometry.descent + (geometry.heightDifference / 2) + ).pixelAligned + CTLineDraw(ctLine, context) + } + + context.restoreGState() + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+InlineSuggestion.swift b/Sources/CodeEditTextView/TextView/TextView+InlineSuggestion.swift new file mode 100644 index 00000000..b173ab0d --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+InlineSuggestion.swift @@ -0,0 +1,35 @@ +// +// TextView+InlineSuggestion.swift +// CodeEditTextView +// +// Created by anxkhn on 6/29/26. +// + +import Foundation + +extension TextView { + /// Displays an inline "ghost text" suggestion anchored at the given document offset. + /// + /// Passing `nil` or an empty string clears any existing suggestion. The suggestion is rendered as an overlay and + /// never mutates ``TextView/textStorage`` or shifts real text layout. + /// - Parameters: + /// - text: The suggestion text. May contain line breaks for a multi-line suggestion. + /// - offset: The document offset to anchor the suggestion to. + public func setInlineSuggestion(_ text: String?, at offset: Int) { + inlineSuggestionManager?.setSuggestion(text, at: offset) + } + + /// Displays an inline "ghost text" suggestion anchored at the primary caret. + /// + /// Passing `nil` or an empty string clears any existing suggestion. + /// - Parameter text: The suggestion text. May contain line breaks for a multi-line suggestion. + public func setInlineSuggestion(_ text: String?) { + let offset = selectionManager.textSelections.first?.range.location ?? 0 + inlineSuggestionManager?.setSuggestion(text, at: offset) + } + + /// Clears any displayed inline suggestion. + public func clearInlineSuggestion() { + inlineSuggestionManager?.clearSuggestion() + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 2fef2aa1..537e27f5 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -12,6 +12,7 @@ extension TextView { super.layout() layoutManager.layoutLines() selectionManager.updateSelectionViews(skipTimerReset: true) + inlineSuggestionManager?.updateLayout() } open override class var isCompatibleWithResponsiveScrolling: Bool { @@ -29,6 +30,7 @@ extension TextView { selectionManager.drawSelections(in: dirtyRect) } emphasisManager?.updateLayerBackgrounds() + inlineSuggestionManager?.updateLayout() } override open var isFlipped: Bool { diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 14ed3914..19bc4b05 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -254,6 +254,9 @@ open class TextView: NSView, NSTextContent { /// Manages emphasized text ranges in the text view public var emphasisManager: EmphasisManager? + /// Manages inline "ghost text" suggestions in the text view. + public private(set) var inlineSuggestionManager: InlineSuggestionManager? + // MARK: - Private Properties var isFirstResponder: Bool = false @@ -322,6 +325,7 @@ open class TextView: NSView, NSTextContent { super.init(frame: .zero) self.emphasisManager = EmphasisManager(textView: self) + self.inlineSuggestionManager = InlineSuggestionManager(textView: self) if let storageDelegate = textStorage.delegate as? MultiStorageDelegate { self.storageDelegate = storageDelegate } else { @@ -381,6 +385,7 @@ open class TextView: NSView, NSTextContent { layoutManager = nil selectionManager = nil textStorage = nil + inlineSuggestionManager = nil NotificationCenter.default.removeObserver(self) } } diff --git a/Tests/CodeEditTextViewTests/InlineSuggestionTests.swift b/Tests/CodeEditTextViewTests/InlineSuggestionTests.swift new file mode 100644 index 00000000..7c72f448 --- /dev/null +++ b/Tests/CodeEditTextViewTests/InlineSuggestionTests.swift @@ -0,0 +1,110 @@ +// +// InlineSuggestionTests.swift +// CodeEditTextView +// +// Created by anxkhn on 6/29/26. +// + +import Testing +import AppKit +@testable import CodeEditTextView + +@Suite +@MainActor +struct InlineSuggestionTests { + let textView: TextView + let textStorage: NSTextStorage + + init() throws { + textView = TextView(string: "Hello World") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textStorage = textView.textStorage + textView.layoutManager.layoutLines() + } + + private func suggestionViews() -> [InlineSuggestionView] { + textView.subviews.compactMap { $0 as? InlineSuggestionView } + } + + @Test + func setSuggestionAddsSingleOverlayWithoutMutatingStorage() throws { + let lengthBefore = textStorage.length + let documentRangeBefore = textView.documentRange + + textView.setInlineSuggestion("suggested", at: 5) + + #expect(suggestionViews().count == 1) + #expect(textView.inlineSuggestionManager?.current == InlineSuggestion(offset: 5, text: "suggested")) + + // The ghost text must never mutate the underlying storage or document range. + #expect(textStorage.length == lengthBefore) + #expect(textView.documentRange == documentRangeBefore) + } + + @Test + func overlayIsAnchoredAtCaretRect() throws { + let offset = 5 + textView.setInlineSuggestion("suggested", at: offset) + + let caretRect = try #require(textView.layoutManager.rectForOffset(offset)) + let view = try #require(suggestionViews().first) + + #expect(view.frame.origin.y.approxEqual(caretRect.minY)) + } + + @Test + func multiLineSuggestionTypesetsOneLinePerBreak() throws { + textView.setInlineSuggestion("a\nb", at: 0) + + let view = try #require(suggestionViews().first) + #expect(view.ctLines.count == 2) + + let expectedHeight = textView.layoutManager.estimateLineHeight() * 2 + #expect(view.frame.height.approxEqual(expectedHeight)) + } + + @Test + func clearRemovesOverlayAndCurrent() throws { + textView.setInlineSuggestion("suggested", at: 5) + #expect(suggestionViews().count == 1) + + textView.clearInlineSuggestion() + + #expect(suggestionViews().isEmpty) + #expect(textView.inlineSuggestionManager?.current == nil) + } + + @Test + func emptyTextClearsSuggestion() throws { + textView.setInlineSuggestion("suggested", at: 5) + #expect(suggestionViews().count == 1) + + textView.setInlineSuggestion("", at: 5) + + #expect(suggestionViews().isEmpty) + #expect(textView.inlineSuggestionManager?.current == nil) + } + + @Test + func setSuggestionAtPrimaryCaretUsesSelection() throws { + textView.selectionManager.setSelectedRange(NSRange(location: 3, length: 0)) + textView.setInlineSuggestion("suggested") + + #expect(textView.inlineSuggestionManager?.current == InlineSuggestion(offset: 3, text: "suggested")) + #expect(suggestionViews().count == 1) + } + + @Test + func outOfRangeOffsetAfterEditClearsSafely() throws { + textView.setInlineSuggestion("suggested", at: textStorage.length) + #expect(suggestionViews().count == 1) + + // Shrink the document so the anchored offset is now past the end. + textView.string = "Hi" + textView.layoutManager.layoutLines() + textView.inlineSuggestionManager?.updateLayout() + + #expect(suggestionViews().isEmpty) + #expect(textView.inlineSuggestionManager?.current == nil) + } +}