skills/txt-viewport-rendering/SKILL.md
Configure TextKit 2 viewport-driven layout, NSTextLayoutFragment / NSTextLineFragment geometry, and rendering attributes vs storage attributes. Covers NSTextViewportLayoutController callbacks, layoutFragmentFrame vs renderingSurfaceBounds, line-fragment local coordinates, the extra trailing line fragment, exclusion paths that split a visual line, lineFragmentPadding vs container insets, font substitution via fixAttributes, and visible/overscroll/estimated regions. Use when working with custom layout fragments, debugging clipped diacritics or descenders, computing document coordinates from a line fragment, integrating with a custom scroll view, or when scroll-bar behavior under estimated heights is the visible problem. Do NOT use for symptom-driven debugging (txt-textkit-debug), the invalidation model (txt-layout-invalidation), or the TextKit 2 API surface in general (txt-textkit2).
npx skillsauth add sitapix/apple-text txt-viewport-renderingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
This skill covers how TextKit 2 actually renders text: the viewport-driven layout pass, the geometry of layout fragments and line fragments, and the rendering-attribute overlay system. The patterns here are a model — before applying them to a specific symptom (text clipped at descenders, scroll bar jiggling, custom drawing disappearing), open the actual fragment subclass or container code and confirm the geometry matches what's described. Before quoting any signature here, fetch the relevant page from Sosumi (sosumi.ai/documentation/uikit/<class>); the viewport controller and fragment APIs have grown each release.
TextKit 2 lays out only what's near the viewport. The document is divided into three regions:
Scrolling moves fragments between regions. Layout cost is O(viewport), not O(document) — that's the central performance promise of TextKit 2.
NSTextViewportLayoutController orchestrates the pass:
willLayout.configureRenderingSurfaceFor: once per visible fragment.didLayout.usageBoundsForTextContainer.A scroll, a text edit, a container resize, or a manual layoutViewport() call kicks the cycle.
// Before layout begins — remove old fragment views from the rendering surface
func textViewportLayoutControllerWillLayout(
_ controller: NSTextViewportLayoutController
) {
fragmentContainer.subviews.forEach { $0.removeFromSuperview() }
}
// For each visible layout fragment — position views/layers
func textViewportLayoutController(
_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor fragment: NSTextLayoutFragment
) {
let frame = fragment.layoutFragmentFrame
let view = makeFragmentView(for: fragment)
view.frame = frame
fragmentContainer.addSubview(view)
}
// After layout completes — update content size for the scroll view
func textViewportLayoutControllerDidLayout(
_ controller: NSTextViewportLayoutController
) {
let height = textLayoutManager.usageBoundsForTextContainer.height
scrollView.contentSize = CGSize(width: bounds.width, height: height)
}
The configureRenderingSurface callback fires once per visible fragment, every layout pass. It's the right place to position views or layers, but a wrong place to allocate them — fragment objects are stable across passes, so cache views by fragment identity.
usageBoundsForTextContainer.height is an estimate. It changes during scroll as fragments lay out and the estimate refines. This produces three user-visible artifacts:
scrollRangeToVisible for a far-off range arrives near-but-not-at the target until the surrounding content lays out.Apple's TextEdit shows all three on long documents. Code that needs exact metrics (line count, exact total height, jump-accurate navigation) either has to force layout for the relevant range first or live with TextKit 1.
Roughly: one NSTextLayoutFragment per paragraph, containing one or more NSTextLineFragment for each visual line that paragraph wraps into.
let fragment: NSTextLayoutFragment
fragment.layoutFragmentFrame // rect in document coordinates
fragment.renderingSurfaceBounds // drawing extent — may exceed layout frame
fragment.textLineFragments // [NSTextLineFragment]
fragment.rangeInElement // NSTextRange for the fragment
fragment.draw(at: origin, in: cgContext)
renderingSurfaceBounds exists because drawing can extend past the layout frame: diacritics on top of the first line, descenders below the last line, custom backgrounds, glow effects. Custom NSTextLayoutFragment subclasses that draw outside the default frame must override renderingSurfaceBounds to expand the dirty rect, or the rendering is clipped at the frame edge.
class BubbleLayoutFragment: NSTextLayoutFragment {
override var renderingSurfaceBounds: CGRect {
layoutFragmentFrame.insetBy(dx: -8, dy: -8)
}
override func draw(at origin: CGPoint, in context: CGContext) {
// draw bubble background
super.draw(at: origin, in: context)
}
}
let line: NSTextLineFragment
line.typographicBounds // rect — local to the parent layout fragment
line.glyphOrigin // point — where glyph drawing starts within the line
line.characterRange // range — local to line.attributedString, not the document
line.attributedString // a copy, not the original document substring
characterRange is the most common bug source. It is local to the line's own attributed string, not document-relative. Code that uses it as a document range will hit the wrong characters as soon as the line isn't at document offset zero.
To convert a point inside a line fragment to document coordinates:
let docPoint = CGPoint(
x: layoutFragment.layoutFragmentFrame.origin.x
+ lineFragment.typographicBounds.origin.x
+ localPoint.x,
y: layoutFragment.layoutFragmentFrame.origin.y
+ lineFragment.typographicBounds.origin.y
+ localPoint.y
)
Three coordinate spaces nest: document, layout fragment frame, line fragment typographic bounds. The glyph origin is inside the line.
When text ends with \n (or the document is empty), an extra empty line fragment exists for cursor placement at the trailing position.
extraLineFragmentRect, extraLineFragmentUsedRect on NSLayoutManager..ensuresExtraLineFragment option in enumerateTextLayoutFragments. Known bug FB15131180 makes the frame incorrect in some configurations.If a custom editor is missing its trailing-empty cursor position, the fix is usually to add .ensuresExtraLineFragment to the enumeration that builds the cursor rectangle.
When NSTextContainer.exclusionPaths is non-empty, a single visual line that crosses an exclusion path splits into multiple line fragments — one for the segment before the exclusion, one for the segment after.
The container's lineFragmentRect(forProposedRect:at:writingDirection:remainingRect:) returns:
Most code never calls this directly — the layout system uses it internally. But when wrap-around-image rendering is broken, the question is usually whether the exclusion path is in the right coordinate space and whether the line fragments produced by the system make sense.
textContainer.lineFragmentPadding = 5 // default 5 points
textView.textContainerInset = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
These are different things and frequently confused:
lineFragmentPadding insets text within each line fragment by the same amount on each end. Purely visual — the fragment rect itself is unchanged. Default 5 points.textContainerInset is the document-level margin around the entire text. Use this for visual padding around the text view's content.Neither is paragraph indentation — that's NSParagraphStyle.headIndent / firstLineHeadIndent.
A common wrong fix: setting lineFragmentPadding = 16 to add document margins. The padding is per-line, not document-level, and 16-point padding produces visibly inset wrap inside the fragment that isn't what was wanted.
Visual styling overlay that does not modify storage and does not invalidate layout. Replaces TextKit 1's temporary attributes.
textLayoutManager.setRenderingAttributes(
[.foregroundColor: UIColor.red],
forTextRange: range
)
textLayoutManager.addRenderingAttribute(
.backgroundColor, value: UIColor.yellow,
forTextRange: range
)
textLayoutManager.removeRenderingAttribute(.backgroundColor, forTextRange: range)
Rendering attributes attach to the layout manager, not to character ranges in storage. Code that uses textStorage.addAttribute for what should be a rendering-only effect (find highlight, transient selection color) modifies the document and ends up in copy/paste, undo, and serialization — the rendering attribute is what was wanted.
Known bug FB9692714: certain rendering-attribute combinations have drawing artifacts. The workaround is a custom NSTextLayoutFragment subclass that draws the effect itself. This is one of the standing reasons to use TextKit 1 + temporary attributes for production syntax highlighting.
Font substitution happens during the editing lifecycle, in fixAttributes, before didProcessEditing runs. Characters with no glyph in the requested font are reassigned to a fallback font during the fix pass.
If a delegate sets a font in didProcessEditing, that change runs after fixAttributes and bypasses substitution. Characters that don't exist in the new font will not render — they'll show as .notdef boxes with no warning.
// WRONG — bypasses fixAttributes; missing glyphs show as boxes
func textStorage(_ ts: NSTextStorage,
didProcessEditing: NSTextStorage.EditActions,
range: NSRange, changeInLength: Int) {
ts.addAttribute(.font, value: customFont, range: range)
}
// CORRECT — set fonts in willProcessEditing; fixAttributes runs after
func textStorage(_ ts: NSTextStorage,
willProcessEditing: NSTextStorage.EditActions,
range: NSRange, changeInLength: Int) {
ts.addAttribute(.font, value: customFont, range: range)
}
The same applies on TextKit 2 — the storage layer hasn't changed.
For context when porting code, TextKit 1's options:
allowsNonContiguousLayout = true). Skips ranges that aren't visible. UITextView enables this by default. Less reliable than TextKit 2's viewport — boundingRect and lineFragmentRect can return slightly wrong coordinates for ranges in the multi-thousand-character region until those ranges are forced to lay out.UITextView.isScrollEnabled = false disables the scroll path entirely. The view expands to its full content size, which neutralizes TextKit 2's viewport optimization — all content gets laid out for Auto Layout intrinsic size. scrollRangeToVisible does not work in this configuration.
Treating NSTextLineFragment.characterRange as document-relative. It is local to the line's attributed string. Convert through the parent layout fragment's range before using it for selection, hit-testing, or attribute lookup.
Custom layout fragment that draws outside layoutFragmentFrame without overriding renderingSurfaceBounds. Drawing is clipped at the layout frame edge. Diacritics, large descenders, and custom shadows disappear.
Reading usageBoundsForTextContainer.height as exact total height while scrolling. It's an estimate that refines. Scroll-bar metrics tied directly to this value will jiggle. If exact metrics matter, force layout for the document range or move to TextKit 1.
Setting fonts in didProcessEditing. Bypasses fixAttributes font substitution. Characters with missing glyphs render as .notdef boxes. Move font changes to willProcessEditing, or supply explicit fallback fonts in the attribute.
Using lineFragmentPadding for document margins. Padding is per-line and small (5pt default). Document margins go on textContainerInset of the text view; paragraph indentation goes on NSParagraphStyle.
Allocating fragment views inside configureRenderingSurfaceFor. Cache views by fragment identity and reuse. The callback fires every layout pass.
Setting transient visual effects (find highlight, selection color) via textStorage.addAttribute. The change persists into the document, copy/paste, and undo. Use rendering attributes (setRenderingAttributes) instead.
Forgetting .ensuresExtraLineFragment. A custom editor that misses the cursor position after a trailing newline usually isn't requesting the extra trailing line fragment in its enumeration.
txt-textkit2 — TextKit 2 API reference covering content manager, layout manager, and the editing transactiontxt-layout-invalidation — what triggers re-layout vs visual-only refreshtxt-textkit-debug — symptom-driven debugging for clipped text, scroll-bar bugs, and missing fragmentstxt-fallback-triggers — when viewport behavior is missing because the view fell back to TextKit 1txt-attachments — how inline views and attachments interact with fragment geometrytxt-exclusion-paths — multi-region containers and exclusion-path layouttools
Integrate Writing Tools into UITextView, NSTextView, custom UITextInput views, or fully custom editors via UIWritingToolsCoordinator. Configure writingToolsBehavior and allowedWritingToolsResultOptions, declare protected ranges via writingToolsIgnoredRangesInEnclosingRange, gate edits with isWritingToolsActive, and pause syncing in willBegin/didEnd. Trigger on 'Apple Intelligence rewrite', 'AI summarize selection', 'compose with AI', 'why won't Writing Tools appear', or 'rewrite is breaking my code blocks' even without UIWritingToolsCoordinator named. Use when Writing Tools is missing from the menu, only the panel mode appears, rewrites corrupt code blocks, the inline animation isn't running, or a custom text engine needs to adopt UIWritingToolsCoordinator. Do NOT use for diagnosing general TextKit 1 fallback symptoms — see txt-fallback-triggers.
tools
Wrap UITextView (UIViewRepresentable) and NSTextView (NSViewRepresentable) inside SwiftUI without breaking editing. Covers binding sync, infinite-update-loop guards, cursor preservation across programmatic mutations, focus / first-responder bridging, auto-sizing strategies, environment value propagation, toolbar integration, and the iOS vs macOS scroll-view differences. Use when building or debugging a SwiftUI text-view wrapper, when cursor jumps after typing, when binding updates don't propagate, when @FocusState seems ignored, or when a wrapped editor won't size to its content. Do NOT use for picking which view class (txt-view-picker) or for which AttributedString attributes survive the SwiftUI boundary (txt-swiftui-interop).
development
Choose between SwiftUI Text/TextField/TextEditor, UIKit UITextView, and AppKit NSTextView. Capability comparison, tradeoffs, and decision criteria for read-only display vs single-line input vs multi-line editing vs rich attributed editing vs TextKit access. Use when the user asks "which text view should I use," "should I use TextField or TextEditor," "do I need UITextView for this," or describes a feature without naming a view class. Do NOT use for wrapping UITextView in SwiftUI — see txt-wrap-textview. Do NOT use for SwiftUI/TextKit attribute compatibility rules — see txt-swiftui-interop. Do NOT use for the iOS 26 SwiftUI TextEditor rich-text APIs themselves — see txt-swiftui-texteditor.
tools
Implement and debug undo and redo in text editors — NSUndoManager grouping with beginUndoGrouping/endUndoGrouping, automatic typing coalescing on UITextView and NSTextView, disabling registration around programmatic edits, the changeInLength bug that breaks undo on custom NSTextStorage subclasses, registering manual inverses on NSTextContentManager, and avoiding cross-talk with Writing Tools revert. Trigger on 'undo isn't working right', 'too many undo steps for one paste', 'undo lost my work after Writing Tools', 'cmd-z behaves wrong' even without NSUndoManager named. Use when undo collapses too many changesUse when undo collapses too many changes, splits one operation into many groups, applies wrong inverse ranges, or stops working entirely after a paste, replace-all, or document load.