667 lines
33 KiB
Swift
667 lines
33 KiB
Swift
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)
|
||
}
|
||
}
|