6.6 KiB
Text system: TextKit 2 drawing model
Expands ../ARCHITECTURE.md §2 (invariants) and §5 (TextKit 2 specifics).
Seeded also from the two archived callout investigations
(../investigations/archives/) and ../investigations/viewport-glitch-investigation.md.
Why TextKit 2 only
TextKit 2 (NSTextLayoutManager) lays out only what's on screen (viewport-
based layout), which is what makes large documents tractable. A TextKit 1
document lays out the whole thing up front. The constraint this buys: the
editor must never touch NSTextView.layoutManager and must never
store NSTextBlock/NSTextTable attributes in the text storage. Either one
silently and permanently switches the view back to TextKit 1. AppKit falls
back without an error, so a stray layoutManager access anywhere in the call
graph (including in a debugger inspection or a well-meaning "just check the
line count" helper) can quietly regress the whole editor to whole-document
layout.
EditorTextView (Sources/EdmundCore/TextView/EditorTextView.swift) guards
this with a #if DEBUG tripwire: it observes
NSTextView.willSwitchToNSLayoutManagerNotification and calls
assertionFailure if it ever fires, so a debug build crashes loudly at the
moment of the regression instead of silently degrading. Release builds have
no such guard. The regression there is just slow.
DecoratedTextLayoutFragment
A custom NSTextLayoutFragment subclass
(Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift) draws two kinds
of custom attribute behind/over the laid-out text:
.blockDecoration(paragraph-level): callout boxes, quote bars, table borders, thematic-break rules, code-block backgrounds. Fragment frames tile vertically, so a multi-line quote/callout run renders as one continuous box or bar across its fragments. A.boxdecoration'sbottomPadgrows the last fragment's ownlayoutFragmentFrame(not just its drawing): TextKit 2 excludes trailingparagraphSpacingfrom a fragment's height, so padding added only at draw time would be dead space. Clicks there would miss the text. Growing the frame instead keeps the extra height genuinely part of the fragment (clickable, and tiling correctly against the next block). A related wrinkle: when a callout is the document's last block and the source ends with a trailing newline, TextKit 2 folds that empty final line into the preceding fragment instead of giving it its own. Painting the decoration over the full frame height would flood callout color onto that empty line.decorationDrawHeightdetects the absorbed empty line and stops the fill at the last real content line plusbottomPad..fragmentOverlay(character-level): an image or a stroked vector path drawn at one character's laid-out position. Examples: rendered math, list bullets/checkboxes, the callout header icon+name image, the custom-title callout icon. The mechanism: the anchor character is hidden (see below), and.kernon that character reserves the overlay's drawing width as advance space, so the following text starts clear of where the overlay will be painted, using the same trick the table renderer uses for column alignment.draw(at:in:)then paints the image or strokes the path at the anchor's computed position once the surrounding text has laid out.
Hiding mechanics
Delimiters and synthesized-but-in-source markers vanish from view without
changing the string: hiddenFont is NSFont.systemFont(ofSize: 0.01),
paired with a clear foregroundColor. The character is still there (a
selection can still span it, undo still round-trips it); it simply occupies
no visible space and paints nothing.
This is also why NSTextAttachment is off the table entirely: TextKit 2 only
honors an attachment on the placeholder character U+FFFC, and rawSource
(the storage==rawSource invariant, ../ARCHITECTURE.md §2) never contains
that character. Edmund's content is always real Markdown text. Every image
or icon the editor draws is therefore a .fragmentOverlay, anchored to a
real (hidden) character, never an attachment.
Height estimates: the root of most viewport glitches
TextKit 2 only gives a layout fragment a real frame once it has actually been laid out; everything else (including the running total used for document height and scroll-position math) is an estimate that gets corrected as layout catches up to it. That correction is what makes the scroller jump, drag-selection autoscroll oscillate, and a scroll-to-target land in the wrong place; it's a widely documented TextKit 2 limitation (even TextEdit shows it).
Edmund's mitigations, all converging in
Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift:
- Documents at or under
fullLayoutMaxLength(100k UTF-16 units) are kept fully laid out once styling converges, via a coalesced next-run-loop settle (scheduleFullLayoutSettle) wrapped inpreservingViewportAnchorso the correction can't visibly shift what's on screen. Larger documents keep viewport-based (estimate-tolerant) layout. A full layout there would be the process-killing path that motivated other mitigations. repairContentAboveOrigindetects a first-fragment origin that has drifted above y=0 (edits near the top can leave the scroller stuck at the top with unreachable content above it) and re-lays start→viewport to renormalize, again insidepreservingViewportAnchor.- Undo/redo avoids resetting layout at all where possible (see
editor-pipeline.md's recompose-path discussion).
Full bug chronicle, including the three separately-reported symptoms this
one root cause explained: ../investigations/viewport-glitch-investigation.md.
The image-wedge constraint
Drawing an image .fragmentOverlay on a wrapping, multi-line layout
fragment re-triggers a TextKit 2 layout pass that collapses ("wedges") that
fragment to a single line, clipping whatever wrapped. Drawing a shape
(a stroked CGPath) on the same fragment does not trigger this. Default
callout headers get away with an image overlay because their synthesized
icon+name text is short and never wraps; the custom-title callout header
can wrap (user-supplied title text), so its icon is a stroked CGPath
instead, parsed from the vendored Lucide SVG geometry by SVGPath
(Sources/EdmundCore/Model/SVGPath.swift), scaled and drawn directly in
CGContext rather than rasterized to an NSImage first. Any new overlay
that could end up sharing a line with wrapping text inherits this same
constraint. Full investigation, including the mechanisms tried and rejected
before landing on the stroked-path fix:
../investigations/archives/callout-title-wrap-investigation.md.