skills/txt-measurement/SKILL.md
Measure rendered size of strings and attributed strings, size views to fit text content, and read per-line metrics from NSLayoutManager and NSTextLayoutManager. Covers boundingRect with NSStringDrawingOptions, NSStringDrawingContext for auto-shrink, sizeThatFits, intrinsicContentSize, usedRect, enumerateLineFragments, usageBoundsForTextContainer, line-fragment typographic bounds, and the lineFragmentPadding/textContainerInset arithmetic that makes measurements match what UITextView actually renders. Use when text clips by a pixel, boundingRect returns a single-line size for multi-line text, a self-sizing UITextView refuses to size, intrinsicContentSize is wrong, or the user needs line counts. Do NOT use for paragraph style, hyphenation, or line height — see txt-line-breaking. Do NOT use for layout invalidation timing — see txt-layout-invalidation.
npx skillsauth add sitapix/apple-text txt-measurementInstall 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.
Measuring text means asking the typesetter how big a string will be once it lays it out. The right question — single-line size, constrained-width multi-line size, per-line metrics, fit-to-content sizing — picks the right API. The wrong question silently returns a number that disagrees with what UITextView later renders, and the difference is usually 1-2 pixels of clipping. The patterns here are starting points; before quoting any specific API signature, fetch the current Apple docs via Sosumi (sosumi.ai/documentation/foundation/nsattributedstring/boundingrect(with:options:context:)) and check that the call site uses the same font, paragraph style, and container insets that the rendering path uses — most measurement bugs are an attribute mismatch, not an API misuse.
The measurement and the render must agree on every relevant attribute: font, line height, line break mode, container width minus lineFragmentPadding, and textContainerInset. A measurement that uses default attributes against a render that uses a customized paragraph style produces predictable disagreement.
boundingRect(with:options:context:) is the right answer for "how big is this attributed string when wrapped to width W." Multi-line measurement requires .usesLineFragmentOrigin. Without it, the call measures as a single line ignoring the width constraint, which is the single most common measurement bug. Pair it with .usesFontLeading so the height includes the leading the renderer will add — without it the measurement comes back consistently shorter than the rendered text and the next line below clips.
let rect = attributedString.boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil)
let measured = CGSize(width: ceil(rect.width), height: ceil(rect.height))
ceil() is mandatory. The returned rect is fractional. Passing a fractional height to a layout that snaps to integer points clips the descender of the last line. Round up.
The other options are situational. .usesDeviceMetrics swaps typographic bounds for actual glyph bounds — useful for pixel-perfect rendering checks, almost never for layout sizing. .truncatesLastVisibleLine tells the measurement to apply truncation when the proposed height is the constraint; use this when you constrain height and want the truncated size, not the full content size.
NSAttributedString.size() and NSString.size(withAttributes:) always return single-line sizes. They ignore any width constraint. Using them for a multi-line label is the bug behind half of "label clips at the bottom" tickets. They are the right answer for badges, single-line chrome, or a width-only check before a multi-line call.
For UILabel-style auto-shrink, pass an NSStringDrawingContext with minimumScaleFactor. After the call, read actualScaleFactor for the scale the typesetter used and totalBounds for where text actually landed:
let ctx = NSStringDrawingContext()
ctx.minimumScaleFactor = 0.5
let rect = attributedString.boundingRect(
with: constrained,
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: ctx)
let scale = ctx.actualScaleFactor
let bounds = ctx.totalBounds
The shrink only triggers when the unscaled text would not fit; a shrunk-and-fits result is in totalBounds, not the returned rect.
minimumScaleFactor is single-line in practice. UILabel honors it for one-line text; multi-paragraph or wrap-mode strings ignore it, and actualScaleFactor reads back as 1.0 even when the text clips. Once an NSParagraphStyle is attached to the run — line height, line break mode, anything paragraph-level — the typesetter often refuses to scale at all (radar://26575435). The reliable path for multi-line shrink-to-fit is to measure at the unscaled font, compute the ratio against the available size yourself, and re-render at a smaller font size. boundingRect with NSStringDrawingContext is correct for the single-line case; for multi-line, treat its actualScaleFactor as advisory at best. See /skill txt-line-breaking for the paragraph-style interaction.
When the question is per-line — line counts, line heights, the y-coordinate of the third line — boundingRect is too coarse. NSLayoutManager has the metrics. Force layout first, then read; layout is lazy and queries before layout return stale data.
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
usedRect is the actual area glyphs occupy; the container's full size is the upper bound. For per-line enumeration, walk fragments:
let fullRange = layoutManager.glyphRange(for: textContainer)
layoutManager.enumerateLineFragments(forGlyphRange: fullRange) {
rect, usedRect, container, glyphRange, stop in
// rect: full line fragment (includes leading/trailing padding)
// usedRect: glyph bounds (tighter)
}
For a line count, the same enumeration is the simplest answer — increment a counter inside the closure. There is no numberOfLines getter on the layout manager.
TextKit 2 uses usageBoundsForTextContainer for total content bounds and enumerateTextLayoutFragments for per-fragment metrics. A layout fragment may contain multiple line fragments (textLineFragments); a typical paragraph has one layout fragment containing several line fragments.
textLayoutManager.ensureLayout(for: textLayoutManager.documentRange)
let total = textLayoutManager.usageBoundsForTextContainer
textLayoutManager.enumerateTextLayoutFragments(
from: textLayoutManager.documentRange.location,
options: [.ensuresLayout]
) { fragment in
let frame = fragment.layoutFragmentFrame
for line in fragment.textLineFragments {
let bounds = line.typographicBounds
// line origin is fragment.frame.origin + bounds.origin
}
return true
}
enumerateTextLayoutFragments over the full document defeats the viewport optimization that makes TK2 fast for long documents. If the work needs all fragments, accept the cost; if it only needs visible fragments, scope the enumeration to the viewport.
For UITextView, the supported path is isScrollEnabled = false plus Auto Layout. With scrolling enabled, intrinsicContentSize returns (.noIntrinsicMetric, .noIntrinsicMetric) and the view will not size itself. With scrolling disabled, intrinsicContentSize returns the full content size and Auto Layout drives the height.
textView.isScrollEnabled = false
// Auto Layout uses intrinsicContentSize from here on
For frame-based layout, ask sizeThatFits:
let fitting = textView.sizeThatFits(
CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
textView.frame.size.height = fitting.height
UITextView adds two amounts on top of the raw text size: textContainerInset (default (8, 0, 8, 0)) and lineFragmentPadding (default 5pt each side). A measurement that ignores either undercuts the actual rendered size:
let textWidth = container.size.width - 2 * container.lineFragmentPadding
// height of measured text + textView.textContainerInset.top + textView.textContainerInset.bottom
For UILabel, intrinsicContentSize is reliable; the label internally uses boundingRect with the right options.
Missing .usesLineFragmentOrigin. Without it, boundingRect measures as a single line and ignores the width constraint. The returned width matches the full string laid out without wrapping; the height is one line. Always include the option for multi-line measurement.
Missing .usesFontLeading. The default measurement omits the font's leading, so the height is consistently shorter than the rendered text. The next view below the label gets clipped from the top. Include the option to match the renderer.
Not calling ceil() on the result. boundingRect returns fractional values. A 24.7-point measurement assigned to a 24-point integer-rounded layout clips the last descender. Round up.
Measuring with attributes that disagree with the render. Default attributes for measurement, customized paragraph style for render — or vice versa — produces a measurement that is correct in isolation and wrong in context. Use the same attributes that the rendering path uses, including paragraph style and any overrides.
Forgetting lineFragmentPadding and textContainerInset. UITextView's default container subtracts 10pt of horizontal padding (5pt each side) from the usable width, and the view adds 16pt of vertical inset (8pt top + 8pt bottom). A measurement against the raw view bounds is off by these amounts.
Reading layout-manager metrics without forcing layout. TextKit layout is lazy. usedRect, lineFragmentRect, and glyphRange queries before the next layout pass return values from the previous layout. Call ensureLayout(for:) (TK1) or ensureLayout(for: documentRange) (TK2) before reading.
Single-line APIs used for multi-line text. NSAttributedString.size() and NSString.size(withAttributes:) ignore width constraints and return one-line sizes. They are the right answer for badges and single-line chrome, never for paragraphs.
Self-sizing UITextView with isScrollEnabled = true. With scrolling enabled, intrinsicContentSize returns no intrinsic metrics and Auto Layout cannot size the view. Disable scrolling for self-sizing; re-enable only when content exceeds the cap.
Measurement on a background thread. All TextKit measurement APIs that touch a layout manager must run on the main thread (or, for TK2, the layout queue). Background measurement crashes sporadically with no obvious frame in the offending code.
Trusting actualScaleFactor on multi-line text. NSStringDrawingContext.minimumScaleFactor is effectively single-line. Multi-paragraph or wrap-mode strings ignore the shrink; actualScaleFactor reads back as 1.0 even when the rendered text clips, and an attached NSParagraphStyle defeats it further (radar://26575435). For multi-line auto-shrink, measure at the base font, compute the ratio yourself, and re-render at a smaller size — don't rely on actualScaleFactor. See /skill txt-line-breaking for the paragraph-style interaction.
/skill txt-line-breaking — paragraph style decisions that change measurement results/skill txt-layout-invalidation — ensureLayout semantics and what makes prior measurements stale/skill txt-viewport-rendering — viewport-scoped TK2 measurement for long documents/skill txt-core-text — glyph-level measurement when typesetter-level measurement is not enoughtools
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.