From 029ba4ef9a4903e795e1cbf792a752578898ccc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 27 Oct 2026 07:38:51 +0100 Subject: [PATCH 1/2] Lay out navigation destinations locally and anchor viewport scrolling --- .../Example/Library/ProcessInfo+Helpers.swift | 7 +- Example/Example/Main/MainViewController.swift | 40 ++- Example/NavigationTesting.md | 56 ++++ .../TextView/Core/LayoutManager.swift | 102 +++++-- .../Runestone/TextView/Core/TextView.swift | 10 +- .../ViewportNavigationTests.swift | 283 ++++++++++++++++++ 6 files changed, 464 insertions(+), 34 deletions(-) create mode 100644 Example/NavigationTesting.md create mode 100644 Tests/RunestoneTests/ViewportNavigationTests.swift diff --git a/Example/Example/Library/ProcessInfo+Helpers.swift b/Example/Example/Library/ProcessInfo+Helpers.swift index efc364d3f..b70824478 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 bb3eb4a53..9d2b5087a 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,14 @@ 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 + navigationItem.leftBarButtonItems = [ + UIBarButtonItem(title: "Top", style: .plain, target: self, action: #selector(jumpToTop)), + UIBarButtonItem(title: "Bottom", style: .plain, target: self, action: #selector(jumpToBottom)) + ] + } } } @@ -80,7 +89,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 +110,22 @@ private extension MainViewController { contentView.textView.setState(state) } + @objc private func jumpToTop() { + jump(to: 0) + } + + @objc 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 +183,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 } @@ -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/NavigationTesting.md b/Example/NavigationTesting.md new file mode 100644 index 000000000..3401895db --- /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 Top and Bottom +buttons. Set `RUNESTONE_WRAP_LINES=0` to start with wrapping disabled. These +buttons 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, jump to Bottom, 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 a5046f91e..161d71d41 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 09cbf4275..55114f8de 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 000000000..03060fe12 --- /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 + } +} From c6e81908f4a0e30dac83b9c50df17ff66df75b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 27 Oct 2026 07:41:34 +0100 Subject: [PATCH 2/2] Move example document jump actions into the ellipsis menu --- Example/Example/Main/MainViewController.swift | 12 +++++------ Example/Example/Main/Menu/MenuButton.swift | 20 ++++++++++++++++++- Example/Example/Main/Menu/MenuItem.swift | 2 ++ Example/NavigationTesting.md | 10 +++++----- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/Example/Example/Main/MainViewController.swift b/Example/Example/Main/MainViewController.swift index 9d2b5087a..af3301a3b 100644 --- a/Example/Example/Main/MainViewController.swift +++ b/Example/Example/Main/MainViewController.swift @@ -68,10 +68,6 @@ final class MainViewController: UIViewController { if ProcessInfo.processInfo.navigationDocumentName != nil { contentView.textView.isLineWrappingEnabled = ProcessInfo.processInfo.environment["RUNESTONE_WRAP_LINES"] != "0" contentView.textView.showLineNumbers = true - navigationItem.leftBarButtonItems = [ - UIBarButtonItem(title: "Top", style: .plain, target: self, action: #selector(jumpToTop)), - UIBarButtonItem(title: "Bottom", style: .plain, target: self, action: #selector(jumpToBottom)) - ] } } } @@ -110,11 +106,11 @@ private extension MainViewController { contentView.textView.setState(state) } - @objc private func jumpToTop() { + private func jumpToTop() { jump(to: 0) } - @objc private func jumpToBottom() { + private func jumpToBottom() { jump(to: navigationDocumentLength) } @@ -210,6 +206,10 @@ extension MainViewController: MenuSelectionHandler { } case .presentGoToLine: presentGoToLineAlert() + case .jumpToTop: + jumpToTop() + case .jumpToBottom: + jumpToBottom() case .presentThemePicker: presentThemePicker() case .toggleEditable: diff --git a/Example/Example/Main/Menu/MenuButton.swift b/Example/Example/Main/Menu/MenuButton.swift index dc33bd657..da2b6830d 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 5928b4340..6c352a667 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 index 3401895db..940c65a71 100644 --- a/Example/NavigationTesting.md +++ b/Example/NavigationTesting.md @@ -11,15 +11,15 @@ 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 Top and Bottom -buttons. Set `RUNESTONE_WRAP_LINES=0` to start with wrapping disabled. These -buttons exercise `scrollRangeToVisible` without showing a keyboard. The +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, jump to Bottom, then immediately scroll toward earlier text -with slow drags and fast flings. Check that lines remain contiguous, the text +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.