From 261c93c14ac60120bb1ff3711430a8451ced56be Mon Sep 17 00:00:00 2001 From: Arnaud Mengus <44212923+isontheline@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:57:23 +0200 Subject: [PATCH] Fixing https://github.com/simonbs/Runestone/issues/415 --- .../Core/LineMovementController.swift | 21 +++++++++++++++++-- .../Core/TextInputStringTokenizer.swift | 19 +++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/Core/LineMovementController.swift b/Sources/Runestone/TextView/Core/LineMovementController.swift index 60638eba5..f7a0fdafb 100644 --- a/Sources/Runestone/TextView/Core/LineMovementController.swift +++ b/Sources/Runestone/TextView/Core/LineMovementController.swift @@ -61,6 +61,12 @@ private extension LineMovementController { return location } let lineLocalLocation = max(min(location - line.location, line.data.totalLength), 0) + // After an edit the line fragment tree is reset and only repopulated for lines laid out + // by the layout manager, so the query below may otherwise hit an empty tree. + // See https://github.com/simonbs/Runestone/issues/415 + if lineController.numberOfLineFragments == 0 || !lineController.isFinishedTypesetting { + lineController.prepareToDisplayString(toLocation: lineLocalLocation, syntaxHighlightAsynchronously: true) + } guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { return location } @@ -78,7 +84,7 @@ private extension LineMovementController { return locationForMovingDownwards(lineOffset: lineOffset, fromLocation: location, inLineFragmentAt: lineFragmentIndex, of: line) } else { // lineOffset is 0 so we shouldn't change the line - let lineController = lineControllerStorage.getOrCreateLineController(for: line) + let lineController = preparedLineController(for: line) let destinationLineFragmentNode = lineController.lineFragmentNode(atIndex: lineFragmentIndex) let lineLocation = line.location let preferredLocation = lineLocation + destinationLineFragmentNode.location + location @@ -132,7 +138,18 @@ private extension LineMovementController { } private func numberOfLineFragments(in line: DocumentLineNode) -> Int { - let lineController = lineControllerStorage.getOrCreateLineController(for: line) + let lineController = preparedLineController(for: line) return lineController.numberOfLineFragments } + + // Ensures the entire line is typeset so its line fragments can be counted and queried. + // After an edit the line fragment tree is reset and only repopulated for lines laid out + // by the layout manager. See https://github.com/simonbs/Runestone/issues/415 + private func preparedLineController(for line: DocumentLineNode) -> LineController { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + if lineController.numberOfLineFragments == 0 || !lineController.isFinishedTypesetting { + lineController.prepareToDisplayString(toLocation: line.data.totalLength, syntaxHighlightAsynchronously: true) + } + return lineController + } } diff --git a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift index 12f45fab2..629bd5cbb 100644 --- a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift @@ -49,6 +49,18 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { // MARK: - Lines private extension TextInputStringTokenizer { + // Ensures the line is typeset far enough that its line fragment tree can answer queries + // at lineLocalLocation. After an edit the line fragment tree is reset and only repopulated + // for lines laid out by the layout manager, so UIKit's selection queries (e.g. during paste) + // may otherwise hit an empty tree. See https://github.com/simonbs/Runestone/issues/415 + private func preparedLineController(for line: DocumentLineNode, toLocation lineLocalLocation: Int) -> LineController { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + if lineController.numberOfLineFragments == 0 || !lineController.isFinishedTypesetting { + lineController.prepareToDisplayString(toLocation: lineLocalLocation, syntaxHighlightAsynchronously: true) + } + return lineController + } + private func isPosition(_ position: UITextPosition, atLineBoundaryInDirection direction: UITextDirection) -> Bool { guard let indexedPosition = position as? IndexedPosition else { return false @@ -59,10 +71,10 @@ private extension TextInputStringTokenizer { } let lineLocation = line.location let lineLocalLocation = location - lineLocation - let lineController = lineControllerStorage.getOrCreateLineController(for: line) guard lineLocalLocation >= 0 && lineLocalLocation <= line.data.totalLength else { return false } + let lineController = preparedLineController(for: line, toLocation: lineLocalLocation) guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { return false } @@ -86,9 +98,12 @@ private extension TextInputStringTokenizer { guard let line = lineManager.line(containingCharacterAt: location) else { return nil } - let lineController = lineControllerStorage.getOrCreateLineController(for: line) let lineLocation = line.location let lineLocalLocation = location - lineLocation + guard lineLocalLocation >= 0 && lineLocalLocation <= line.data.totalLength else { + return nil + } + let lineController = preparedLineController(for: line, toLocation: lineLocalLocation) guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { return nil }