From e6f7350d8144a08896855d899a1fc800c3f3ff59 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:43:22 +0530 Subject: [PATCH] feat: add inline suggestion (ghost text) rendering primitive Add an overlay-based inline suggestion primitive that draws dimmed ghost text anchored at the caret without mutating textStorage or shifting real text layout. This is the foundation for inline completions such as GitHub Copilot. - InlineSuggestion: Equatable model holding an offset and text. - InlineSuggestionView: sibling NSView overlay that typesets and draws CTLines using the same Core Text context setup as LineFragmentRenderer. - InlineSuggestionManager: builds CTLines from typing attributes, anchors the overlay at rectForOffset(offset), and repositions on layout. - TextView gains an inlineSuggestionManager and forwarding helpers, with updateLayout hooks in draw(_:) and layout(). --- .../InlineSuggestion/InlineSuggestion.swift | 30 +++ .../InlineSuggestionManager.swift | 202 ++++++++++++++++++ .../InlineSuggestionView.swift | 118 ++++++++++ .../TextView/TextView+InlineSuggestion.swift | 35 +++ .../TextView/TextView+Layout.swift | 2 + .../CodeEditTextView/TextView/TextView.swift | 5 + .../InlineSuggestionTests.swift | 110 ++++++++++ 7 files changed, 502 insertions(+) create mode 100644 Sources/CodeEditTextView/InlineSuggestion/InlineSuggestion.swift create mode 100644 Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionManager.swift create mode 100644 Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionView.swift create mode 100644 Sources/CodeEditTextView/TextView/TextView+InlineSuggestion.swift create mode 100644 Tests/CodeEditTextViewTests/InlineSuggestionTests.swift 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) + } +}