Skip to content

Commit 350dffc

Browse files
committed
Start Implementing Drawing View
1 parent 7f51e85 commit 350dffc

File tree

11 files changed

+372
-78
lines changed

11 files changed

+372
-78
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//
2+
// File.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 11/25/24.
6+
//
7+
8+
import Foundation

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import AppKit
99

1010
extension TextLayoutManager {
11+
// MARK: - Estimate
12+
1113
public func estimatedHeight() -> CGFloat {
1214
max(lineStorage.height, estimateLineHeight())
1315
}
@@ -16,6 +18,8 @@ extension TextLayoutManager {
1618
maxLineWidth + edgeInsets.horizontal
1719
}
1820

21+
// MARK: - Text Lines
22+
1923
/// Finds a text line for the given y position relative to the text view.
2024
///
2125
/// Y values begin at the top of the view and extend down. Eg, a `0` y value would return the first line in
@@ -101,6 +105,8 @@ extension TextLayoutManager {
101105
}
102106
}
103107

108+
// MARK: - Rect For Offset
109+
104110
/// Find a position for the character at a given offset.
105111
/// Returns the rect of the character at the given offset.
106112
/// The rect may represent more than one unicode unit, for instance if the offset is at the beginning of an
@@ -175,6 +181,8 @@ extension TextLayoutManager {
175181
return nil
176182
}
177183

184+
// MARK: - Ensure Layout
185+
178186
/// Forces layout calculation for all lines up to and including the given offset.
179187
/// - Parameter offset: The offset to ensure layout until.
180188
public func ensureLayoutUntil(_ offset: Int) {

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public class TextLayoutManager: NSObject {
7070
weak var textStorage: NSTextStorage?
7171
var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
7272
var markedTextManager: MarkedTextManager = MarkedTextManager()
73-
private let viewReuseQueue: ViewReuseQueue<LineFragmentView, UUID> = ViewReuseQueue()
73+
let viewReuseQueue: ViewReuseQueue<LineFragmentView, UUID> = ViewReuseQueue()
7474
package var visibleLineIds: Set<TextLine.ID> = []
7575
/// Used to force a complete re-layout using `setNeedsLayout`
7676
package var needsLayout: Bool = false

Sources/CodeEditTextView/TextLine/LineFragment.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import AppKit
9+
import CodeEditTextViewObjC
910

1011
/// A ``LineFragment`` represents a subrange of characters in a line. Every text line contains at least one line
1112
/// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment.
@@ -39,4 +40,36 @@ public final class LineFragment: Identifiable, Equatable {
3940
public static func == (lhs: LineFragment, rhs: LineFragment) -> Bool {
4041
lhs.id == rhs.id
4142
}
43+
44+
/// Finds the x position of the offset in the string the fragment represents.
45+
/// - Parameter offset: The offset, relative to the start of the *line*.
46+
/// - Returns: The x position of the character in the drawn line, from the left.
47+
public func xPos(for offset: Int) -> CGFloat {
48+
let lineRange = CTLineGetStringRange(ctLine)
49+
return CTLineGetOffsetForStringIndex(ctLine, offset, nil)
50+
}
51+
52+
public func draw(in context: CGContext, yPos: CGFloat) {
53+
context.saveGState()
54+
55+
context.setAllowsAntialiasing(true)
56+
context.setShouldAntialias(true)
57+
context.setAllowsFontSmoothing(false)
58+
context.setShouldSmoothFonts(false)
59+
context.setAllowsFontSubpixelPositioning(true)
60+
context.setShouldSubpixelPositionFonts(true)
61+
context.setAllowsFontSubpixelQuantization(true)
62+
context.setShouldSubpixelQuantizeFonts(true)
63+
64+
ContextSetHiddenSmoothingStyle(context, 16)
65+
66+
context.textMatrix = .init(scaleX: 1, y: -1)
67+
context.textPosition = CGPoint(
68+
x: 0,
69+
y: yPos + (height - descent + (heightDifference/2))
70+
).pixelAligned
71+
72+
CTLineDraw(ctLine, context)
73+
context.restoreGState()
74+
}
4275
}

Sources/CodeEditTextView/TextLine/LineFragmentView.swift

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
//
77

88
import AppKit
9-
import CodeEditTextViewObjC
109

1110
/// Displays a line fragment.
1211
final class LineFragmentView: NSView {
@@ -40,26 +39,6 @@ final class LineFragmentView: NSView {
4039
guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else {
4140
return
4241
}
43-
context.saveGState()
44-
45-
context.setAllowsAntialiasing(true)
46-
context.setShouldAntialias(true)
47-
context.setAllowsFontSmoothing(false)
48-
context.setShouldSmoothFonts(false)
49-
context.setAllowsFontSubpixelPositioning(true)
50-
context.setShouldSubpixelPositionFonts(true)
51-
context.setAllowsFontSubpixelQuantization(true)
52-
context.setShouldSubpixelQuantizeFonts(true)
53-
54-
ContextSetHiddenSmoothingStyle(context, 16)
55-
56-
context.textMatrix = .init(scaleX: 1, y: -1)
57-
context.textPosition = CGPoint(
58-
x: 0,
59-
y: lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2)
60-
).pixelAligned
61-
62-
CTLineDraw(lineFragment.ctLine, context)
63-
context.restoreGState()
42+
lineFragment.draw(in: context, yPos: 0.0)
6443
}
6544
}

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,19 @@ public class TextSelectionManager: NSObject {
134134

135135
for textSelection in textSelections {
136136
if textSelection.range.isEmpty {
137-
let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin
137+
guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else {
138+
continue
139+
}
138140

139141
var doesViewNeedReposition: Bool
140142

141143
// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
142144
// approximate equals in that case to avoid extra updates.
143145
if useSystemCursor, #available(macOS 14.0, *) {
144-
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorOrigin)
146+
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin)
145147
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
146148
} else {
147-
doesViewNeedReposition = textSelection.boundingRect.origin != cursorOrigin
149+
doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin
148150
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
149151
}
150152

@@ -170,8 +172,8 @@ public class TextSelectionManager: NSObject {
170172
textView?.addSubview(cursorView)
171173
}
172174

173-
cursorView.frame.origin = cursorOrigin
174-
cursorView.frame.size.height = heightForCursorAt(textSelection.range) ?? 0
175+
cursorView.frame.origin = cursorRect.origin
176+
cursorView.frame.size.height = cursorRect.height
175177

176178
textSelection.view = cursorView
177179
textSelection.boundingRect = cursorView.frame
@@ -201,22 +203,6 @@ public class TextSelectionManager: NSObject {
201203
}
202204
}
203205

