txt-textkit-choice — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited txt-textkit-choice (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
This skill is the picker. It does not list APIs — see txt-textkit1 and txt-textkit2 for those — and it does not chase symptoms — see txt-textkit-debug. It owns the decision: which layout manager fits the feature, where each stack still has hard limits, what the performance evidence actually says, and how migration risk decomposes when an existing codebase weighs moving over. The recommendations here are calibrated to current shipping behavior; before committing to one based on a performance claim quoted below, run a benchmark on the target OS with realistic content.
| Aspect | NSLayoutManager (TextKit 1) | NSTextLayoutManager (TextKit 2) |
|---|---|---|
| Available since | iOS 7 / macOS 10.0 | iOS 15 / macOS 12 |
| Default for new text views | Through iOS 15 | iOS 16+ / macOS 13+ |
| Layout model | Contiguous (or non-contiguous opt-in) | Always non-contiguous, viewport-driven |
| Abstraction | Glyph-based | Element / fragment-based |
| Text containers per manager | Multiple | Exactly one |
| Performance model | O(document) or O(visible) | O(viewport) |
| International text | Manual glyph handling | Correct by design |
| Writing Tools | Panel only | Full inline rewriting |
| Custom rendering | NSLayoutManager subclass + drawGlyphs | NSTextLayoutFragment subclass |
| Visual overlay | Temporary attributes (setTemporaryAttributes) | Rendering attributes (setRenderingAttributes) |
| Printing | Full | Basic on iOS 18+ / macOS 15+; multi-page still TK1 |
NSTextTable | Supported | Triggers fallback |
| Glyph APIs | Yes | None |
NSLayoutManager is fully supported and not deprecated. Apple's own apps — Pages, Xcode, Notes — still use TextKit 1 as of recent releases. TextEdit uses TextKit 2 and falls back to TextKit 1 for tables, page layout, and printing. TextKit 1 is not "legacy mode to migrate away from"; it is the correct choice when feature requirements include things TextKit 2 does not support.
NSTextLayoutManager supports exactly one NSTextContainer. There is no workaround.addTemporaryAttribute is rendering-only and well-tested. TextKit 2's setRenderingAttributes has known drawing bugs (FB9692714) and the workaround is a custom NSTextLayoutFragment subclass per attribute combination.drawGlyphs, drawBackground, or the shouldGenerateGlyphs delegate. None of these have direct TextKit 2 equivalents; rewriting against NSTextLayoutFragment is a real port, not a swap.usageBoundsForTextContainer.height is an estimate that refines while scrolling. TextKit 1 contiguous layout gives an exact value. Code that wires document height directly to a scroll view's content size will see scroll-bar jiggle on TextKit 2.UITextView defaults to TextKit 1 on iOS 15. Roughly 2-3% of devices as of early 2026, but the share matters for apps with broad reach.NSTextLayoutFragment is a cleaner, more maintainable API than overriding drawGlyphs on NSLayoutManager.Apple's WWDC21 framing was that TextKit 2 is fast across a wide range from short labels to hundreds-of-megabytes documents at interactive rates. That framing is technically true and practically misleading: each release has improved TextKit 2 performance, but several real-world reports as recent as 2025 still show problems above 3,000 lines on some configurations.
What the public evidence says:
NSTextLayoutFragment / NSTextLineFragment value types have real overhead.For short text — labels, chat bubbles, single-line fields — TextKit 2 is the right call regardless. The penalty is zero and the international-text correctness wins are free.
For documents large enough to scroll meaningfully, the answer depends on the target OS and the actual content. The benchmark is more reliable than the framing.
Both systems struggle with this. On TextKit 1, line counting requires numberOfGlyphs and enumeration with allowsNonContiguousLayout = false for accuracy, or accept approximate counts with non-contiguous layout. On TextKit 2, line counting requires enumerating every layout fragment with .ensuresLayout, defeating the viewport optimization.
The right approach on either stack is to maintain a separate line count incrementally on edit rather than asking the layout system.
Both UITextView and NSTextView default to TextKit 2 on iOS 16+ / macOS 13+. Force a specific stack only with a documented reason.
// TextKit 1 — explicit, no fallback risk
let textView = UITextView(usingTextLayoutManager: false)
// textView.textLayoutManager == nil from the start// Manual TextKit 1 construction (custom views)
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
layoutManager.allowsNonContiguousLayout = true // critical for scroll perf
storage.addLayoutManager(layoutManager)
let container = NSTextContainer(size: CGSize(width: 300, height: .greatestFiniteMagnitude))
layoutManager.addTextContainer(container)
let textView = UITextView(frame: .zero, textContainer: container)// TextKit 2 — default
let textView = UITextView()
assert(textView.textLayoutManager != nil)// Manual TextKit 2 construction (custom views)
let contentStorage = NSTextContentStorage()
let layoutManager = NSTextLayoutManager()
let container = NSTextContainer()
contentStorage.addTextLayoutManager(layoutManager)
layoutManager.textContainer = container
contentStorage.attributedString = NSAttributedString(string: "Hello")If TextKit 1 is the right call, opt in explicitly with usingTextLayoutManager: false. Don't construct a TextKit 2 view and let it fall back — that wastes the TextKit 2 layout manager initialization and produces a worse view than starting on TextKit 1.
The check that does not flip the view:
func currentStack(for textView: UITextView) -> String {
textView.textLayoutManager != nil ? "TextKit 2" : "TextKit 1"
}The check that flips the view to TextKit 1 by reading it:
// WRONG — this triggers fallback
if textView.layoutManager != nil { … }textView.layoutManager and textView.textContainer.layoutManager both pull TextKit 1 infrastructure into existence on a TextKit 2 view. Always check textLayoutManager first; only after confirming it's nil is it safe to use the TextKit 1 layout manager. Full catalog of fallback triggers in txt-fallback-triggers.
Reasons not to migrate an existing TextKit 1 codebase:
NSLayoutManager subclass with drawGlyphs represents real investment.Reasons to consider migration:
Strategy when migrating:
// Dual code path
if let textLayoutManager = textView.textLayoutManager {
textLayoutManager.enumerateTextLayoutFragments(
from: textLayoutManager.documentRange.location,
options: []
) { fragment in
// TextKit 2 path
return true
}
} else {
let layoutManager = textView.layoutManager
layoutManager.ensureLayout(forBoundingRect: textView.bounds, in: textView.textContainer)
// TextKit 1 path
}Production code editors at >100K LOC do not treat NSTextStorage as the source of truth. They treat it as a view cache. The document lives in a structure designed for fast random insertion and line-indexed access, and NSTextStorage is a thin window onto whatever slice the layout manager currently needs to render. The pattern shows up across every shipping editor that has had to handle million-line files at interactive rates.
Runestone ports AvalonEdit's red-black-tree line manager from C# to Swift. The tree is the document; the NSTextStorage is fed paragraphs out of it on demand. CodeEditTextView abandoned TextKit entirely after hitting issues that could not be fixed inside the framework — it now uses Core Text directly with a lazy line-layout pipeline, and loads million-line files in milliseconds. Bear, iA Writer, Drafts, Pretext, and Runestone all keep UTF-8 on disk and recompute attributes from Markdown on load; the persisted document is plain text and the attributed string is rebuilt every session. None of them store NSAttributedString as the document. ChimeHQ's BufferingTextStorage is a related but smaller-scoped pattern: it interposes a low-overhead mutation history between the user's edit and the displayed storage so that highlighters, language servers, and tree-sitter parsers run asynchronously with respect to the display path rather than blocking inside the edit transaction. The throughline is the same in every case: the editing transaction stays cheap because the heavy work is never inside it.
The decision this forces on the picker: if the editor is for code, structured prose, or any content type where the user expects the editor to feel snappy at sizes above a few thousand lines, the storage model is more important than the choice between TextKit 1 and TextKit 2. Plan the document layer first; let the choice of layout manager follow from what the document layer needs to render.
There is also a TextKit-2-specific reason this pattern bites code editors particularly hard: TextKit 2 has no glyph-index API. There is no way to ask "what is the visual rect of character index 12345" without forcing layout of every fragment ahead of it, because offscreen fragments only have estimated geometry. This is Runestone's stated reason for staying on TextKit 1 — its line-numbers gutter needs to know exactly where each line will draw, and NSLayoutManager's glyph-rect APIs answer that in O(line) on TextKit 1 versus O(document-prefix) on TextKit 2. A custom line-numbers gutter on TextKit 2 has to be built as a NSTextViewportLayoutControllerDelegate that creates one CALayer per visible fragment in configureRenderingSurfaceFor and tears them down on the way out — a different architecture entirely from the AppKit ruler-view pattern.
ensureLayout(forBoundingRect:in:) over the visible rect, or the range-scoped variant..estimatesSize.usageBoundsForTextContainer.height is an estimate that refines while scrolling. If exact metrics matter, stay on TextKit 1.textLayoutManager first.UITextView(usingTextLayoutManager: false) from the start when TextKit 1 is the right stack.txt-textkit1 — TextKit 1 API referencetxt-textkit2 — TextKit 2 API referencetxt-fallback-triggers — every API access that flips a TextKit 2 view to TextKit 1txt-textkit-debug — symptom-driven debugging when stack-related bugs are showingtxt-view-picker — picking between SwiftUI Text/TextField/TextEditor, UITextView, NSTextView when the question is the view, not the layout manager~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.