285 lines
14 KiB
Swift
285 lines
14 KiB
Swift
import AppKit
|
|
|
|
/// The edit pipeline: how keystrokes flow from NSTextView into `rawSource`,
|
|
/// reparse, and an attribute-only restyle of exactly the affected blocks.
|
|
extension EditorTextView {
|
|
|
|
/// NSTextView copies the attributes next to the caret into `typingAttributes`.
|
|
/// When the caret sits beside a hidden delimiter (our near-zero-size
|
|
/// `hiddenFont` + clear color), newly inserted text inherits that invisible
|
|
/// font. Regular typing self-heals via `applyBlockStyle` on each keystroke,
|
|
/// but IME composition (e.g. Pinyin) is deferred while marked — so the
|
|
/// composing text would render invisibly and input appears broken. Refuse the
|
|
/// invisible font here so composition is always visible; the block restyle
|
|
/// still applies the correct final styling on commit.
|
|
public override var typingAttributes: [NSAttributedString.Key: Any] {
|
|
get { super.typingAttributes }
|
|
set {
|
|
var attrs = newValue
|
|
if let font = attrs[.font] as? NSFont, font.pointSize < 1.0 {
|
|
attrs[.font] = bodyFont
|
|
if (attrs[.foregroundColor] as? NSColor) == .clear {
|
|
attrs[.foregroundColor] = foregroundColor
|
|
}
|
|
}
|
|
super.typingAttributes = attrs
|
|
}
|
|
}
|
|
|
|
public override func shouldChangeText(in affectedCharRange: NSRange, replacementString: String?) -> Bool {
|
|
if isUpdating {
|
|
traceEdit("shouldChangeText REJECTED (isUpdating) range=\(affectedCharRange) repl=\(logSnippet(replacementString))")
|
|
return false
|
|
}
|
|
if let replacement = replacementString {
|
|
if !isUndoRedoing {
|
|
recordUndoIfNeeded(editRange: affectedCharRange, replacement: replacement)
|
|
}
|
|
}
|
|
traceEdit("shouldChangeText OK range=\(affectedCharRange) repl=\(logSnippet(replacementString))")
|
|
scheduleBypassedEditSyncCheck()
|
|
return true
|
|
}
|
|
|
|
/// AppKit does not pair every storage mutation with `didChangeText()`.
|
|
/// Known case: after a drag of the selected text whose drop falls through
|
|
/// to no valid target (e.g. released past the end of the document), the
|
|
/// drag-move's source deletion runs shouldChangeText → replaceCharacters
|
|
/// and never calls didChangeText. rawSource/blocks then silently freeze:
|
|
/// every later edit does its offset math against the stale model (the
|
|
/// issue-#156 caret leap) and autosave writes the stale rawSource.
|
|
/// didChangeText consumes the storage's pendingEdit synchronously within
|
|
/// the same event turn, so a pendingEdit still unconsumed on the next
|
|
/// run-loop pass is exactly the "didChangeText was bypassed" signal —
|
|
/// heal by running the sync it would have run.
|
|
private func scheduleBypassedEditSyncCheck() {
|
|
guard !bypassedEditCheckScheduled else { return }
|
|
bypassedEditCheckScheduled = true
|
|
// RunLoop.perform, not DispatchQueue.main.async: identical "next
|
|
// run-loop pass" timing in the app, but also drainable by
|
|
// `RunLoop.main.run(until:)` in tests.
|
|
RunLoop.main.perform { [weak self] in
|
|
// The main run loop only ever performs on the main thread; the
|
|
// closure just isn't statically annotated as such.
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
self.bypassedEditCheckScheduled = false
|
|
guard let storage = self.textStorage as? EditorTextStorage,
|
|
storage.pendingEdit != nil,
|
|
!self.isUpdating, !self.isUndoRedoing,
|
|
// During IME composition the unconsumed pendingEdit is
|
|
// legitimate — didChangeText defers the sync until commit.
|
|
!self.hasMarkedText() else { return }
|
|
// Permanent breadcrumb (release builds too): if a desync recurs,
|
|
// grep ~/.edmund/logs for this line to see which path bypassed
|
|
// didChangeText.
|
|
Log.info("healing storage edit that bypassed didChangeText: " +
|
|
"storLen=\(storage.length) rawLen=\((self.rawSource as NSString).length)",
|
|
category: .edit)
|
|
// The bypassing path also skips TextKit 2's selection fixup —
|
|
// it stays queued and fires at the NEXT endEditing (our restyle),
|
|
// via _fixSelectionAfterChangeInCharacterRange, mapping the
|
|
// stale selection against post-edit coordinates. That's the
|
|
// issue-#156 caret leap: the fixer lands the caret a couple of
|
|
// lines away. Collapse the selection to the edit's end point
|
|
// ourselves before syncing; the late fixer then has a valid
|
|
// caret to map and leaves it in place.
|
|
var caretAfterEdit: Int?
|
|
if let pending = storage.pendingEdit {
|
|
let newLength = max(0, pending.oldRange.length + pending.delta)
|
|
caretAfterEdit = min(pending.oldRange.location + newLength, storage.length)
|
|
self.setSelectedRange(NSRange(location: caretAfterEdit!, length: 0))
|
|
}
|
|
self.syncRawSourceFromDisplay()
|
|
// The queued fixer fires during the sync's endEditing and moves
|
|
// the caret even when it was just set to a valid spot — so
|
|
// re-assert after the sync; by the next edit the fixer state is
|
|
// clean (verified: follow-up deletes behave).
|
|
if let caret = caretAfterEdit {
|
|
self.setSelectedRange(NSRange(location: min(caret, storage.length),
|
|
length: 0))
|
|
}
|
|
self.document?.updateChangeCount(.changeDone)
|
|
}
|
|
}
|
|
}
|
|
|
|
public override func didChangeText() {
|
|
super.didChangeText()
|
|
guard !isUpdating, !isUndoRedoing else {
|
|
traceEdit("didChangeText SKIPPED sync (isUpdating=\(isUpdating) isUndoRedoing=\(isUndoRedoing))")
|
|
return
|
|
}
|
|
// While an input method is composing (marked text — e.g. emoji search,
|
|
// accent/IME entry), the storage holds provisional text. Re-styling it
|
|
// (setAttributes over the marked range) interferes with the composition
|
|
// and can abort it, so defer all syncing/restyling until the IME commits
|
|
// — which fires didChangeText again with no marked text.
|
|
guard !hasMarkedText() else {
|
|
traceEdit("didChangeText DEFERRED sync (marked text active)")
|
|
return
|
|
}
|
|
syncRawSourceFromDisplay()
|
|
document?.updateChangeCount(.changeDone)
|
|
scrollCursorToCenter()
|
|
}
|
|
|
|
/// Syncs rawSource from the text storage, re-parses blocks, and restyles
|
|
/// exactly the blocks the edit affected: the parser's changed window, the
|
|
/// old and new active blocks, and — when the document-global list indent
|
|
/// unit moved — every list block. One flush, attribute-only; the storage
|
|
/// string is never replaced on the edit path.
|
|
private func syncRawSourceFromDisplay() {
|
|
guard let ts = textStorage else { return }
|
|
|
|
let oldIndentUnit = listIndentUnit
|
|
rawSource = ts.string
|
|
let sel = selectedRange()
|
|
let cursorRaw = min(sel.location, (rawSource as NSString).length)
|
|
|
|
// Where this edit should leave the caret, derived from the storage's
|
|
// pending edit (same hull formula as the bypassed-edit heal). TextKit 2's
|
|
// queued selection fixup (`_fixSelectionAfterChangeInCharacterRange`) can
|
|
// fire during `recomposeDirty`'s `endEditing` below and remap a stale
|
|
// selection, leaping the caret to a block boundary — issue #156 round 7,
|
|
// the round-6 mechanism on the *normal* edit path (armed by a cross-block
|
|
// caret move rather than a drag bypass). This path styles with
|
|
// `settingSelection` false and otherwise trusts NSTextView's caret, so a
|
|
// leap here sticks and every later edit re-triggers it. Capture the
|
|
// intended caret now, before `consumePendingEdit` clears it, and re-assert
|
|
// it after the restyle if the fixup moved it.
|
|
let expectedCaret: Int? = (ts as? EditorTextStorage)?.pendingEdit.map {
|
|
min($0.oldRange.location + max(0, $0.oldRange.length + $0.delta), ts.length)
|
|
}
|
|
|
|
let oldCount = blocks.count
|
|
let oldActive = activeBlockIndex
|
|
// Incremental parse from the storage's accumulated edit — O(edit);
|
|
// full positional-diff parse as the fallback.
|
|
let newBlocks: [Block]
|
|
let changed: Range<Int>
|
|
// Whether the document's link reference definitions changed — a `[label]:
|
|
// url` line added/removed/edited can affect a reference link in *any*
|
|
// block, so on change every bracket-bearing block is restyled below.
|
|
var defsChanged = false
|
|
if let pending = (ts as? EditorTextStorage)?.consumePendingEdit(),
|
|
let incremental = BlockParser.incrementalParse(text: rawSource,
|
|
old: blocks,
|
|
editedOldRange: pending.oldRange,
|
|
delta: pending.delta) {
|
|
(newBlocks, changed) = incremental
|
|
#if DEBUG
|
|
verifyIncrementalParse(newBlocks)
|
|
#endif
|
|
// Update the indent histogram and link definitions from exactly the
|
|
// replaced blocks (old) and their replacements (new) — O(edit), same
|
|
// effect as a whole-document rescan.
|
|
let oldDefState = linkDefState
|
|
let suffixCount = newBlocks.count - changed.upperBound
|
|
for i in changed.lowerBound ..< (oldCount - suffixCount) {
|
|
listIndentState.remove(blocks[i].content)
|
|
linkDefState.remove(blocks[i].content)
|
|
}
|
|
for i in changed {
|
|
listIndentState.add(newBlocks[i].content)
|
|
linkDefState.add(newBlocks[i].content)
|
|
}
|
|
listIndentUnit = listIndentState.unit
|
|
defsChanged = linkDefState != oldDefState
|
|
} else {
|
|
(newBlocks, changed) = BlockParser.parseWithDiff(rawSource, previous: blocks)
|
|
let oldDefState = linkDefState
|
|
rebuildListIndentState()
|
|
rebuildLinkDefState()
|
|
defsChanged = linkDefState != oldDefState
|
|
}
|
|
blocks = newBlocks
|
|
|
|
var dirty = IndexSet(integersIn: changed)
|
|
|
|
// Map the old active block through the diff so its deactivation
|
|
// restyle lands on the right index: prefix indices are unchanged,
|
|
// suffix indices shift by the count delta, and anything inside the
|
|
// window is already dirty.
|
|
if let old = oldActive {
|
|
let suffixCount = newBlocks.count - changed.upperBound
|
|
if old < changed.lowerBound {
|
|
dirty.insert(old)
|
|
} else if old >= oldCount - suffixCount {
|
|
dirty.insert(old + (newBlocks.count - oldCount))
|
|
}
|
|
}
|
|
|
|
// The block under the cursor gets cursor-aware delimiter styling
|
|
// (this also subsumes the old per-keystroke applyBlockStyle pass).
|
|
if let newActive = blockIndexForRawOffset(cursorRaw) {
|
|
dirty.insert(newActive)
|
|
}
|
|
|
|
// listIndentUnit is document-global: when it changes, the rendered
|
|
// indentation of every list block changes with it.
|
|
if listIndentUnit != oldIndentUnit {
|
|
for (i, block) in blocks.enumerated() where block.kind == .listItem {
|
|
dirty.insert(i)
|
|
}
|
|
}
|
|
|
|
// A changed link definition can flip any reference link (even a bare
|
|
// `[label]` shortcut) elsewhere in the document, so restyle every block
|
|
// that could hold one. Bracket-free blocks can't, so skip them.
|
|
if defsChanged {
|
|
for (i, block) in blocks.enumerated() where block.content.contains("[") {
|
|
dirty.insert(i)
|
|
}
|
|
}
|
|
|
|
recomposeDirty(dirty, cursorInRaw: cursorRaw)
|
|
|
|
// If the queued selection fixup leaped the caret off the edit point
|
|
// during the restyle's `endEditing`, put it back. Only fires on a real
|
|
// mismatch — normal edits already sit at `expectedCaret`, so this is a
|
|
// no-op then (no spurious selection notification). Permanent breadcrumb
|
|
// (release builds too): a recurrence prints which edit leaped.
|
|
if let want = expectedCaret {
|
|
let now = selectedRange()
|
|
if now.location != want || now.length != 0 {
|
|
Log.info("re-asserting caret after fixup leap (normal path): " +
|
|
"\(now.location)→\(want)", category: .edit)
|
|
setSelectedRange(NSRange(location: want, length: 0))
|
|
}
|
|
}
|
|
|
|
renumberOrderedListRunsIfNeeded(touching: changed)
|
|
|
|
traceEdit("synced cursorRaw=\(cursorRaw) changed=\(changed.lowerBound)..<\(changed.upperBound) oldActive=\(oldActive.map(String.init) ?? "nil")")
|
|
verifyEditorInvariants("syncRawSourceFromDisplay")
|
|
}
|
|
|
|
#if DEBUG
|
|
/// Debug oracle for the incremental parser: every incremental result must
|
|
/// equal a from-scratch parse (content, ranges, kinds — IDs are allowed
|
|
/// to differ). Skipped under MD_PERF so measurements stay representative.
|
|
private func verifyIncrementalParse(_ incremental: [Block]) {
|
|
guard ProcessInfo.processInfo.environment["MD_PERF"] == nil else { return }
|
|
let reference = BlockParser.parse(rawSource)
|
|
guard incremental.count == reference.count else {
|
|
assertionFailure("""
|
|
incremental parse diverged: \(incremental.count) blocks \
|
|
vs \(reference.count) reference
|
|
""")
|
|
return
|
|
}
|
|
for (a, b) in zip(incremental, reference) {
|
|
if a.content != b.content || a.range != b.range || a.kind != b.kind {
|
|
assertionFailure("""
|
|
incremental parse diverged at \(a.range): \
|
|
\(String(reflecting: a.content)) [\(a.kind)] vs \
|
|
\(String(reflecting: b.content)) [\(b.kind)] at \(b.range)
|
|
""")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|