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
30 changes: 30 additions & 0 deletions Sources/CodeEditTextView/InlineSuggestion/InlineSuggestion.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
118 changes: 118 additions & 0 deletions Sources/CodeEditTextView/InlineSuggestion/InlineSuggestionView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
35 changes: 35 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView+InlineSuggestion.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading