147 lines
6.6 KiB
Swift
147 lines
6.6 KiB
Swift
import AppKit
|
|
import CoreText
|
|
|
|
/// A text storage subclass whose `fixAttributes` does font substitution only.
|
|
///
|
|
/// The editor explicitly manages every attribute on every character (custom
|
|
/// keys like `.blockDecoration` / `.fragmentOverlay` included), so the
|
|
/// framework's default attribute "fixing" has nothing useful to add — and
|
|
/// historically it stripped attributes the renderer depended on.
|
|
public class EditorTextStorage: NSTextStorage {
|
|
private let backing = NSMutableAttributedString()
|
|
|
|
/// The accumulated string mutation since the last consume, expressed as
|
|
/// "this range of the OLD string was replaced, shifting lengths by
|
|
/// `delta`". Multiple mutations coalesce into the conservative hull.
|
|
/// This is the single funnel for all string edits (typing, paste, IME),
|
|
/// so the incremental block parser can re-split only the affected lines.
|
|
public struct PendingEdit {
|
|
public var oldRange: NSRange
|
|
public var delta: Int
|
|
}
|
|
public private(set) var pendingEdit: PendingEdit?
|
|
|
|
/// Returns and clears the accumulated edit.
|
|
public func consumePendingEdit() -> PendingEdit? {
|
|
defer { pendingEdit = nil }
|
|
return pendingEdit
|
|
}
|
|
|
|
/// Drops accumulated-edit tracking. Programmatic whole-document
|
|
/// replacements (recompose after load/undo/indent) call this — they
|
|
/// re-parse from scratch themselves.
|
|
public func clearPendingEdit() {
|
|
pendingEdit = nil
|
|
}
|
|
|
|
/// Coalesces a new edit (given in CURRENT-string coordinates) into the
|
|
/// pending edit (kept in OLD-string coordinates).
|
|
private func accumulateEdit(currentRange r: NSRange, delta d: Int) {
|
|
guard var p = pendingEdit else {
|
|
pendingEdit = PendingEdit(oldRange: r, delta: d)
|
|
return
|
|
}
|
|
// Map the new edit's bounds back to old-string coordinates and take
|
|
// the hull. Positions at/after the previous edit's replacement shift
|
|
// back by the previous delta; the max() keeps positions inside or
|
|
// before it clamped to the previous old range.
|
|
let start = min(p.oldRange.location, r.location)
|
|
let end = max(p.oldRange.upperBound, r.upperBound - p.delta)
|
|
p.oldRange = NSRange(location: start, length: max(0, end - start))
|
|
p.delta += d
|
|
pendingEdit = p
|
|
}
|
|
|
|
override public var string: String { backing.string }
|
|
|
|
override public func attributes(
|
|
at location: Int, effectiveRange range: NSRangePointer?
|
|
) -> [NSAttributedString.Key: Any] {
|
|
backing.attributes(at: location, effectiveRange: range)
|
|
}
|
|
|
|
override public func replaceCharacters(in range: NSRange, with str: String) {
|
|
let delta = (str as NSString).length - range.length
|
|
accumulateEdit(currentRange: range, delta: delta)
|
|
backing.replaceCharacters(in: range, with: str)
|
|
edited(.editedCharacters, range: range, changeInLength: delta)
|
|
}
|
|
|
|
override public func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) {
|
|
let delta = attrString.length - range.length
|
|
accumulateEdit(currentRange: range, delta: delta)
|
|
backing.replaceCharacters(in: range, with: attrString)
|
|
edited([.editedCharacters, .editedAttributes], range: range,
|
|
changeInLength: delta)
|
|
}
|
|
|
|
override public func setAttributes(
|
|
_ attrs: [NSAttributedString.Key: Any]?, range: NSRange
|
|
) {
|
|
backing.setAttributes(attrs, range: range)
|
|
edited(.editedAttributes, range: range, changeInLength: 0)
|
|
}
|
|
|
|
override public func fixAttributes(in range: NSRange) {
|
|
// We deliberately skip the framework's default attribute fixing — the
|
|
// editor manages all attributes itself, and the default pass has a
|
|
// history of stripping what the renderer depends on.
|
|
//
|
|
// But one part of fixing is still needed: font substitution. The body
|
|
// font (a serif/mono) has no glyphs for emoji, CJK, etc.; without
|
|
// substitution those render as missing-glyph boxes. So we do font
|
|
// fixing ourselves, leaving every other attribute (attachments included)
|
|
// untouched.
|
|
fixFontSubstitution(in: range)
|
|
}
|
|
|
|
/// Replaces the font on any character the run's font cannot render with a
|
|
/// fallback font that can (e.g. Apple Color Emoji), preserving the original
|
|
/// size. Substitutions are computed first, then applied, so we never mutate
|
|
/// the attribute we're enumerating mid-pass.
|
|
private func fixFontSubstitution(in range: NSRange) {
|
|
guard range.length > 0, range.upperBound <= backing.length else { return }
|
|
let ns = backing.string as NSString
|
|
var fixes: [(NSRange, NSFont)] = []
|
|
|
|
backing.enumerateAttribute(.font, in: range, options: []) { value, runRange, _ in
|
|
// Skip the tiny hidden-delimiter font — those chars are invisible
|
|
// and are plain ASCII delimiters the base font already covers.
|
|
guard let font = value as? NSFont, font.pointSize > 1.0 else { return }
|
|
|
|
// Fast path: does the font cover the whole run?
|
|
let runChars = Array(ns.substring(with: runRange).utf16)
|
|
var runGlyphs = [CGGlyph](repeating: 0, count: runChars.count)
|
|
if CTFontGetGlyphsForCharacters(font as CTFont, runChars, &runGlyphs, runChars.count) {
|
|
return
|
|
}
|
|
|
|
// Substitute per composed-character sequence so we never split a
|
|
// grapheme (emoji ZWJ sequences, skin-tone modifiers, é, …).
|
|
var i = runRange.location
|
|
let end = runRange.upperBound
|
|
while i < end {
|
|
let seq = ns.rangeOfComposedCharacterSequence(at: i)
|
|
let seqRange = NSRange(location: seq.location,
|
|
length: min(seq.length, end - seq.location))
|
|
let seqStr = ns.substring(with: seqRange)
|
|
let seqChars = Array(seqStr.utf16)
|
|
var seqGlyphs = [CGGlyph](repeating: 0, count: seqChars.count)
|
|
let covered = CTFontGetGlyphsForCharacters(
|
|
font as CTFont, seqChars, &seqGlyphs, seqChars.count)
|
|
if !covered {
|
|
let substitute = CTFontCreateForString(
|
|
font as CTFont, seqStr as CFString,
|
|
CFRange(location: 0, length: seqChars.count)) as NSFont
|
|
fixes.append((seqRange, substitute))
|
|
}
|
|
i = seqRange.upperBound
|
|
}
|
|
}
|
|
|
|
for (r, f) in fixes {
|
|
backing.addAttribute(.font, value: f, range: r)
|
|
}
|
|
}
|
|
}
|