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