Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Example/Example/Library/ProcessInfo+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 38 additions & 2 deletions Example/Example/Main/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ final class MainViewController: UIViewController {
}

private let contentView = MainView()
private var navigationDocumentLength = 0
#if os(iOS)
private let toolsView: KeyboardToolsView
#endif
Expand Down Expand Up @@ -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
}
}
}

Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Expand All @@ -174,6 +206,10 @@ extension MainViewController: MenuSelectionHandler {
}
case .presentGoToLine:
presentGoToLineAlert()
case .jumpToTop:
jumpToTop()
case .jumpToBottom:
jumpToBottom()
case .presentThemePicker:
presentThemePicker()
case .toggleEditable:
Expand All @@ -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()
Expand Down
20 changes: 19 additions & 1 deletion Example/Example/Main/Menu/MenuButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, *) {
Expand Down
2 changes: 2 additions & 0 deletions Example/Example/Main/Menu/MenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ enum MenuItem {
case presentFind
case presentFindAndReplace
case presentGoToLine
case jumpToTop
case jumpToBottom
case presentThemePicker
case toggleEditable
case toggleInvisibleCharacters
Expand Down
56 changes: 56 additions & 0 deletions Example/NavigationTesting.md
Original file line number Diff line number Diff line change
@@ -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='<simulator UDID>'
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).
102 changes: 74 additions & 28 deletions Sources/Runestone/TextView/Core/LayoutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -299,9 +300,9 @@ extension LayoutManager {
needsLayout = false
CATransaction.begin()
CATransaction.setDisableActions(true)
layoutLinesInViewport()
layoutGutter()
layoutLineSelection()
layoutLinesInViewport()
updateLineNumberColors()
CATransaction.commit()
}
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions Sources/Runestone/TextView/Core/TextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading
Loading