diff --git a/Example/Example/Library/ProcessInfo+Helpers.swift b/Example/Example/Library/ProcessInfo+Helpers.swift index efc364d3..b7082447 100644 --- a/Example/Example/Library/ProcessInfo+Helpers.swift +++ b/Example/Example/Library/ProcessInfo+Helpers.swift @@ -2,7 +2,12 @@ import Foundation extension ProcessInfo { var disableTextPersistance: Bool { - environment["disableTextPersistance"] != nil + environment["disableTextPersistance"] != nil || navigationDocumentName != nil + } + + /// A UTF-8 fixture in the example app's Documents directory. + var navigationDocumentName: String? { + environment["RUNESTONE_DOCUMENT"] } var useCRLFLineEndings: Bool { diff --git a/Example/Example/Main/MainViewController.swift b/Example/Example/Main/MainViewController.swift index bb3eb4a5..af3301a3 100644 --- a/Example/Example/Main/MainViewController.swift +++ b/Example/Example/Main/MainViewController.swift @@ -10,6 +10,7 @@ final class MainViewController: UIViewController { } private let contentView = MainView() + private var navigationDocumentLength = 0 #if os(iOS) private let toolsView: KeyboardToolsView #endif @@ -64,6 +65,10 @@ final class MainViewController: UIViewController { setupMenuButton() setupTextView() updateTextViewSettings() + if ProcessInfo.processInfo.navigationDocumentName != nil { + contentView.textView.isLineWrappingEnabled = ProcessInfo.processInfo.environment["RUNESTONE_WRAP_LINES"] != "0" + contentView.textView.showLineNumbers = true + } } } @@ -80,7 +85,15 @@ private extension MainViewController { private func setupTextView() { var text = "" - if !ProcessInfo.processInfo.disableTextPersistance, let persistedText = UserDefaults.standard.text { + if let name = ProcessInfo.processInfo.navigationDocumentName { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + do { + text = try String(contentsOf: documents.appendingPathComponent(name), encoding: .utf8) + navigationDocumentLength = (text as NSString).length + } catch { + text = "Could not load navigation document: \(error.localizedDescription)" + } + } else if !ProcessInfo.processInfo.disableTextPersistance, let persistedText = UserDefaults.standard.text { text = persistedText } let themeSetting = UserDefaults.standard.theme @@ -93,6 +106,22 @@ private extension MainViewController { contentView.textView.setState(state) } + private func jumpToTop() { + jump(to: 0) + } + + private func jumpToBottom() { + jump(to: navigationDocumentLength) + } + + private func jump(to location: Int) { + let start = CACurrentMediaTime() + contentView.textView.scrollRangeToVisible(NSRange(location: location, length: 0)) + contentView.textView.layoutIfNeeded() + let milliseconds = (CACurrentMediaTime() - start) * 1_000 + print("Navigation to \(location): \(milliseconds) ms") + } + private func updateTextViewSettings() { let settings = UserDefaults.standard let theme = settings.theme.makeTheme() @@ -150,6 +179,9 @@ private extension MainViewController { extension MainViewController: TextViewDelegate { func textViewDidChange(_ textView: TextView) { + if ProcessInfo.processInfo.navigationDocumentName != nil { + navigationDocumentLength = (textView.text as NSString).length + } if !ProcessInfo.processInfo.disableTextPersistance { UserDefaults.standard.text = textView.text } @@ -174,6 +206,10 @@ extension MainViewController: MenuSelectionHandler { } case .presentGoToLine: presentGoToLineAlert() + case .jumpToTop: + jumpToTop() + case .jumpToBottom: + jumpToBottom() case .presentThemePicker: presentThemePicker() case .toggleEditable: @@ -195,7 +231,7 @@ extension MainViewController: MenuSelectionHandler { UserDefaults.standard.isSelectable.toggle() updateTextViewSettings() case .toggleWrapLines: - UserDefaults.standard.wrapLines.toggle() + UserDefaults.standard.wrapLines = !contentView.textView.isLineWrappingEnabled updateTextViewSettings() case .toggleSpellChecking: UserDefaults.standard.checkSpelling.toggle() diff --git a/Example/Example/Main/Menu/MenuButton.swift b/Example/Example/Main/Menu/MenuButton.swift index dc33bd65..da2b6830 100644 --- a/Example/Example/Main/Menu/MenuButton.swift +++ b/Example/Example/Main/Menu/MenuButton.swift @@ -16,11 +16,29 @@ final class MenuButton: UIButton { private extension MenuButton { private func setupMenu() { - menu = UIMenu(children: makeFeaturesMenuElements() + makeSettingsMenuElements() + makeThemeMenuElements()) + menu = UIMenu(children: makeNavigationMenuElements() + makeFeaturesMenuElements() + makeSettingsMenuElements() + makeThemeMenuElements()) } } private extension MenuButton { + private func makeNavigationMenuElements() -> [UIMenuElement] { + guard ProcessInfo.processInfo.navigationDocumentName != nil else { + return [] + } + return [ + UIMenu(options: .displayInline, children: [ + UIAction(title: "Jump to Top") { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .jumpToTop) + } + ]), + UIMenu(options: .displayInline, children: [ + UIAction(title: "Jump to Bottom") { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .jumpToBottom) + } + ]) + ] + } + private func makeFeaturesMenuElements() -> [UIMenuElement] { var menuElements: [UIMenuElement] = [] if #available(iOS 16, *) { diff --git a/Example/Example/Main/Menu/MenuItem.swift b/Example/Example/Main/Menu/MenuItem.swift index 5928b434..6c352a66 100644 --- a/Example/Example/Main/Menu/MenuItem.swift +++ b/Example/Example/Main/Menu/MenuItem.swift @@ -4,6 +4,8 @@ enum MenuItem { case presentFind case presentFindAndReplace case presentGoToLine + case jumpToTop + case jumpToBottom case presentThemePicker case toggleEditable case toggleInvisibleCharacters diff --git a/Example/NavigationTesting.md b/Example/NavigationTesting.md new file mode 100644 index 00000000..940c65a7 --- /dev/null +++ b/Example/NavigationTesting.md @@ -0,0 +1,56 @@ +# Large-document navigation + +The example can open a local UTF-8 fixture without persisting it to preferences. +Build and install the Example scheme, copy the fixture into the app's Documents +directory, and launch with `RUNESTONE_DOCUMENT` set to its filename: + +```sh +device='' +container=$(xcrun simctl get_app_container "$device" dk.simonbs.Example data) +cp '/path/to/large-file.json' "$container/Documents/large-file.json" +SIMCTL_CHILD_RUNESTONE_DOCUMENT=large-file.json xcrun simctl launch "$device" dk.simonbs.Example +``` + +Fixture mode initially enables wrapping and line numbers and adds Jump to Top and +Jump to Bottom to the ellipsis menu, with a divider between the actions. Set +`RUNESTONE_WRAP_LINES=0` to start with wrapping disabled. These actions exercise `scrollRangeToVisible` without showing a keyboard. The +console reports synchronous navigation/layout duration; this is not a complete +frame-time measurement. The regular Go to Line menu exercises caret navigation. +The fixture remains local and should not be added to the repository. + +On a fresh launch, choose Jump to Bottom in the ellipsis menu, then immediately +scroll toward earlier text with slow drags and fast flings. Check that lines remain contiguous, the text +under the finger does not jump as wrapped lines are measured, and there are no +blank frames. Reverse direction and repeat Top/Bottom navigation. Repeat after +changing orientation or wrapping, and use Go to Line with the keyboard visible. +Capture video when evaluating motion; still screenshots only verify individual +frames. Use a Release build for representative timing and distinguish initial +document parsing from navigation latency. + +## Simulator validation (2026-10-27) + +Release build on an isolated iPhone 17 Pro simulator running iOS 27.2, with a +local JSON fixture of 26,141,343 bytes, 11,352 logical lines, and a longest line +of 58,901 bytes. The fixture and recordings were kept local. + +Cold EOF navigation after loading measured 14.34 ms with wrapping enabled and +96.92 ms with wrapping disabled. These are single-run synchronous navigation +and layout measurements, excluding initial document loading/parsing and not +device frame-rate guarantees. Unwrapped mode displays and measures more logical +lines; its existing 10,000-point maximum line width still splits extreme lines. + +Verified with real simulator gestures and local video captures: + +- EOF navigation followed by slow upward dragging, fast upward flings, and + reverse scrolling through wrapped content. +- Cold EOF navigation and upward flinging with wrapping disabled. +- Switching wrapping off near EOF and navigating again. +- Top/Bottom round trips and Go to Line 11342, followed by upward dragging to + line 11341 with the editor first responder and keyboard accessory visible. +- The first recorded destination frame has syntax colors already applied. + +No blank viewports or layout discontinuities were found in the inspected +captures. Sampling and manual inspection do not prove absence of every possible +visual glitch. Physical-device performance, interactive rotation, and scrolling +with the full software keyboard occupying the screen remain unverified in this +pass (the simulator used hardware-keyboard mode after entering the line number). diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index a5046f91..161d71d4 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -151,6 +151,7 @@ final class LayoutManager { private let invisibleCharacterConfiguration: InvisibleCharacterConfiguration private let lineControllerStorage: LineControllerStorage private var needsLayout = false + private var needsSynchronousDestinationHighlight = false private var needsLayoutLineSelection = false init(lineManager: LineManager, @@ -205,7 +206,7 @@ final class LayoutManager { // Only display the line if it's currently visible on the screen. Otherwise it's enough to invalidate it and redisplay it later. if visibleLineIDs.contains(lineID) { let lineYPosition = lineController.line.yPosition - let lineLocalViewport = CGRect(x: 0, y: lineYPosition, width: insetViewport.width, height: insetViewport.maxY - lineYPosition) + let lineLocalViewport = CGRect(x: 0, y: 0, width: insetViewport.width, height: max(0, insetViewport.maxY - lineYPosition)) lineController.prepareToDisplayString(in: lineLocalViewport, syntaxHighlightAsynchronously: false) } } @@ -299,9 +300,9 @@ extension LayoutManager { needsLayout = false CATransaction.begin() CATransaction.setDisableActions(true) + layoutLinesInViewport() layoutGutter() layoutLineSelection() - layoutLinesInViewport() updateLineNumberColors() CATransaction.commit() } @@ -371,24 +372,49 @@ extension LayoutManager { } } + /// Prepare a destination without measuring the document prefix. Heights outside the + /// destination's neighborhood remain estimates in the line tree. func layoutLines(toLocation location: Int) { - var nextLine: DocumentLineNode? = lineManager.firstLine - let isLocationEndOfString = location >= stringView.string.length - while let line = nextLine { - let lineLocation = line.location - let endTypesettingLocation = min(lineLocation + line.data.length, location) - lineLocation - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - lineController.constrainingWidth = constrainingLineWidth - lineController.prepareToDisplayString(toLocation: endTypesettingLocation, syntaxHighlightAsynchronously: true) - let lineSize = CGSize(width: lineController.lineWidth, height: lineController.lineHeight) - contentSizeService.setSize(of: lineController.line, to: lineSize) - let lineEndLocation = lineLocation + line.data.length - if ((lineEndLocation < location) || (lineLocation == location && !isLocationEndOfString)) && line.index < lineManager.lineCount - 1 { - nextLine = lineManager.line(atRow: line.index + 1) - } else { - nextLine = nil - } + guard constrainingLineWidth > 0, + let line = lineManager.line(containingCharacterAt: min(max(0, location), stringView.string.length)) else { + return } + needsSynchronousDestinationHighlight = true + let controller = lineControllerStorage.getOrCreateLineController(for: line) + controller.constrainingWidth = constrainingLineWidth + let localLocation = min(line.data.length, max(0, location - line.location)) + controller.prepareToDisplayString(toLocation: localLocation, syntaxHighlightAsynchronously: false) + contentSizeService.setSize(of: line, to: CGSize(width: controller.lineWidth, height: controller.lineHeight)) + let destinationY = controller.caretRect(atIndex: localLocation).minY + prepareLines(before: line, covering: max(0, viewport.height * 1.5 - destinationY), + syntaxHighlightAsynchronously: false) + } + + /// Measure backwards from a known line so that discovering wrapped predecessors + /// cannot displace it. Stop using measured heights, not the initial estimates. + private func prepareLines(before anchor: DocumentLineNode, covering distance: CGFloat, + syntaxHighlightAsynchronously: Bool = true) { + var remaining = distance + var row = anchor.index - 1 + while row >= 0 && remaining > 0 { + let line = lineManager.line(atRow: row) + let controller = lineControllerStorage.getOrCreateLineController(for: line) + controller.constrainingWidth = constrainingLineWidth + controller.prepareToDisplayString(toLocation: line.data.length, syntaxHighlightAsynchronously: syntaxHighlightAsynchronously) + contentSizeService.setSize(of: line, to: CGSize(width: controller.lineWidth, height: controller.lineHeight)) + remaining -= line.data.lineHeight + row -= 1 + } + } + + /// Prefer text already on screen when scrolling into an unmeasured region above it. + /// A disjoint jump instead starts with the line at the new viewport position. + private func viewportAnchor() -> DocumentLineNode? { + let overlappingLines = visibleLineIDs.compactMap { lineControllerStorage[$0]?.line }.filter { line in + line.yPosition + line.data.lineHeight > insetViewport.minY && line.yPosition < insetViewport.maxY + } + return overlappingLines.min { $0.yPosition < $1.yPosition } + ?? lineManager.line(containingYOffset: max(0, insetViewport.minY)) } // swiftlint:disable:next function_body_length @@ -399,6 +425,18 @@ extension LayoutManager { visibleTextRanges = [] return } + let highlightDestinationSynchronously = needsSynchronousDestinationHighlight + needsSynchronousDestinationHighlight = false + var contentOffsetAdjustmentY: CGFloat = 0 + if constrainingLineWidth > 0, let anchor = viewportAnchor() { + let oldAnchorY = anchor.yPosition + let distance = max(0, oldAnchorY - insetViewport.minY + viewport.height * 0.5) + prepareLines(before: anchor, covering: distance, + syntaxHighlightAsynchronously: !highlightDestinationSynchronously) + contentOffsetAdjustmentY = anchor.yPosition - oldAnchorY + // Render against the corrected viewport in this same transaction. + viewport.origin.y += contentOffsetAdjustmentY + } let oldVisibleLineIDs = visibleLineIDs let oldVisibleLineFragmentIDs = Set(lineFragmentViewReuseQueue.visibleViews.keys) // Layout lines until we have filled the viewport. @@ -408,15 +446,29 @@ extension LayoutManager { var visibleRange: NSRange? var visibleRanges: [NSRange] = [] var maxY = insetViewport.minY - var contentOffsetAdjustmentY: CGFloat = 0 while let line = nextLine, maxY < insetViewport.maxY, constrainingLineWidth > 0 { appearedLineIDs.insert(line.id) // Prepare to line controller to display text. - let lineLocalViewport = CGRect(x: 0, y: maxY, width: insetViewport.width, height: insetViewport.maxY - maxY) + let lineLocalViewport = CGRect(x: 0, y: 0, width: insetViewport.width, + height: max(0, insetViewport.maxY - line.yPosition)) let lineController = lineControllerStorage.getOrCreateLineController(for: line) - let oldLineHeight = lineController.lineHeight lineController.constrainingWidth = constrainingLineWidth - lineController.prepareToDisplayString(in: lineLocalViewport, syntaxHighlightAsynchronously: true) + // A newly revealed destination should appear with its final colors on + // the first frame. Ordinary scrolling keeps asynchronous highlighting. + lineController.prepareToDisplayString(in: lineLocalViewport, + syntaxHighlightAsynchronously: !highlightDestinationSynchronously) + let lineSize = CGSize(width: lineController.lineWidth, height: lineController.lineHeight) + contentSizeService.setSize(of: line, to: lineSize) + // A jump into the estimated tail of a partially measured line may finish + // it above the viewport. There is no real text at that estimated position: + // relocate to its actual tail before displaying any frames. + if appearedLineIDs.count == 1 && lineController.isFinishedTypesetting + && line.yPosition + lineSize.height <= insetViewport.minY { + let correctedTop = max(line.yPosition, line.yPosition + lineSize.height - viewport.height) + let adjustment = correctedTop - insetViewport.minY + viewport.origin.y += adjustment + contentOffsetAdjustmentY += adjustment + } layoutLineNumberView(for: line) // Layout line fragments ("sublines") in the line until we have filled the viewport. let lineYPosition = line.yPosition @@ -448,12 +500,6 @@ extension LayoutManager { lineController.setMarkedTextOnLineFragments(nil) } let stoppedGeneratingLineFragments = lineFragmentControllers.isEmpty - let lineSize = CGSize(width: lineController.lineWidth, height: lineController.lineHeight) - contentSizeService.setSize(of: lineController.line, to: lineSize) - let isSizingLineAboveTopEdge = line.yPosition < insetViewport.minY + textContainerInset.top - if isSizingLineAboveTopEdge && lineController.isFinishedTypesetting { - contentOffsetAdjustmentY += lineController.lineHeight - oldLineHeight - } if !stoppedGeneratingLineFragments && line.index < lineManager.lineCount - 1 { nextLine = lineManager.line(atRow: line.index + 1) } else { diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/TextView.swift index 09cbf427..55114f8d 100644 --- a/Sources/Runestone/TextView/Core/TextView.swift +++ b/Sources/Runestone/TextView/Core/TextView.swift @@ -1008,6 +1008,7 @@ open class TextView: UIScrollView { becomeFirstResponder() let line = textInputView.lineManager.line(atRow: lineIndex) textInputView.layoutLines(toLocation: line.location) + handleContentSizeUpdateIfNeeded() scrollLocationToVisible(line.location) layoutIfNeeded() switch selection { @@ -1240,7 +1241,11 @@ extension TextView { /// - Parameters: /// - range: The range of text to scroll into view. public func scrollRangeToVisible(_ range: NSRange) { - textInputView.layoutLines(toLocation: range.upperBound) + textInputView.layoutLines(toLocation: range.lowerBound) + if range.length > 0 { + textInputView.layoutLines(toLocation: range.upperBound) + } + handleContentSizeUpdateIfNeeded() justScrollRangeToVisible(range) } } @@ -1523,8 +1528,7 @@ extension TextView: TextInputViewDelegate { } func textInputView(_ view: TextInputView, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { - let isScrolling = isDragging || isDecelerating - if contentOffsetAdjustment != .zero && isScrolling { + if contentOffsetAdjustment != .zero { contentOffset = CGPoint(x: contentOffset.x + contentOffsetAdjustment.x, y: contentOffset.y + contentOffsetAdjustment.y) } } diff --git a/Tests/RunestoneTests/ViewportNavigationTests.swift b/Tests/RunestoneTests/ViewportNavigationTests.swift new file mode 100644 index 00000000..03060fe1 --- /dev/null +++ b/Tests/RunestoneTests/ViewportNavigationTests.swift @@ -0,0 +1,283 @@ +@testable import Runestone +import TestTreeSitterLanguages +import UIKit +import XCTest + +@MainActor +final class ViewportNavigationTests: XCTestCase { + private var window: UIWindow? + + override func tearDown() { + window?.isHidden = true + window = nil + super.tearDown() + } + + func testGoToDistantLineDoesNotMeasureInterveningWrappedLines() throws { + let view = try makeTextView() + let input = try textInput(in: view) + let middleLine = input.lineManager.line(atRow: 500) + let estimatedHeight = middleLine.data.lineHeight + let target = input.lineManager.line(atRow: 999) + + XCTAssertTrue(view.goToLine(999)) + settleLayout(view, input: input) + + XCTAssertEqual(view.selectedRange, NSRange(location: target.location, length: 0)) + XCTAssertEqual(middleLine.data.lineHeight, estimatedHeight, + "A distant jump must not typeset the wrapped lines between the old and new viewport.") + assertCaretVisible(at: target.location, in: view, input: input) + } + + func testScrollRangeToDocumentEndDoesNotMeasureInterveningWrappedLines() throws { + let view = try makeTextView() + let input = try textInput(in: view) + let middleLine = input.lineManager.line(atRow: 500) + let estimatedHeight = middleLine.data.lineHeight + let end = input.string.length + + view.scrollRangeToVisible(NSRange(location: end, length: 0)) + settleLayout(view, input: input) + + XCTAssertEqual(middleLine.data.lineHeight, estimatedHeight, + "Revealing the end must leave unrelated offscreen line heights estimated.") + assertCaretVisible(at: end, in: view, input: input) + } + + func testScrollingUpKeepsVisibleTextAnchoredAsWrappedPredecessorsAreMeasured() throws { + let view = try makeTextView() + let input = try textInput(in: view) + XCTAssertTrue(view.goToLine(999)) + settleLayout(view, input: input) + + // Cross several viewport boundaries so this exercises previously unmeasured + // predecessors, not only the small margin prepared during navigation. + for _ in 0..<20 { + let documentY = view.contentOffset.y - view.textContainerInset.top + let anchor = try XCTUnwrap(input.lineManager.line(containingYOffset: documentY)) + let oldScreenY = anchor.yPosition - view.contentOffset.y + let requestedOffset = view.contentOffset.y - 40 + + view.contentOffset.y = requestedOffset + settleLayout(view, input: input) + + assertViewportIsCovered(in: view, input: input) + XCTAssertEqual(anchor.yPosition - view.contentOffset.y, oldScreenY + 40, accuracy: 0.5, + "Resolving wrapped heights above the viewport must not move existing text beyond the requested scroll.") + } + } + + func testScrollingIntoOverestimatedWrappedTailKeepsViewportFilled() throws { + let view = try makeTextView() + let input = try textInput(in: view) + // Wide CJK characters occupy more width than the ASCII remainder. Measuring + // only this prefix deliberately overestimates the remaining wrapped height. + view.text = String(repeating: "漢", count: 1_000) + String(repeating: "a", count: 10_000) + + "\n" + Array(repeating: "following line", count: 100).joined(separator: "\n") + settleLayout(view, input: input) + let wrappedLine = input.lineManager.firstLine + let estimatedHeight = wrappedLine.data.lineHeight + + view.contentOffset.y = estimatedHeight - view.bounds.height + settleLayout(view, input: input) + + XCTAssertLessThan(wrappedLine.data.lineHeight, estimatedHeight * 0.75, + "This fixture must resolve a substantial overestimate to exercise recovery.") + assertViewportIsCovered(in: view, input: input) + } + + func testDisjointJumpsRevealWrappedInteriorThenBeginningAndEnd() throws { + let view = try makeTextView() + let input = try textInput(in: view) + let wrapped = input.lineManager.line(atRow: 900) + let interior = wrapped.location + wrapped.data.length / 2 + let untouched = input.lineManager.line(atRow: 500) + let estimatedHeight = untouched.data.lineHeight + + for location in [interior, 0, input.string.length, interior] { + view.scrollRangeToVisible(NSRange(location: location, length: 0)) + settleLayout(view, input: input) + assertCaretVisible(at: location, in: view, input: input) + } + + XCTAssertEqual(untouched.data.lineHeight, estimatedHeight) + } + + func testDestinationUsesHighlightedFontMetricsBeforeScrolling() throws { + let view = try makeTextView() + let input = try textInput(in: view) + let language = TreeSitterLanguage(tree_sitter_json(), highlightsQuery: .init(string: "(string) @string")) + let text = "[\n" + Array(repeating: "0,", count: 999).joined(separator: "\n") + + "\n\"" + String(repeating: "highlighted text ", count: 20) + "\"\n]" + view.setState(TextViewState(text: text, theme: NavigationHighlightTheme(), language: language)) + settleLayout(view, input: input) + let end = input.string.length + + view.scrollRangeToVisible(NSRange(location: end, length: 0)) + settleLayout(view, input: input) + + let predecessor = input.lineManager.line(atRow: input.lineManager.lineCount - 2) + XCTAssertGreaterThan(predecessor.data.lineHeight, view.bounds.height, + "The highlighted predecessor must wrap beyond the viewport to exercise metric changes.") + assertCaretVisible(at: end, in: view, input: input) + let screenY = input.caretRect(at: end).minY - view.contentOffset.y + settleLayout(view, input: input) + XCTAssertEqual(input.caretRect(at: end).minY - view.contentOffset.y, screenY, accuracy: 0.5, + "Applying token fonts must not displace the destination on the following layout pass.") + } + + func testRangeAcrossWrappedLineBoundaryRevealsBothEndpoints() throws { + let view = try makeTextView() + let input = try textInput(in: view) + let first = input.lineManager.line(atRow: 900) + let second = input.lineManager.line(atRow: 901) + let start = first.location + first.data.length - 8 + let end = second.location + 8 + + view.scrollRangeToVisible(NSRange(location: start, length: end - start)) + settleLayout(view, input: input) + + assertCaretVisible(at: start, in: view, input: input) + assertCaretVisible(at: end, in: view, input: input) + } + + func testUnwrappedEndJumpAndUpwardScrollingPreserveGeometry() throws { + let view = try makeTextView(isLineWrappingEnabled: false) + let input = try textInput(in: view) + let longLine = input.lineManager.line(atRow: 900) + let interior = longLine.location + longLine.data.length / 2 + view.scrollRangeToVisible(NSRange(location: interior, length: 0)) + settleLayout(view, input: input) + assertCaretVisible(at: interior, in: view, input: input) + let interiorRect = input.convert(input.caretRect(at: interior), to: view) + XCTAssertGreaterThan(view.contentOffset.x, 0) + XCTAssertGreaterThanOrEqual(interiorRect.minX, view.bounds.minX - 0.5) + XCTAssertLessThanOrEqual(interiorRect.maxX, view.bounds.maxX + 0.5) + + let end = input.string.length + view.scrollRangeToVisible(NSRange(location: end, length: 0)) + settleLayout(view, input: input) + assertCaretVisible(at: end, in: view, input: input) + + for _ in 0..<8 { + let documentY = view.contentOffset.y - view.textContainerInset.top + let anchor = try XCTUnwrap(input.lineManager.line(containingYOffset: documentY)) + let oldScreenY = anchor.yPosition - view.contentOffset.y + view.contentOffset.y -= 40 + settleLayout(view, input: input) + + XCTAssertEqual(anchor.yPosition - view.contentOffset.y, oldScreenY + 40, accuracy: 0.5) + assertViewportIsCovered(in: view, input: input) + } + } + + func testChangingWrappingNearDocumentEndCanRevealDestinationAndScrollUp() throws { + let view = try makeTextView() + let input = try textInput(in: view) + let end = input.string.length + view.scrollRangeToVisible(NSRange(location: end, length: 0)) + settleLayout(view, input: input) + + // Mode changes invalidate cached heights. Navigation must use the new + // wrapping metrics even when the previous viewport was near the end. + for wrappingEnabled in [false, true, false, true] { + view.isLineWrappingEnabled = wrappingEnabled + view.scrollRangeToVisible(NSRange(location: end, length: 0)) + settleLayout(view, input: input) + assertCaretVisible(at: end, in: view, input: input) + + view.contentOffset.y -= 40 + settleLayout(view, input: input) + assertViewportIsCovered(in: view, input: input) + } + } + + /// Catch missing or overlapping fragment views while new wrapped lines enter from above. + private func assertViewportIsCovered(in view: TextView, input: TextInputView, + file: StaticString = #filePath, line: UInt = #line) { + func fragments(in parent: UIView) -> [LineFragmentView] { + parent.subviews.flatMap { child in + if let fragment = child as? LineFragmentView { + return [fragment] + } + return fragments(in: child) + } + } + let frames = fragments(in: input).map { $0.convert($0.bounds, to: view) } + .sorted { $0.minY < $1.minY } + XCTAssertFalse(frames.isEmpty, file: file, line: line) + guard let first = frames.first, let last = frames.last else { + return + } + XCTAssertLessThanOrEqual(first.minY, view.bounds.minY + 0.5, file: file, line: line) + XCTAssertGreaterThanOrEqual(last.maxY, view.bounds.maxY - 0.5, file: file, line: line) + for (previous, next) in zip(frames, frames.dropFirst()) { + XCTAssertEqual(previous.maxY, next.minY, accuracy: 0.5, + "Visible fragments must meet without gaps or overlaps.", file: file, line: line) + } + } + + private func makeTextView(isLineWrappingEnabled: Bool = true) throws -> TextView { + let view = TextView(frame: CGRect(x: 0, y: 0, width: 320, height: 240)) + view.contentInsetAdjustmentBehavior = .never + view.isLineWrappingEnabled = isLineWrappingEnabled + view.isAutomaticScrollEnabled = false + view.textContainerInset = UIEdgeInsets(top: 11, left: 8, bottom: 13, right: 8) + let window = UIWindow(frame: view.frame) + let controller = UIViewController() + window.rootViewController = controller + controller.view.addSubview(view) + window.isHidden = false + self.window = window + view.layoutIfNeeded() + let wrappedLine = String(repeating: "wrapped text ", count: 20) + view.text = Array(repeating: wrappedLine, count: 999).joined(separator: "\n") + "\nlast line" + let input = try textInput(in: view) + settleLayout(view, input: input) + return view + } + + private func textInput(in view: TextView) throws -> TextInputView { + try XCTUnwrap(view.subviews.compactMap { $0 as? TextInputView }.first) + } + + private func settleLayout(_ view: TextView, input: TextInputView) { + view.setNeedsLayout() + view.layoutIfNeeded() + input.setNeedsLayout() + input.layoutIfNeeded() + view.layoutIfNeeded() + } + + private func assertCaretVisible(at location: Int, in view: TextView, input: TextInputView, + file: StaticString = #filePath, line: UInt = #line) { + let caret = input.caretRect(at: location) + let screenRect = input.convert(caret, to: view) + XCTAssertGreaterThanOrEqual(screenRect.minY, view.bounds.minY - 0.5, file: file, line: line) + XCTAssertLessThanOrEqual(screenRect.maxY, view.bounds.maxY + 0.5, file: file, line: line) + } +} + +private final class NavigationHighlightTheme: Theme { + let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) + let textColor = UIColor.black + let gutterBackgroundColor = UIColor.white + let gutterHairlineColor = UIColor.gray + let lineNumberColor = UIColor.gray + let lineNumberFont = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) + let selectedLineBackgroundColor = UIColor.clear + let selectedLinesLineNumberColor = UIColor.black + let selectedLinesGutterBackgroundColor = UIColor.white + let invisibleCharactersColor = UIColor.gray + let pageGuideHairlineColor = UIColor.gray + let pageGuideBackgroundColor = UIColor.white + let markedTextBackgroundColor = UIColor.yellow + + func textColor(for highlightName: String) -> UIColor? { + nil + } + + func font(for highlightName: String) -> UIFont? { + highlightName == "string" ? .monospacedSystemFont(ofSize: 34, weight: .regular) : nil + } +}