204-
/// Get the height for a cursor placed at the beginning of the given range.
205-
/// - Parameter range: The range the cursor is at.
206-
/// - Returns: The height the cursor should be to match the text at that location.
207-
fileprivate func heightForCursorAt(_ range: NSRange) -> CGFloat? {
208-
guard let selectedLine = layoutManager?.textLineForOffset(range.location) else {
209-
return layoutManager?.estimateLineHeight()
210-
}
211-
return selectedLine
212-
.data
213-
.lineFragments
214-
.getLine(atOffset: range.location - (selectedLine.range.location))?
215-
.height
216-
?? layoutManager?.estimateLineHeight()
217-
218-
}
219-
220206
/// Removes all cursor views and stops the cursor blink timer.
221207
func removeCursors() {
222208
cursorTimer.stopTimer()
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//
2+
// DraggingTextRenderer.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 11/24/24.
6+
//
7+
8+
import AppKit
9+
10+
class DraggingTextRenderer: NSView {
11+
let ranges: [NSRange]
12+
let layoutManager: TextLayoutManager
13+
14+
override var isFlipped: Bool {
15+
true
16+
}
17+
18+
override var intrinsicContentSize: NSSize {
19+
self.frame.size
20+
}
21+
22+
init?(ranges: [NSRange], layoutManager: TextLayoutManager) {
23+
self.ranges = ranges
24+
self.layoutManager = layoutManager
25+
26+
assert(!ranges.isEmpty, "Empty ranges not allowed")
27+
28+
guard let lastRange = ranges.last else { return nil }
29+
30+
var minY: CGFloat = .infinity
31+
var maxY: CGFloat = 0.0
32+
33+
for range in ranges {
34+
for line in layoutManager.lineStorage.linesInRange(range) {
35+
guard layoutManager.visibleLineIds.contains(line.data.id), // Only grab visible text
36+
let width = line.data.maxWidth else {
37+
break
38+
}
39+
minY = min(minY, line.yPos)
40+
maxY = max(maxY, line.yPos + line.height)
41+
}
42+
}
43+
44+
let frame = CGRect(
45+
x: layoutManager.edgeInsets.left,
46+
y: minY,
47+
width: layoutManager.maxLineWidth,
48+
height: maxY - minY
49+
)
50+
51+
super.init(frame: frame)
52+
}
53+
54+
required init?(coder: NSCoder) {
55+
fatalError("init(coder:) has not been implemented")
56+
}
57+
58+
override func draw(_ dirtyRect: NSRect) {
59+
super.draw(dirtyRect)
60+
guard let context = NSGraphicsContext.current?.cgContext,
61+
let firstRange = ranges.first,
62+
let minRect = layoutManager.rectForOffset(firstRange.lowerBound) else {
63+
return
64+
}
65+
66+
for range in ranges {
67+
for line in layoutManager.lineStorage.linesInRange(range) {
68+
drawLine(line, in: range, yOffset: minRect.minY, context: context)
69+
}
70+
}
71+
}
72+
73+
private func drawLine(
74+
_ line: TextLineStorage<TextLine>.TextLinePosition,
75+
in selectedRange: NSRange,
76+
yOffset: CGFloat,
77+
context: CGContext
78+
) {
79+
for fragment in line.data.lineFragments {
80+
guard let fragmentRange = fragment.range.shifted(by: line.range.location),
81+
fragmentRange.intersection(selectedRange) != nil else {
82+
continue
83+
}
84+
let fragmentYPos = line.yPos + fragment.yPos - yOffset
85+
fragment.data.draw(in: context, yPos: fragmentYPos)
86+
87+
// Clear text that's not selected
88+
if fragmentRange.contains(selectedRange.lowerBound) {
89+
let relativeOffset = selectedRange.lowerBound - line.range.lowerBound
90+
let selectionXPos = fragment.data.xPos(for: relativeOffset)
91+
context.clear(
92+
CGRect(
93+
x: 0.0,
94+
y: fragmentYPos,
95+
width: selectionXPos,
96+
height: fragment.height
97+
).pixelAligned
98+
)
99+
}
100+
101+
if fragmentRange.contains(selectedRange.upperBound) {
102+
let relativeOffset = selectedRange.upperBound - line.range.lowerBound
103+
let selectionXPos = fragment.data.xPos(for: relativeOffset)
104+
context.clear(
105+
CGRect(
106+
x: selectionXPos,
107+
y: fragmentYPos,
108+
width: frame.width - selectionXPos,
109+
height: fragment.height
110+
).pixelAligned
111+
)
112+
}
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)