Skip to content

Commit 94065e7

Browse files
committed
Merge branch 'main' into feat/override-layout-behavior
2 parents 4974ac6 + 66e1065 commit 94065e7

File tree

5 files changed

+187
-24
lines changed

5 files changed

+187
-24
lines changed

Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ public final class TextLineStorage<Data: Identifiable> {
5858

5959
public init() { }
6060

61+
init(root: Node<Data>, count: Int, length: Int, height: CGFloat) {
62+
self.root = root
63+
self.count = count
64+
self.length = length
65+
self.height = height
66+
}
67+
6168
// MARK: - Public Methods
6269

6370
/// Inserts a new line for the given range.
@@ -408,9 +415,9 @@ private extension TextLineStorage {
408415
} else {
409416
transplant(nodeY, with: nodeY.right)
410417

411-
nodeY.right?.leftSubtreeCount = nodeY.leftSubtreeCount
412-
nodeY.right?.leftSubtreeHeight = nodeY.leftSubtreeHeight
413-
nodeY.right?.leftSubtreeOffset = nodeY.leftSubtreeOffset
418+
nodeY.right?.leftSubtreeCount += nodeY.leftSubtreeCount
419+
nodeY.right?.leftSubtreeHeight += nodeY.leftSubtreeHeight
420+
nodeY.right?.leftSubtreeOffset += nodeY.leftSubtreeOffset
414421

415422
nodeY.right = nodeZ.right
416423
nodeY.right?.parent = nodeY

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ extension TextSelectionManager {
3939
}
4040
}
4141

42-
func notifyAfterEdit() {
43-
updateSelectionViews()
42+
public func notifyAfterEdit(force: Bool = false) {
43+
updateSelectionViews(force: force)
4444
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
4545
}
4646
}

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public class TextSelectionManager: NSObject {
138138

139139
/// Update all selection cursors. Placing them in the correct position for each text selection and reseting the
140140
/// blink timer.
141-
func updateSelectionViews() {
141+
func updateSelectionViews(force: Bool = false) {
142142
guard textView?.isFirstResponder ?? false else { return }
143143
var didUpdate: Bool = false
144144

@@ -197,7 +197,7 @@ public class TextSelectionManager: NSObject {
197197
}
198198
}
199199

200-
if didUpdate {
200+
if didUpdate || force {
201201
delegate?.setNeedsDisplay()
202202
cursorTimer.resetTimer()
203203
resetSystemCursorTimers()

Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,23 @@ extension TextView {
1313
/// - Parameters:
1414
/// - ranges: The ranges to replace
1515
/// - string: The string to insert in the ranges.
16-
public func replaceCharacters(in ranges: [NSRange], with string: String) {
16+
/// - skipUpdateSelection: Skips the selection update step
17+
public func replaceCharacters(
18+
in ranges: [NSRange],
19+
with string: String,
20+
skipUpdateSelection: Bool = false
21+
) {
1722
guard isEditable else { return }
1823
NotificationCenter.default.post(name: Self.textWillChangeNotification, object: self)
1924
textStorage.beginEditing()
2025

26+
func valid(range: NSRange, string: String) -> Bool {
27+
(!range.isEmpty || !string.isEmpty) &&
28+
(delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true)
29+
}
30+
2131
// Can't insert an empty string into an empty range. One must be not empty
22-
for range in ranges.sorted(by: { $0.location > $1.location }) where
23-
(!range.isEmpty || !string.isEmpty) &&
24-
(delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) {
32+
for range in ranges.sorted(by: { $0.location > $1.location }) where valid(range: range, string: string) {
2533
delegate?.textView(self, willReplaceContentsIn: range, with: string)
2634

2735
_undoManager?.registerMutation(
@@ -31,13 +39,23 @@ extension TextView {
3139
in: range,
3240
with: NSAttributedString(string: string, attributes: typingAttributes)
3341
)
34-
selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length)
42+
if !skipUpdateSelection {
43+
selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length)
44+
}
3545

3646
delegate?.textView(self, didReplaceContentsIn: range, with: string)
3747
}
3848

3949
textStorage.endEditing()
40-
selectionManager.notifyAfterEdit()
50+
51+
// Cause a layout pass now that we've finished editing, if there were any edits.
52+
if !ranges.isEmpty {
53+
layout()
54+
}
55+
56+
if !skipUpdateSelection {
57+
selectionManager.notifyAfterEdit()
58+
}
4159
NotificationCenter.default.post(name: Self.textDidChangeNotification, object: self)
4260

4361
// `scrollSelectionToVisible` is a little expensive to call every time. Instead we just check if the first
@@ -51,7 +69,33 @@ extension TextView {
5169
/// - Parameters:
5270
/// - range: The range to replace.
5371
/// - string: The string to insert in the range.
54-
public func replaceCharacters(in range: NSRange, with string: String) {
55-
replaceCharacters(in: [range], with: string)
72+
/// - skipUpdateSelection: Skips the selection update step
73+
public func replaceCharacters(
74+
in range: NSRange,
75+
with string: String,
76+
skipUpdateSelection: Bool = false
77+
) {
78+
replaceCharacters(in: [range], with: string, skipUpdateSelection: skipUpdateSelection)
79+
}
80+
81+
/// Iterates over all text selections in the `TextView` and applies the provided callback.
82+
///
83+
/// This method is typically used when you need to perform an operation on each text selection in the editor,
84+
/// such as adjusting indentation, or other selection-based operations. The callback
85+
/// is executed for each selection, and you can modify the selection or perform related tasks.
86+
///
87+
/// - Parameters:
88+
/// - callback: A closure that will be executed for each selection in the `TextView`. It takes two parameters:
89+
/// a `TextView` instance, allowing access to the view's properties and methods and a
90+
/// `TextSelectionManager.TextSelection` representing the current selection to operate on.
91+
///
92+
/// - Note: The selections are iterated in reverse order, so modifications to earlier selections won't affect later
93+
/// ones. The method automatically calls `notifyAfterEdit()` on the `selectionManager` after all
94+
/// selections are processed.
95+
public func editSelections(callback: (TextView, TextSelectionManager.TextSelection) -> Void) {
96+
for textSelection in selectionManager.textSelections.reversed() {
97+
callback(self, textSelection)
98+
}
99+
selectionManager.notifyAfterEdit(force: true)
56100
}
57101
}

Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ fileprivate extension CGFloat {
77
}
88
}
99

10-
final class TextLayoutLineStorageTests: XCTestCase {
10+
extension UUID: @retroactive Identifiable {
11+
public var id: UUID { self }
12+
}
13+
14+
final class TextLayoutLineStorageTests: XCTestCase { // swiftlint:disable:this type_body_length
15+
1116
/// Creates a balanced height=3 tree useful for testing and debugging.
1217
/// - Returns: A new tree.
1318
fileprivate func createBalancedTree() -> TextLineStorage<TextLine> {
@@ -20,16 +25,16 @@ final class TextLayoutLineStorageTests: XCTestCase {
2025
return tree
2126
}
2227

28+
struct ChildData {
29+
let length: Int
30+
let count: Int
31+
let height: CGFloat
32+
}
33+
2334
/// Recursively checks that the given tree has the correct metadata everywhere.
2435
/// - Parameter tree: The tree to check.
25-
fileprivate func assertTreeMetadataCorrect(_ tree: TextLineStorage<TextLine>) throws {
26-
struct ChildData {
27-
let length: Int
28-
let count: Int
29-
let height: CGFloat
30-
}
31-
32-
func checkChildren(_ node: TextLineStorage<TextLine>.Node<TextLine>?) -> ChildData {
36+
fileprivate func assertTreeMetadataCorrect<T: Identifiable>(_ tree: TextLineStorage<T>) throws {
37+
func checkChildren(_ node: TextLineStorage<T>.Node<T>?) -> ChildData {
3338
guard let node else { return ChildData(length: 0, count: 0, height: 0.0) }
3439
let leftSubtreeData = checkChildren(node.left)
3540
let rightSubtreeData = checkChildren(node.right)
@@ -272,4 +277,111 @@ final class TextLayoutLineStorageTests: XCTestCase {
272277
}
273278
}
274279
}
280+
281+
func test_transplantWithExistingLeftNodes() throws { // swiftlint:disable:this function_body_length
282+
typealias Storage = TextLineStorage<UUID>
283+
typealias Node = TextLineStorage<UUID>.Node
284+
// Test that when transplanting a node with no left nodes, with a node with left nodes, that
285+
// the resulting tree has valid 'left_' metadata
286+
// 1
287+
// / \
288+
// 7 2
289+
// /
290+
// 3 ← this will be moved, this test ensures 4 retains it's left subtree count
291+
// \
292+
// 4
293+
// | |
294+
// 5 6
295+
296+
let node5 = Node(
297+
length: 5,
298+
data: UUID(),
299+
leftSubtreeOffset: 0,
300+
leftSubtreeHeight: 0,
301+
leftSubtreeCount: 0,
302+
height: 1,
303+
left: nil,
304+
right: nil,
305+
parent: nil,
306+
color: .black
307+
)
308+
309+
let node6 = Node(
310+
length: 6,
311+
data: UUID(),
312+
leftSubtreeOffset: 0,
313+
leftSubtreeHeight: 0,
314+
leftSubtreeCount: 0,
315+
height: 1,
316+
left: nil,
317+
right: nil,
318+
parent: nil,
319+
color: .black
320+
)
321+
322+
let node4 = Node(
323+
length: 4,
324+
data: UUID(),
325+
leftSubtreeOffset: 5,
326+
leftSubtreeHeight: 1,
327+
leftSubtreeCount: 1, // node5 is on the left
328+
height: 1,
329+
left: node5,
330+
right: node6,
331+
parent: nil,
332+
color: .black
333+
)
334+
node5.parent = node4
335+
node6.parent = node4
336+
337+
let node3 = Node(
338+
length: 3,
339+
data: UUID(),
340+
leftSubtreeOffset: 0,
341+
leftSubtreeHeight: 0,
342+
leftSubtreeCount: 0,
343+
height: 1,
344+
left: nil,
345+
right: node4,
346+
parent: nil,
347+
color: .black
348+
)
349+
node4.parent = node3
350+
351+
let node2 = Node(
352+
length: 2,
353+
data: UUID(),
354+
leftSubtreeOffset: 18,
355+
leftSubtreeHeight: 4,
356+
leftSubtreeCount: 4, // node3 is on the left
357+
height: 1,
358+
left: node3,
359+
right: nil,
360+
parent: nil,
361+
color: .black
362+
)
363+
node3.parent = node2
364+
365+
let node7 = Node(length: 7, data: UUID(), height: 1)
366+
367+
let node1 = Node(
368+
length: 1,
369+
data: UUID(),
370+
leftSubtreeOffset: 7,
371+
leftSubtreeHeight: 1,
372+
leftSubtreeCount: 1,
373+
height: 1,
374+
left: node7,
375+
right: node2,
376+
parent: nil,
377+
color: .black
378+
)
379+
node2.parent = node1
380+
381+
let storage = Storage(root: node1, count: 7, length: 28, height: 7)
382+
383+
storage.delete(lineAt: 7) // Delete the root
384+
385+
try assertTreeMetadataCorrect(storage)
386+
}
275387
}

0 commit comments

Comments
 (0)