Files
i7t5--edmund/Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift
T
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

667 lines
33 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
// MARK: - TextKit 2 Support
//
// The editor runs on TextKit 2 (NSTextLayoutManager): layout is viewport-based
// — the system only lays out what's on screen, which is what makes large
// documents tractable. The hard rule that follows: never touch
// `NSTextView.layoutManager` or store NSTextBlock/NSTextTable attributes —
// either silently switches the view back to TextKit 1 for good.
//
// Two custom attributes drive a custom layout fragment:
//
// - `.blockDecoration` (paragraph-level): callout boxes, quote bars, table
// borders, thematic-break rules. Fragment frames tile vertically, so
// per-paragraph drawing renders a multi-line quote run as one continuous
// box/bar.
// - `.fragmentOverlay` (character-level): images drawn at a character's
// position — callout header (icon + title), rendered math, list bullets and
// checkboxes. TextKit 1 rendered `.attachment` over any character; TextKit 2
// only honors attachments on U+FFFC, which the storage==rawSource invariant
// forbids. Instead the anchor character is hidden, `.kern` reserves the
// image's advance width (the same trick the table renderer uses for column
// alignment), and the fragment draws the image at the anchor's position.
// - `.tableCellWraps` (paragraph-level): a table cell too wide for its column
// can't wrap in place — TextKit 2 only wraps a whole paragraph at the
// container's edge, it has no notion of an independent per-cell flow region
// (that's what NSTextTable/NSTextBlock exist for, and they're banned). So an
// overflowing cell's real characters are hidden, and its styled text is laid
// out separately in a small detached text stack sized to the column's
// width; the fragment draws the resulting lines stacked at the cell's x.
public extension NSAttributedString.Key {
/// Paragraph-level decoration drawn behind the text by
/// `DecoratedTextLayoutFragment`. Value: `BlockDecoration`.
static let blockDecoration = NSAttributedString.Key("MarkdownEditor.blockDecoration")
/// Character-level image drawn at the character's position by
/// `DecoratedTextLayoutFragment`. Value: `FragmentOverlay`. The styling
/// code pairs it with a hidden anchor glyph plus `.kern` for layout space.
static let fragmentOverlay = NSAttributedString.Key("MarkdownEditor.fragmentOverlay")
/// A table row's overflowing cells, wrapped and drawn by
/// `DecoratedTextLayoutFragment`. Value: `TableCellWrapList`.
static let tableCellWraps = NSAttributedString.Key("MarkdownEditor.tableCellWraps")
}
/// Value object describing what to draw behind a decorated paragraph.
/// Reference type (NSObject) so it lives in attributed strings; value
/// equality so attribute-run merging and the test oracle behave.
public final class BlockDecoration: NSObject, @unchecked Sendable {
public enum Kind: Equatable {
/// Filled box across the text column (callouts), with optional borders.
/// `bottomPad` extends the fill/border below the fragment's text frame —
/// TextKit 2 does not include trailing `paragraphSpacing` in the
/// fragment height, so a callout's last line carries the bottom padding
/// here (and a matching paragraphSpacing pushes the next block clear).
case box(background: NSColor, borderColor: NSColor?,
borderEdges: CalloutStyle.Edges, borderWidth: CGFloat,
bottomPad: CGFloat)
/// Vertical bar just left of the paragraph's text (plain block quotes).
case leftBar(color: NSColor, width: CGFloat)
/// Table-row chrome: vertical column borders at text-relative x
/// offsets, and a horizontal rule through the separator row. `width`
/// is the table's full width; `leftInset` the text's inset from the
/// table's left edge. `bottomBorder` draws a full-width line at this
/// row's bottom edge — the grid line between data rows (the header/
/// separator boundary already gets its line from `separator`).
case tableRow(columnXOffsets: [CGFloat], width: CGFloat,
leftInset: CGFloat, separator: Bool, bottomBorder: Bool)
/// Horizontal hairline across the text column, drawn `centerOffset`
/// points below the fragment's vertical center. The offset compensates
/// for adjacent text sitting at its baseline (low in its line box), so
/// the rule looks equidistant from the text above and below rather
/// than hugging the line above it.
case horizontalRule(color: NSColor, centerOffset: CGFloat)
}
public let kind: Kind
/// For `.box`: horizontal inset (points) from the text column's left and
/// right edges, non-zero for a box nested inside another box (e.g. a
/// callout inside a callout), so the inner box sits within the outer one.
/// For `.leftBar`: rightward shift (points) from the outermost bar
/// position — one `quoteMarkerWidth` per nesting level, mirroring the
/// hidden `> ` marker that indents the text, so each nested quote's bar
/// (e.g. `> > text`) sits just left of its own level's text. Absolute per
/// level: the same level's bar lands at the same x on every line, which
/// keeps stacked bars tiling into continuous columns. Ignored by other
/// kinds.
public let inset: CGFloat
/// For `.leftBar`: start the bar at the first line's glyph top (baseline
/// minus ascender) instead of the fragment top. The line box carries its
/// extra spacing (lineSpacing) *above* the glyphs, so a bar over the full
/// fragment pokes past the text. Set only on a quote run's first line —
/// interior lines must fill the whole fragment so consecutive lines' bars
/// tile without gaps. Ignored by other kinds.
public let hugsTextTop: Bool
public init(_ kind: Kind, inset: CGFloat = 0, hugsTextTop: Bool = false) {
self.kind = kind
self.inset = inset
self.hugsTextTop = hugsTextTop
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? BlockDecoration else { return false }
return kind == other.kind && inset == other.inset
&& hugsTextTop == other.hugsTextTop
}
public override var hash: Int {
switch kind {
case .box: return 1
case .leftBar: return 2
case .tableRow: return 3
case .horizontalRule: return 4
}
}
}
/// An ordered stack of decorations drawn behind one paragraph, outermost
/// first. Used when nesting puts more than one box/bar on the same line — e.g.
/// a callout's outer box plus an inner nested callout's box. A single
/// decoration still uses a bare `BlockDecoration`; the fragment reads either.
public final class BlockDecorationList: NSObject, @unchecked Sendable {
public let decorations: [BlockDecoration]
public init(_ decorations: [BlockDecoration]) {
self.decorations = decorations
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? BlockDecorationList else { return false }
return decorations == other.decorations
}
public override var hash: Int { decorations.count }
}
/// An image or stroked vector path drawn at a character's laid-out position,
/// with attachment-style bounds: `bounds.origin.y` is the drawing's bottom
/// relative to the text baseline (negative descends below it).
///
/// The path form exists because of a TextKit 2 wedge: drawing an *image* on a
/// wrapping, multi-line layout fragment collapses that fragment's layout to a
/// single line, while drawing a *shape* does not (see
/// docs/investigations/archives/callout-title-wrap-investigation.md). Overlays that can share a line
/// with wrapping text (the custom-callout-title icon) must use the path form.
public final class FragmentOverlay: NSObject, @unchecked Sendable {
public let image: NSImage?
/// Stroked path in bounds-local coordinates (y-down, origin at the
/// bounds' top-left), pre-scaled to the bounds size.
public let path: CGPath?
public let pathColor: NSColor?
public let pathLineWidth: CGFloat
public let bounds: CGRect
public init(image: NSImage, bounds: CGRect) {
self.image = image
self.path = nil
self.pathColor = nil
self.pathLineWidth = 0
self.bounds = bounds
super.init()
}
public init(path: CGPath, color: NSColor, lineWidth: CGFloat, bounds: CGRect) {
self.image = nil
self.path = path
self.pathColor = color
self.pathLineWidth = lineWidth
self.bounds = bounds
super.init()
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? FragmentOverlay else { return false }
return other.image === image && other.path == path
&& other.pathColor == pathColor && other.pathLineWidth == pathLineWidth
&& other.bounds == bounds
}
public override var hash: Int { Int(bounds.width) ^ Int(bounds.height) }
}
/// A table cell too wide for its column: its real characters are hidden, and
/// this holds what to draw instead. `x` is text-relative (same coordinate
/// space as `BlockDecoration.tableRow`'s `columnXOffsets`) — the cell's
/// content start. `contentWidth` is the column's clamped content width (the
/// width the cell's text must wrap within).
public final class TableCellWrap: NSObject, @unchecked Sendable {
public let styled: NSAttributedString
public let x: CGFloat
public let contentWidth: CGFloat
public init(styled: NSAttributedString, x: CGFloat, contentWidth: CGFloat) {
self.styled = styled
self.x = x
self.contentWidth = contentWidth
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? TableCellWrap else { return false }
return other.styled.string == styled.string
&& abs(other.x - x) < 0.5 && abs(other.contentWidth - contentWidth) < 0.5
}
public override var hash: Int { styled.string.hashValue }
}
/// A table row's overflowing cells, one `TableCellWrap` per overflowing cell.
public final class TableCellWrapList: NSObject, @unchecked Sendable {
public let wraps: [TableCellWrap]
public init(_ wraps: [TableCellWrap]) {
self.wraps = wraps
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? TableCellWrapList else { return false }
return wraps == other.wraps
}
public override var hash: Int { wraps.count }
}
/// Layout fragment that draws its paragraph's `BlockDecoration` behind the
/// text and any `FragmentOverlay` images at their characters' positions.
final class DecoratedTextLayoutFragment: NSTextLayoutFragment {
/// Decorations drawn behind the paragraph, outermost first.
let decorations: [BlockDecoration]
/// Paragraph-relative anchor offsets and their overlays.
let overlays: [(offset: Int, overlay: FragmentOverlay)]
/// Whether the text is antialiased (editor-wide setting).
let antialias: Bool
/// Each overflowing cell's x and pre-laid-out lines, from a detached
/// scratch text stack sized to the column's content width. The stack
/// itself is retained (`scratchStacks`) so the line fragments stay valid.
private let resolvedCellWraps: [(x: CGFloat, lines: [NSTextLineFragment])]
private let scratchStacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)]
init(textElement: NSTextElement, range: NSTextRange?,
decorations: [BlockDecoration],
overlays: [(offset: Int, overlay: FragmentOverlay)],
cellWraps: [TableCellWrap],
antialias: Bool) {
self.decorations = decorations
self.overlays = overlays
self.antialias = antialias
var resolved: [(x: CGFloat, lines: [NSTextLineFragment])] = []
var stacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)] = []
for wrap in cellWraps {
let contentStorage = NSTextContentStorage()
contentStorage.textStorage = NSTextStorage(attributedString: wrap.styled)
let layoutManager = NSTextLayoutManager()
contentStorage.addTextLayoutManager(layoutManager)
let container = NSTextContainer(
size: NSSize(width: max(1, wrap.contentWidth), height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0
layoutManager.textContainer = container
var lines: [NSTextLineFragment] = []
layoutManager.enumerateTextLayoutFragments(
from: layoutManager.documentRange.location, options: [.ensuresLayout]
) { frag in
lines.append(contentsOf: frag.textLineFragments)
return true
}
resolved.append((wrap.x, lines))
stacks.append((contentStorage, layoutManager, container))
}
self.resolvedCellWraps = resolved
self.scratchStacks = stacks
super.init(textElement: textElement, range: range)
}
/// Extra row height needed to fit the tallest wrapped cell, beyond the
/// row's natural (single-line) height.
private var tableRowExtraHeight: CGFloat {
guard !resolvedCellWraps.isEmpty else { return 0 }
let tallest = resolvedCellWraps
.map { $0.lines.reduce(0) { $0 + $1.typographicBounds.height } }
.max() ?? 0
return max(0, tallest - super.layoutFragmentFrame.height)
}
required init?(coder: NSCoder) {
fatalError("DecoratedTextLayoutFragment does not support coding")
}
/// Fragment-local x of the text container's left edge. The fragment's
/// frame hugs the laid-out text, so container x = 0 sits at -frame.minX.
private var containerLeft: CGFloat { -layoutFragmentFrame.minX }
private var containerWidth: CGFloat {
textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width
}
/// A box decoration's `bottomPad` grows the fragment's own frame (not just
/// its drawing): TextKit 2 leaves trailing `paragraphSpacing` out of the
/// fragment, so padding added that way is dead space — clicks there miss the
/// text. Making the fragment frame taller means the line fragments stay
/// anchored at the top, the extra height is genuine clickable space below
/// the last line, the next block tiles clear of it, and the box (drawn over
/// the full frame height) covers it. Mirrors how the header's raised
/// minimumLineHeight makes the top padding clickable text space.
///
/// Padding is *summed* across stacked boxes: when a nested callout is the
/// last line of its parent, the line needs the nested box's bottom padding
/// *and* the parent's below it (see `draw`), so both fit.
private var boxBottomPad: CGFloat {
decorations.reduce(0) { acc, deco in
if case .box(_, _, _, _, let bottomPad) = deco.kind { return acc + bottomPad }
return acc
}
}
/// Height to actually paint a filled decoration (box / left bar) over,
/// which is *not* always the full frame height. When a callout or quote is
/// the last block AND the document ends with a newline, TextKit 2 folds the
/// document's final empty line into this (the preceding) layout fragment
/// instead of giving it its own fragment — it shows up as a trailing
/// zero-length line fragment. Painting the decoration over the full frame
/// then floods the callout color onto that trailing empty line (the
/// "extra colored line at the bottom" bug). Detect the absorbed empty line
/// and stop the fill at the last real content line plus the box's bottom
/// padding.
var decorationDrawHeight: CGFloat {
let full = layoutFragmentFrame.height
let lines = textLineFragments
guard lines.count > 1, let last = lines.last,
last.characterRange.length == 0 else { return full }
// Bottom of the last line that actually holds text (fragment-local).
let contentBottom = lines.dropLast().map { $0.typographicBounds.maxY }.max() ?? 0
// `super` frame excludes our bottomPad; its extent past the content is
// exactly the absorbed empty line. Remove that, keep the bottomPad.
let emptyLineHeight = max(0, super.layoutFragmentFrame.height - contentBottom)
return max(0, full - emptyLineHeight)
}
override var layoutFragmentFrame: CGRect {
var frame = super.layoutFragmentFrame
// A row is never both a box and a table row, so at most one of these
// two is ever nonzero.
frame.size.height += boxBottomPad + tableRowExtraHeight
return frame
}
override var renderingSurfaceBounds: CGRect {
var bounds = super.renderingSurfaceBounds
let frame = layoutFragmentFrame
if !decorations.isEmpty {
bounds = bounds.union(CGRect(x: containerLeft - 4, y: 0,
width: containerWidth + 8, height: frame.height))
}
for (offset, overlay) in overlays {
if let rect = overlayRect(anchorOffset: offset, overlay: overlay) {
bounds = bounds.union(rect.insetBy(dx: -2, dy: -2))
}
}
return bounds
}
override func draw(at point: CGPoint, in context: CGContext) {
context.saveGState()
// Decorations are stacked outermost-first. Each box stops short of the
// fragment bottom by the padding of the boxes drawn before it, so an
// outer box's bottom padding stays visible *below* an inner nested box
// (e.g. the parent callout's padding under a nested callout) instead of
// being covered by it.
var precedingBottomPad: CGFloat = 0
for decoration in decorations {
drawDecoration(decoration, at: point, in: context, bottomInset: precedingBottomPad)
if case .box(_, _, _, _, let bottomPad) = decoration.kind {
precedingBottomPad += bottomPad
}
}
context.restoreGState()
context.saveGState()
context.setShouldAntialias(antialias)
super.draw(at: point, in: context)
context.restoreGState()
for (offset, overlay) in overlays {
guard let rect = overlayRect(anchorOffset: offset, overlay: overlay) else { continue }
let drawRect = rect.offsetBy(dx: point.x, dy: point.y)
if let image = overlay.image {
// Draw the (resolution-independent) NSImage into the flipped context,
// so it rasterizes at the screen's backing scale — crisp on Retina,
// and positioned precisely. (Converting to a CGImage first would bake
// it at 1×, then upscale: soft, and quantized a pixel low.) The math
// image carries a small transparent inset, so the flipped draw can't
// clip a descender at the image edge.
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = nsContext
image.draw(in: drawRect, from: .zero, operation: .sourceOver,
fraction: 1, respectFlipped: true, hints: nil)
NSGraphicsContext.restoreGraphicsState()
} else if let path = overlay.path, let color = overlay.pathColor {
// Stroke the vector path directly in CG — never rasterize it to
// an image first: an image drawn on a multi-line fragment wedges
// its layout to one line (see the FragmentOverlay note). Path
// coords are bounds-local and y-down, matching this flipped
// context, so a translate places them.
context.saveGState()
context.translateBy(x: drawRect.minX, y: drawRect.minY)
context.addPath(path)
context.setStrokeColor(color.cgColor)
context.setLineWidth(overlay.pathLineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
context.strokePath()
context.restoreGState()
}
}
// Overflowing table cells: the real characters are hidden, so draw
// each cell's pre-wrapped lines here instead, stacked top-down at the
// cell's column x. Left-aligned regardless of the column's declared
// alignment (ponytail: not requested; upgrade path is the same
// per-line x-shift math the kern-based alignment above already uses).
for cellWrap in resolvedCellWraps {
var y = point.y
for line in cellWrap.lines {
line.draw(at: CGPoint(x: point.x + cellWrap.x, y: y), in: context)
y += line.typographicBounds.height
}
}
}
/// Fragment-local rect for an overlay image, anchored to the character at
/// the given paragraph-relative offset.
private func overlayRect(anchorOffset: Int, overlay: FragmentOverlay) -> CGRect? {
guard let line = textLineFragments.first(where: {
NSLocationInRange(anchorOffset, $0.characterRange)
}) ?? textLineFragments.last else { return nil }
let anchorX = line.typographicBounds.minX
+ line.locationForCharacter(at: anchorOffset).x
// Baseline (flipped coords): the line's glyph origin sits at its
// typographic origin plus the ascent-derived glyph origin.
let baselineY = line.typographicBounds.minY + line.glyphOrigin.y
return CGRect(x: anchorX + overlay.bounds.minX,
y: baselineY - overlay.bounds.height - overlay.bounds.minY,
width: overlay.bounds.width,
height: overlay.bounds.height)
}
/// Fragment-local y of the first line's glyph top (baseline minus the
/// line's font ascender). The line box can hold extra space above the
/// glyphs (lineSpacing lands there), which a text-hugging bar skips.
private var firstLineGlyphTop: CGFloat? {
guard let line = textLineFragments.first,
line.characterRange.length > 0,
let font = line.attributedString.attribute(
.font, at: line.characterRange.location, effectiveRange: nil) as? NSFont
else { return nil }
return line.typographicBounds.minY + line.glyphOrigin.y - font.ascender
}
private func drawDecoration(_ decoration: BlockDecoration, at point: CGPoint,
in context: CGContext, bottomInset: CGFloat = 0) {
let frame = layoutFragmentFrame
// Filled decorations (box, bar) stop above an absorbed trailing empty
// line; center-line decorations (rule, table) still use the full frame.
let fillHeight = decorationDrawHeight
// Fragment-local rect spanning the full text column for this fragment.
let columnRect = CGRect(x: point.x + containerLeft, y: point.y,
width: containerWidth, height: fillHeight)
switch decoration.kind {
case .box(let background, let borderColor, let edges, let borderWidth, _):
// The fragment frame already includes any box bottomPad (see
// layoutFragmentFrame), so columnRect covers the padded area. A
// nested box insets symmetrically so it sits within its parent box,
// and stops `bottomInset` short of the frame bottom so the enclosing
// box's padding shows below it.
var columnRect = decoration.inset > 0
? columnRect.insetBy(dx: decoration.inset, dy: 0)
: columnRect
columnRect.size.height -= bottomInset
context.setFillColor(background.cgColor)
context.fill(columnRect)
if let borderColor, !edges.isEmpty {
context.setFillColor(borderColor.cgColor)
if edges.contains(.left) {
context.fill(CGRect(x: columnRect.minX, y: columnRect.minY,
width: borderWidth, height: columnRect.height))
}
if edges.contains(.right) {
context.fill(CGRect(x: columnRect.maxX - borderWidth, y: columnRect.minY,
width: borderWidth, height: columnRect.height))
}
if edges.contains(.top) {
context.fill(CGRect(x: columnRect.minX, y: columnRect.minY,
width: columnRect.width, height: borderWidth))
}
if edges.contains(.bottom) {
context.fill(CGRect(x: columnRect.minX, y: columnRect.maxY - borderWidth,
width: columnRect.width, height: borderWidth))
}
}
case .leftBar(let color, let width):
// The bar sits immediately left of the text (the paragraph style
// insets the text by the bar's width) — or `inset` further right,
// for a nested quote's bar next to its own level's text.
var barTop = point.y
var barHeight = fillHeight
if decoration.hugsTextTop, let glyphTop = firstLineGlyphTop {
barTop += glyphTop
barHeight -= glyphTop
}
context.setFillColor(color.cgColor)
context.fill(CGRect(x: point.x - width + decoration.inset, y: barTop,
width: width, height: barHeight))
case .tableRow(let xOffsets, let width, let leftInset, let separator, let bottomBorder):
// Offsets are text-relative; the fragment's origin is the text start.
context.setStrokeColor(NSColor.separatorColor.cgColor)
context.setLineWidth(1)
for x in xOffsets {
let lineX = round(point.x + x) + 0.5
context.move(to: CGPoint(x: lineX, y: point.y))
context.addLine(to: CGPoint(x: lineX, y: point.y + frame.height))
}
if separator {
let y = round(point.y + frame.height / 2) + 0.5
context.move(to: CGPoint(x: point.x - leftInset, y: y))
context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y))
}
if bottomBorder {
let y = round(point.y + frame.height) + 0.5
context.move(to: CGPoint(x: point.x - leftInset, y: y))
context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y))
}
context.strokePath()
case .horizontalRule(let color, let centerOffset):
context.setStrokeColor(color.cgColor)
context.setLineWidth(1)
let y = round(point.y + frame.height / 2 + centerOffset) + 0.5
context.move(to: CGPoint(x: columnRect.minX, y: y))
context.addLine(to: CGPoint(x: columnRect.maxX, y: y))
context.strokePath()
}
}
}
// MARK: - Fragment Vending
extension EditorTextView: NSTextLayoutManagerDelegate {
public nonisolated func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
textLayoutFragmentFor location: NSTextLocation,
in textElement: NSTextElement
) -> NSTextLayoutFragment {
guard let paragraph = textElement as? NSTextParagraph,
paragraph.attributedString.length > 0 else {
return NSTextLayoutFragment(textElement: textElement,
range: textElement.elementRange)
}
let str = paragraph.attributedString
let decoValue = str.attribute(.blockDecoration, at: 0, effectiveRange: nil)
let decorations: [BlockDecoration]
if let list = decoValue as? BlockDecorationList {
decorations = list.decorations
} else if let single = decoValue as? BlockDecoration {
decorations = [single]
} else {
decorations = []
}
var overlays: [(offset: Int, overlay: FragmentOverlay)] = []
str.enumerateAttribute(.fragmentOverlay,
in: NSRange(location: 0, length: str.length),
options: []) { value, range, _ in
if let overlay = value as? FragmentOverlay {
overlays.append((range.location, overlay))
}
}
let cellWrapsValue = str.attribute(.tableCellWraps, at: 0, effectiveRange: nil)
let cellWraps = (cellWrapsValue as? TableCellWrapList)?.wraps ?? []
// A plain fragment suffices only when there's nothing to draw over the
// text and antialiasing is on (the default); otherwise vend the custom
// fragment so its draw can disable antialiasing.
guard !decorations.isEmpty || !overlays.isEmpty || !cellWraps.isEmpty || !textAntialias
else {
return NSTextLayoutFragment(textElement: textElement,
range: textElement.elementRange)
}
return DecoratedTextLayoutFragment(textElement: textElement,
range: textElement.elementRange,
decorations: decorations,
overlays: overlays,
cellWraps: cellWraps,
antialias: textAntialias)
}
}
// MARK: - Overlay Application
extension EditorTextView {
/// Renders `overlay` at `anchor` (a single character): hides the anchor
/// glyph, reserves the image's advance width with kern so following text
/// flows around it, and stores the overlay for the layout fragment to draw.
///
/// The kern is capped just short of the full line width: a full-width
/// image/equation (the common case — anything wider than the column gets
/// scaled to exactly fill it) would otherwise reserve 100% of the line,
/// leaving zero room for the hidden markdown text that follows the anchor
/// on the same line. TextKit then force-wraps that hidden run onto a new
/// line fragment — and since `minimumLineHeight` (reserveLineHeight) is a
/// paragraph-wide property applying to every line fragment, that phantom
/// wrapped line also inflates to the overlay's full height, doubling the
/// reserved space below the image. The slack is comfortably larger than
/// any realistic hidden-text width (near-zero at `hiddenFont`'s size).
func applyOverlay(_ overlay: FragmentOverlay, anchor: NSRange,
in result: NSMutableAttributedString) {
guard anchor.upperBound <= result.length else { return }
let kernSlack: CGFloat = 8
let kernWidth = min(overlay.bounds.width, max(0, availableContentWidth - kernSlack))
result.addAttribute(.font, value: hiddenFont, range: anchor)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: anchor)
result.addAttribute(.kern, value: kernWidth, range: anchor)
result.addAttribute(.fragmentOverlay, value: overlay, range: anchor)
}
/// Reserves vertical room for an overlay taller than the text line that
/// carries it. A `FragmentOverlay` only reserves horizontal advance (kern),
/// so — unlike the old `NSTextAttachment`, which grew its line fragment —
/// a tall image (e.g. inline math scaled to a heading's size) would
/// otherwise overlap the line below. Raises the enclosing paragraph's
/// `minimumLineHeight` to fit, preserving any other paragraph attributes.
func reserveLineHeight(_ height: CGFloat, forOverlayAt location: Int,
in result: NSMutableAttributedString) {
guard location < result.length else { return }
let ns = result.string as NSString
// The enclosing paragraph (between newlines): minimumLineHeight is a
// paragraph attribute, and for the heading/inline cases the math sits
// on a single line, so this grows exactly the line that needs it.
let para = ns.paragraphRange(for: NSRange(location: location, length: 0))
let base = (result.attribute(.paragraphStyle, at: location, effectiveRange: nil)
as? NSParagraphStyle) ?? bodyParagraphStyle
guard height > base.minimumLineHeight else { return }
let ps = (base.mutableCopy() as! NSMutableParagraphStyle)
ps.minimumLineHeight = height
result.addAttribute(.paragraphStyle, value: ps, range: para)
}
}
// MARK: - Stack Construction
public extension EditorTextView {
/// Builds the TextKit 2 text system chain and returns the wired editor:
/// EditorTextStorage → NSTextContentStorage → NSTextLayoutManager
/// → NSTextContainer → EditorTextView
static func makeTextKit2(frame: NSRect, containerSize: NSSize) -> EditorTextView {
let contentStorage = NSTextContentStorage()
contentStorage.textStorage = EditorTextStorage()
let layoutManager = NSTextLayoutManager()
contentStorage.addTextLayoutManager(layoutManager)
let container = NSTextContainer(size: containerSize)
container.widthTracksTextView = true
layoutManager.textContainer = container
return EditorTextView(frame: frame, textContainer: container)
}
}