This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user