diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 6cb5298..8300b48 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Select latest stable Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "latest-stable" + xcode-version: "16.4" - name: Install xcsift run: brew install xcsift @@ -42,7 +42,7 @@ jobs: - name: Select latest stable Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "latest-stable" + xcode-version: "16.4" - name: Install xcsift run: brew install xcsift @@ -53,6 +53,10 @@ jobs: mkdir -p "$SNAPSHOT_ARTIFACTS" swift test --filter TextDiffSnapshotTests 2>&1 | tee "$SNAPSHOT_ARTIFACTS/swift-test.log" | xcsift --warnings + - name: Collect snapshot artifact bundle + if: always() + run: swift Scripts/collect_snapshot_artifacts.swift + - name: Upload snapshot artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/356ED75C-DC2E-441F-90EF-CD873A212B51.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/356ED75C-DC2E-441F-90EF-CD873A212B51.plist new file mode 100644 index 0000000..1327ec8 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/356ED75C-DC2E-441F-90EF-CD873A212B51.plist @@ -0,0 +1,42 @@ + + + + + classNames + + DiffLayouterPerformanceTests + + testLayoutPerformance1000Words() + + com.apple.dt.XCTMetric_Clock.time.monotonic + + baselineAverage + 0.0168714 + baselineIntegrationDisplayName + Local Baseline + + + testLayoutPerformance200Words() + + com.apple.dt.XCTMetric_Clock.time.monotonic + + baselineAverage + 0.003373 + baselineIntegrationDisplayName + Local Baseline + + + testLayoutPerformance500Words() + + com.apple.dt.XCTMetric_Clock.time.monotonic + + baselineAverage + 0.0082662 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/Info.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/Info.plist index 1a5d3e5..23cab07 100644 --- a/.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/Info.plist +++ b/.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/Info.plist @@ -28,6 +28,30 @@ targetArchitecture arm64 + 356ED75C-DC2E-441F-90EF-CD873A212B51 + + localComputer + + busSpeedInMHz + 0 + cpuCount + 1 + cpuKind + Apple M5 Pro + cpuSpeedInMHz + 0 + logicalCPUCoresPerPackage + 18 + modelCode + Mac17,9 + physicalCPUCoresPerPackage + 18 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + arm64 + diff --git a/AGENTS.md b/AGENTS.md index d235d6e..9a14f66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,3 +34,32 @@ Use the flags above (e.g., `--coverage-details`, `--coverage-path`) as needed, b ## Code search - Use [`ripgrep`](https://github.com/BurntSushi/ripgrep) (`rg`) for searching within files—it is much faster than grep/ack/ag, respects `.gitignore`, and has smart defaults. - Typical commands: `rg "TODO"` (find TODOs), `rg -n --glob '!dist' pattern.swift` (search with line numbers while excluding `dist`). + + + + + +## BACKLOG WORKFLOW INSTRUCTIONS + +This project uses Backlog.md MCP for all task and project management activities. + +**CRITICAL GUIDANCE** + +- If your client supports MCP resources, read `backlog://workflow/overview` to understand when and how to use Backlog for this project. +- If your client only supports tools or the above request fails, call `backlog.get_workflow_overview()` tool to load the tool-oriented overview (it lists the matching guide tools). + +- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow +- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)") +- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work + +These guides cover: +- Decision framework for when to create tasks +- Search-first workflow to avoid duplicates +- Links to detailed guides for task creation, execution, and finalization +- MCP tools reference + +You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here. + + + + diff --git a/README.md b/README.md index 5694b78..ce44696 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,17 @@ Update baselines intentionally: 2. Run `swift test 2>&1 | xcsift --quiet` once to rewrite baselines. 3. Switch the suite trait back to `.missing`. 4. Review snapshot image diffs in your PR before merging. + +## Performance Testing + +- Performance baselines for `DiffLayouterPerformanceTests` are stored under `.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/`. +- `swift test` runs the performance tests, but it does not surface the committed Xcode baseline values in its output. +- For baseline-aware runs, use the generated SwiftPM workspace and the `TextDiff` scheme. + +Run the layouter performance suite with Xcode: + +```bash +xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -scheme TextDiff -destination 'platform=macOS' -configuration Debug test -only-testing:TextDiffTests/DiffLayouterPerformanceTests 2>&1 | xcsift +``` + +If you need the raw measured averages for comparison, run the same command once without `xcsift` because XCTest prints the per-test values directly in the plain `xcodebuild` output. diff --git a/Scripts/collect_snapshot_artifacts.swift b/Scripts/collect_snapshot_artifacts.swift new file mode 100644 index 0000000..ff5224a --- /dev/null +++ b/Scripts/collect_snapshot_artifacts.swift @@ -0,0 +1,107 @@ +#!/usr/bin/env swift + +import AppKit +import CoreImage +import Foundation + +let fileManager = FileManager.default +let repoRoot = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) +let artifactsURL = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["SNAPSHOT_ARTIFACTS"] + ?? repoRoot.appendingPathComponent("snapshot-artifacts", isDirectory: true).path, + isDirectory: true +) +let referencesRootURL = repoRoot + .appendingPathComponent("Tests", isDirectory: true) + .appendingPathComponent("TextDiffTests", isDirectory: true) + .appendingPathComponent("__Snapshots__", isDirectory: true) + +guard fileManager.fileExists(atPath: artifactsURL.path) else { + print("No snapshot artifacts directory found at \(artifactsURL.path)") + exit(0) +} + +let ciContext = CIContext(options: nil) +let pngExtension = "png" + +func loadCIImage(from url: URL) -> CIImage? { + guard let image = NSImage(contentsOf: url), + let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) else { + return nil + } + return CIImage(bitmapImageRep: bitmap) +} + +func writePNG(ciImage: CIImage, to url: URL) throws { + let extent = ciImage.extent.integral + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let cgImage = ciContext.createCGImage(ciImage, from: extent, format: .RGBA8, colorSpace: colorSpace) else { + throw NSError(domain: "collect_snapshot_artifacts", code: 1) + } + + let rep = NSBitmapImageRep(cgImage: cgImage) + guard let data = rep.representation(using: .png, properties: [:]) else { + throw NSError(domain: "collect_snapshot_artifacts", code: 2) + } + try data.write(to: url) +} + +func diffImage(referenceURL: URL, failedURL: URL) -> CIImage? { + guard let reference = loadCIImage(from: referenceURL), + let failed = loadCIImage(from: failedURL), + let filter = CIFilter(name: "CIDifferenceBlendMode") else { + return nil + } + filter.setValue(reference, forKey: kCIInputImageKey) + filter.setValue(failed, forKey: kCIInputBackgroundImageKey) + return filter.outputImage +} + +let artifactFiles = fileManager.enumerator( + at: artifactsURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] +) ?? NSEnumerator() + +var enrichedCount = 0 + +for case let fileURL as URL in artifactFiles { + guard fileURL.pathExtension == pngExtension else { continue } + + let relativePath = fileURL.path.replacingOccurrences(of: artifactsURL.path + "/", with: "") + guard !relativePath.contains(".reference."), + !relativePath.contains(".failed."), + !relativePath.contains(".diff.") else { + continue + } + + let referenceURL = referencesRootURL.appendingPathComponent(relativePath) + guard fileManager.fileExists(atPath: referenceURL.path) else { + continue + } + + let baseURL = fileURL.deletingPathExtension() + let failedCopyURL = baseURL.appendingPathExtension("failed").appendingPathExtension(pngExtension) + let referenceCopyURL = baseURL.appendingPathExtension("reference").appendingPathExtension(pngExtension) + let diffURL = baseURL.appendingPathExtension("diff").appendingPathExtension(pngExtension) + + if fileManager.fileExists(atPath: failedCopyURL.path) { + try fileManager.removeItem(at: fileURL) + } else { + try fileManager.moveItem(at: fileURL, to: failedCopyURL) + } + + if !fileManager.fileExists(atPath: referenceCopyURL.path) { + try fileManager.copyItem(at: referenceURL, to: referenceCopyURL) + } + + if !fileManager.fileExists(atPath: diffURL.path), + let diff = diffImage(referenceURL: referenceURL, failedURL: failedCopyURL) { + try writePNG(ciImage: diff, to: diffURL) + } + + enrichedCount += 1 +} + +print("Enriched \(enrichedCount) snapshot artifact bundle(s) in \(artifactsURL.path)") diff --git a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift new file mode 100644 index 0000000..ed21b55 --- /dev/null +++ b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift @@ -0,0 +1,437 @@ +import CoreGraphics +import Foundation + +struct IndexedSegment { + let segmentIndex: Int + let segment: DiffSegment + let originalCursor: Int + let updatedCursor: Int + let originalRange: NSRange + let updatedRange: NSRange +} + +enum DiffRevertCandidateKind: Equatable { + case singleInsertion + case singleDeletion + case pairedReplacement +} + +struct DiffRevertCandidate: Equatable { + let id: Int + let kind: DiffRevertCandidateKind + let tokenKind: DiffTokenKind + let segmentIndices: [Int] + let updatedRange: NSRange + let replacementText: String + let originalTextFragment: String? + let updatedTextFragment: String? +} + +struct DiffRevertInteractionContext { + let candidatesByID: [Int: DiffRevertCandidate] + let runIndicesByActionID: [Int: [Int]] + let chipRectsByActionID: [Int: [CGRect]] + let unionChipRectByActionID: [Int: CGRect] +} + +enum DiffRevertActionResolver { + static func indexedSegments( + from segments: [DiffSegment], + original: String, + updated: String + ) -> [IndexedSegment] { + var output: [IndexedSegment] = [] + output.reserveCapacity(segments.count) + + let originalNSString = original as NSString + let updatedNSString = updated as NSString + var originalCursor = 0 + var updatedCursor = 0 + + for (index, segment) in segments.enumerated() { + let textLength = segment.text.utf16.count + let originalRange: NSRange + let updatedRange: NSRange + + switch segment.kind { + case .equal: + originalRange = NSRange(location: originalCursor, length: textLength) + updatedRange = NSRange(location: updatedCursor, length: textLength) + let originalMatches = textMatches(segment.text, source: originalNSString, at: originalCursor) + let updatedMatches = textMatches(segment.text, source: updatedNSString, at: updatedCursor) + #if !TESTING + assert( + originalMatches, + "Equal segment text mismatch in original at \(originalCursor) for segment \(index): \(segment.text)" + ) + assert( + updatedMatches, + "Equal segment text mismatch in updated at \(updatedCursor) for segment \(index): \(segment.text)" + ) + #endif + originalCursor += textLength + updatedCursor += textLength + case .delete: + originalRange = NSRange(location: originalCursor, length: textLength) + updatedRange = NSRange(location: updatedCursor, length: 0) + originalCursor += textLength + case .insert: + originalRange = NSRange(location: originalCursor, length: 0) + updatedRange = NSRange(location: updatedCursor, length: textLength) + updatedCursor += textLength + } + + output.append( + IndexedSegment( + segmentIndex: index, + segment: segment, + originalCursor: originalRange.location, + updatedCursor: updatedRange.location, + originalRange: originalRange, + updatedRange: updatedRange + ) + ) + } + + return output + } + + static func candidates( + from segments: [DiffSegment], + mode: TextDiffComparisonMode + ) -> [DiffRevertCandidate] { + let original = segments + .filter { $0.kind != .insert } + .map(\.text) + .joined() + let updated = segments + .filter { $0.kind != .delete } + .map(\.text) + .joined() + return candidates(from: segments, mode: mode, original: original, updated: updated) + } + + static func candidates( + from segments: [DiffSegment], + mode: TextDiffComparisonMode, + original: String, + updated: String + ) -> [DiffRevertCandidate] { + guard mode == .token else { + return [] + } + + let indexed = indexedSegments(from: segments, original: original, updated: updated) + guard !indexed.isEmpty else { + return [] + } + + var output: [DiffRevertCandidate] = [] + output.reserveCapacity(indexed.count) + + var candidateID = 0 + var index = 0 + while index < indexed.count { + let current = indexed[index] + let isCurrentLexical = isLexicalChange(current.segment) + + if index + 1 < indexed.count { + let next = indexed[index + 1] + if current.segment.kind == .delete, + next.segment.kind == .insert, + isReplacementPair(delete: current.segment, insert: next.segment) { + output.append( + DiffRevertCandidate( + id: candidateID, + kind: .pairedReplacement, + tokenKind: current.segment.tokenKind, + segmentIndices: [current.segmentIndex, next.segmentIndex], + updatedRange: next.updatedRange, + replacementText: current.segment.text, + originalTextFragment: current.segment.text, + updatedTextFragment: next.segment.text + ) + ) + candidateID += 1 + index += 2 + continue + } + } + + if isCurrentLexical { + switch current.segment.kind { + case .insert: + output.append( + DiffRevertCandidate( + id: candidateID, + kind: .singleInsertion, + tokenKind: current.segment.tokenKind, + segmentIndices: [current.segmentIndex], + updatedRange: current.updatedRange, + replacementText: "", + originalTextFragment: nil, + updatedTextFragment: current.segment.text + ) + ) + candidateID += 1 + case .delete: + output.append( + DiffRevertCandidate( + id: candidateID, + kind: .singleDeletion, + tokenKind: current.segment.tokenKind, + segmentIndices: [current.segmentIndex], + updatedRange: NSRange(location: current.updatedCursor, length: 0), + replacementText: current.segment.text, + originalTextFragment: current.segment.text, + updatedTextFragment: nil + ) + ) + candidateID += 1 + case .equal: + break + } + } + + index += 1 + } + + return output + } + + static func interactionContext( + segments: [DiffSegment], + runs: [LaidOutRun], + mode: TextDiffComparisonMode, + original: String, + updated: String + ) -> DiffRevertInteractionContext? { + let candidates = candidates(from: segments, mode: mode, original: original, updated: updated) + guard !candidates.isEmpty else { + return nil + } + + var actionIDBySegmentIndex: [Int: Int] = [:] + actionIDBySegmentIndex.reserveCapacity(candidates.count * 2) + var candidatesByID: [Int: DiffRevertCandidate] = [:] + candidatesByID.reserveCapacity(candidates.count) + + for candidate in candidates { + candidatesByID[candidate.id] = candidate + for segmentIndex in candidate.segmentIndices { + actionIDBySegmentIndex[segmentIndex] = candidate.id + } + } + + var runIndicesByActionID: [Int: [Int]] = [:] + var chipRectsByActionID: [Int: [CGRect]] = [:] + var unionChipRectByActionID: [Int: CGRect] = [:] + + for (runIndex, run) in runs.enumerated() { + guard let chipRect = run.chipRect else { + continue + } + guard let actionID = actionIDBySegmentIndex[run.segmentIndex] else { + continue + } + runIndicesByActionID[actionID, default: []].append(runIndex) + chipRectsByActionID[actionID, default: []].append(chipRect) + if let currentUnion = unionChipRectByActionID[actionID] { + unionChipRectByActionID[actionID] = currentUnion.union(chipRect) + } else { + unionChipRectByActionID[actionID] = chipRect + } + } + + guard !runIndicesByActionID.isEmpty else { + return nil + } + + candidatesByID = candidatesByID.filter { runIndicesByActionID[$0.key] != nil } + + return DiffRevertInteractionContext( + candidatesByID: candidatesByID, + runIndicesByActionID: runIndicesByActionID, + chipRectsByActionID: chipRectsByActionID, + unionChipRectByActionID: unionChipRectByActionID + ) + } + + static func action( + from candidate: DiffRevertCandidate, + updated: String + ) -> TextDiffRevertAction? { + let nsUpdated = updated as NSString + var updatedRange = candidate.updatedRange + if candidate.kind == .singleDeletion, updatedRange.location > nsUpdated.length { + updatedRange.location = nsUpdated.length + } + if candidate.kind == .singleInsertion, candidate.tokenKind == .word { + updatedRange = adjustedStandaloneWordInsertionRemovalRange( + updatedRange, + updated: nsUpdated + ) + } + guard updatedRange.location >= 0 else { + return nil + } + guard NSMaxRange(updatedRange) <= nsUpdated.length else { + return nil + } + + let replacementText: String + if candidate.kind == .singleDeletion, candidate.tokenKind == .word { + replacementText = adjustedStandaloneWordDeletionReplacement( + candidate.replacementText, + insertionLocation: updatedRange.location, + updated: nsUpdated + ) + } else { + replacementText = candidate.replacementText + } + + let resultingUpdated = nsUpdated.replacingCharacters( + in: updatedRange, + with: replacementText + ) + let actionKind: TextDiffRevertActionKind + switch candidate.kind { + case .singleInsertion: + actionKind = .singleInsertion + case .singleDeletion: + actionKind = .singleDeletion + case .pairedReplacement: + actionKind = .pairedReplacement + } + + return TextDiffRevertAction( + kind: actionKind, + updatedRange: updatedRange, + replacementText: replacementText, + originalTextFragment: candidate.originalTextFragment, + updatedTextFragment: candidate.updatedTextFragment, + resultingUpdated: resultingUpdated + ) + } + + private static func isLexicalChange(_ segment: DiffSegment) -> Bool { + segment.tokenKind != .whitespace && segment.kind != .equal + } + + private static func isReplacementPair(delete: DiffSegment, insert: DiffSegment) -> Bool { + if isLexicalChange(delete), isLexicalChange(insert) { + return true + } + + // Treat deleted spacing replaced by punctuation as one reversible edit. + if delete.tokenKind == .whitespace, + insert.tokenKind == .punctuation { + return true + } + + return false + } + + private static func textMatches(_ text: String, source: NSString, at location: Int) -> Bool { + let length = text.utf16.count + guard location >= 0, location + length <= source.length else { + return false + } + return source.substring(with: NSRange(location: location, length: length)) == text + } + + private static func adjustedStandaloneWordDeletionReplacement( + _ replacement: String, + insertionLocation: Int, + updated: NSString + ) -> String { + guard !replacement.isEmpty else { + return replacement + } + guard replacement.rangeOfCharacter(from: .alphanumerics) != nil else { + return replacement + } + + let hasLeadingWhitespace = replacement.unicodeScalars.first + .map { CharacterSet.whitespacesAndNewlines.contains($0) } ?? false + let hasTrailingWhitespace = replacement.unicodeScalars.last + .map { CharacterSet.whitespacesAndNewlines.contains($0) } ?? false + + let updatedString = updated as String + let beforeIsWordLike = characterBeforeUTF16Offset(insertionLocation, in: updatedString) + .map(isWordLike) ?? false + let afterIsWordLike = characterAtUTF16Offset(insertionLocation, in: updatedString) + .map(isWordLike) ?? false + + var output = replacement + if beforeIsWordLike && !hasLeadingWhitespace { + output = " " + output + } + if afterIsWordLike && !hasTrailingWhitespace { + output += " " + } + return output + } + + private static func adjustedStandaloneWordInsertionRemovalRange( + _ range: NSRange, + updated: NSString + ) -> NSRange { + guard range.location >= 0, range.length >= 0 else { + return range + } + guard NSMaxRange(range) <= updated.length else { + return range + } + + let updatedString = updated as String + let hasLeadingWhitespace = characterBeforeUTF16Offset(range.location, in: updatedString) + .map(isWhitespaceCharacter) ?? false + let hasTrailingWhitespace = characterAtUTF16Offset(NSMaxRange(range), in: updatedString) + .map(isWhitespaceCharacter) ?? false + + if hasLeadingWhitespace, hasTrailingWhitespace { + return NSRange(location: range.location, length: range.length + 1) + } + + if range.location == 0, hasTrailingWhitespace { + return NSRange(location: range.location, length: range.length + 1) + } + + if NSMaxRange(range) == updated.length, hasLeadingWhitespace { + return NSRange(location: range.location - 1, length: range.length + 1) + } + + return range + } + + private static func isWhitespaceCharacter(_ scalarString: String) -> Bool { + scalarString.unicodeScalars.allSatisfy { CharacterSet.whitespacesAndNewlines.contains($0) } + } + + private static func isWordLike(_ scalarString: String) -> Bool { + scalarString.rangeOfCharacter(from: .alphanumerics) != nil + } + + private static func characterBeforeUTF16Offset(_ offset: Int, in string: String) -> String? { + guard offset > 0 else { + return nil + } + let index = String.Index(utf16Offset: offset, in: string) + guard index > string.startIndex else { + return nil + } + return String(string[string.index(before: index)]) + } + + private static func characterAtUTF16Offset(_ offset: Int, in string: String) -> String? { + guard offset >= 0 else { + return nil + } + let index = String.Index(utf16Offset: offset, in: string) + guard index < string.endIndex else { + return nil + } + return String(string[index]) + } +} diff --git a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift index 73d59f0..f195e42 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift @@ -4,8 +4,16 @@ import SwiftUI struct DiffTextViewRepresentable: NSViewRepresentable { let original: String let updated: String + let updatedBinding: Binding? let style: TextDiffStyle let mode: TextDiffComparisonMode + let showsInvisibleCharacters: Bool + let isRevertActionsEnabled: Bool + let onRevertAction: ((TextDiffRevertAction) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator() + } func makeNSView(context: Context) -> NSTextDiffView { let view = NSTextDiffView( @@ -16,10 +24,28 @@ struct DiffTextViewRepresentable: NSViewRepresentable { ) view.setContentCompressionResistancePriority(.required, for: .vertical) view.setContentHuggingPriority(.required, for: .vertical) + context.coordinator.update( + updatedBinding: updatedBinding, + onRevertAction: onRevertAction + ) + view.showsInvisibleCharacters = showsInvisibleCharacters + view.isRevertActionsEnabled = isRevertActionsEnabled + view.onRevertAction = { [coordinator = context.coordinator] action in + coordinator.handle(action) + } return view } func updateNSView(_ view: NSTextDiffView, context: Context) { + context.coordinator.update( + updatedBinding: updatedBinding, + onRevertAction: onRevertAction + ) + view.onRevertAction = { [coordinator = context.coordinator] action in + coordinator.handle(action) + } + view.showsInvisibleCharacters = showsInvisibleCharacters + view.isRevertActionsEnabled = isRevertActionsEnabled view.setContent( original: original, updated: updated, @@ -27,4 +53,22 @@ struct DiffTextViewRepresentable: NSViewRepresentable { mode: mode ) } + + final class Coordinator { + private var updatedBinding: Binding? + private var onRevertAction: ((TextDiffRevertAction) -> Void)? + + func update( + updatedBinding: Binding?, + onRevertAction: ((TextDiffRevertAction) -> Void)? + ) { + self.updatedBinding = updatedBinding + self.onRevertAction = onRevertAction + } + + func handle(_ action: TextDiffRevertAction) { + updatedBinding?.wrappedValue = action.resultingUpdated + onRevertAction?(action) + } + } } diff --git a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift index 75ed788..2139d79 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift @@ -1,7 +1,9 @@ import AppKit +import CoreText import Foundation struct LaidOutRun { + let segmentIndex: Int let segment: DiffSegment let attributedText: NSAttributedString let textRect: CGRect @@ -14,6 +16,7 @@ struct LaidOutRun { struct DiffLayout { let runs: [LaidOutRun] + let lineBreakMarkers: [CGPoint] let contentSize: CGSize } @@ -38,47 +41,50 @@ enum DiffTokenLayouter { var maxUsedX = lineStartX var lineCount = 1 var lineHasContent = false - let lineText = NSMutableString() - var lineTextWidth: CGFloat = 0 + var lineBreakMarkers: [CGPoint] = [] + var widthCache: [WidthCacheKey: CGFloat] = [:] var previousChangedLexical = false func moveToNewLine() { lineTop += lineHeight cursorX = lineStartX lineHasContent = false - lineText.setString("") - lineTextWidth = 0 previousChangedLexical = false lineCount += 1 } for piece in pieces(from: segments) { if piece.isLineBreak { + lineBreakMarkers.append( + CGPoint(x: cursorX, y: lineTop + (lineHeight / 2)) + ) moveToNewLine() continue } - guard !piece.text.isEmpty else { - continue - } + guard !piece.text.isEmpty else { continue } let segment = DiffSegment(kind: piece.kind, tokenKind: piece.tokenKind, text: piece.text) - let isChangedLexical = segment.kind != .equal && segment.tokenKind != .whitespace + let isChangedLexical = segment.kind != .equal + && (segment.tokenKind != .whitespace || segment.kind == .delete) var leadingGap: CGFloat = 0 if previousChangedLexical && isChangedLexical { leadingGap = max(0, style.interChipSpacing) } let attributedText = attributedToken(for: segment, style: style) - var textMeasurement = measuredIncrementalTextWidth( + let displayTextWidth = measuredTextWidth( for: piece.text, font: style.font, - lineText: lineText, - lineTextWidth: lineTextWidth + cache: &widthCache ) - var textSize = CGSize(width: textMeasurement.textWidth, height: textHeight) + let textSize = CGSize(width: displayTextWidth, height: textHeight) let chipInsets = effectiveChipInsets(for: style) - var runWidth = isChangedLexical ? textSize.width + chipInsets.left + chipInsets.right : textSize.width + + var runWidth = isChangedLexical + ? displayTextWidth + chipInsets.left + chipInsets.right + : displayTextWidth + let requiredWidth = leadingGap + runWidth let wrapped = lineHasContent && cursorX + requiredWidth > maxLineX @@ -86,19 +92,14 @@ enum DiffTokenLayouter { moveToNewLine() leadingGap = 0 - // Soft-wrap boundary: do not carry inter-word whitespace to next line. + // Drop leading whitespace after wrap. if piece.tokenKind == .whitespace { continue } - textMeasurement = measuredIncrementalTextWidth( - for: piece.text, - font: style.font, - lineText: lineText, - lineTextWidth: lineTextWidth - ) - textSize = CGSize(width: textMeasurement.textWidth, height: textHeight) - runWidth = isChangedLexical ? textSize.width + chipInsets.left + chipInsets.right : textSize.width + runWidth = isChangedLexical + ? displayTextWidth + chipInsets.left + chipInsets.right + : displayTextWidth } cursorX += leadingGap @@ -112,18 +113,14 @@ enum DiffTokenLayouter { if isChangedLexical { let chipHeight = textSize.height + chipInsets.top + chipInsets.bottom let chipY = lineTop + ((lineHeight - chipHeight) / 2) - chipRect = CGRect( - x: cursorX, - y: chipY, - width: runWidth, - height: chipHeight - ) + chipRect = CGRect(x: cursorX, y: chipY, width: runWidth, height: chipHeight) chipFillColor = chipFillColorForOperation(segment.kind, style: style) chipStrokeColor = chipStrokeColorForOperation(segment.kind, style: style) } runs.append( LaidOutRun( + segmentIndex: piece.segmentIndex, segment: segment, attributedText: attributedText, textRect: textRect, @@ -138,7 +135,6 @@ enum DiffTokenLayouter { cursorX += runWidth maxUsedX = max(maxUsedX, cursorX) lineHasContent = true - lineTextWidth = textMeasurement.combinedLineWidth previousChangedLexical = isChangedLexical } @@ -150,10 +146,37 @@ enum DiffTokenLayouter { return DiffLayout( runs: runs, + lineBreakMarkers: lineBreakMarkers, contentSize: CGSize(width: max(intrinsicWidth, usedWidth), height: contentHeight) ) } + private static func measuredTextWidth( + for text: String, + font: NSFont, + cache: inout [WidthCacheKey: CGFloat] + ) -> CGFloat { + guard !text.isEmpty else { return 0 } + + let key = WidthCacheKey( + text: text, + fontName: font.fontName, + fontSize: font.pointSize + ) + if let cached = cache[key] { + return cached + } + + let attributed = NSAttributedString( + string: text, + attributes: [.font: font] + ) + let line = CTLineCreateWithAttributedString(attributed) + let width = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + cache[key] = width + return width + } + private static func attributedToken(for segment: DiffSegment, style: TextDiffStyle) -> NSAttributedString { var attributes: [NSAttributedString.Key: Any] = [ .font: style.font, @@ -167,30 +190,6 @@ enum DiffTokenLayouter { return NSAttributedString(string: segment.text, attributes: attributes) } - private static func measuredIncrementalTextWidth( - for text: String, - font: NSFont, - lineText: NSMutableString, - lineTextWidth: CGFloat - ) -> IncrementalTextWidth { - guard !text.isEmpty else { - return IncrementalTextWidth( - textWidth: 0, - combinedLineWidth: lineTextWidth - ) - } - - lineText.append(text) - // TODO: Fix this later - // This now appends each token to lineText and calls size(withAttributes:) on the entire accumulated line every iteration, which makes layout cost grow quadratically with line length. On long unwrapped diffs (hundreds/thousands of tokens), this is a significant regression from the prior per-token measurement approach and can noticeably slow rendering even though the new performance tests only capture baselines and do not enforce thresholds. - let combinedWidth = lineText.size(withAttributes: [.font: font]).width - let textWidth = max(0, combinedWidth - lineTextWidth) - return IncrementalTextWidth( - textWidth: textWidth, - combinedLineWidth: combinedWidth - ) - } - private static func effectiveChipInsets(for style: TextDiffStyle) -> NSEdgeInsets { NSEdgeInsets( top: style.chipInsets.top, @@ -252,13 +251,14 @@ enum DiffTokenLayouter { var output: [LayoutPiece] = [] output.reserveCapacity(segments.count) - for segment in segments { + for (segmentIndex, segment) in segments.enumerated() { var buffer = "" for scalar in segment.text.unicodeScalars { if scalar == "\n" { if !buffer.isEmpty { output.append( LayoutPiece( + segmentIndex: segmentIndex, kind: segment.kind, tokenKind: segment.tokenKind, text: buffer, @@ -269,6 +269,7 @@ enum DiffTokenLayouter { } output.append( LayoutPiece( + segmentIndex: segmentIndex, kind: segment.kind, tokenKind: .whitespace, text: "", @@ -283,6 +284,7 @@ enum DiffTokenLayouter { if !buffer.isEmpty { output.append( LayoutPiece( + segmentIndex: segmentIndex, kind: segment.kind, tokenKind: segment.tokenKind, text: buffer, @@ -297,13 +299,15 @@ enum DiffTokenLayouter { } private struct LayoutPiece { + let segmentIndex: Int let kind: DiffOperationKind let tokenKind: DiffTokenKind let text: String let isLineBreak: Bool } -private struct IncrementalTextWidth { - let textWidth: CGFloat - let combinedLineWidth: CGFloat +private struct WidthCacheKey: Hashable { + let text: String + let fontName: String + let fontSize: CGFloat } diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index 00920a3..a6dc468 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -50,6 +50,29 @@ public final class NSTextDiffView: NSView { } } + /// Enables hover affordances and revert action hit-testing. + public var isRevertActionsEnabled: Bool = false { + didSet { + guard oldValue != isRevertActionsEnabled else { + return + } + invalidateCachedLayout() + } + } + + /// Debug overlay that draws visible symbols for otherwise invisible characters in red. + public var showsInvisibleCharacters: Bool = false { + didSet { + guard oldValue != showsInvisibleCharacters else { + return + } + needsDisplay = true + } + } + + /// Callback invoked when user clicks the revert icon. + public var onRevertAction: ((TextDiffRevertAction) -> Void)? + private var segments: [DiffSegment] private let diffProvider: DiffProvider @@ -58,10 +81,35 @@ public final class NSTextDiffView: NSView { private var lastModeKey: Int private var isBatchUpdating = false private var pendingStyleInvalidation = false + private var segmentGeneration: Int = 0 private var cachedWidth: CGFloat = -1 private var cachedLayout: DiffLayout? + private var cachedInteractionContext: DiffRevertInteractionContext? + private var cachedInteractionWidth: CGFloat = -1 + private var cachedInteractionGeneration: Int = -1 + + private var trackedArea: NSTrackingArea? + private var hoveredActionID: Int? + private var hoveredIconRect: CGRect? + private let hoverDismissDelay: TimeInterval = 0.5 + private var pendingHoverDismissWorkItem: DispatchWorkItem? + private var hoverDismissGeneration: Int = 0 + private var isPointingHandCursorActive = false + + #if TESTING + private var testingHoverDismissScheduler: ((TimeInterval, @escaping () -> Void) -> Void)? + private var testingScheduledHoverDismissBlocks: [() -> Void] = [] + #endif + + private let hoverOutlineColor = NSColor.controlAccentColor.withAlphaComponent(0.9) + private let hoverButtonFillColor = NSColor.black + private let hoverButtonStrokeColor = NSColor.clear + private let hoverIconName = "arrow.turn.down.left" + private let hoverButtonSize = CGSize(width: 16, height: 16) + private let hoverButtonGap: CGFloat = 4 + override public var isFlipped: Bool { true } @@ -124,6 +172,22 @@ public final class NSTextDiffView: NSView { fatalError("Use init(original:updated:style:mode:)") } + override public func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackedArea { + removeTrackingArea(trackedArea) + } + let options: NSTrackingArea.Options = [ + .mouseMoved, + .mouseEnteredAndExited, + .activeInKeyWindow, + .inVisibleRect + ] + let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(area) + trackedArea = area + } + override public func setFrameSize(_ newSize: NSSize) { let previousWidth = frame.width super.setFrameSize(newSize) @@ -147,7 +211,34 @@ public final class NSTextDiffView: NSView { } run.attributedText.draw(in: run.textRect) + if showsInvisibleCharacters { + drawInvisibleCharacters(for: run) + } + } + if showsInvisibleCharacters { + drawLineBreakMarkers(layout.lineBreakMarkers) + } + + drawHoveredRevertAffordance(layout: layout) + } + + override public func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + let location = convert(event.locationInWindow, from: nil) + updateHoverState(location: location) + } + + override public func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + scheduleHoverDismiss() + } + + override public func mouseDown(with event: NSEvent) { + let location = convert(event.locationInWindow, from: nil) + if handleIconClick(at: location) { + return } + super.mouseDown(with: event) } /// Atomically updates view inputs and recomputes diff segments at most once. @@ -186,6 +277,7 @@ public final class NSTextDiffView: NSView { lastUpdated = updated lastModeKey = newModeKey segments = diffProvider(original, updated, mode) + segmentGeneration += 1 invalidateCachedLayout() return true } @@ -208,16 +300,309 @@ public final class NSTextDiffView: NSView { cachedWidth = width cachedLayout = layout + invalidateInteractionCache() return layout } + private func interactionContext(for layout: DiffLayout) -> DiffRevertInteractionContext? { + guard isRevertActionsEnabled, mode == .token else { + return nil + } + + let width = max(bounds.width, 1) + if let cachedInteractionContext, + abs(cachedInteractionWidth - width) <= 0.5, + cachedInteractionGeneration == segmentGeneration { + return cachedInteractionContext + } + + let context = DiffRevertActionResolver.interactionContext( + segments: segments, + runs: layout.runs, + mode: mode, + original: original, + updated: updated + ) + cachedInteractionContext = context + cachedInteractionWidth = width + cachedInteractionGeneration = segmentGeneration + return context + } + private func invalidateCachedLayout() { cachedLayout = nil cachedWidth = -1 + invalidateInteractionCache() + cancelPendingHoverDismiss() + clearHoverStateNow() needsDisplay = true invalidateIntrinsicContentSize() } + private func invalidateInteractionCache() { + cachedInteractionContext = nil + cachedInteractionWidth = -1 + cachedInteractionGeneration = -1 + } + + private func updateHoverState(location: CGPoint) { + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout) else { + cancelPendingHoverDismiss() + clearHoverStateNow() + return + } + + if let actionID = actionIDForHitTarget(at: location, layout: layout, context: context) { + let iconRect = iconRect(for: actionID, context: context) + if hoveredActionID == actionID { + cancelPendingHoverDismiss() + applyImmediateHover(actionID: actionID, iconRect: iconRect) + } else { + switchHoverImmediately(to: actionID, iconRect: iconRect) + } + setPointingHandCursorActive(iconRect?.contains(location) == true) + return + } + + setPointingHandCursorActive(false) + scheduleHoverDismiss() + } + + private func clearHoverStateNow() { + guard hoveredActionID != nil || hoveredIconRect != nil || isPointingHandCursorActive else { + return + } + hoveredActionID = nil + hoveredIconRect = nil + setPointingHandCursorActive(false) + needsDisplay = true + } + + private func applyImmediateHover(actionID: Int, iconRect: CGRect?) { + let didChangeHover = hoveredActionID != actionID || hoveredIconRect != iconRect + hoveredActionID = actionID + hoveredIconRect = iconRect + if didChangeHover { + needsDisplay = true + } + } + + private func switchHoverImmediately(to actionID: Int, iconRect: CGRect?) { + cancelPendingHoverDismiss() + applyImmediateHover(actionID: actionID, iconRect: iconRect) + } + + private func cancelPendingHoverDismiss() { + pendingHoverDismissWorkItem?.cancel() + pendingHoverDismissWorkItem = nil + hoverDismissGeneration += 1 + } + + private func scheduleHoverDismiss() { + guard pendingHoverDismissWorkItem == nil else { + return + } + + hoverDismissGeneration += 1 + let generation = hoverDismissGeneration + let workItem = DispatchWorkItem { [weak self] in + guard let self else { + return + } + guard self.hoverDismissGeneration == generation else { + return + } + self.pendingHoverDismissWorkItem = nil + self.clearHoverStateNow() + } + pendingHoverDismissWorkItem = workItem + + #if TESTING + if let testingHoverDismissScheduler { + testingHoverDismissScheduler(hoverDismissDelay) { + workItem.perform() + } + return + } + #endif + + DispatchQueue.main.asyncAfter(deadline: .now() + hoverDismissDelay) { + workItem.perform() + } + } + + private func setPointingHandCursorActive(_ isActive: Bool) { + guard isPointingHandCursorActive != isActive else { + return + } + isPointingHandCursorActive = isActive + if isActive { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + @discardableResult + private func handleIconClick(at location: CGPoint) -> Bool { + guard isRevertActionsEnabled, mode == .token else { + return false + } + + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout), + let actionID = actionIDForHitTarget(at: location, layout: layout, context: context) else { + return false + } + + guard let candidate = context.candidatesByID[actionID] else { + return false + } + + if let action = DiffRevertActionResolver.action(from: candidate, updated: updated) { + onRevertAction?(action) + } + return true + } + + private func actionIDForHitTarget( + at point: CGPoint, + layout: DiffLayout, + context: DiffRevertInteractionContext + ) -> Int? { + for actionID in context.candidatesByID.keys.sorted() { + let includeIcon = hoveredActionID == actionID + if isPointWithinActionHitTarget( + point, + actionID: actionID, + layout: layout, + context: context, + includeIcon: includeIcon + ) { + return actionID + } + } + return nil + } + + private func isPointWithinActionHitTarget( + _ point: CGPoint, + actionID: Int, + layout: DiffLayout, + context: DiffRevertInteractionContext, + includeIcon: Bool + ) -> Bool { + if let runIndices = context.runIndicesByActionID[actionID] { + for runIndex in runIndices { + guard layout.runs.indices.contains(runIndex), + let chipRect = layout.runs[runIndex].chipRect else { + continue + } + if chipRect.contains(point) { + return true + } + } + } + + if includeIcon, let iconRect = iconRect(for: actionID, context: context), iconRect.contains(point) { + return true + } + + return false + } + + private func iconRect(for actionID: Int, context: DiffRevertInteractionContext) -> CGRect? { + guard let unionRect = context.unionChipRectByActionID[actionID] else { + return nil + } + + let maxX = bounds.maxX - hoverButtonSize.width - 2 + var originX = unionRect.maxX + hoverButtonGap + if originX > maxX { + originX = max(bounds.minX + 2, unionRect.maxX - hoverButtonSize.width) + } + + var originY = unionRect.midY - (hoverButtonSize.height / 2) + originY = max(bounds.minY + 2, min(originY, bounds.maxY - hoverButtonSize.height - 2)) + + return CGRect(origin: CGPoint(x: originX, y: originY), size: hoverButtonSize) + } + + private func drawHoveredRevertAffordance(layout: DiffLayout) { + guard let hoveredActionID else { + return + } + guard let context = interactionContext(for: layout), + let chipRects = context.chipRectsByActionID[hoveredActionID], + !chipRects.isEmpty else { + return + } + + hoverOutlineColor.setStroke() + if chipRects.count > 1, let unionRect = context.unionChipRectByActionID[hoveredActionID] { + let groupRect = unionRect.insetBy(dx: -1.5, dy: -1.5) + let groupPath = NSBezierPath( + roundedRect: groupRect, + xRadius: style.chipCornerRadius + 2, + yRadius: style.chipCornerRadius + 2 + ) + applyGroupStrokeStyle(to: groupPath) + groupPath.stroke() + } else { + for chipRect in chipRects { + let outlineRect = chipRect.insetBy(dx: -1.5, dy: -1.5) + let outlinePath = NSBezierPath( + roundedRect: outlineRect, + xRadius: style.chipCornerRadius + 1, + yRadius: style.chipCornerRadius + 1 + ) + applyGroupStrokeStyle(to: outlinePath) + outlinePath.stroke() + } + } + + let iconRect = hoveredIconRect ?? iconRect(for: hoveredActionID, context: context) + guard let iconRect else { + return + } + drawIconButton(in: iconRect) + } + + private func drawIconButton(in rect: CGRect) { + let buttonPath = NSBezierPath(ovalIn: rect) + hoverButtonFillColor.setFill() + buttonPath.fill() + hoverButtonStrokeColor.setStroke() + buttonPath.lineWidth = 1 + buttonPath.stroke() + + let symbolRect = rect.insetBy(dx: 4, dy: 4) + + let base = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + let white = NSImage.SymbolConfiguration(hierarchicalColor: .white) + let config = base + .applying(.preferringMonochrome()) + .applying(white) + + guard let icon = NSImage(systemSymbolName: hoverIconName, accessibilityDescription: "Revert"), + let configured = icon.withSymbolConfiguration(config) else { + return + } + configured.draw(in: symbolRect) + } + + private func applyGroupStrokeStyle(to path: NSBezierPath) { + path.lineWidth = 1.5 + switch style.groupStrokeStyle { + case .solid: + path.setLineDash([], count: 0, phase: 0) + case .dashed: + var pattern: [CGFloat] = [4, 2] + path.setLineDash(&pattern, count: pattern.count, phase: 0) + } + } + private func drawChip( chipRect: CGRect, fillColor: NSColor?, @@ -228,11 +613,21 @@ public final class NSTextDiffView: NSView { return } - let fillPath = NSBezierPath(roundedRect: chipRect, xRadius: cornerRadius, yRadius: cornerRadius) + let alignedChipRect = backingAlignedRect( + chipRect, + options: [ + .alignMinXOutward, + .alignMinYOutward, + .alignMaxXOutward, + .alignMaxYOutward + ] + ) + + let fillPath = NSBezierPath(roundedRect: alignedChipRect, xRadius: cornerRadius, yRadius: cornerRadius) fillColor?.setFill() fillPath.fill() - let strokeRect = chipRect.insetBy(dx: 0.5, dy: 0.5) + let strokeRect = alignedChipRect.insetBy(dx: 0.5, dy: 0.5) guard strokeRect.width > 0, strokeRect.height > 0 else { return } @@ -243,6 +638,78 @@ public final class NSTextDiffView: NSView { strokePath.stroke() } + private func drawInvisibleCharacters(for run: LaidOutRun) { + guard run.segment.text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) }) else { + return + } + + let font = (run.attributedText.attribute(.font, at: 0, effectiveRange: nil) as? NSFont) ?? style.font + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.systemRed + ] + + var x = run.textRect.minX + for character in run.segment.text { + let source = String(character) + let width = (source as NSString).size(withAttributes: [.font: font]).width + defer { x += width } + + guard let symbol = visibleSymbol(for: character) else { + continue + } + + let symbolWidth = (symbol as NSString).size(withAttributes: attributes).width + let symbolX = x + max(0, (width - symbolWidth) / 2) + (symbol as NSString).draw( + at: CGPoint(x: symbolX, y: run.textRect.minY), + withAttributes: attributes + ) + } + } + + private func visibleSymbol(for character: Character) -> String? { + guard character.unicodeScalars.allSatisfy({ CharacterSet.whitespacesAndNewlines.contains($0) }) else { + return nil + } + if character == " " { + return "·" + } + if character == "\t" { + return "⇥" + } + if character == "\n" || character == "\r" { + return "↩" + } + if character == "\u{00A0}" { + return "⍽" + } + return "·" + } + + private func drawLineBreakMarkers(_ markers: [CGPoint]) { + guard !markers.isEmpty else { + return + } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: style.font, + .foregroundColor: NSColor.systemRed + ] + let symbol = "↩" as NSString + let symbolSize = symbol.size(withAttributes: attributes) + + for marker in markers { + symbol.draw( + at: CGPoint( + x: marker.x, + y: marker.y - (symbolSize.height / 2) + ), + withAttributes: attributes + ) + } + } + private static func modeKey(for mode: TextDiffComparisonMode) -> Int { switch mode { case .token: @@ -251,4 +718,88 @@ public final class NSTextDiffView: NSView { return 1 } } + + #if TESTING + @discardableResult + func _testingSetHoveredFirstRevertAction() -> Bool { + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout), + let firstActionID = context.candidatesByID.keys.sorted().first else { + return false + } + cancelPendingHoverDismiss() + hoveredActionID = firstActionID + hoveredIconRect = iconRect(for: firstActionID, context: context) + needsDisplay = true + return true + } + + @discardableResult + func _testingTriggerHoveredRevertAction() -> Bool { + guard let hoveredActionID else { + return false + } + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout), + let candidate = context.candidatesByID[hoveredActionID] else { + return false + } + if let action = DiffRevertActionResolver.action(from: candidate, updated: updated) { + onRevertAction?(action) + } + return true + } + + func _testingHasInteractionContext() -> Bool { + let layout = layoutForCurrentWidth() + return interactionContext(for: layout) != nil + } + + func _testingHoveredActionID() -> Int? { + hoveredActionID + } + + func _testingHasPendingHoverDismiss() -> Bool { + pendingHoverDismissWorkItem != nil + } + + func _testingActionCenters() -> [CGPoint] { + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout) else { + return [] + } + return context.candidatesByID.keys.sorted().compactMap { actionID in + guard let rect = context.unionChipRectByActionID[actionID] else { + return nil + } + return CGPoint(x: rect.midX, y: rect.midY) + } + } + + func _testingUpdateHover(location: CGPoint) { + updateHoverState(location: location) + } + + func _testingEnableManualHoverDismissScheduler() { + testingScheduledHoverDismissBlocks.removeAll() + testingHoverDismissScheduler = { [weak self] _, block in + self?.testingScheduledHoverDismissBlocks.append(block) + } + } + + @discardableResult + func _testingRunNextScheduledHoverDismiss() -> Bool { + guard !testingScheduledHoverDismissBlocks.isEmpty else { + return false + } + let block = testingScheduledHoverDismissBlocks.removeFirst() + block() + return true + } + + func _testingClearManualHoverDismissScheduler() { + testingHoverDismissScheduler = nil + testingScheduledHoverDismissBlocks.removeAll() + } + #endif } diff --git a/Sources/TextDiff/DiffTypes.swift b/Sources/TextDiff/DiffTypes.swift index f5bdf7d..4bc24d0 100644 --- a/Sources/TextDiff/DiffTypes.swift +++ b/Sources/TextDiff/DiffTypes.swift @@ -49,3 +49,45 @@ public struct DiffSegment: Sendable, Equatable { self.text = text } } + +/// The change variant represented by a user-initiated revert action. +public enum TextDiffRevertActionKind: Sendable, Equatable { + /// Revert a standalone inserted segment by removing it from updated text. + case singleInsertion + /// Revert a standalone deleted segment by inserting it into updated text. + case singleDeletion + /// Revert an adjacent delete+insert replacement pair. + case pairedReplacement +} + +/// A revert intent payload describing how to edit updated text toward original text. +public struct TextDiffRevertAction: Sendable, Equatable { + /// The semantic action kind that triggered this payload. + public let kind: TextDiffRevertActionKind + /// The UTF-16 range in pre-click updated text to replace. + public let updatedRange: NSRange + /// The text used to replace `updatedRange`. + public let replacementText: String + /// Optional source-side text fragment associated with this action. + public let originalTextFragment: String? + /// Optional updated-side text fragment associated with this action. + public let updatedTextFragment: String? + /// The resulting updated text after applying the replacement. + public let resultingUpdated: String + + public init( + kind: TextDiffRevertActionKind, + updatedRange: NSRange, + replacementText: String, + originalTextFragment: String?, + updatedTextFragment: String?, + resultingUpdated: String + ) { + self.kind = kind + self.updatedRange = updatedRange + self.replacementText = replacementText + self.originalTextFragment = originalTextFragment + self.updatedTextFragment = updatedTextFragment + self.resultingUpdated = resultingUpdated + } +} diff --git a/Sources/TextDiff/TextDiffEngine.swift b/Sources/TextDiff/TextDiffEngine.swift index c7accb2..e25ee15 100644 --- a/Sources/TextDiff/TextDiffEngine.swift +++ b/Sources/TextDiff/TextDiffEngine.swift @@ -58,6 +58,15 @@ public enum TextDiffEngine { segments.append( DiffSegment(kind: .equal, tokenKind: .whitespace, text: deletedWhitespace) ) + } else if isAdjacentToInsertedLexicalToken( + operations: operations, + runStart: runStart, + runEnd: runEnd + ) { + let deletedWhitespace = whitespaceRun.map(\.token.text).joined() + segments.append( + DiffSegment(kind: .delete, tokenKind: .whitespace, text: deletedWhitespace) + ) } index = runEnd @@ -154,14 +163,41 @@ public enum TextDiffEngine { operations: [MyersDiff.Operation], runStart: Int, runEnd: Int + ) -> Bool { + isAdjacentToLexicalToken( + operations: operations, + runStart: runStart, + runEnd: runEnd, + kind: .delete + ) + } + + private static func isAdjacentToInsertedLexicalToken( + operations: [MyersDiff.Operation], + runStart: Int, + runEnd: Int + ) -> Bool { + isAdjacentToLexicalToken( + operations: operations, + runStart: runStart, + runEnd: runEnd, + kind: .insert + ) + } + + private static func isAdjacentToLexicalToken( + operations: [MyersDiff.Operation], + runStart: Int, + runEnd: Int, + kind: DiffOperationKind ) -> Bool { if let previousLexicalIndex = previousLexicalOperationIndex(in: operations, before: runStart), - operations[previousLexicalIndex].kind == .delete { + operations[previousLexicalIndex].kind == kind { return true } if let nextLexicalIndex = nextLexicalOperationIndex(in: operations, after: runEnd), - operations[nextLexicalIndex].kind == .delete { + operations[nextLexicalIndex].kind == kind { return true } diff --git a/Sources/TextDiff/TextDiffGroupStrokeStyle.swift b/Sources/TextDiff/TextDiffGroupStrokeStyle.swift new file mode 100644 index 0000000..c676a71 --- /dev/null +++ b/Sources/TextDiff/TextDiffGroupStrokeStyle.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Stroke style used for the interactive revert-group outline. +public enum TextDiffGroupStrokeStyle: Sendable { + /// Draws a continuous stroke. + case solid + /// Draws a dashed stroke. + case dashed +} diff --git a/Sources/TextDiff/TextDiffStyle.swift b/Sources/TextDiff/TextDiffStyle.swift index ada66cb..5653acf 100644 --- a/Sources/TextDiff/TextDiffStyle.swift +++ b/Sources/TextDiff/TextDiffStyle.swift @@ -20,6 +20,8 @@ public struct TextDiffStyle: @unchecked Sendable { public var interChipSpacing: CGFloat /// Additional vertical spacing between wrapped lines. public var lineSpacing: CGFloat + /// Stroke style used for interactive revert-group outlines. + public var groupStrokeStyle: TextDiffGroupStrokeStyle /// Creates a style for rendering text diffs. /// @@ -32,6 +34,7 @@ public struct TextDiffStyle: @unchecked Sendable { /// - chipInsets: Insets applied around changed-token text when drawing chips. /// - interChipSpacing: Gap between adjacent changed lexical chips. /// - lineSpacing: Additional vertical spacing between wrapped lines. + /// - groupStrokeStyle: Stroke style for revert-group hover outlines. public init( additionsStyle: TextDiffChangeStyle = .defaultAddition, removalsStyle: TextDiffChangeStyle = .defaultRemoval, @@ -40,7 +43,8 @@ public struct TextDiffStyle: @unchecked Sendable { chipCornerRadius: CGFloat = 4, chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), interChipSpacing: CGFloat = 0, - lineSpacing: CGFloat = 2 + lineSpacing: CGFloat = 2, + groupStrokeStyle: TextDiffGroupStrokeStyle = .solid ) { self.additionsStyle = additionsStyle self.removalsStyle = removalsStyle @@ -50,6 +54,7 @@ public struct TextDiffStyle: @unchecked Sendable { self.chipInsets = chipInsets self.interChipSpacing = interChipSpacing self.lineSpacing = lineSpacing + self.groupStrokeStyle = groupStrokeStyle } /// Creates a style by converting protocol-based operation styles to concrete change styles. @@ -63,6 +68,7 @@ public struct TextDiffStyle: @unchecked Sendable { /// - chipInsets: Insets applied around changed-token text when drawing chips. /// - interChipSpacing: Gap between adjacent changed lexical chips. /// - lineSpacing: Additional vertical spacing between wrapped lines. + /// - groupStrokeStyle: Stroke style for revert-group hover outlines. public init( additionsStyle: some TextDiffStyling, removalsStyle: some TextDiffStyling, @@ -71,7 +77,8 @@ public struct TextDiffStyle: @unchecked Sendable { chipCornerRadius: CGFloat = 4, chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), interChipSpacing: CGFloat = 0, - lineSpacing: CGFloat = 2 + lineSpacing: CGFloat = 2, + groupStrokeStyle: TextDiffGroupStrokeStyle = .solid ) { self.init( additionsStyle: TextDiffChangeStyle(additionsStyle), @@ -81,7 +88,8 @@ public struct TextDiffStyle: @unchecked Sendable { chipCornerRadius: chipCornerRadius, chipInsets: chipInsets, interChipSpacing: interChipSpacing, - lineSpacing: lineSpacing + lineSpacing: lineSpacing, + groupStrokeStyle: groupStrokeStyle ) } diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index c1cabb7..0df08d2 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -4,9 +4,13 @@ import SwiftUI /// A SwiftUI view that renders a merged visual diff between two strings. public struct TextDiffView: View { private let original: String - private let updated: String + private let updatedValue: String + private let updatedBinding: Binding? private let mode: TextDiffComparisonMode private let style: TextDiffStyle + private let showsInvisibleCharacters: Bool + private let isRevertActionsEnabled: Bool + private let onRevertAction: ((TextDiffRevertAction) -> Void)? /// Creates a text diff view for two versions of content. /// @@ -15,25 +19,65 @@ public struct TextDiffView: View { /// - updated: The source text after edits. /// - style: Visual style used to render additions, deletions, and unchanged text. /// - mode: Comparison mode that controls token-level or character-refined output. + /// - showsInvisibleCharacters: Debug-only overlay that draws whitespace/newline symbols in red. public init( original: String, updated: String, style: TextDiffStyle = .default, - mode: TextDiffComparisonMode = .token + mode: TextDiffComparisonMode = .token, + showsInvisibleCharacters: Bool = false ) { self.original = original - self.updated = updated + self.updatedValue = updated + self.updatedBinding = nil self.mode = mode self.style = style + self.showsInvisibleCharacters = showsInvisibleCharacters + self.isRevertActionsEnabled = false + self.onRevertAction = nil + } + + /// Creates a text diff view backed by a mutable updated binding. + /// + /// - Parameters: + /// - original: The source text before edits. + /// - updated: The source text after edits. + /// - style: Visual style used to render additions, deletions, and unchanged text. + /// - mode: Comparison mode that controls token-level or character-refined output. + /// - showsInvisibleCharacters: Debug-only overlay that draws whitespace/newline symbols in red. + /// - isRevertActionsEnabled: Enables hover affordance and revert actions. + /// - onRevertAction: Optional callback invoked on revert clicks. + public init( + original: String, + updated: Binding, + style: TextDiffStyle = .default, + mode: TextDiffComparisonMode = .token, + showsInvisibleCharacters: Bool = false, + isRevertActionsEnabled: Bool = true, + onRevertAction: ((TextDiffRevertAction) -> Void)? = nil + ) { + self.original = original + self.updatedValue = updated.wrappedValue + self.updatedBinding = updated + self.mode = mode + self.style = style + self.showsInvisibleCharacters = showsInvisibleCharacters + self.isRevertActionsEnabled = isRevertActionsEnabled + self.onRevertAction = onRevertAction } /// The view body that renders the current diff content. public var body: some View { + let updated = updatedBinding?.wrappedValue ?? updatedValue DiffTextViewRepresentable( original: original, updated: updated, + updatedBinding: updatedBinding, style: style, - mode: mode + mode: mode, + showsInvisibleCharacters: showsInvisibleCharacters, + isRevertActionsEnabled: isRevertActionsEnabled, + onRevertAction: onRevertAction ) .accessibilityLabel("Text diff") } @@ -49,6 +93,7 @@ public struct TextDiffView: View { } #Preview("TextDiffView") { + @Previewable @State var updatedText = "Added a diff view. It looks good!" let font: NSFont = .systemFont(ofSize: 16, weight: .regular) let style = TextDiffStyle( additionsStyle: TextDiffChangeStyle( @@ -65,9 +110,10 @@ public struct TextDiffView: View { textColor: .labelColor, font: font, chipCornerRadius: 3, - chipInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + chipInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), interChipSpacing: 1, - lineSpacing: 2 + lineSpacing: 2, + groupStrokeStyle: .dashed ) VStack(alignment: .leading, spacing: 4) { Text("Diff by characters") @@ -88,13 +134,14 @@ public struct TextDiffView: View { ) } Divider() - Text("Diff by words") + Text("Diff by words and revertible") .bold() TextDiffView( original: "Add a diff view! Looks good!", - updated: "Added a diff view. It looks good!", + updated: $updatedText, style: style, - mode: .token + mode: .token, + isRevertActionsEnabled: true ) HStack { Text("dog → fog:") @@ -129,6 +176,10 @@ public struct TextDiffView: View { .frame(width: 320) } +#Preview("Revert Binding") { + RevertBindingPreview() +} + #Preview("Height diff") { let font: NSFont = .systemFont(ofSize: 32, weight: .regular) let style = TextDiffStyle( @@ -164,3 +215,22 @@ public struct TextDiffView: View { } .padding() } + +private struct RevertBindingPreview: View { + @State private var updated = "To switch back to your computer, simply press any key on your keyboard." + + var body: some View { + var style = TextDiffStyle.default + style.font = .systemFont(ofSize: 13) + return TextDiffView( + original: "To switch back to your computer, just press any key on your keyboard.", + updated: $updated, + style: style, + mode: .token, + showsInvisibleCharacters: false, + isRevertActionsEnabled: true + ) + .padding() + .frame(width: 500) + } +} diff --git a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift b/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift index a8598de..0d42ef4 100644 --- a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift +++ b/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift @@ -17,6 +17,10 @@ final class DiffLayouterPerformanceTests: XCTestCase { runLayoutPerformanceTest(wordCount: 1000) } + func testLayoutPerformance500WordsWithRevertInteractions() { + runLayoutWithRevertInteractionsPerformanceTest(wordCount: 500) + } + private func runLayoutPerformanceTest(wordCount: Int) { let style = TextDiffStyle.default let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) @@ -27,6 +31,30 @@ final class DiffLayouterPerformanceTests: XCTestCase { let updated = Self.replacingLastWord(in: original) let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .character) + var lastLayout: DiffLayout? + measure(metrics: [XCTClockMetric()]) { + lastLayout = DiffTokenLayouter.layout( + segments: segments, + style: style, + availableWidth: availableWidth, + contentInsets: contentInsets + ) + } + XCTAssertFalse(lastLayout?.runs.isEmpty ?? true) + } + + private func runLayoutWithRevertInteractionsPerformanceTest(wordCount: Int) { + let style = TextDiffStyle.default + let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) + let contentInsets = NSEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) + let availableWidth: CGFloat = 520 + + let original = Self.largeText(wordCount: wordCount) + let updated = Self.replacingLastWord(in: original) + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + var lastLayout: DiffLayout? + var lastContext: DiffRevertInteractionContext? measure(metrics: [XCTClockMetric()]) { let layout = DiffTokenLayouter.layout( segments: segments, @@ -34,8 +62,18 @@ final class DiffLayouterPerformanceTests: XCTestCase { availableWidth: availableWidth, contentInsets: contentInsets ) - XCTAssertFalse(layout.runs.isEmpty) + let context = DiffRevertActionResolver.interactionContext( + segments: segments, + runs: layout.runs, + mode: .token, + original: original, + updated: updated + ) + lastLayout = layout + lastContext = context } + XCTAssertFalse(lastLayout?.runs.isEmpty ?? true) + XCTAssertNotNil(lastContext) } private static func largeText(wordCount: Int) -> String { diff --git a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift new file mode 100644 index 0000000..0fe5155 --- /dev/null +++ b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift @@ -0,0 +1,306 @@ +import Foundation +import Testing +@testable import TextDiff + +@Test +func candidatesBuildPairedReplacementForAdjacentDeleteInsert() throws { + let segments = [ + DiffSegment(kind: .delete, tokenKind: .word, text: "old"), + DiffSegment(kind: .insert, tokenKind: .word, text: "new") + ] + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "old", + updated: "new" + ) + #expect(candidates.count == 1) + #expect(candidates[0].kind == .pairedReplacement) + #expect(candidates[0].updatedRange == NSRange(location: 0, length: 3)) + #expect(candidates[0].replacementText == "old") + + let action = try #require(DiffRevertActionResolver.action(from: candidates[0], updated: "new")) + #expect(action.kind == .pairedReplacement) + #expect(action.resultingUpdated == "old") +} + +@Test +func indexedSegmentsAdvancePastEqualSegmentsEvenWhenTextValidationFails() { + let segments = [ + DiffSegment(kind: .equal, tokenKind: .word, text: "abc"), + DiffSegment(kind: .delete, tokenKind: .word, text: "X"), + DiffSegment(kind: .insert, tokenKind: .word, text: "Y") + ] + + let indexed = DiffRevertActionResolver.indexedSegments( + from: segments, + original: "zzzX", + updated: "zzzY" + ) + + #expect(indexed.count == 3) + #expect(indexed[0].originalRange == NSRange(location: 0, length: 3)) + #expect(indexed[0].updatedRange == NSRange(location: 0, length: 3)) + #expect(indexed[1].originalRange == NSRange(location: 3, length: 1)) + #expect(indexed[1].updatedRange == NSRange(location: 3, length: 0)) + #expect(indexed[2].originalRange == NSRange(location: 4, length: 0)) + #expect(indexed[2].updatedRange == NSRange(location: 3, length: 1)) +} + +@Test +func candidatesDoNotPairWhenAnySegmentExistsBetweenDeleteAndInsert() { + let segments = [ + DiffSegment(kind: .delete, tokenKind: .word, text: "old"), + DiffSegment(kind: .equal, tokenKind: .whitespace, text: " "), + DiffSegment(kind: .insert, tokenKind: .word, text: "new") + ] + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "old ", + updated: " new" + ) + #expect(candidates.count == 2) + #expect(candidates[0].kind == .singleDeletion) + #expect(candidates[1].kind == .singleInsertion) +} + +@Test +func singleInsertionActionRemovesInsertedFragment() throws { + let segments = [ + DiffSegment(kind: .equal, tokenKind: .word, text: "a"), + DiffSegment(kind: .insert, tokenKind: .word, text: "ß"), + DiffSegment(kind: .equal, tokenKind: .word, text: "c") + ] + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "ac", + updated: "aßc" + ) + let insertion = try #require(candidates.first(where: { $0.kind == .singleInsertion })) + + let action = try #require(DiffRevertActionResolver.action(from: insertion, updated: "aßc")) + #expect(action.kind == .singleInsertion) + #expect(action.updatedRange == NSRange(location: 1, length: 1)) + #expect(action.replacementText.isEmpty) + #expect(action.resultingUpdated == "ac") +} + +@Test +func singleDeletionActionReinsertsDeletedFragment() throws { + let segments = [ + DiffSegment(kind: .equal, tokenKind: .word, text: "a"), + DiffSegment(kind: .delete, tokenKind: .word, text: "🌍"), + DiffSegment(kind: .equal, tokenKind: .word, text: "b") + ] + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "a🌍b", + updated: "ab" + ) + let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) + + let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: "ab")) + #expect(action.kind == .singleDeletion) + #expect(action.updatedRange == NSRange(location: 1, length: 0)) + #expect(action.replacementText == "🌍") + #expect(action.resultingUpdated == "a🌍b") +} + +@Test +func candidatesAreEmptyInCharacterMode() { + let original = "old value" + let updated = "new value" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .character) + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .character, + original: original, + updated: updated + ) + #expect(candidates.isEmpty) +} + +@Test +func standaloneDeletionRevertRestoresWordBoundarySpacing() throws { + let original = "Hello brave world" + let updated = "Hello world" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) + + let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: updated)) + #expect(action.resultingUpdated == original) +} + +@Test +func standaloneDeletionAtEndRevertRestoresSpacing() throws { + let original = "Hello brave" + let updated = "Hello" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) + + let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: updated)) + #expect(action.resultingUpdated == original) +} + +@Test +func standaloneDeletionAfterSupplementaryPlaneLetterRestoresSpacing() throws { + let original = "𐐀 cat" + let updated = "𐐀" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) + + let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: updated)) + #expect(action.resultingUpdated == original) +} + +@Test +func hyphenReplacingWhitespaceRevertRestoresOriginalSpacing() throws { + let original = "in app purchase" + let updated = "in-app purchase" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let replacement = try #require(candidates.first(where: { $0.kind == .pairedReplacement })) + + #expect(replacement.originalTextFragment == " ") + #expect(replacement.updatedTextFragment == "-") + + let action = try #require(DiffRevertActionResolver.action(from: replacement, updated: updated)) + #expect(action.kind == .pairedReplacement) + #expect(action.resultingUpdated == original) +} + +@Test +func singleInsertionWordRevertCollapsesBoundaryWhitespace() throws { + let original = "A B" + let updated = "A X B" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let insertion = try #require(candidates.first(where: { + $0.kind == .singleInsertion && $0.updatedTextFragment == "X" + })) + + let action = try #require(DiffRevertActionResolver.action(from: insertion, updated: updated)) + #expect(action.resultingUpdated == original) +} + +@Test +func sequentialRevertsKeepLooksItAsPairedReplacement() throws { + let original = "Add a diff view! Looks good!" + var updated = "Added a diff view. It looks good!" + + updated = try applyingRevert( + original: original, + updated: updated, + kind: .pairedReplacement, + originalFragment: "Add", + updatedFragment: "Added" + ) + + updated = try applyingRevert( + original: original, + updated: updated, + kind: .pairedReplacement, + originalFragment: "!", + updatedFragment: "." + ) + + updated = try applyingRevert( + original: original, + updated: updated, + kind: .singleInsertion, + originalFragment: nil, + updatedFragment: "looks" + ) + #expect(updated == "Add a diff view! It good!") + + let remaining = revertCandidates(original: original, updated: updated) + let looksItPair = remaining.first { + $0.kind == .pairedReplacement + && $0.originalTextFragment == "Looks" + && $0.updatedTextFragment == "It" + } + + #expect(looksItPair != nil) + #expect(!remaining.contains { + $0.kind == .singleDeletion && $0.originalTextFragment == "Looks" + }) + #expect(!remaining.contains { + $0.kind == .singleInsertion && $0.updatedTextFragment == "It" + }) +} + +private func applyingRevert( + original: String, + updated: String, + kind: DiffRevertCandidateKind, + originalFragment: String?, + updatedFragment: String? +) throws -> String { + let candidates = revertCandidates(original: original, updated: updated) + var matched: DiffRevertCandidate? + for candidate in candidates { + guard candidate.kind == kind else { + continue + } + guard candidate.originalTextFragment == originalFragment else { + continue + } + guard candidate.updatedTextFragment == updatedFragment else { + continue + } + matched = candidate + break + } + + let candidate = try #require(matched) + let action = try #require(DiffRevertActionResolver.action(from: candidate, updated: updated)) + return action.resultingUpdated +} + +private func revertCandidates(original: String, updated: String) -> [DiffRevertCandidate] { + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + return DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) +} diff --git a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift index e98ed74..91dcd3a 100644 --- a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift +++ b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift @@ -1,11 +1,11 @@ import AppKit import SnapshotTesting -import TextDiff import XCTest +@testable import TextDiff final class NSTextDiffSnapshotTests: XCTestCase { override func invokeTest() { - withSnapshotTesting(record: .missing) { + withSnapshotTesting(record: snapshotRecordMode()) { super.invokeTest() } } @@ -54,6 +54,17 @@ final class NSTextDiffSnapshotTests: XCTestCase { ) } + @MainActor + func testNarrowWidthWrapping() { + assertNSTextDiffSnapshot( + original: sampleOriginalSentence, + updated: sampleUpdatedSentence, + mode: .token, + size: CGSize(width: 220, height: 180), + testName: "narrow_width_wrapping()" + ) + } + @MainActor func testCustomStyleSpacingStrikethrough() { var style = TextDiffStyle.default @@ -70,6 +81,85 @@ final class NSTextDiffSnapshotTests: XCTestCase { ) } + @MainActor + func testHoverSingleAdditionShowsAffordance() { + assertNSTextDiffSnapshot( + original: "cat", + updated: "cat!", + mode: .token, + size: CGSize(width: 260, height: 90), + configureView: { view in + view.isRevertActionsEnabled = true + _ = view._testingSetHoveredFirstRevertAction() + }, + testName: "hover_single_addition_affordance()" + ) + } + + @MainActor + func testHoverSingleDeletionShowsAffordance() { + assertNSTextDiffSnapshot( + original: "cat!", + updated: "cat", + mode: .token, + size: CGSize(width: 260, height: 90), + configureView: { view in + view.isRevertActionsEnabled = true + _ = view._testingSetHoveredFirstRevertAction() + }, + testName: "hover_single_deletion_affordance()" + ) + } + + @MainActor + func testHoverPairShowsAffordance() { + assertNSTextDiffSnapshot( + original: "old value", + updated: "new value", + mode: .token, + size: CGSize(width: 280, height: 90), + configureView: { view in + view.isRevertActionsEnabled = true + _ = view._testingSetHoveredFirstRevertAction() + }, + testName: "hover_pair_affordance()" + ) + } + + @MainActor + func testCharacterModeDoesNotShowAffordance() { + assertNSTextDiffSnapshot( + original: "Add a diff", + updated: "Added a diff", + mode: .character, + size: CGSize(width: 320, height: 110), + configureView: { view in + view.isRevertActionsEnabled = true + XCTAssertFalse(view._testingSetHoveredFirstRevertAction()) + }, + testName: "character_mode_no_affordance()" + ) + } + + @MainActor + func testInvisibleCharactersDebugOverlay() { + var style = TextDiffStyle.default + style.font = .monospacedSystemFont(ofSize: 20, weight: .regular) + style.lineSpacing = 4 + let text = "space tab\tnbsp:\u{00A0} newline:\nnext line" + assertNSTextDiffSnapshot( + original: text, + updated: text, + mode: .token, + style: style, + size: CGSize(width: 560, height: 170), + configureView: { view in + view.showsInvisibleCharacters = true + }, + testName: "invisible_characters_debug_overlay()" + ) + } + private let sampleOriginalSentence = "A quick brown fox jumps over a lazy dog." private let sampleUpdatedSentence = "A quick fox hops over the lazy dog!" } diff --git a/Tests/TextDiffTests/NSTextDiffViewTests.swift b/Tests/TextDiffTests/NSTextDiffViewTests.swift index 014daee..f73e1e2 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Testing @testable import TextDiff @@ -92,6 +93,25 @@ func nsTextDiffViewStyleChangeDoesNotRecomputeDiff() { #expect(callCount == 1) } +@Test +@MainActor +func nsTextDiffViewDebugInvisiblesToggleDoesNotRecomputeDiff() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + view.showsInvisibleCharacters = true + view.showsInvisibleCharacters = false + + #expect(callCount == 1) +} + @Test @MainActor func nsTextDiffViewSetContentBatchesRecompute() { @@ -141,3 +161,213 @@ func nsTextDiffViewSetContentStyleOnlyDoesNotRecomputeDiff() { #expect(callCount == 1) } + +@Test +@MainActor +func nsTextDiffViewRevertDisabledDoesNotEmitAction() { + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == false) + #expect(view._testingTriggerHoveredRevertAction() == false) + #expect(captured == nil) +} + +@Test +@MainActor +func nsTextDiffViewRevertSingleInsertionEmitsExpectedAction() throws { + let view = NSTextDiffView( + original: "cat", + updated: "cat!", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.isRevertActionsEnabled = true + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == true) + #expect(view._testingTriggerHoveredRevertAction() == true) + + let action = try #require(captured) + #expect(action.kind == .singleInsertion) + #expect(action.replacementText == "") + #expect(action.resultingUpdated == "cat") +} + +@Test +@MainActor +func nsTextDiffViewRevertSingleDeletionEmitsExpectedAction() throws { + let view = NSTextDiffView( + original: "cat!", + updated: "cat", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.isRevertActionsEnabled = true + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == true) + #expect(view._testingTriggerHoveredRevertAction() == true) + + let action = try #require(captured) + #expect(action.kind == .singleDeletion) + #expect(action.replacementText == "!") + #expect(action.resultingUpdated == "cat!") +} + +@Test +@MainActor +func nsTextDiffViewRevertPairEmitsExpectedAction() throws { + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.isRevertActionsEnabled = true + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == true) + #expect(view._testingTriggerHoveredRevertAction() == true) + + let action = try #require(captured) + #expect(action.kind == .pairedReplacement) + #expect(action.replacementText == "old") + #expect(action.resultingUpdated == "old") +} + +@Test +@MainActor +func hoverLeaveSchedulesDismissNotImmediate() { + let view = NSTextDiffView( + original: "old value", + updated: "new value", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 300, height: 100) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + let centers = view._testingActionCenters() + #expect(centers.count == 1) + guard let center = centers.first else { + Issue.record("Expected at least one action center") + return + } + + view._testingUpdateHover(location: center) + #expect(view._testingHoveredActionID() != nil) + + view._testingUpdateHover(location: CGPoint(x: -10, y: -10)) + + #expect(view._testingHasPendingHoverDismiss() == true) + #expect(view._testingHoveredActionID() != nil) +} + +@Test +@MainActor +func hoverDismissesAfterDelay() { + let view = NSTextDiffView( + original: "old value", + updated: "new value", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 300, height: 100) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + guard let center = view._testingActionCenters().first else { + Issue.record("Expected at least one action center") + return + } + view._testingUpdateHover(location: center) + view._testingUpdateHover(location: CGPoint(x: -10, y: -10)) + #expect(view._testingHasPendingHoverDismiss() == true) + + #expect(view._testingRunNextScheduledHoverDismiss() == true) + #expect(view._testingHoveredActionID() == nil) + #expect(view._testingHasPendingHoverDismiss() == false) +} + +@Test +@MainActor +func hoverSwitchesImmediatelyBetweenGroups() { + let view = NSTextDiffView( + original: "old A old B", + updated: "new A new B", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 340, height: 110) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + let centers = view._testingActionCenters() + #expect(centers.count >= 2) + guard centers.count >= 2 else { + return + } + + view._testingUpdateHover(location: centers[0]) + let firstAction = view._testingHoveredActionID() + #expect(firstAction != nil) + + view._testingUpdateHover(location: centers[1]) + let secondAction = view._testingHoveredActionID() + + #expect(secondAction != nil) + #expect(secondAction != firstAction) + #expect(view._testingHasPendingHoverDismiss() == false) +} + +@Test +@MainActor +func hoverReentryCancelsPendingDismiss() { + let view = NSTextDiffView( + original: "old value", + updated: "new value", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 300, height: 100) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + guard let center = view._testingActionCenters().first else { + Issue.record("Expected at least one action center") + return + } + + view._testingUpdateHover(location: center) + let hovered = view._testingHoveredActionID() + #expect(hovered != nil) + + view._testingUpdateHover(location: CGPoint(x: -10, y: -10)) + #expect(view._testingHasPendingHoverDismiss() == true) + + view._testingUpdateHover(location: center) + #expect(view._testingHasPendingHoverDismiss() == false) + #expect(view._testingHoveredActionID() == hovered) + + #expect(view._testingRunNextScheduledHoverDismiss() == true) + #expect(view._testingHoveredActionID() == hovered) +} diff --git a/Tests/TextDiffTests/SnapshotTestSupport.swift b/Tests/TextDiffTests/SnapshotTestSupport.swift index fdf3be2..a1157a0 100644 --- a/Tests/TextDiffTests/SnapshotTestSupport.swift +++ b/Tests/TextDiffTests/SnapshotTestSupport.swift @@ -6,6 +6,19 @@ import TextDiff private let snapshotPrecision: Float = 0.995 private let snapshotPerceptualPrecision: Float = 0.98 +func snapshotRecordMode( + default defaultMode: SnapshotTestingConfiguration.Record = .missing +) -> SnapshotTestingConfiguration.Record { + guard + let rawValue = ProcessInfo.processInfo.environment["SNAPSHOT_TESTING_RECORD"], + let recordMode = SnapshotTestingConfiguration.Record(rawValue: rawValue) + else { + return defaultMode + } + + return recordMode +} + @MainActor func assertTextDiffSnapshot( original: String, @@ -21,11 +34,12 @@ func assertTextDiffSnapshot( column: UInt = #column ) { configureSnapshotArtifactsDirectory(filePath: filePath) + let snapshotStyle = stableSnapshotStyle(from: style) let rootView = TextDiffView( original: original, updated: updated, - style: style, + style: snapshotStyle, mode: mode ) .frame(width: size.width, height: size.height, alignment: .topLeading) @@ -62,6 +76,7 @@ func assertNSTextDiffSnapshot( mode: TextDiffComparisonMode = .token, style: TextDiffStyle = .default, size: CGSize, + configureView: ((NSTextDiffView) -> Void)? = nil, named name: String? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, @@ -70,11 +85,12 @@ func assertNSTextDiffSnapshot( column: UInt = #column ) { configureSnapshotArtifactsDirectory(filePath: filePath) + let snapshotStyle = stableSnapshotStyle(from: style) let diffView = NSTextDiffView( original: original, updated: updated, - style: style, + style: snapshotStyle, mode: mode ) @@ -87,6 +103,8 @@ func assertNSTextDiffSnapshot( diffView.autoresizingMask = [.width, .height] container.addSubview(diffView) container.layoutSubtreeIfNeeded() + configureView?(diffView) + container.layoutSubtreeIfNeeded() let snapshotImage = renderSnapshotImage1x(view: container, size: size) @@ -108,6 +126,10 @@ func assertNSTextDiffSnapshot( } private func configureSnapshotArtifactsDirectory(filePath: StaticString) { + if getenv("SNAPSHOT_ARTIFACTS") != nil { + return + } + let fileURL = URL(fileURLWithPath: "\(filePath)") let repoRootURL = fileURL .deletingLastPathComponent() // TextDiffTests @@ -117,6 +139,41 @@ private func configureSnapshotArtifactsDirectory(filePath: StaticString) { setenv("SNAPSHOT_ARTIFACTS", artifactsPath, 1) } +private func stableSnapshotStyle(from base: TextDiffStyle) -> TextDiffStyle { + var style = base + + style.additionsStyle = stableSnapshotChangeStyle( + from: base.additionsStyle, + fillColor: NSColor( + srgbRed: 0.29, + green: 0.73, + blue: 0.37, + alpha: 1 + ) + ) + style.removalsStyle = stableSnapshotChangeStyle( + from: base.removalsStyle, + fillColor: NSColor( + srgbRed: 0.96, + green: 0.42, + blue: 0.42, + alpha: 1 + ) + ) + + return style +} + +private func stableSnapshotChangeStyle( + from base: TextDiffChangeStyle, + fillColor: NSColor +) -> TextDiffChangeStyle { + var style = base + style.fillColor = fillColor + style.strokeColor = fillColor + return style +} + @MainActor private func renderSnapshotImage1x(view: NSView, size: CGSize) -> NSImage { let rep = NSBitmapImageRep( @@ -127,7 +184,7 @@ private func renderSnapshotImage1x(view: NSView, size: CGSize) -> NSImage { samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .deviceRGB, + colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0 )! diff --git a/Tests/TextDiffTests/TextDiffEngineTests.swift b/Tests/TextDiffTests/TextDiffEngineTests.swift index 07784f8..ef56e11 100644 --- a/Tests/TextDiffTests/TextDiffEngineTests.swift +++ b/Tests/TextDiffTests/TextDiffEngineTests.swift @@ -51,6 +51,25 @@ func punctuationEditsAreLexicalDiffSegments() { #expect(joinedText(segments) == "Hello,. world!?") } +@Test +func punctuationInsertionReplacingWhitespaceKeepsWhitespaceDeletionVisible() { + let segments = TextDiffEngine.diff( + original: "in app purchase", + updated: "in-app purchase" + ) + + let deletedWhitespaceIndex = segments.firstIndex { + $0.kind == .delete && $0.tokenKind == .whitespace && $0.text == " " + } + let insertedHyphenIndex = segments.firstIndex { + $0.kind == .insert && $0.tokenKind == .punctuation && $0.text == "-" + } + + #expect(deletedWhitespaceIndex != nil) + #expect(insertedHyphenIndex != nil) + #expect((deletedWhitespaceIndex ?? 0) < (insertedHyphenIndex ?? 0)) +} + @Test func whitespaceOnlyChangesPreserveUpdatedLayoutWithoutWhitespaceDiffMarkers() { let updated = "Hello world\n" @@ -168,6 +187,11 @@ func defaultStyleInterChipSpacingMatchesCurrentDefault() { #expect(TextDiffStyle.default.interChipSpacing == 0) } +@Test +func defaultGroupStrokeStyleIsSolid() { + #expect(TextDiffStyle.default.groupStrokeStyle == .solid) +} + @Test func textDiffStyleDefaultUsesDefaultAdditionAndRemovalStyles() { let style = TextDiffStyle.default @@ -194,7 +218,8 @@ func textDiffStyleProtocolInitConvertsCustomConformers() { let style = TextDiffStyle( additionsStyle: additions, - removalsStyle: removals + removalsStyle: removals, + groupStrokeStyle: .dashed ) expectColorEqual(style.additionsStyle.fillColor, additions.fillColor) @@ -206,6 +231,7 @@ func textDiffStyleProtocolInitConvertsCustomConformers() { expectColorEqual(style.removalsStyle.strokeColor, removals.strokeColor) expectColorEqual(style.removalsStyle.textColorOverride ?? .clear, removals.textColorOverride ?? .clear) #expect(style.removalsStyle.strikethrough == removals.strikethrough) + #expect(style.groupStrokeStyle == .dashed) } @Test @@ -266,6 +292,34 @@ func layouterAppliesGapForPunctuationAdjacency() { #expect(chips[1].minX - chips[0].maxX >= 4 - 0.0001) } +@Test +func layouterRendersDeletedWhitespaceAsChipWhenReplacedByPunctuation() throws { + let style = TextDiffStyle.default + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .equal, tokenKind: .word, text: "in"), + DiffSegment(kind: .delete, tokenKind: .whitespace, text: " "), + DiffSegment(kind: .insert, tokenKind: .punctuation, text: "-"), + DiffSegment(kind: .equal, tokenKind: .word, text: "app") + ], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let deletedWhitespaceRun = layout.runs.first { + $0.segment.kind == .delete && $0.segment.tokenKind == .whitespace + } + let insertedHyphenRun = layout.runs.first { + $0.segment.kind == .insert && $0.segment.tokenKind == .punctuation && $0.segment.text == "-" + } + + let deletedWhitespaceChip = try #require(deletedWhitespaceRun?.chipRect) + let insertedHyphenChip = try #require(insertedHyphenRun?.chipRect) + #expect(deletedWhitespaceChip.width > 0) + #expect(insertedHyphenChip.width > 0) +} + @Test func layouterDoesNotInjectAdjacencyGapAcrossUnchangedWhitespace() throws { let style = TextDiffStyle.default @@ -290,6 +344,32 @@ func layouterDoesNotInjectAdjacencyGapAcrossUnchangedWhitespace() throws { #expect(abs(actualGap - whitespaceRun.textRect.width) < 0.0001) } +@Test +func layouterPreventsInsertedTokenClipWithProportionalSystemFont() throws { + var style = TextDiffStyle.default + style.font = .systemFont(ofSize: 13) + + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .delete, tokenKind: .word, text: "just"), + DiffSegment(kind: .insert, tokenKind: .word, text: "simply") + ], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let insertedRunCandidate = layout.runs.first(where: { + $0.segment.kind == .insert && $0.segment.tokenKind == .word && $0.segment.text == "simply" + }) + let insertedRun = try #require(insertedRunCandidate) + let insertedChip = try #require(insertedRun.chipRect) + let standaloneWidth = ("simply" as NSString).size(withAttributes: [.font: style.font]).width + + #expect(insertedRun.textRect.width >= standaloneWidth - 0.0001) + #expect(insertedChip.maxX >= insertedRun.textRect.maxX - 0.0001) +} + @Test func layouterWrapsByTokenAndRespectsExplicitNewlines() { let layout = DiffTokenLayouter.layout( diff --git a/Tests/TextDiffTests/TextDiffSnapshotTests.swift b/Tests/TextDiffTests/TextDiffSnapshotTests.swift index 5f5a7de..0651547 100644 --- a/Tests/TextDiffTests/TextDiffSnapshotTests.swift +++ b/Tests/TextDiffTests/TextDiffSnapshotTests.swift @@ -5,7 +5,7 @@ import XCTest final class TextDiffSnapshotTests: XCTestCase { override func invokeTest() { - withSnapshotTesting(record: .missing) { + withSnapshotTesting(record: snapshotRecordMode()) { super.invokeTest() } } @@ -65,17 +65,6 @@ final class TextDiffSnapshotTests: XCTestCase { ) } - @MainActor - func testNarrowWidthWrapping() { - assertTextDiffSnapshot( - original: sampleOriginalSentence, - updated: sampleUpdatedSentence, - mode: .token, - size: CGSize(width: 220, height: 180), - testName: "narrow_width_wrapping()" - ) - } - @MainActor func testCustomStyleSpacingStrikethrough() { var style = TextDiffStyle.default diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png new file mode 100644 index 0000000..224e65d Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png index 4031876..224e65d 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png index dd5178d..494ee13 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png new file mode 100644 index 0000000..771a14d Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png new file mode 100644 index 0000000..e92da2f Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png new file mode 100644 index 0000000..dead55c Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png new file mode 100644 index 0000000..7097fbf Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png index fd10089..51dc523 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png new file mode 100644 index 0000000..96d2422 Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png index 90d6985..8c760f9 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png index 2cdec60..63d3f69 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png index 4031876..224e65d 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png and b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png index dd5178d..494ee13 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png and b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png index fd10089..51dc523 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png and b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/narrow_width_wrapping.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/narrow_width_wrapping.1.png deleted file mode 100644 index 626e00d..0000000 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/narrow_width_wrapping.1.png and /dev/null differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png index 90d6985..8c760f9 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png and b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png index 2cdec60..63d3f69 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png and b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png index 1098d6f..c877e38 100644 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png and b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png differ diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..b717ccc --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,16 @@ +project_name: "TextDiff" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +definition_of_done: [] +date_format: yyyy-mm-dd +max_column_width: 20 +default_editor: "nano" +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md b/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md new file mode 100644 index 0000000..6cb3194 --- /dev/null +++ b/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md @@ -0,0 +1,40 @@ +--- +id: TASK-1 +title: Fix missing last character rendering in added text +status: Done +assignee: [] +created_date: '2026-02-24 19:37' +updated_date: '2026-02-27 00:08' +labels: + - bug +dependencies: [] +--- + +## Description + + +As a user comparing text diffs, I need every character in added text to be visible so I can trust what is shown on screen. There is an intermittent issue where the final character of an addition is not rendered visually, even though the character exists in the underlying text (for example, after pasting). + + +## Acceptance Criteria + +- [x] #1 When an addition is created by typing, the full added text is rendered including the final character. +- [x] #2 When the same addition is created by paste, the rendered result matches the typed result exactly with no missing final character. +- [x] #3 A regression test covers the scenario where the last character of an addition was previously not visible. + + +## Implementation Notes + + +Root cause: +The layouter measured token width for each run using cumulative line-width deltas (combinedWidth - previousLineWidth). With proportional fonts (for example .systemFont(ofSize: 13)), kerning/context shaping across token boundaries can make this delta slightly smaller than the token's standalone draw width. Because tokens are drawn individually in NSTextDiffView using run.textRect, the underestimated width could clip the trailing glyph (observed with "simply" where "y" disappeared in RevertBindingPreview). + +Fix: +In DiffTokenLayouter, we now compute standalone token width and use max(incrementalWidth, standaloneWidth) for displayed changed lexical runs (insert/delete chips). This guarantees textRect/chip width is never narrower than the rendered token while preserving incremental measurement for line-flow decisions. + +Regression coverage: +Added layouterPreventsInsertedTokenClipWithProportionalSystemFont in Tests/TextDiffTests/TextDiffEngineTests.swift to assert inserted "simply" width is at least standalone width and that chip bounds fully cover text bounds when using .systemFont(ofSize: 13). + +Verification: +Ran swift test 2>&1 | xcsift and confirmed the new test executes and passes. +