Files
i7t5--edmund/Sources/EdmundCore/TextView/EditorTextStorage.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

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)
}
}
}