Skip to content

Commit 82bc832

Browse files
Improve Drag and Drop (#80)
### Description Improves the drag and drop capabilities of the editor. In particular: - Adds a new `DraggingTextRenderer` that renders text into itself using knowledge of existing text layout to match text while being dragged. - Overrides drag and drop methods on the text view to correctly support the gesture. - Moves line fragment drawing logic into the `LineFragment` class instead of `LineFragmentView` to enable drawing contents into whatever drawing target is necessary. ### Related Issues * closes #42 ### Checklist ~~TODO:~~ - [x] Drag text without background - [x] Cursor updates to indicate drag destination location. - [x] Modifier flags - [x] Hold option to copy instead of cut - [x] Escape to cancel - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/09070c3d-e512-47d7-a362-a1d73148ff00
1 parent 47faec9 commit 82bc832

File tree

12 files changed

+446
-96
lines changed

12 files changed

+446
-96
lines changed

Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ struct CodeEditTextViewExampleDocument: FileDocument {
2525
guard let data = configuration.file.regularFileContents else {
2626
throw CocoaError(.fileReadCorruptFile)
2727
}
28-
text = String(bytes: data, encoding: .utf8)
28+
text = String(bytes: data, encoding: .utf8) ?? ""
2929
}
3030

3131
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

+8
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
@@ -263,6 +269,8 @@ extension TextLayoutManager {
263269
return nil
264270
}
265271

272+
// MARK: - Ensure Layout
273+
266274
/// Forces layout calculation for all lines up to and including the given offset.
267275
/// - Parameter offset: The offset to ensure layout until.
268276
public func ensureLayoutUntil(_ offset: Int) {

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift renamed to Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// TextLayoutManger+ensureLayout.swift
2+
// TextLayoutManager+ensureLayout.swift
33
// CodeEditTextView
44
//
55
// Created by Khan Winter on 4/7/25.

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

+1-1
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

+38
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.
@@ -40,6 +41,43 @@ public final class LineFragment: Identifiable, Equatable {
4041
lhs.id == rhs.id
4142
}
4243

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+
return CTLineGetOffsetForStringIndex(ctLine, offset, nil)
49+
}
50+
51+
public func draw(in context: CGContext, yPos: CGFloat) {
52+
context.saveGState()
53+
54+
// Removes jagged edges
55+
context.setAllowsAntialiasing(true)
56+
context.setShouldAntialias(true)
57+
58+
// Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than
59+
// the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness
60+
// in low-contrast settings.
61+
context.setAllowsFontSubpixelPositioning(true)
62+
context.setShouldSubpixelPositionFonts(true)
63+
64+
// Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher
65+
// quality bitmaps and performance.
66+
context.setAllowsFontSubpixelQuantization(true)
67+
context.setShouldSubpixelQuantizeFonts(true)
68+
69+
ContextSetHiddenSmoothingStyle(context, 16)
70+
71+
context.textMatrix = .init(scaleX: 1, y: -1)
72+
context.textPosition = CGPoint(
73+
x: 0,
74+
y: yPos + height - descent + (heightDifference/2)
75+
).pixelAligned
76+
77+
CTLineDraw(ctLine, context)
78+
context.restoreGState()
79+
}
80+
4381
/// Calculates the drawing rect for a given range.
4482
/// - Parameter range: The range to calculate the bounds for, relative to the line.
4583
/// - Returns: A rect that contains the text contents in the given range.

Sources/CodeEditTextView/TextLine/LineFragmentView.swift

+1-28
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,32 +39,6 @@ final class LineFragmentView: NSView {
4039
guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else {
4140
return
4241
}
43-
context.saveGState()
44-
45-
// Removes jagged edges
46-
context.setAllowsAntialiasing(true)
47-
context.setShouldAntialias(true)
48-
49-
// Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than
50-
// the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness
51-
// in low-contrast settings.
52-
context.setAllowsFontSubpixelPositioning(true)
53-
context.setShouldSubpixelPositionFonts(true)
54-
55-
// Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher
56-
// quality bitmaps and performance.
57-
context.setAllowsFontSubpixelQuantization(true)
58-
context.setShouldSubpixelQuantizeFonts(true)
59-
60-
ContextSetHiddenSmoothingStyle(context, 16)
61-
62-
context.textMatrix = .init(scaleX: 1, y: -1)
63-
context.textPosition = CGPoint(
64-
x: 0,
65-
y: lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2)
66-
).pixelAligned
67-
68-
CTLineDraw(lineFragment.ctLine, context)
69-
context.restoreGState()
42+
lineFragment.draw(in: context, yPos: 0.0)
7043
}
7144
}

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

