Skip to content

Commit e6f7350

Browse files
committed
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().
1 parent d7ac3f1 commit e6f7350

7 files changed

Lines changed: 502 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// InlineSuggestion.swift
3+
// CodeEditTextView
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
import Foundation
9+
10+
/// Represents an inline "ghost text" suggestion to display in a text view.
11+
///
12+
/// A suggestion is purely a rendering hint. It is drawn as a sibling overlay anchored at the caret and never mutates
13+
/// the text view's ``TextView/textStorage`` or shifts real text layout. This makes it suitable as the foundation for
14+
/// inline completions, such as those provided by GitHub Copilot.
15+
public struct InlineSuggestion: Equatable {
16+
/// The document offset the suggestion is anchored to. This is typically the primary caret position.
17+
public let offset: Int
18+
19+
/// The text to display. May contain line breaks to render a multi-line suggestion.
20+
public let text: String
21+
22+
/// Create an inline suggestion.
23+
/// - Parameters:
24+
/// - offset: The document offset to anchor the suggestion to.
25+
/// - text: The text to display. May contain line breaks for a multi-line suggestion.
26+
public init(offset: Int, text: String) {
27+
self.offset = offset
28+
self.text = text
29+
}
30+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
//
2+
// InlineSuggestionManager.swift
3+
// CodeEditTextView
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
import AppKit
9+
10+
/// Manages a single inline "ghost text" suggestion within a ``TextView``.
11+
///
12+
/// The suggestion is rendered by an ``InlineSuggestionView`` inserted as a sibling overlay below the text view's
13+
/// content. The manager never mutates ``TextView/textStorage`` and never shifts real text layout. It only typesets the
14+
/// suggestion text and positions the overlay at the caret using ``TextLayoutManager/rectForOffset(_:)``.
15+
public final class InlineSuggestionManager {
16+
weak var textView: TextView?
17+
18+
/// The color used to draw the ghost text. Defaults to ``NSColor/placeholderTextColor``.
19+
public var suggestionColor: NSColor = .placeholderTextColor
20+
21+
/// The suggestion currently being displayed, if any.
22+
public private(set) var current: InlineSuggestion?
23+
24+
/// The overlay view drawing the current suggestion, if any.
25+
private var suggestionView: InlineSuggestionView?
26+
27+
init(textView: TextView) {
28+
self.textView = textView
29+
}
30+
31+
// MARK: - Set, Clear
32+
33+
/// Sets the suggestion text anchored at the given document offset.
34+
///
35+
/// Passing `nil` or an empty string clears any existing suggestion.
36+
/// - Parameters:
37+
/// - text: The suggestion text. May contain line breaks for a multi-line suggestion.
38+
/// - offset: The document offset to anchor the suggestion to.
39+
public func setSuggestion(_ text: String?, at offset: Int) {
40+
guard let text, !text.isEmpty else {
41+
clearSuggestion()
42+
return
43+
}
44+
setSuggestion(InlineSuggestion(offset: offset, text: text))
45+
}
46+
47+
/// Sets the suggestion to display.
48+
///
49+
/// Passing `nil` clears any existing suggestion.
50+
/// - Parameter suggestion: The suggestion to display, or `nil` to clear.
51+
public func setSuggestion(_ suggestion: InlineSuggestion?) {
52+
guard let suggestion else {
53+
clearSuggestion()
54+
return
55+
}
56+
current = suggestion
57+
render(suggestion, rebuildLines: true)
58+
}
59+
60+
/// Clears any displayed suggestion and removes the overlay view.
61+
public func clearSuggestion() {
62+
current = nil
63+
suggestionView?.removeFromSuperview()
64+
suggestionView = nil
65+
}
66+
67+
// MARK: - Layout
68+
69+
/// Recomputes the suggestion's geometry and repositions the overlay.
70+
///
71+
/// This is a cheap no-op when no suggestion is being displayed. If the anchored offset no longer fits within the
72+
/// document (for instance after an edit shrinks the text), the suggestion is cleared safely.
73+
public func updateLayout() {
74+
guard let current else { return }
75+
guard let textView, current.offset <= textView.textStorage.length else {
76+
clearSuggestion()
77+
return
78+
}
79+
render(current, rebuildLines: false)
80+
}
81+
82+
// MARK: - Rendering
83+
84+
/// Renders the given suggestion, inserting or updating the overlay view.
85+
/// - Parameters:
86+
/// - suggestion: The suggestion to render.
87+
/// - rebuildLines: Whether the typeset lines should be rebuilt. Pass `false` to only reposition.
88+
private func render(_ suggestion: InlineSuggestion, rebuildLines: Bool) {
89+
guard let textView else { return }
90+
91+
let ctLines = rebuildLines || suggestionView == nil
92+
? makeCTLines(for: suggestion.text)
93+
: suggestionView?.ctLines ?? makeCTLines(for: suggestion.text)
94+
95+
guard let layout = makeLayout(for: suggestion, lineCount: ctLines.count) else {
96+
clearSuggestion()
97+
return
98+
}
99+
100+
if let suggestionView {
101+
suggestionView.frame = layout.frame
102+
if rebuildLines {
103+
suggestionView.update(ctLines: ctLines, geometry: layout.geometry)
104+
} else {
105+
suggestionView.update(geometry: layout.geometry)
106+
}
107+
} else {
108+
let view = InlineSuggestionView(ctLines: ctLines, geometry: layout.geometry)
109+
view.frame = layout.frame
110+
textView.addSubview(view, positioned: .below, relativeTo: nil)
111+
suggestionView = view
112+
}
113+
}
114+
115+
/// The drawing attributes for the ghost text, read from the text view's typing attributes.
116+
private func suggestionAttributes() -> [NSAttributedString.Key: Any] {
117+
guard let textView else { return [:] }
118+
return [
119+
.font: textView.font,
120+
.foregroundColor: suggestionColor,
121+
.kern: textView.kern
122+
]
123+
}
124+
125+
/// Typesets the suggestion text into one `CTLine` per line break.
126+
private func makeCTLines(for text: String) -> [CTLine] {
127+
let attributes = suggestionAttributes()
128+
return splitIntoLines(text).map { line in
129+
CTLineCreateWithAttributedString(NSAttributedString(string: line, attributes: attributes))
130+
}
131+
}
132+
133+
/// Splits the text into lines on any ``LineEnding`` sequence, preserving empty trailing lines.
134+
private func splitIntoLines(_ text: String) -> [String] {
135+
let endings = LineEnding.allCases.sorted { $0.length > $1.length }
136+
var lines: [String] = []
137+
var currentLine = ""
138+
var index = text.startIndex
139+
while index < text.endIndex {
140+
let remaining = text[index...]
141+
if let ending = endings.first(where: { remaining.hasPrefix($0.rawValue) }) {
142+
lines.append(currentLine)
143+
currentLine = ""
144+
index = text.index(index, offsetBy: ending.length)
145+
} else {
146+
currentLine.append(text[index])
147+
index = text.index(after: index)
148+
}
149+
}
150+
lines.append(currentLine)
151+
return lines
152+
}
153+
154+
/// The computed frame and geometry for a suggestion.
155+
private struct Layout {
156+
let frame: CGRect
157+
let geometry: InlineSuggestionView.Geometry
158+
}
159+
160+
/// Computes the overlay frame and drawing geometry for a suggestion.
161+
private func makeLayout(for suggestion: InlineSuggestion, lineCount: Int) -> Layout? {
162+
guard let textView,
163+
let layoutManager = textView.layoutManager,
164+
let caretRect = layoutManager.rectForOffset(suggestion.offset) else {
165+
return nil
166+
}
167+
168+
let lineHeight = layoutManager.estimateLineHeight()
169+
let (descent, heightDifference) = lineMetrics(multiplier: layoutManager.lineHeightMultiplier)
170+
171+
let geometry = InlineSuggestionView.Geometry(
172+
firstLineXPosition: caretRect.minX,
173+
continuationXPosition: layoutManager.edgeInsets.left,
174+
lineHeight: lineHeight,
175+
descent: descent,
176+
heightDifference: heightDifference
177+
)
178+
179+
let frame = CGRect(
180+
x: 0,
181+
y: caretRect.minY,
182+
width: textView.frame.width,
183+
height: lineHeight * CGFloat(lineCount)
184+
)
185+
186+
return Layout(frame: frame, geometry: geometry)
187+
}
188+
189+
/// Computes the descent and scaled height difference for the current suggestion attributes.
190+
private func lineMetrics(multiplier: CGFloat) -> (descent: CGFloat, heightDifference: CGFloat) {
191+
let referenceLine = CTLineCreateWithAttributedString(
192+
NSAttributedString(string: "0", attributes: suggestionAttributes())
193+
)
194+
var ascent: CGFloat = 0
195+
var descent: CGFloat = 0
196+
var leading: CGFloat = 0
197+
CTLineGetTypographicBounds(referenceLine, &ascent, &descent, &leading)
198+
let unscaledHeight = ascent + descent + leading
199+
let heightDifference = (unscaledHeight * multiplier) - unscaledHeight
200+
return (descent, heightDifference)
201+
}
202+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// InlineSuggestionView.swift
3+
// CodeEditTextView
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextViewObjC
10+
11+
/// Draws inline "ghost text" as a sibling overlay of a ``TextView``.
12+
///
13+
/// This view holds pre-typeset `CTLine`s and the geometry needed to position them. It draws them in a dimmed color
14+
/// using the same Core Text drawing context setup as ``LineFragmentRenderer``, so the ghost text visually matches the
15+
/// real text. The view never mutates text storage and ignores hit testing so it does not interfere with selection or
16+
/// editing.
17+
open class InlineSuggestionView: NSView {
18+
/// The geometry needed to position the suggestion's typeset lines.
19+
struct Geometry {
20+
/// The x position of the first line, anchored to the caret.
21+
let firstLineXPosition: CGFloat
22+
/// The x position of wrapped or subsequent lines, anchored to the text view's leading edge inset.
23+
let continuationXPosition: CGFloat
24+
/// The height of each line.
25+
let lineHeight: CGFloat
26+
/// The descent of the typeset lines.
27+
let descent: CGFloat
28+
/// The difference between the scaled and unscaled line height.
29+
let heightDifference: CGFloat
30+
}
31+
32+
/// The pre-typeset lines to draw. Line `0` is drawn at the caret, lines `1...` at the continuation x position.
33+
private(set) var ctLines: [CTLine]
34+
private var geometry: Geometry
35+
36+
override open var isFlipped: Bool {
37+
true
38+
}
39+
40+
override open var isOpaque: Bool {
41+
false
42+
}
43+
44+
override open func hitTest(_ point: NSPoint) -> NSView? { nil }
45+
46+
/// Create an inline suggestion view.
47+
/// - Parameters:
48+
/// - ctLines: The pre-typeset lines to draw.
49+
/// - geometry: The geometry used to position the lines.
50+
init(ctLines: [CTLine], geometry: Geometry) {
51+
self.ctLines = ctLines
52+
self.geometry = geometry
53+
super.init(frame: .zero)
54+
wantsLayer = true
55+
}
56+
57+
public required init?(coder: NSCoder) {
58+
fatalError("init(coder:) has not been implemented")
59+
}
60+
61+
/// Update the typeset lines and geometry, then request a redraw.
62+
/// - Parameters:
63+
/// - ctLines: The new pre-typeset lines to draw.
64+
/// - geometry: The new geometry used to position the lines.
65+
func update(ctLines: [CTLine], geometry: Geometry) {
66+
self.ctLines = ctLines
67+
self.geometry = geometry
68+
needsDisplay = true
69+
}
70+
71+
/// Update the geometry without re-typesetting the lines, then request a redraw.
72+
/// - Parameter geometry: The new geometry used to position the lines.
73+
func update(geometry: Geometry) {
74+
self.geometry = geometry
75+
needsDisplay = true
76+
}
77+
78+
/// The x position to draw the line at the given index.
79+
private func xFor(_ index: Int) -> CGFloat {
80+
index == 0 ? geometry.firstLineXPosition : geometry.continuationXPosition
81+
}
82+
83+
override open func draw(_ dirtyRect: NSRect) {
84+
super.draw(dirtyRect)
85+
guard let context = NSGraphicsContext.current?.cgContext else { return }
86+
87+
context.saveGState()
88+
// Removes jagged edges
89+
context.setAllowsAntialiasing(true)
90+
context.setShouldAntialias(true)
91+
92+
// Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than
93+
// the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness
94+
// in low-contrast settings.
95+
context.setAllowsFontSubpixelPositioning(true)
96+
context.setShouldSubpixelPositionFonts(true)
97+
98+
// Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher
99+
// quality bitmaps and performance.
100+
context.setAllowsFontSubpixelQuantization(true)
101+
context.setShouldSubpixelQuantizeFonts(true)
102+
103+
ContextSetHiddenSmoothingStyle(context, 16)
104+
105+
context.textMatrix = .init(scaleX: 1, y: -1)
106+
107+
for (index, ctLine) in ctLines.enumerated() {
108+
context.textPosition = CGPoint(
109+
x: xFor(index),
110+
y: CGFloat(index) * geometry.lineHeight
111+
+ geometry.lineHeight - geometry.descent + (geometry.heightDifference / 2)
112+
).pixelAligned
113+
CTLineDraw(ctLine, context)
114+
}
115+
116+
context.restoreGState()
117+
}
118+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// TextView+InlineSuggestion.swift
3+
// CodeEditTextView
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
import Foundation
9+
10+
extension TextView {
11+
/// Displays an inline "ghost text" suggestion anchored at the given document offset.
12+
///
13+
/// Passing `nil` or an empty string clears any existing suggestion. The suggestion is rendered as an overlay and
14+
/// never mutates ``TextView/textStorage`` or shifts real text layout.
15+
/// - Parameters:
16+
/// - text: The suggestion text. May contain line breaks for a multi-line suggestion.
17+
/// - offset: The document offset to anchor the suggestion to.
18+
public func setInlineSuggestion(_ text: String?, at offset: Int) {
19+
inlineSuggestionManager?.setSuggestion(text, at: offset)
20+
}
21+
22+
/// Displays an inline "ghost text" suggestion anchored at the primary caret.
23+
///
24+
/// Passing `nil` or an empty string clears any existing suggestion.
25+
/// - Parameter text: The suggestion text. May contain line breaks for a multi-line suggestion.
26+
public func setInlineSuggestion(_ text: String?) {
27+
let offset = selectionManager.textSelections.first?.range.location ?? 0
28+
inlineSuggestionManager?.setSuggestion(text, at: offset)
29+
}
30+
31+
/// Clears any displayed inline suggestion.
32+
public func clearInlineSuggestion() {
33+
inlineSuggestionManager?.clearSuggestion()
34+
}
35+
}

0 commit comments

Comments
 (0)