skills/txt-textkit1/SKILL.md
--- name: txt-textkit1 description: Reference for TextKit 1 — NSTextStorage, NSLayoutManager, NSTextContainer. Covers the storage / layout manager / container triad, glyph generation and queries, line-fragment geometry, multi-container layout, exclusion paths, temporary attributes, non-contiguous layout, and NSLayoutManager / NSTextStorage delegate hooks. Use when working with code that already uses NSLayoutManager, when an editor was created with UITextView(usingTextLayoutManager: false), when
npx skillsauth add sitapix/apple-text skills/txt-textkit1Install 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 is the API reference for the original TextKit stack — NSTextStorage, NSLayoutManager, NSTextContainer — available since iOS 7 / macOS 10.0 and still the right choice for glyph access, multi-container layout, NSTextTable, and reliable temporary attributes. The class is not deprecated; Apple's own apps (Pages, Xcode, Notes) ship on it as of recent releases. Before claiming any specific signature here is current, fetch the relevant page from Sosumi (sosumi.ai/documentation/uikit/<class>) — TextKit 1's surface area is large and stable, but enumeration options and delegate signatures pick up small additions each release.
NSTextStorage ←→ NSLayoutManager ←→ NSTextContainer → UITextView / NSTextView
(model) (controller) (geometry) (view)
The relationships are one-to-many in both directions:
NSTextStorage can have many NSLayoutManagers. The same characters, laid out independently in multiple windows or panels.NSLayoutManager can have many NSTextContainers. Multi-page and multi-column layout flow text from one container into the next.NSTextContainer is owned by at most one text view.The storage notifies its layout managers; layout managers feed their containers; containers are exposed by views. The reverse path is direct property access (textView.layoutManager, layoutManager.textStorage, etc.) — and on iOS 16+ that reverse path is also the most common cause of TextKit 1 fallback on a TextKit 2 view.
NSTextStorage is a subclass of NSMutableAttributedString. It is an attributed string with edit tracking and notifications.
class NSTextStorage: NSMutableAttributedString {
var layoutManagers: [NSLayoutManager] { get }
var editedMask: EditActions { get }
var editedRange: NSRange { get }
var changeInLength: Int { get }
func addLayoutManager(_ layoutManager: NSLayoutManager)
func removeLayoutManager(_ layoutManager: NSLayoutManager)
func edited(_ mask: EditActions, range: NSRange, changeInLength delta: Int)
func processEditing()
var delegate: NSTextStorageDelegate?
}
The lifecycle on a single mutation:
replaceCharacters(in:with:) or setAttributes(_:range:).edited(_:range:changeInLength:) with the right mask and delta.endEditing() (or the implicit single-mutation equivalent) calls processEditing().delegate.textStorage(_:willProcessEditing:…) fires — characters and attributes both writable here.fixAttributes(in:) runs — font substitution, paragraph-style fixing.delegate.textStorage(_:didProcessEditing:…) fires — attributes only, characters are committed.processEditing(for:edited:range:changeInLength:invalidatedRange:) is called.Edit masks combine when both kinds of change happen in one mutation:
NSTextStorage.EditActions.editedCharacters // text changed
NSTextStorage.EditActions.editedAttributes // attributes changed
[.editedCharacters, .editedAttributes] // both
Batched edits coalesce into one processEditing() pass:
textStorage.beginEditing()
textStorage.replaceCharacters(in: r1, with: "new text")
textStorage.addAttribute(.font, value: bold, range: r2)
textStorage.deleteCharacters(in: r3)
textStorage.endEditing()
// processEditing() runs once with the union of edits
Without beginEditing() / endEditing(), each individual mutation triggers a separate processEditing() and a separate layout invalidation pass. For subclassing detail (rope, piece table, gap buffer) see txt-nstextstorage.
NSLayoutManager translates characters into glyphs and lays them out.
layoutManager.ensureGlyphs(forCharacterRange: range)
layoutManager.ensureGlyphs(forGlyphRange: glyphRange)
let glyph = layoutManager.glyph(at: glyphIndex)
let glyphRange = layoutManager.glyphRange(
forCharacterRange: charRange,
actualCharacterRange: nil
)
let charRange = layoutManager.characterRange(
forGlyphRange: glyphRange,
actualGlyphRange: nil
)
The character-to-glyph mapping is not 1:1. Ligatures combine multiple characters into one glyph; combining marks expand one character into multiple glyphs; complex scripts reorder glyphs. Code that assumes one character equals one glyph fails on Arabic, Devanagari, emoji ZWJ sequences, and most non-Latin scripts.
Glyph generation is lazy. Querying a glyph for an index that hasn't been generated triggers generation. Querying layout for a range whose glyphs haven't been laid out triggers both glyph generation and layout. The internal warning _NSLayoutTreeLineFragmentRectForGlyphAtIndex in console output usually means a layout query ran ahead of the layout pass — call ensureLayout(forCharacterRange:) covering the queried range first.
layoutManager.ensureLayout(for: textContainer) // O(document) — avoid
layoutManager.ensureLayout(forCharacterRange: range)
layoutManager.ensureLayout(forGlyphRange: glyphRange)
layoutManager.ensureLayout(forBoundingRect: rect, in: container) // best for visible
Geometry queries:
let rect = layoutManager.boundingRect(forGlyphRange: range, in: container)
var effective = NSRange()
let lineRect = layoutManager.lineFragmentRect(
forGlyphAt: glyphIndex,
effectiveRange: &effective
)
let usedRect = layoutManager.lineFragmentUsedRect(
forGlyphAt: glyphIndex,
effectiveRange: &effective
)
let originInLine = layoutManager.location(forGlyphAt: glyphIndex)
var fraction: CGFloat = 0
let charIndex = layoutManager.characterIndex(
for: point,
in: container,
fractionOfDistanceBetweenInsertionPoints: &fraction
)
lineFragmentRect is the full allocation for the line — padding, leading, paragraph spacing. lineFragmentUsedRect is just the part the glyphs actually occupy. Hit-testing and cursor positioning use the used rect; stacking lines and drawing backgrounds use the full rect.
Manual invalidation:
layoutManager.invalidateGlyphs(
forCharacterRange: range,
changeInLength: 0,
actualCharacterRange: nil
)
layoutManager.invalidateLayout(
forCharacterRange: range,
actualCharacterRange: nil
)
layoutManager.invalidateDisplay(forCharacterRange: range)
layoutManager.invalidateDisplay(forGlyphRange: glyphRange)
invalidateDisplay only marks the screen for redraw — it does not recompute layout. Use it after temporary-attribute changes when the visible rect needs to refresh.
layoutManager.allowsNonContiguousLayout = true
if layoutManager.hasNonContiguousLayout {
// Some ranges may not be laid out yet.
}
When enabled, the layout manager can skip ranges that aren't currently visible. Essential for documents large enough to scroll meaningfully. UITextView enables it by default; NSTextView does not.
The trade-off is reliability. boundingRect(forGlyphRange:in:) and lineFragmentRect(forGlyphAt:effectiveRange:) can return slightly wrong coordinates for ranges that haven't been laid out — usually around several thousand characters in. If exact geometry matters for a query, force layout for that range first with ensureLayout(forCharacterRange:).
Temporary attributes are visual overlays that don't modify the storage. Used for spell-check underlines, find highlights, syntax-color overlays. They are TextKit 1 only — TextKit 2's equivalent is rendering attributes on NSTextLayoutManager.
layoutManager.setTemporaryAttributes(
[.foregroundColor: UIColor.red],
forCharacterRange: range
)
layoutManager.addTemporaryAttribute(
.backgroundColor,
value: UIColor.yellow,
forCharacterRange: range
)
layoutManager.removeTemporaryAttribute(
.backgroundColor,
forCharacterRange: range
)
Temporary attributes don't persist across archiving, don't trigger layout invalidation, and are well-tested for syntax highlighting workloads. This last point is one of the standing reasons to stay on TextKit 1 for a code editor.
addTemporaryAttributes(_:forCharacterRange:) is the production highlighting path. Because it bypasses storage, it does not invalidate layout, does not run fixAttributes, and does not force glyph regeneration. Compare to calling addAttribute directly on the storage with the same attribute key — that mutates the document, runs the editing lifecycle, and regenerates glyphs over the affected range. CotEditor's published optimization arc on a syntax-highlight pass measures the difference at roughly 4.46s using storage attributes, 3.35s using temporary attributes synchronously, and effectively 0s when the temporary-attribute application is moved to a background queue and applied per-paragraph in batches. Display-only highlighting (search matches, find-bar results, find-and-replace previews, syntax color overlays that are not part of the persisted document) belongs on temporary attributes; storage attributes are for things the user would expect to copy, paste, save, and undo.
The container defines the geometric region where text is laid out.
let container = NSTextContainer(
size: CGSize(width: 300, height: .greatestFiniteMagnitude)
)
container.lineFragmentPadding = 5 // default 5 points
container.maximumNumberOfLines = 0 // 0 = unlimited
container.lineBreakMode = .byWordWrapping
container.exclusionPaths = [
UIBezierPath(ovalIn: CGRect(x: 50, y: 50, width: 100, height: 100))
]
Exclusion paths are in the container's coordinate space. A single visual line that crosses an exclusion path splits into multiple line fragments; the container's lineFragmentRect(forProposedRect:at:writingDirection:remainingRect:) returns both the largest available rectangle and the remainder.
Setting the height to .greatestFiniteMagnitude is the standard way to say "no clipping, no maximum height" — a finite height clips text and is a common cause of "missing text at the bottom of the view".
Multiple containers on one layout manager give multi-page and multi-column flow:
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
storage.addLayoutManager(layoutManager)
let column1 = NSTextContainer(size: CGSize(width: 300, height: 500))
let column2 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager.addTextContainer(column1)
layoutManager.addTextContainer(column2)
let view1 = UITextView(frame: frame1, textContainer: column1)
let view2 = UITextView(frame: frame2, textContainer: column2)
Text overflowing column1 flows into column2. This is the pattern used by page-layout, multi-column reading views, and any layout that needs to span multiple frames. There is no equivalent in TextKit 2 — NSTextLayoutManager has exactly one container.
Custom glyph drawing happens in a NSLayoutManager subclass:
class GutterLayoutManager: NSLayoutManager {
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
drawCustomBackground(forGlyphRange: glyphsToShow, at: origin)
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
}
override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
drawHighlights(forGlyphRange: glyphsToShow, at: origin)
}
}
Inline attachments use NSTextAttachment directly:
let attachment = NSTextAttachment()
attachment.image = UIImage(named: "icon")
attachment.bounds = CGRect(x: 0, y: -4, width: 20, height: 20)
let attrString = NSAttributedString(attachment: attachment)
textStorage.insert(attrString, at: insertionPoint)
NSTextAttachmentCell is TextKit 1 only; for view-backed attachments on TextKit 2, use NSTextAttachmentViewProvider instead.
NSTextStorageDelegate exposes willProcessEditing (characters and attributes both writable; used for auto-correct and text transforms) and didProcessEditing (attributes only; used for syntax highlighting that applies color attributes based on just-committed text). Mutating characters in didProcessEditing is the most common storage-related crash; the editing-lifecycle detail lives in txt-nstextstorage.
NSLayoutManagerDelegate exposes hooks for line and paragraph spacing, custom line-fragment rects, and glyph generation. The notable methods:
layoutManager(_:lineSpacingAfterGlyphAt:withProposedLineFragmentRect:) — return additional spacing after the glyph at the given index.layoutManager(_:paragraphSpacingAfterGlyphAt:withProposedLineFragmentRect:) — return additional spacing after the paragraph.layoutManager(_:shouldUse:forTextContainer:) — modify the line fragment rect before layout commits.layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:) — custom glyph mapping. No TextKit 2 equivalent — glyph customization lives in TextKit 1 only.Forgetting edited() in an NSTextStorage subclass. Without the call, layout managers never learn the text changed. The most common subclassing bug.
// WRONG
override func replaceCharacters(in range: NSRange, with str: String) {
backingStore.replaceCharacters(in: range, with: str)
}
// CORRECT
override func replaceCharacters(in range: NSRange, with str: String) {
backingStore.replaceCharacters(in: range, with: str)
let delta = (str as NSString).length - range.length
edited(.editedCharacters, range: range, changeInLength: delta)
}
Mutating characters in didProcessEditing. The delegate runs after the storage has committed; characters are no longer writable. Mutating them re-enters the editing lifecycle with stale ranges and crashes. Move character changes to willProcessEditing, or use addAttribute only.
Not batching edits. Each unbatched mutation runs processEditing() separately. Wrap multi-step changes in beginEditing() / endEditing() so the layout invalidation pass runs once.
Reading textView.layoutManager on a TextKit 2 view. Triggers irreversible fallback. Always check textView.textLayoutManager != nil first. The full catalog is in txt-fallback-triggers.
ensureLayout(for: textContainer) on a large document. Forces layout for the entire document — O(document). Use the rect-scoped variant ensureLayout(forBoundingRect:in:) over the visible rect, or the range-scoped ensureLayout(forCharacterRange:).
Assuming character-glyph 1:1 mapping. Ligatures, combining marks, and emoji ZWJ sequences break this. Code that uses numberOfGlyphs as a stand-in for string.count produces wrong answers on every non-Latin script and most user-generated text with emoji.
String.count vs (string as NSString).length in range arithmetic. They diverge on emoji and combining marks. Mixing them inside edited(_:range:changeInLength:) corrupts ranges silently. Normalize at the boundary with (text as NSString).length or NSRange(swiftRange, in: text).
Setting isScrollEnabled = false on a UITextView to size-fit it in a cell. This is the canonical autosizing pattern, and it has a non-obvious cost. UITextView's layout manager has allowsNonContiguousLayout = true by default, but only while isScrollEnabled == true. Disabling scroll silently re-enables full-document layout because intrinsic content size needs an exact height. A small autosizing field is fine; an autosizing field bound to a long document quietly lays out every line on the first pass, every container resize, and every Dynamic Type change. This is the most common cause of "my autosize UITextView got slow" and is called out in WWDC 2018 #221. If the content can grow large, either keep scrolling enabled and constrain the height externally, or page the content so the autosizing path only ever sees a bounded slice.
txt-textkit2 — the TextKit 2 API surface, for the same problem on the modern stacktxt-textkit-choice — picking between TextKit 1 and TextKit 2txt-fallback-triggers — every API access that flips a TextKit 2 view to TextKit 1txt-nstextstorage — storage subclassing and editing-lifecycle deep divetxt-layout-invalidation — what invalidates layout and how to force a refreshtools
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).
tools
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).
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.