diff --git a/Sources/Core/Source/DocumentMetrics.swift b/Sources/Core/Source/DocumentMetrics.swift index 5f852a3..e098157 100644 --- a/Sources/Core/Source/DocumentMetrics.swift +++ b/Sources/Core/Source/DocumentMetrics.swift @@ -1,7 +1,7 @@ import Foundation struct DocumentMetrics: Sendable, Equatable { - private static let mermaidNeedle: [unichar] = Array("mermaid".utf16) + private static let mermaidNeedle = Array("mermaid".utf8) private static let editContextLength = mermaidNeedle.count let utf8ByteCount: Int @@ -13,10 +13,12 @@ struct DocumentMetrics: Sendable, Equatable { } init(text: String) { - let source = text as NSString - utf8ByteCount = text.utf8.count - lineCount = Self.logicalLineBreakCount(in: source) + 1 - mermaidCandidateCount = Self.mermaidCount(in: source) + let counts = + text.utf8.withContiguousStorageIfAvailable(Self.scan) + ?? Array(text.utf8).withUnsafeBufferPointer(Self.scan) + utf8ByteCount = counts.bytes + lineCount = counts.lineBreaks + 1 + mermaidCandidateCount = counts.mermaid } func applying(_ edit: SourceEdit, to previous: String) -> DocumentMetrics { @@ -50,6 +52,8 @@ struct DocumentMetrics: Sendable, Equatable { with: edit.replacement ) + let oldMetrics = DocumentMetrics(text: oldContext as String) + let newMetrics = DocumentMetrics(text: newContext as String) let removed = source.substring(with: edit.range).utf8.count return DocumentMetrics( utf8ByteCount: max( @@ -59,14 +63,14 @@ struct DocumentMetrics: Sendable, Equatable { lineCount: max( 1, lineCount - - Self.logicalLineBreakCount(in: oldContext) - + Self.logicalLineBreakCount(in: newContext) + - oldMetrics.lineCount + + newMetrics.lineCount ), mermaidCandidateCount: max( 0, mermaidCandidateCount - - Self.mermaidCount(in: oldContext) - + Self.mermaidCount(in: newContext) + - oldMetrics.mermaidCandidateCount + + newMetrics.mermaidCandidateCount ) ) } @@ -81,51 +85,43 @@ struct DocumentMetrics: Sendable, Equatable { self.mermaidCandidateCount = mermaidCandidateCount } - private static func logicalLineBreakCount(in text: NSString) -> Int { - var count = 0 - var index = 0 - while index < text.length { - switch text.character(at: index) { - case 0x000D: - count += 1 - if index + 1 < text.length, - text.character(at: index + 1) == 0x000A - { - index += 1 + // UTF-8 preserves ASCII delimiters verbatim. Scan the native bytes once, + // including the two Unicode line separators, without bridging the document + // to NSString or sending an Objective-C message for each code unit. + private static func scan( + _ bytes: UnsafeBufferPointer + ) -> (bytes: Int, lineBreaks: Int, mermaid: Int) { + var lineBreaks = 0 + var mermaid = 0 + for index in bytes.indices { + let byte = bytes[index] + if byte == 0x0D { + lineBreaks += 1 + } else if byte == 0x0A { + if index == 0 || bytes[index - 1] != 0x0D { + lineBreaks += 1 } - case 0x000A, 0x2028, 0x2029: - count += 1 - default: - break + } else if byte == 0xE2, index + 2 < bytes.count, + bytes[index + 1] == 0x80, + bytes[index + 2] == 0xA8 || bytes[index + 2] == 0xA9 + { + lineBreaks += 1 } - index += 1 - } - return count - } - - private static func mermaidCount(in text: NSString) -> Int { - guard text.length >= mermaidNeedle.count else { return 0 } - var count = 0 - let lastStart = text.length - mermaidNeedle.count - for start in 0...lastStart { - var matches = true - for offset in mermaidNeedle.indices { - let character = text.character(at: start + offset) - let folded: unichar - if character >= 0x0041, character <= 0x005A { - folded = character + 0x0020 - } else { - folded = character + if byte | 0x20 == mermaidNeedle[0], + bytes.count - index >= mermaidNeedle.count + { + var matches = true + for offset in 1.. 0 { - rescanStartIndex -= 1 + // Only line breaks inside the edit and its immediate CRLF context can + // change. Starting at a line boundary would rescan an arbitrarily long + // unchanged line on every keystroke. + var rescanStart = max(0, edit.range.location - 1) + if rescanStart > 0, + previous.character(at: rescanStart) == 0x000A, + previous.character(at: rescanStart - 1) == 0x000D + { + rescanStart -= 1 } - guard let rescanStart = value(at: rescanStartIndex) else { - return reset(to: updated) + var oldRescanEnd = min(previous.length, NSMaxRange(edit.range) + 1) + if oldRescanEnd < previous.length, + previous.character(at: oldRescanEnd - 1) == 0x000D, + previous.character(at: oldRescanEnd) == 0x000A + { + oldRescanEnd += 1 } - - let endContext = min( - previous.length, - NSMaxRange(edit.range) + 1 - ) - let firstStartAfterContext = upperBound(of: endContext) - let preservedSuffixIndex = min( - root?.entryCount ?? 0, - firstStartAfterContext + 1 - ) - let oldRescanEnd = - value(at: preservedSuffixIndex) - ?? previous.length + let preservedPrefixCount = upperBound(of: rescanStart) + let preservedSuffixIndex = upperBound(of: oldRescanEnd) let lengthDelta = updated.length - previous.length - let newRescanEnd = min( - updated.length, - max(rescanStart, oldRescanEnd + lengthDelta) - ) + let newRescanEnd = oldRescanEnd + lengthDelta - let (prefix, remaining) = split(root, at: rescanStartIndex) + let (prefix, remaining) = split(root, at: preservedPrefixCount) let (_, unshiftedSuffix) = split( remaining, - at: preservedSuffixIndex - rescanStartIndex + at: preservedSuffixIndex - preservedPrefixCount ) unshiftedSuffix?.applyShift(lengthDelta) guard @@ -154,16 +149,9 @@ private struct CompleteSourceLineIndex: @unchecked Sendable { else { return false } - var rescanned: SourceLineIndexNode? = rescannedRoot - if let rescannedRoot = rescanned, - let unshiftedSuffix, - lastValue(in: rescannedRoot) == firstValue(in: unshiftedSuffix) - { - (rescanned, _) = split( - rescannedRoot, - at: rescannedRoot.entryCount - 1 - ) - } + // makeTree includes its starting offset; that synthetic entry already + // belongs to the preserved prefix or lies inside an existing line. + let (_, rescanned) = split(rescannedRoot, at: 1) root = merge(prefix, merge(rescanned, unshiftedSuffix)) textLength = updated.length return storedEntryCount <= Self.maximumStoredLineStarts @@ -268,22 +256,6 @@ private struct CompleteSourceLineIndex: @unchecked Sendable { ) } - private func firstValue(in node: SourceLineIndexNode) -> Int { - node.pushPendingShift() - guard let left = node.left else { - return node.value(at: 0) - } - return firstValue(in: left) - } - - private func lastValue(in node: SourceLineIndexNode) -> Int { - node.pushPendingShift() - guard let right = node.right else { - return node.value(at: node.blockCount - 1) - } - return lastValue(in: right) - } - private mutating func split( _ node: SourceLineIndexNode?, at requestedIndex: Int diff --git a/Sources/Core/Source/UTF16TextDifference.swift b/Sources/Core/Source/UTF16TextDifference.swift index 9a45d7f..bda7b1a 100644 --- a/Sources/Core/Source/UTF16TextDifference.swift +++ b/Sources/Core/Source/UTF16TextDifference.swift @@ -8,28 +8,25 @@ struct UTF16TextDifference: Sendable, Equatable { original: NSString, updated: NSString ) -> UTF16TextDifference { - var prefix = 0 let sharedLength = min(original.length, updated.length) - while prefix < sharedLength, - original.character(at: prefix) - == updated.character(at: prefix) - { - prefix += 1 - } + var prefix = matchingLength( + original: original, + updated: updated, + limit: sharedLength, + backwards: false + ) if splitsSurrogatePair(at: prefix, in: original) || splitsSurrogatePair(at: prefix, in: updated) { prefix -= 1 } - var suffix = 0 - while suffix < original.length - prefix, - suffix < updated.length - prefix, - original.character(at: original.length - suffix - 1) - == updated.character(at: updated.length - suffix - 1) - { - suffix += 1 - } + var suffix = matchingLength( + original: original, + updated: updated, + limit: sharedLength - prefix, + backwards: true + ) if splitsSurrogatePair( at: original.length - suffix, in: original @@ -54,6 +51,49 @@ struct UTF16TextDifference: Sendable, Equatable { ) } + private static func matchingLength( + original: NSString, + updated: NSString, + limit: Int, + backwards: Bool + ) -> Int { + guard limit > 0 else { return 0 } + let capacity = 1_024 + // Bound scratch space independently of document size. Bulk extraction + // avoids millions of character(at:) dispatches for unchanged spans. + return withUnsafeTemporaryAllocation(of: unichar.self, capacity: capacity * 2) { buffer in + let oldUnits = buffer.baseAddress! + let newUnits = oldUnits + capacity + var matched = 0 + while matched < limit { + let count = min(capacity, limit - matched) + original.getCharacters( + oldUnits, + range: NSRange( + location: backwards ? original.length - matched - count : matched, + length: count + )) + updated.getCharacters( + newUnits, + range: NSRange( + location: backwards ? updated.length - matched - count : matched, + length: count + )) + if memcmp(oldUnits, newUnits, count * MemoryLayout.stride) == 0 { + matched += count + continue + } + for offset in 0.. Bool { guard @@ -118,6 +119,12 @@ enum MarkdownEngineCompatibility { else { return false } + // The engine uses editability to decide whether selection reveals + // syntax. Keep peer edits and selection restoration in one focus-aware + // styling scope, just like the full-restyle path below. + let wasEditable = textView.isEditable + textView.isEditable = wasEditable && textView.window?.firstResponder === textView + defer { textView.isEditable = wasEditable } let unchangedText = textView.string guard coordinator.textView( @@ -136,6 +143,9 @@ enum MarkdownEngineCompatibility { ) textStorage.endEditing() textView.didChangeText() + if textView.selectedRange() != selectedRange { + textView.setSelectedRange(selectedRange) + } return textView.string == updatedPresentation.text } diff --git a/Sources/Editor/Composition/EditorPaneStateCoordinator.swift b/Sources/Editor/Composition/EditorPaneStateCoordinator.swift index a2f9ff7..e00b488 100644 --- a/Sources/Editor/Composition/EditorPaneStateCoordinator.swift +++ b/Sources/Editor/Composition/EditorPaneStateCoordinator.swift @@ -478,6 +478,9 @@ final class EditorPaneStateCoordinator: NSObject { edit, from: previousPresentation, to: newPresentation, + restoringSelection: newPresentation.presentedRange( + forSourceRange: pendingSelectionRestore ?? pane.selectedRange + ), in: textView ) pane.bindingMutationAccumulator.reset() diff --git a/Sources/Editor/Composition/LivePreviewTextView.swift b/Sources/Editor/Composition/LivePreviewTextView.swift index 167acea..6f9dcf7 100644 --- a/Sources/Editor/Composition/LivePreviewTextView.swift +++ b/Sources/Editor/Composition/LivePreviewTextView.swift @@ -357,7 +357,7 @@ enum MarkdownEditorTextAdapter { let presented = presentedSource as NSString let edited = editorText as NSString - let difference = UTF16TextDifference.between( + let difference = differenceForNewlineNormalization( original: presented, updated: edited ) @@ -426,15 +426,29 @@ enum MarkdownEditorTextAdapter { return nil } + // TextKit can coalesce character and styling changes into a wider + // edited range. Trim only that bounded window so a single keystroke + // remains an incremental source edit and leaves neighboring newlines + // untouched by normalization. + let source = currentRevision.text as NSString + let capturedSourceRange = NSRange( + location: sourceRange.location + capturedMutation.range.location, + length: capturedMutation.range.length + ) + let replacement = capturedMutation.replacement as NSString + let difference = differenceForNewlineNormalization( + original: source.substring(with: capturedSourceRange) as NSString, + updated: replacement + ) let normalizedReplacement = normalizeNewlines( - capturedMutation.replacement, + replacement.substring(with: difference.updatedRange), to: newlineStyle ) return SourceEdit( range: NSRange( - location: sourceRange.location - + capturedMutation.range.location, - length: capturedMutation.range.length + location: capturedSourceRange.location + + difference.originalRange.location, + length: difference.originalRange.length ), replacement: terminatingFrontMatterNewlineIfNeeded( normalizedReplacement, @@ -503,6 +517,36 @@ enum MarkdownEditorTextAdapter { || character == 0x2029 } + private static func differenceForNewlineNormalization( + original: NSString, + updated: NSString + ) -> UTF16TextDifference { + let difference = UTF16TextDifference.between(original: original, updated: updated) + var start = difference.originalRange.location + var originalEnd = NSMaxRange(difference.originalRange) + var updatedEnd = NSMaxRange(difference.updatedRange) + // A common prefix/suffix can end between CR and LF. Normalize the + // entire pair so retaining its other half cannot duplicate a newline + // or turn a CRLF into a lone CR/LF. Both binding paths share this rule. + if splitsCRLF(at: start, in: original) || splitsCRLF(at: start, in: updated) { + start -= 1 + } + if splitsCRLF(at: originalEnd, in: original) || splitsCRLF(at: updatedEnd, in: updated) { + originalEnd += 1 + updatedEnd += 1 + } + return UTF16TextDifference( + originalRange: NSRange(location: start, length: originalEnd - start), + updatedRange: NSRange(location: start, length: updatedEnd - start) + ) + } + + private static func splitsCRLF(at location: Int, in text: NSString) -> Bool { + location > 0 && location < text.length + && text.character(at: location - 1) == 0x0D + && text.character(at: location) == 0x0A + } + private static func normalizeNewlines( _ text: String, to style: NewlineStyle diff --git a/Tests/Performance/EditPipelinePerformanceAuditTests.swift b/Tests/Performance/EditPipelinePerformanceAuditTests.swift index 3f121a8..d71bb2a 100644 --- a/Tests/Performance/EditPipelinePerformanceAuditTests.swift +++ b/Tests/Performance/EditPipelinePerformanceAuditTests.swift @@ -13,6 +13,54 @@ final class EditPipelinePerformanceAuditTests: XCTestCase { let capturedMutationP95BudgetMilliseconds: Double } + func testLongLineIndexEditsStayWithinIncrementalBudget() throws { + let source = String(repeating: "x", count: 1_024 * 1_024) + let updated = source + "y" + var index = SourceLineIndex(text: source) + let insertion = SourceEdit( + range: NSRange(location: source.utf16.count, length: 0), + replacement: "y", + expectedRevision: 0, + origin: .localEditor(paneID: UUID()) + ) + let removal = SourceEdit( + range: NSRange(location: source.utf16.count, length: 1), + replacement: "", + expectedRevision: 1, + origin: .undo + ) + let samples = try measureSamples { + XCTAssertTrue(index.apply(insertion, previousText: source, updatedText: updated)) + XCTAssertEqual( + index.position(atUTF16Location: updated.utf16.count, in: updated).column, + updated.utf16.count + 1) + XCTAssertTrue(index.apply(removal, previousText: updated, updatedText: source)) + } + XCTAssertLessThanOrEqual( + report(name: "line-index-long-line-1mib", samples: samples), + 1, + "Small edits must not rescan the surrounding long line." + ) + } + + func testFourMiBMetadataPreparationStaysWithinBudget() throws { + let line = "中文 😀 e\u{301} MeRmAiD\r\nparagraph\u{2028}next\u{2029}end\r" + let repetitions = 4 * 1_024 * 1_024 / line.utf8.count + let text = String(repeating: line, count: repetitions) + var metrics: DocumentMetrics? + let samples = try measureSamples { + metrics = DocumentMetrics(text: text) + } + XCTAssertEqual(metrics?.utf8ByteCount, text.utf8.count) + XCTAssertEqual(metrics?.lineCount, repetitions * 4 + 1) + XCTAssertEqual(metrics?.mermaidCandidateCount, repetitions) + XCTAssertLessThanOrEqual( + report(name: "metadata-unicode-4mib", samples: samples), + 10, + "Metadata preparation must avoid per-code-unit Foundation dispatch." + ) + } + @MainActor func testPreparedFourMiBInstallationStaysWithinMainActorBudget() async throws { let source = source(byteCount: 4 * 1_024 * 1_024) @@ -116,6 +164,7 @@ final class EditPipelinePerformanceAuditTests: XCTestCase { var insertionLocation = NSMaxRange(target) var samples: [Double] = [] var originatingPaneSamples: [Double] = [] + let secondaryUpdateVersion = workspace.secondaryPane.markdownEngineUpdateVersion for index in 0..<9 { let replacement = String(index) let expected = NSMutableString(string: buffer.revision.text) @@ -143,6 +192,10 @@ final class EditPipelinePerformanceAuditTests: XCTestCase { && textViews.allSatisfy { $0.string == expectedText } } samples.append(milliseconds(ContinuousClock.now - start)) + XCTAssertEqual( + workspace.secondaryPane.markdownEngineUpdateVersion, secondaryUpdateVersion) + XCTAssertEqual(buffer.revision.number, UInt64(index + 1)) + XCTAssertEqual(buffer.lastAppliedEdit?.range, insertionRange) } _ = report( @@ -155,7 +208,7 @@ final class EditPipelinePerformanceAuditTests: XCTestCase { ) XCTAssertLessThanOrEqual( p95, - 1_000, + 250, "The 128 KiB continuous-list workload must avoid full pane rebuilds." ) window.contentView = nil @@ -186,10 +239,15 @@ final class EditPipelinePerformanceAuditTests: XCTestCase { } XCTAssertEqual(resultLength, source.utf16.count + 1) - report( + let p95 = report( name: "legacy-binding-\(workload.name)", samples: samples ) + XCTAssertLessThanOrEqual( + p95, + workload.capturedMutationP95BudgetMilliseconds, + "Fallback reconciliation must scan unchanged spans in bulk." + ) } } diff --git a/Tests/Unit/Core/SourceScanningTests.swift b/Tests/Unit/Core/SourceScanningTests.swift new file mode 100644 index 0000000..44eb1f3 --- /dev/null +++ b/Tests/Unit/Core/SourceScanningTests.swift @@ -0,0 +1,108 @@ +import Foundation +import XCTest + +@testable import DarthScriptum + +final class SourceScanningTests: XCTestCase { + func testDifferenceMatchesReferenceAcrossBufferAndUnicodeBoundaries() { + let fragments = ["", "x", "\r\n", "😀", "😁", "中", "e\u{301}", "é"] + for padding in [0, 1, 1_023, 1_024, 1_025, 2_047, 2_048] { + let prefix = String(repeating: "a", count: padding) + let suffix = String(repeating: "z", count: padding) + for old in fragments { + for new in fragments { + let original = (prefix + old + suffix) as NSString + let updated = (prefix + new + suffix) as NSString + let difference = UTF16TextDifference.between( + original: original, updated: updated) + XCTAssertEqual(difference, referenceDifference(original, updated)) + let reconstructed = NSMutableString(string: original) + reconstructed.replaceCharacters( + in: difference.originalRange, + with: updated.substring(with: difference.updatedRange)) + XCTAssertEqual(reconstructed, updated) + } + } + } + } + + func testMetricsPreserveASCIIFoldingAndUnicodeLineSeparators() { + let text = + "mermermaid MERMAID mermaidmermaid mermMermaid\r\n" + + "中文 😀 e\u{301}\u{2028}next\u{2029}\r\n\r\n\n\r" + let metrics = DocumentMetrics(text: text) + XCTAssertEqual(metrics.utf8ByteCount, text.utf8.count) + XCTAssertEqual(metrics.lineCount, 8) + XCTAssertEqual(metrics.mermaidCandidateCount, 5) + XCTAssertEqual(DocumentMetrics(text: "").lineCount, 1) + XCTAssertEqual(DocumentMetrics(text: "mermaİd").mermaidCandidateCount, 0) + } + + func testLineIndexEditsPreserveCRLFAtEveryRescanBoundary() throws { + let fragments = ["", "a", "\r", "\n", "\r\n", "\n\r", "😀e\u{301}"] + for prefix in fragments { + for suffix in fragments { + let source = prefix + "\r\nx\r\n" + suffix + let revision = SourceRevision(number: 0, text: source) + let length = (source as NSString).length + for location in 0...length { + for removal in 0...min(3, length - location) { + for replacement in fragments { + let edit = SourceEdit( + range: NSRange(location: location, length: removal), + replacement: replacement, + expectedRevision: 0, + origin: .localEditor(paneID: UUID()) + ) + guard let updated = try? edit.applying(to: revision) else { continue } + var index = SourceLineIndex(text: source) + XCTAssertTrue( + index.apply(edit, previousText: source, updatedText: updated.text)) + let reference = SourceLineIndex(text: updated.text) + for offset in 0...(updated.text as NSString).length { + let actual = index.position( + atUTF16Location: offset, in: updated.text) + let expected = reference.position( + atUTF16Location: offset, in: updated.text) + XCTAssertEqual(actual.line, expected.line) + XCTAssertEqual(actual.column, expected.column) + } + } + } + } + } + } + } + + private func referenceDifference(_ original: NSString, _ updated: NSString) + -> UTF16TextDifference + { + var prefix = 0 + while prefix < min(original.length, updated.length), + original.character(at: prefix) == updated.character(at: prefix) + { + prefix += 1 + } + if UTF16TextDifference.splitsSurrogatePair(at: prefix, in: original) + || UTF16TextDifference.splitsSurrogatePair(at: prefix, in: updated) + { + prefix -= 1 + } + var suffix = 0 + while suffix < min(original.length, updated.length) - prefix, + original.character(at: original.length - suffix - 1) + == updated.character(at: updated.length - suffix - 1) + { + suffix += 1 + } + if UTF16TextDifference.splitsSurrogatePair(at: original.length - suffix, in: original) + || UTF16TextDifference.splitsSurrogatePair(at: updated.length - suffix, in: updated) + { + suffix -= 1 + } + return UTF16TextDifference( + originalRange: NSRange(location: prefix, length: original.length - prefix - suffix), + updatedRange: NSRange(location: prefix, length: updated.length - prefix - suffix) + ) + } +} diff --git a/Tests/Unit/Editor/Compatibility/MarkdownEngineCompatibilityTests.swift b/Tests/Unit/Editor/Compatibility/MarkdownEngineCompatibilityTests.swift index c32d7d7..b12b9b3 100644 --- a/Tests/Unit/Editor/Compatibility/MarkdownEngineCompatibilityTests.swift +++ b/Tests/Unit/Editor/Compatibility/MarkdownEngineCompatibilityTests.swift @@ -118,6 +118,7 @@ final class MarkdownEngineCompatibilityTests: XCTestCase { source: updated.text, rendersMarkdown: true ), + restoringSelection: textView.selectedRange(), in: textView ) ) diff --git a/Tests/Unit/Editor/Composition/LivePreviewTextViewTests.swift b/Tests/Unit/Editor/Composition/LivePreviewTextViewTests.swift index 7a3d624..3ae9022 100644 --- a/Tests/Unit/Editor/Composition/LivePreviewTextViewTests.swift +++ b/Tests/Unit/Editor/Composition/LivePreviewTextViewTests.swift @@ -55,6 +55,75 @@ final class LivePreviewTextViewTests: XCTestCase { XCTAssertEqual(try edit.applying(to: revision).text, "alpha\nxomega") } + func testEditorTextAdapterTrimsRestyledContextFromCapturedMutation() throws { + let source = "---\ntitle: Test\n---\n\nalpha **fast** 😀 e\u{301}\r\nomega" + let revision = SourceRevision(number: 7, text: source) + let presentation = MarkdownSourcePresentation.make(source: source, rendersMarkdown: true) + let original = presentation.text as NSString + let location = NSMaxRange(original.range(of: "fast")) + let updated = original.replacingCharacters( + in: NSRange(location: location, length: 0), with: "x") + let edit = try XCTUnwrap( + MarkdownEditorTextAdapter.sourceEdit( + editorText: updated, + capturedMutation: EditorBindingMutation( + range: NSRange(location: 0, length: original.length), + replacement: updated, + sourceRevisionNumber: revision.number, + presentedSourceRange: presentation.sourceRange, + originalPresentedLength: original.length, + updatedPresentedLength: original.length + 1 + ), + currentRevision: revision, + newlineStyle: .lf, + origin: .localEditor(paneID: UUID()), + presentedSourceRange: presentation.sourceRange + )) + + XCTAssertEqual( + edit.range, NSRange(location: presentation.sourceRange.location + location, length: 0)) + XCTAssertEqual(edit.replacement, "x") + XCTAssertEqual( + try edit.applying(to: revision).text, + (source as NSString).replacingCharacters( + in: NSRange(location: presentation.sourceRange.location + location, length: 0), + with: "x")) + } + + func testEditorTextAdapterKeepsCRLFPairsIntactWhenTrimmingReplacements() throws { + let changes: [(String, String, NewlineStyle, String)] = [ + ("\n", "\r\n", .lf, "\n"), + ("\r\n", "\n", .crlf, "\r\n"), + ("\r", "\r\n", .crlf, "\r\n"), + ("\r\n", "\r", .lf, "\n"), + ("\rX\n", "\r\n", .lf, "\n"), + ] + for (old, replacement, style, expected) in changes { + let source = "a" + old + "b" + let edited = "a" + replacement + "b" + let revision = SourceRevision(number: 7, text: source) + for usesCapture in [false, true] { + let mutation = EditorBindingMutation( + range: NSRange(location: 1, length: old.utf16.count), + replacement: replacement, + sourceRevisionNumber: revision.number, + presentedSourceRange: NSRange(location: 0, length: source.utf16.count), + originalPresentedLength: source.utf16.count, + updatedPresentedLength: edited.utf16.count + ) + let edit = try XCTUnwrap( + MarkdownEditorTextAdapter.sourceEdit( + editorText: edited, + capturedMutation: usesCapture ? mutation : nil, + currentRevision: revision, + newlineStyle: style, + origin: .localEditor(paneID: UUID()) + )) + XCTAssertEqual(try edit.applying(to: revision).text, "a" + expected + "b") + } + } + } + func testEditorTextAdapterFallsBackForStaleCapturedMutation() throws { let source = "alpha\nomega" let revision = SourceRevision(number: 7, text: source) @@ -546,6 +615,63 @@ final class LivePreviewTextViewTests: XCTestCase { _ = window } + func testSplitPaneNativeTypingPreservesUnfocusedSyntaxAndSelection() async throws { + let source = "# Heading\n\nalpha **fast** omega" + let caretInBody = NSMaxRange((source as NSString).range(of: "fast")) + for initialSelection in [0, caretInBody] { + let buffer = MarkdownSourceBuffer( + snapshot: DocumentSnapshot(text: source, format: .newDocument)) + let primaryPane = EditorPaneModel() + let secondaryPane = EditorPaneModel() + secondaryPane.selectedRange = NSRange(location: initialSelection, length: 0) + let hostingView = NSHostingView( + rootView: HStack { + LivePreviewTextView( + sourceBuffer: buffer, pane: primaryPane, + sourceMode: false, fontSize: 14, newlineStyle: .lf) + LivePreviewTextView( + sourceBuffer: buffer, pane: secondaryPane, + sourceMode: false, fontSize: 14, newlineStyle: .lf) + }.frame(width: 800, height: 400)) + hostingView.frame = NSRect(x: 0, y: 0, width: 800, height: 400) + let window = NSWindow( + contentRect: hostingView.frame, styleMask: [.borderless], + backing: .buffered, defer: false) + window.contentView = hostingView + window.layoutIfNeeded() + defer { window.contentView = nil } + try await waitUntil { + let views = MarkdownEngineCompatibility.nativeTextViews(in: hostingView) + return views.count == 2 + && views.allSatisfy { $0.identifier != nil && $0.string == source } + } + let textViews = MarkdownEngineCompatibility.nativeTextViews(in: hostingView) + let primary = try XCTUnwrap( + textViews.first { + $0.identifier?.rawValue + == "DarthScriptum.MarkdownEditor.\(primaryPane.id.uuidString)" + }) + let secondary = try XCTUnwrap(textViews.first { $0 !== primary }) + let location = NSMaxRange((source as NSString).range(of: "fast")) + let insertion = NSRange(location: location, length: 0) + primary.setSelectedRange(insertion) + primary.insertText("x", replacementRange: insertion) + let expected = (source as NSString).replacingCharacters(in: insertion, with: "x") + try await waitUntil { + buffer.revision.text == expected && secondary.string == expected + } + let storage = try XCTUnwrap(secondary.textStorage) + XCTAssertTrue(isSyntaxHidden(in: storage, at: 0)) + let expectedSelection = NSRange( + location: initialSelection == 0 ? 0 : initialSelection + 1, length: 0) + XCTAssertEqual(secondary.selectedRange(), expectedSelection) + XCTAssertEqual(secondaryPane.selectedRange, expectedSelection) + XCTAssertTrue(secondary.isEditable) + XCTAssertTrue( + isSyntaxHidden(in: storage, at: (source as NSString).range(of: "**").location)) + } + } + func testSplitPaneAppliesPlainSharedSourceEditBeforeSwiftUIRebuild() async throws {