+7-5
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,19 @@ public class TextSelectionManager: NSObject {
139139

140140
for textSelection in textSelections {
141141
if textSelection.range.isEmpty {
142-
let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin
142+
guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else {
143+
continue
144+
}
143145

144146
var doesViewNeedReposition: Bool
145147

146148
// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
147149
// approximate equals in that case to avoid extra updates.
148150
if useSystemCursor, #available(macOS 14.0, *) {
149-
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorOrigin)
151+
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin)
150152
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
151153
} else {
152-
doesViewNeedReposition = textSelection.boundingRect.origin != cursorOrigin
154+
doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin
153155
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
154156
}
155157

@@ -175,8 +177,8 @@ public class TextSelectionManager: NSObject {
175177
textView?.addSubview(cursorView)
176178
}
177179

178-
cursorView.frame.origin = cursorOrigin
179-
cursorView.frame.size.height = heightForCursorAt(textSelection.range) ?? 0
180+
cursorView.frame.origin = cursorRect.origin
181+
cursorView.frame.size.height = cursorRect.height
180182

181183
textSelection.view = cursorView
182184
textSelection.boundingRect = cursorView.frame
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
var minY: CGFloat = .infinity
29+
var maxY: CGFloat = 0.0
30+
31+
for range in ranges {
32+
for line in layoutManager.lineStorage.linesInRange(range) {
33+
minY = min(minY, line.yPos)
34+
maxY = max(maxY, line.yPos + line.height)
35+
}
36+
}
37+
38+
let frame = CGRect(
39+
x: layoutManager.edgeInsets.left,
40+
y: minY,
41+
width: layoutManager.maxLineWidth,
42+
height: maxY - minY
43+
)
44+
45+
super.init(frame: frame)
46+
}
47+
48+
required init?(coder: NSCoder) {
49+
fatalError("init(coder:) has not been implemented")
50+
}
51+
52+
override func draw(_ dirtyRect: NSRect) {
53+
super.draw(dirtyRect)
54+
guard let context = NSGraphicsContext.current?.cgContext,
55+
let firstRange = ranges.first,
56+
let minRect = layoutManager.rectForOffset(firstRange.lowerBound) else {
57+
return
58+
}
59+
60+
for range in ranges {
61+
for line in layoutManager.lineStorage.linesInRange(range) {
62+
drawLine(line, in: range, yOffset: minRect.minY, context: context)
63+
}
64+
}
65+
}
66+
67+
private func drawLine(
68+
_ line: TextLineStorage<TextLine>.TextLinePosition,
69+
in selectedRange: NSRange,
70+
yOffset: CGFloat,
71+
context: CGContext
72+
) {
73+
for fragment in line.data.lineFragments {
74+
guard let fragmentRange = fragment.range.shifted(by: line.range.location),
75+
fragmentRange.intersection(selectedRange) != nil else {
76+
continue
77+
}
78+
let fragmentYPos = line.yPos + fragment.yPos - yOffset
79+
fragment.data.draw(in: context, yPos: fragmentYPos)
80+
81+
// Clear text that's not selected
82+
if fragmentRange.contains(selectedRange.lowerBound) {
83+
let relativeOffset = selectedRange.lowerBound - line.range.lowerBound
84+
let selectionXPos = fragment.data.xPos(for: relativeOffset)
85+
context.clear(
86+
CGRect(
87+
x: 0.0,
88+
y: fragmentYPos,
89+
width: selectionXPos,
90+
height: fragment.height
91+
).pixelAligned
92+
)
93+
}
94+
95+
if fragmentRange.contains(selectedRange.upperBound) {
96+
let relativeOffset = selectedRange.upperBound - line.range.lowerBound
97+
let selectionXPos = fragment.data.xPos(for: relativeOffset)
98+
context.clear(
99+
CGRect(
100+
x: selectionXPos,
101+
y: fragmentYPos,
102+
width: frame.width - selectionXPos,
103+
height: fragment.height
104+
).pixelAligned
105+
)
106+
}
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)