Skip to content

Commit 8654afc

Browse files
committed
Add adaptive text layout preparation
1 parent 05050b8 commit 8654afc

6 files changed

Lines changed: 285 additions & 7 deletions

File tree

README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
CodeViewerKit is a minimal, read-only source-code viewer for SwiftUI on iOS,
88
iPadOS, and macOS. It uses native TextKit views for selection and scrolling,
9-
with viewport-based layout instead of rendering a complete document as one
10-
SwiftUI `Text` value. Noncontiguous layout keeps distant jumps through very
11-
large files responsive on every supported platform.
9+
with viewport-based layout by default instead of rendering a complete document
10+
as one SwiftUI `Text` value. Noncontiguous layout keeps distant jumps through
11+
very large files responsive on every supported platform.
1212

1313
## Features
1414

@@ -18,7 +18,7 @@ large files responsive on every supported platform.
1818
- Menlo with a monospaced system fallback;
1919
- native selection and horizontal and vertical scrolling;
2020
- Command-Plus and Command-Minus font scaling;
21-
- sparse, viewport-driven layout on iOS, iPadOS, and macOS;
21+
- configurable progressive, complete, or size-adaptive native text layout;
2222
- shared highlighting cache for navigation-heavy apps.
2323

2424
## Requirements
@@ -170,6 +170,32 @@ CodeViewer(
170170
On macOS, each legacy scrollbar is hidden automatically when the content fits
171171
along its axis.
172172

173+
### Layout preparation
174+
175+
Layout is automatic by default: sources up to 64,000 UTF-16 code units receive
176+
complete layout, while larger sources remain progressive so distant jumps do
177+
not synchronously typeset every intervening line. Progressive layout can refine
178+
the scrollbar extent the first time a previously unlaid range becomes visible.
179+
180+
Use complete layout when an exact initial scrollbar extent is more important
181+
than the up-front layout cost. Automatic mode lets the application choose that
182+
tradeoff by the source's UTF-16 length, which is the native `NSTextStorage`
183+
unit:
184+
185+
```swift
186+
CodeViewer(
187+
documentID: "adaptive-layout",
188+
sourceCode: source,
189+
highlightStore: highlights,
190+
lineWrapping: .word,
191+
layoutPreparation: .automatic(maximumUTF16Length: 64_000)
192+
)
193+
```
194+
195+
Sources at or below the configured limit receive complete layout. Larger
196+
sources keep progressive, viewport-driven layout. Pass `.complete` or
197+
`.progressive` to select either behavior without a size threshold.
198+
173199
The viewer is visually neutral: apply your own frame, material, or Liquid Glass
174200
container around it. Source ranges without a syntax color use black in light
175201
mode and white in dark mode; override that fallback with `plainTextColor`:
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/// Controls how much native text layout `CodeViewer` prepares before scrolling.
2+
public enum CodeLayoutPreparation: Hashable, Sendable {
3+
/// The UTF-16 threshold used by `CodeViewer` when no explicit layout
4+
/// preparation policy is supplied.
5+
public static let defaultAutomaticMaximumUTF16Length = 64_000
6+
7+
/// Lays out visible ranges on demand. This is the default and keeps very
8+
/// large documents responsive, but the scroll extent can be refined as
9+
/// previously unlaid ranges become visible.
10+
case progressive
11+
12+
/// Lays out the complete document once the native text container has a
13+
/// usable width. This produces an exact initial scroll extent, but can
14+
/// block the main thread for large documents.
15+
case complete
16+
17+
/// Uses complete layout when the document's UTF-16 length is at or below
18+
/// `maximumUTF16Length`, and progressive layout otherwise.
19+
///
20+
/// Negative limits are treated as zero.
21+
case automatic(maximumUTF16Length: Int)
22+
}
23+
24+
extension CodeLayoutPreparation {
25+
func preparesCompleteLayout(forUTF16Length length: Int) -> Bool {
26+
switch self {
27+
case .progressive:
28+
false
29+
case .complete:
30+
true
31+
case let .automatic(maximumUTF16Length):
32+
length <= max(0, maximumUTF16Length)
33+
}
34+
}
35+
}

Sources/CodeViewerKit/CodeTextView.swift

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct CodeTextView: View {
2020
let text: String
2121
let plainTextColor: Color
2222
let lineWrapping: CodeLineWrapping
23+
let layoutPreparation: CodeLayoutPreparation
2324
let highlightLanguage: CodeLanguage
2425
let highlightAppearance: CodeHighlightAppearance
2526
let highlightBatches: [CodeHighlightBatch]
@@ -34,6 +35,7 @@ struct CodeTextView: View {
3435
plainTextColor: plainTextColor,
3536
fontSize: fontSize,
3637
lineWrapping: lineWrapping,
38+
layoutPreparation: layoutPreparation,
3739
highlightLanguage: highlightLanguage,
3840
highlightAppearance: highlightAppearance,
3941
highlightBatches: highlightBatches
@@ -104,6 +106,7 @@ private struct CodeTextRenderRequest {
104106
let plainTextColor: Color
105107
let fontSize: CGFloat
106108
let lineWrapping: CodeLineWrapping
109+
let layoutPreparation: CodeLayoutPreparation
107110
let highlightLanguage: CodeLanguage
108111
let highlightAppearance: CodeHighlightAppearance
109112
let highlightBatches: [CodeHighlightBatch]
@@ -327,6 +330,31 @@ private struct CodeTextPreparedUpdate {
327330
let lineCount: Int
328331
}
329332

333+
private struct CodeCompleteLayoutKey: Equatable {
334+
let generation: Int
335+
let containerWidth: CGFloat
336+
}
337+
338+
@MainActor
339+
enum CodeNativeLayoutPreparation {
340+
@discardableResult
341+
static func prepare(
342+
_ preparation: CodeLayoutPreparation,
343+
layoutManager: NSLayoutManager,
344+
textContainer: NSTextContainer
345+
) -> Bool {
346+
let textLength = layoutManager.textStorage?.length ?? 0
347+
guard preparation.preparesCompleteLayout(
348+
forUTF16Length: textLength
349+
) else { return false }
350+
351+
layoutManager.ensureLayout(
352+
forCharacterRange: NSRange(location: 0, length: textLength)
353+
)
354+
return true
355+
}
356+
}
357+
330358
private extension CodeTextDocumentState {
331359
mutating func prepareUpdate(
332360
for request: CodeTextRenderRequest
@@ -454,6 +482,9 @@ private final class MacCodeTextContainer: NSView {
454482
private let gutterUpdates = CodeGutterUpdateCoordinator()
455483
private var lastAppliedHighlightSequence = -1
456484
private var lineWrapping: CodeLineWrapping?
485+
private var layoutPreparation = CodeLayoutPreparation.progressive
486+
private var layoutGeneration = 0
487+
private var completeLayoutKey: CodeCompleteLayoutKey?
457488

458489
override var isFlipped: Bool { true }
459490

@@ -514,21 +545,26 @@ private final class MacCodeTextContainer: NSView {
514545
)
515546
gutterView.frame = frames.gutter
516547
scrollView.frame = frames.text
548+
prepareCompleteLayoutIfNeeded()
517549
scheduleGutterUpdate()
518550
}
519551

520552
func update(_ request: CodeTextRenderRequest) {
553+
updateLayoutPreparation(request.layoutPreparation)
521554
updateLineWrapping(request.lineWrapping)
522555

523556
guard let update = documentState.prepareUpdate(for: request) else {
524557
applyNewHighlightBatches(from: request)
558+
prepareCompleteLayoutIfNeeded()
525559
scheduleGutterUpdate()
526560
return
527561
}
528562

529563
let visibleOrigin = scrollView.contentView.bounds.origin
530564
let selectedRanges = textView.selectedRanges
531565

566+
layoutGeneration &+= 1
567+
completeLayoutKey = nil
532568
textView.textStorage?.setAttributedString(update.attributedText)
533569
lastAppliedHighlightSequence = request.highlightBatches.last?.sequence ?? -1
534570
gutterView.updateMetrics(
@@ -541,6 +577,7 @@ private final class MacCodeTextContainer: NSView {
541577
}
542578

543579
layoutSubtreeIfNeeded()
580+
prepareCompleteLayoutIfNeeded()
544581
let destination = update.change.isNewDocument
545582
? CGPoint.zero
546583
: visibleOrigin
@@ -563,6 +600,7 @@ private final class MacCodeTextContainer: NSView {
563600
to: textStorage
564601
)
565602
textStorage.endEditing()
603+
completeLayoutKey = nil
566604
lastAppliedHighlightSequence = batches.last?.sequence
567605
?? lastAppliedHighlightSequence
568606
}
@@ -573,6 +611,7 @@ private final class MacCodeTextContainer: NSView {
573611
else { return }
574612

575613
self.lineWrapping = lineWrapping
614+
completeLayoutKey = nil
576615
textContainer.lineBreakMode = .byWordWrapping
577616

578617
switch lineWrapping {
@@ -593,6 +632,45 @@ private final class MacCodeTextContainer: NSView {
593632
textView.needsLayout = true
594633
}
595634

635+
private func updateLayoutPreparation(
636+
_ layoutPreparation: CodeLayoutPreparation
637+
) {
638+
guard self.layoutPreparation != layoutPreparation else { return }
639+
self.layoutPreparation = layoutPreparation
640+
completeLayoutKey = nil
641+
}
642+
643+
private func prepareCompleteLayoutIfNeeded() {
644+
guard let layoutManager = textView.layoutManager,
645+
let textContainer = textView.textContainer
646+
else { return }
647+
648+
let textLength = layoutManager.textStorage?.length ?? 0
649+
guard layoutPreparation.preparesCompleteLayout(
650+
forUTF16Length: textLength
651+
) else { return }
652+
653+
let containerWidth = lineWrapping == .word
654+
? scrollView.contentSize.width
655+
: 0
656+
guard lineWrapping != .word || containerWidth > 0 else { return }
657+
658+
let key = CodeCompleteLayoutKey(
659+
generation: layoutGeneration,
660+
containerWidth: containerWidth
661+
)
662+
guard key != completeLayoutKey else { return }
663+
completeLayoutKey = key
664+
665+
CodeNativeLayoutPreparation.prepare(
666+
layoutPreparation,
667+
layoutManager: layoutManager,
668+
textContainer: textContainer
669+
)
670+
textView.layoutSubtreeIfNeeded()
671+
scrollView.reflectScrolledClipView(scrollView.contentView)
672+
}
673+
596674
@objc private func clipViewBoundsDidChange() {
597675
scheduleGutterUpdate()
598676
}
@@ -689,6 +767,9 @@ private final class MobileCodeTextContainer: UIView, UITextViewDelegate {
689767
private let gutterUpdates = CodeGutterUpdateCoordinator()
690768
private var lastAppliedHighlightSequence = -1
691769
private var lineWrapping: CodeLineWrapping?
770+
private var layoutPreparation = CodeLayoutPreparation.progressive
771+
private var layoutGeneration = 0
772+
private var completeLayoutKey: CodeCompleteLayoutKey?
692773

693774
override init(frame: CGRect) {
694775
super.init(frame: frame)
@@ -718,21 +799,26 @@ private final class MobileCodeTextContainer: UIView, UITextViewDelegate {
718799
)
719800
gutterView.frame = frames.gutter
720801
textView.frame = frames.text
802+
prepareCompleteLayoutIfNeeded()
721803
scheduleGutterUpdate()
722804
}
723805

724806
func update(_ request: CodeTextRenderRequest) {
807+
updateLayoutPreparation(request.layoutPreparation)
725808
updateLineWrapping(request.lineWrapping)
726809

727810
guard let update = documentState.prepareUpdate(for: request) else {
728811
applyNewHighlightBatches(from: request)
812+
prepareCompleteLayoutIfNeeded()
729813
scheduleGutterUpdate()
730814
return
731815
}
732816

733817
let contentOffset = textView.contentOffset
734818
let selectedRange = textView.selectedRange
735819

820+
layoutGeneration &+= 1
821+
completeLayoutKey = nil
736822
textView.textStorage.setAttributedString(update.attributedText)
737823
lastAppliedHighlightSequence = request.highlightBatches.last?.sequence ?? -1
738824
gutterView.updateMetrics(
@@ -745,6 +831,7 @@ private final class MobileCodeTextContainer: UIView, UITextViewDelegate {
745831
}
746832

747833
layoutIfNeeded()
834+
prepareCompleteLayoutIfNeeded()
748835
textView.setContentOffset(
749836
update.change.isNewDocument ? .zero : contentOffset,
750837
animated: false
@@ -765,6 +852,7 @@ private final class MobileCodeTextContainer: UIView, UITextViewDelegate {
765852
to: textView.textStorage
766853
)
767854
textView.textStorage.endEditing()
855+
completeLayoutKey = nil
768856
lastAppliedHighlightSequence = batches.last?.sequence
769857
?? lastAppliedHighlightSequence
770858
}
@@ -773,6 +861,7 @@ private final class MobileCodeTextContainer: UIView, UITextViewDelegate {
773861
guard self.lineWrapping != lineWrapping else { return }
774862

775863
self.lineWrapping = lineWrapping
864+
completeLayoutKey = nil
776865
textView.textContainer.lineBreakMode = .byWordWrapping
777866
textView.textContainer.widthTracksTextView = lineWrapping == .word
778867

@@ -786,6 +875,42 @@ private final class MobileCodeTextContainer: UIView, UITextViewDelegate {
786875
textView.setNeedsLayout()
787876
}
788877

878+
private func updateLayoutPreparation(
879+
_ layoutPreparation: CodeLayoutPreparation
880+
) {
881+
guard self.layoutPreparation != layoutPreparation else { return }
882+
self.layoutPreparation = layoutPreparation
883+
completeLayoutKey = nil
884+
}
885+
886+
private func prepareCompleteLayoutIfNeeded() {
887+
let textLength = textView.textStorage.length
888+
guard layoutPreparation.preparesCompleteLayout(
889+
forUTF16Length: textLength
890+
) else { return }
891+
892+
let containerWidth = lineWrapping == .word
893+
? textView.bounds.width
894+
: 0
895+
guard lineWrapping != .word || containerWidth > 0 else { return }
896+
897+
let key = CodeCompleteLayoutKey(
898+
generation: layoutGeneration,
899+
containerWidth: containerWidth
900+
)
901+
guard key != completeLayoutKey else { return }
902+
completeLayoutKey = key
903+
904+
textView.layoutIfNeeded()
905+
CodeNativeLayoutPreparation.prepare(
906+
layoutPreparation,
907+
layoutManager: textView.layoutManager,
908+
textContainer: textView.textContainer
909+
)
910+
textView.setNeedsLayout()
911+
textView.layoutIfNeeded()
912+
}
913+
789914
func scrollViewDidScroll(_ scrollView: UIScrollView) {
790915
scheduleGutterUpdate()
791916
}

0 commit comments

Comments
 (0)