diff --git a/Package.swift b/Package.swift index 7c1edb8..0fee76c 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,11 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "TextDiff"), + name: "TextDiff", + swiftSettings: [ + .define("TESTING", .when(configuration: .debug)) + ] + ), .testTarget( name: "TextDiffTests", dependencies: [ diff --git a/README.md b/README.md index 7f68c03..c8b2d25 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TextDiff -TextDiff is a macOS Swift package that computes token-level diffs and renders a merged, display-only SwiftUI view backed by a custom AppKit renderer. +TextDiff is a macOS Swift package that computes token-level diffs and renders a merged, display-only diff view for both SwiftUI (`TextDiffView`) and AppKit (`NSTextDiffView`) via the same custom AppKit renderer. ![TextDiff preview](Resources/textdiff-preview.png) @@ -43,6 +43,30 @@ struct DemoView: View { } ``` +## AppKit Usage + +```swift +import AppKit +import TextDiff + +let diffView = NSTextDiffView( + original: "This is teh old sentence.", + updated: "This is the updated sentence!", + mode: .token +) + +// Constrain width in your layout. Height is intrinsic and computed from width. +diffView.translatesAutoresizingMaskIntoConstraints = false +``` + +You can update content in place: + +```swift +diffView.mode = .character +diffView.original = "Add a diff" +diffView.updated = "Added a diff" +``` + ## Comparison Modes ```swift @@ -101,14 +125,15 @@ struct StyledDemoView: View { - No synthetic spacer characters are inserted into the rendered text stream. - Chip top/bottom clipping is prevented internally via explicit line-height and vertical content insets. - Moved text is not detected as a move; it appears as delete + insert. -- Rendering uses a custom AppKit draw view bridged into SwiftUI. +- Rendering uses a custom AppKit draw view shared by both `TextDiffView` and `NSTextDiffView`. ## Snapshot Testing Snapshot coverage uses [Point-Free SnapshotTesting](https://github.com/pointfreeco/swift-snapshot-testing) with `swift-testing`. - Snapshot tests live in `Tests/TextDiffTests/TextDiffSnapshotTests.swift`. -- Baselines are stored under `Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/`. +- AppKit snapshot tests live in `Tests/TextDiffTests/NSTextDiffSnapshotTests.swift`. +- Baselines are stored under `Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/` and `Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/`. - The suite uses `@Suite(.snapshots(record: .missing))` to record only missing baselines. Run all tests: @@ -119,7 +144,7 @@ swift test 2>&1 | xcsift --quiet Update baselines intentionally: -1. Temporarily switch the suite trait in `Tests/TextDiffTests/TextDiffSnapshotTests.swift` from `.missing` to `.all`. +1. Temporarily switch the suite trait in snapshot suites (for example, `Tests/TextDiffTests/TextDiffSnapshotTests.swift` and `Tests/TextDiffTests/NSTextDiffSnapshotTests.swift`) from `.missing` to `.all`. 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. diff --git a/Sources/TextDiff/AppKit/DiffCanvasView.swift b/Sources/TextDiff/AppKit/DiffCanvasView.swift deleted file mode 100644 index 8793172..0000000 --- a/Sources/TextDiff/AppKit/DiffCanvasView.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AppKit -import Foundation - -final class DiffCanvasView: NSView { - private var segments: [DiffSegment] = [] - private var style: TextDiffStyle = .default - - private var cachedWidth: CGFloat = -1 - private var cachedLayout: DiffLayout? - - override var isFlipped: Bool { - true - } - - override var intrinsicContentSize: NSSize { - let layout = layoutForCurrentWidth() - return NSSize(width: NSView.noIntrinsicMetric, height: ceil(layout.contentSize.height)) - } - - override func setFrameSize(_ newSize: NSSize) { - let previousWidth = frame.width - super.setFrameSize(newSize) - if abs(previousWidth - newSize.width) > 0.5 { - invalidateCachedLayout() - } - } - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - - let layout = layoutForCurrentWidth() - for run in layout.runs { - if let chipRect = run.chipRect { - drawChip( - chipRect: chipRect, - fillColor: run.chipFillColor, - strokeColor: run.chipStrokeColor, - cornerRadius: run.chipCornerRadius - ) - } - - run.attributedText.draw(in: run.textRect) - } - } - - func update(segments: [DiffSegment], style: TextDiffStyle) { - self.segments = segments - self.style = style - invalidateCachedLayout() - } - - private func drawChip( - chipRect: CGRect, - fillColor: NSColor?, - strokeColor: NSColor?, - cornerRadius: CGFloat - ) { - guard chipRect.width > 0, chipRect.height > 0 else { - return - } - - let fillPath = NSBezierPath(roundedRect: chipRect, xRadius: cornerRadius, yRadius: cornerRadius) - fillColor?.setFill() - fillPath.fill() - - let strokeRect = chipRect.insetBy(dx: 0.5, dy: 0.5) - guard strokeRect.width > 0, strokeRect.height > 0 else { - return - } - - let strokePath = NSBezierPath(roundedRect: strokeRect, xRadius: cornerRadius, yRadius: cornerRadius) - strokeColor?.setStroke() - strokePath.lineWidth = 1 - strokePath.stroke() - } - - private func layoutForCurrentWidth() -> DiffLayout { - let width = max(bounds.width, 1) - if let cachedLayout, abs(cachedWidth - width) <= 0.5 { - return cachedLayout - } - - let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) - let contentInsets = NSEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) - let availableWidth = max(1, width - contentInsets.left - contentInsets.right) - let layout = DiffTokenLayouter.layout( - segments: segments, - style: style, - availableWidth: availableWidth, - contentInsets: contentInsets - ) - - cachedWidth = width - cachedLayout = layout - return layout - } - - private func invalidateCachedLayout() { - cachedLayout = nil - cachedWidth = -1 - needsDisplay = true - invalidateIntrinsicContentSize() - } -} diff --git a/Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift b/Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift new file mode 100644 index 0000000..b73b532 --- /dev/null +++ b/Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift @@ -0,0 +1,13 @@ +import AppKit + +enum DiffTextLayoutMetrics { + static func verticalTextInset(for style: TextDiffStyle) -> CGFloat { + ceil(max(2, style.chipInsets.top + 2, style.chipInsets.bottom + 2)) + } + + static func lineHeight(for style: TextDiffStyle) -> CGFloat { + let textHeight = ceil(style.font.ascender - style.font.descender + style.font.leading) + let chipHeight = textHeight + style.chipInsets.top + style.chipInsets.bottom + return ceil(chipHeight + max(0, style.lineSpacing)) + } +} diff --git a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift index d790a85..73d59f0 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift @@ -1,31 +1,30 @@ import AppKit import SwiftUI -enum DiffTextLayoutMetrics { - static func verticalTextInset(for style: TextDiffStyle) -> CGFloat { - ceil(max(2, style.chipInsets.top + 2, style.chipInsets.bottom + 2)) - } - - static func lineHeight(for style: TextDiffStyle) -> CGFloat { - let textHeight = ceil(style.font.ascender - style.font.descender + style.font.leading) - let chipHeight = textHeight + style.chipInsets.top + style.chipInsets.bottom - return ceil(chipHeight + max(0, style.lineSpacing)) - } -} - struct DiffTextViewRepresentable: NSViewRepresentable { - let segments: [DiffSegment] + let original: String + let updated: String let style: TextDiffStyle + let mode: TextDiffComparisonMode - func makeNSView(context: Context) -> DiffCanvasView { - let view = DiffCanvasView() + func makeNSView(context: Context) -> NSTextDiffView { + let view = NSTextDiffView( + original: original, + updated: updated, + style: style, + mode: mode + ) view.setContentCompressionResistancePriority(.required, for: .vertical) view.setContentHuggingPriority(.required, for: .vertical) - view.update(segments: segments, style: style) return view } - func updateNSView(_ view: DiffCanvasView, context: Context) { - view.update(segments: segments, style: style) + func updateNSView(_ view: NSTextDiffView, context: Context) { + view.setContent( + original: original, + updated: updated, + style: style, + mode: mode + ) } } diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift new file mode 100644 index 0000000..00920a3 --- /dev/null +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -0,0 +1,254 @@ +import AppKit +import Foundation + +/// An AppKit view that renders a merged visual diff between two strings. +public final class NSTextDiffView: NSView { + typealias DiffProvider = (String, String, TextDiffComparisonMode) -> [DiffSegment] + + /// The source text before edits. + /// Setting this value updates rendered diff output when content changes. + public var original: String { + didSet { + guard !isBatchUpdating else { + return + } + _ = updateSegmentsIfNeeded() + } + } + + /// The source text after edits. + /// Setting this value updates rendered diff output when content changes. + public var updated: String { + didSet { + guard !isBatchUpdating else { + return + } + _ = updateSegmentsIfNeeded() + } + } + + /// Visual style used to render additions, deletions, and unchanged text. + /// Setting this value redraws the view without recomputing diff segments. + public var style: TextDiffStyle { + didSet { + guard !isBatchUpdating else { + pendingStyleInvalidation = true + return + } + invalidateCachedLayout() + } + } + + /// Comparison mode that controls token-level or character-refined output. + /// Setting this value updates rendered diff output when mode changes. + public var mode: TextDiffComparisonMode { + didSet { + guard !isBatchUpdating else { + return + } + _ = updateSegmentsIfNeeded() + } + } + + private var segments: [DiffSegment] + private let diffProvider: DiffProvider + + private var lastOriginal: String + private var lastUpdated: String + private var lastModeKey: Int + private var isBatchUpdating = false + private var pendingStyleInvalidation = false + + private var cachedWidth: CGFloat = -1 + private var cachedLayout: DiffLayout? + + override public var isFlipped: Bool { + true + } + + override public var intrinsicContentSize: NSSize { + let layout = layoutForCurrentWidth() + return NSSize(width: NSView.noIntrinsicMetric, height: ceil(layout.contentSize.height)) + } + + /// Creates a text diff view for two versions of content. + /// + /// - 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. + public init( + original: String, + updated: String, + style: TextDiffStyle = .default, + mode: TextDiffComparisonMode = .token + ) { + self.original = original + self.updated = updated + self.style = style + self.mode = mode + self.diffProvider = { original, updated, mode in + TextDiffEngine.diff(original: original, updated: updated, mode: mode) + } + self.lastOriginal = original + self.lastUpdated = updated + self.lastModeKey = Self.modeKey(for: mode) + self.segments = self.diffProvider(original, updated, mode) + super.init(frame: .zero) + } + + #if TESTING + init( + original: String, + updated: String, + style: TextDiffStyle = .default, + mode: TextDiffComparisonMode = .token, + diffProvider: @escaping DiffProvider + ) { + self.original = original + self.updated = updated + self.style = style + self.mode = mode + self.diffProvider = diffProvider + self.lastOriginal = original + self.lastUpdated = updated + self.lastModeKey = Self.modeKey(for: mode) + self.segments = diffProvider(original, updated, mode) + super.init(frame: .zero) + } + #endif + + @available(*, unavailable, message: "Use init(original:updated:style:mode:)") + required init?(coder: NSCoder) { + fatalError("Use init(original:updated:style:mode:)") + } + + override public func setFrameSize(_ newSize: NSSize) { + let previousWidth = frame.width + super.setFrameSize(newSize) + if abs(previousWidth - newSize.width) > 0.5 { + invalidateCachedLayout() + } + } + + override public func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let layout = layoutForCurrentWidth() + for run in layout.runs { + if let chipRect = run.chipRect { + drawChip( + chipRect: chipRect, + fillColor: run.chipFillColor, + strokeColor: run.chipStrokeColor, + cornerRadius: run.chipCornerRadius + ) + } + + run.attributedText.draw(in: run.textRect) + } + } + + /// Atomically updates view inputs and recomputes diff segments at most once. + public func setContent( + original: String, + updated: String, + style: TextDiffStyle, + mode: TextDiffComparisonMode + ) { + isBatchUpdating = true + defer { + isBatchUpdating = false + let needsStyleInvalidation = pendingStyleInvalidation + pendingStyleInvalidation = false + + let didRecompute = updateSegmentsIfNeeded() + if needsStyleInvalidation, !didRecompute { + invalidateCachedLayout() + } + } + + self.style = style + self.mode = mode + self.original = original + self.updated = updated + } + + @discardableResult + private func updateSegmentsIfNeeded() -> Bool { + let newModeKey = Self.modeKey(for: mode) + guard original != lastOriginal || updated != lastUpdated || newModeKey != lastModeKey else { + return false + } + + lastOriginal = original + lastUpdated = updated + lastModeKey = newModeKey + segments = diffProvider(original, updated, mode) + invalidateCachedLayout() + return true + } + + private func layoutForCurrentWidth() -> DiffLayout { + let width = max(bounds.width, 1) + if let cachedLayout, abs(cachedWidth - width) <= 0.5 { + return cachedLayout + } + + let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) + let contentInsets = NSEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) + let availableWidth = max(1, width - contentInsets.left - contentInsets.right) + let layout = DiffTokenLayouter.layout( + segments: segments, + style: style, + availableWidth: availableWidth, + contentInsets: contentInsets + ) + + cachedWidth = width + cachedLayout = layout + return layout + } + + private func invalidateCachedLayout() { + cachedLayout = nil + cachedWidth = -1 + needsDisplay = true + invalidateIntrinsicContentSize() + } + + private func drawChip( + chipRect: CGRect, + fillColor: NSColor?, + strokeColor: NSColor?, + cornerRadius: CGFloat + ) { + guard chipRect.width > 0, chipRect.height > 0 else { + return + } + + let fillPath = NSBezierPath(roundedRect: chipRect, xRadius: cornerRadius, yRadius: cornerRadius) + fillColor?.setFill() + fillPath.fill() + + let strokeRect = chipRect.insetBy(dx: 0.5, dy: 0.5) + guard strokeRect.width > 0, strokeRect.height > 0 else { + return + } + + let strokePath = NSBezierPath(roundedRect: strokeRect, xRadius: cornerRadius, yRadius: cornerRadius) + strokeColor?.setStroke() + strokePath.lineWidth = 1 + strokePath.stroke() + } + + private static func modeKey(for mode: TextDiffComparisonMode) -> Int { + switch mode { + case .token: + return 0 + case .character: + return 1 + } + } +} diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index 43e78d5..2548056 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -7,7 +7,6 @@ public struct TextDiffView: View { private let updated: String private let mode: TextDiffComparisonMode private let style: TextDiffStyle - @StateObject private var model: TextDiffViewModel /// Creates a text diff view for two versions of content. /// @@ -26,33 +25,17 @@ public struct TextDiffView: View { self.updated = updated self.mode = mode self.style = style - _model = StateObject( - wrappedValue: TextDiffViewModel(original: original, updated: updated, mode: mode) - ) } /// The view body that renders the current diff content. public var body: some View { - DiffTextViewRepresentable(segments: model.segments, style: style) + DiffTextViewRepresentable( + original: original, + updated: updated, + style: style, + mode: mode + ) .accessibilityLabel("Text diff") - .onChange(of: original) { _, _ in - model.updateIfNeeded(original: original, updated: updated, mode: mode) - } - .onChange(of: updated) { _, _ in - model.updateIfNeeded(original: original, updated: updated, mode: mode) - } - .onChange(of: modeKey) { _, _ in - model.updateIfNeeded(original: original, updated: updated, mode: mode) - } - } - - private var modeKey: Int { - switch mode { - case .token: - return 0 - case .character: - return 1 - } } } @@ -65,13 +48,13 @@ public struct TextDiffView: View { .frame(width: 500) } -#Preview("Custom Style") { +#Preview("TextDiffView") { let style = TextDiffStyle( - additionFillColor: NSColor.systemGreen.withAlphaComponent(0.28), - additionStrokeColor: NSColor.systemGreen.withAlphaComponent(0.75), + additionFillColor: .systemGreen.withAlphaComponent(0.28), + additionStrokeColor: .systemGreen.withAlphaComponent(0.75), additionTextColorOverride: .labelColor, - deletionFillColor: NSColor.systemRed.withAlphaComponent(0.24), - deletionStrokeColor: NSColor.systemRed.withAlphaComponent(0.75), + deletionFillColor: .systemRed.withAlphaComponent(0.24), + deletionStrokeColor: .systemRed.withAlphaComponent(0.75), deletionTextColorOverride: .secondaryLabelColor, unchangedTextColor: .labelColor, font: .systemFont(ofSize: 16, weight: .regular), diff --git a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift new file mode 100644 index 0000000..7af8152 --- /dev/null +++ b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift @@ -0,0 +1,66 @@ +import AppKit +import SnapshotTesting +import Testing +import TextDiff + +@Suite(.snapshots(record: .missing)) +@MainActor +struct NSTextDiffSnapshotTests { + @Test + func token_basic_replacement() { + assertNSTextDiffSnapshot( + original: "Apply old value in this sentence.", + updated: "Apply new value in this sentence.", + mode: .token, + size: CGSize(width: 500, height: 120) + ) + } + + @Test + func character_suffix_refinement() { + assertNSTextDiffSnapshot( + original: "Add a diff", + updated: "Added a diff", + mode: .character, + size: CGSize(width: 320, height: 110) + ) + } + + @Test + func punctuation_replacement() { + assertNSTextDiffSnapshot( + original: "Wait!", + updated: "Wait.", + mode: .token, + size: CGSize(width: 320, height: 100) + ) + } + + @Test + func multiline_insertion_wrap() { + assertNSTextDiffSnapshot( + original: "line1\nline2", + updated: "line1\nlineX\nline2", + mode: .token, + size: CGSize(width: 300, height: 150) + ) + } + + @Test + func custom_style_spacing_strikethrough() { + var style = TextDiffStyle.default + style.deletionStrikethrough = true + style.interChipSpacing = 1 + + assertNSTextDiffSnapshot( + original: sampleOriginalSentence, + updated: sampleUpdatedSentence, + mode: .character, + style: style, + size: CGSize(width: 300, height: 180) + ) + } + + 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 new file mode 100644 index 0000000..85968c3 --- /dev/null +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -0,0 +1,143 @@ +import Testing +@testable import TextDiff + +@Test +@MainActor +func nsTextDiffViewInitComputesExactlyOnce() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + #expect(callCount == 1) + #expect(view.intrinsicContentSize.height > 0) +} + +@Test +@MainActor +func nsTextDiffViewRecomputesWhenOriginalChanges() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + view.original = "old-2" + + #expect(callCount == 2) +} + +@Test +@MainActor +func nsTextDiffViewRecomputesWhenUpdatedChanges() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + view.updated = "new-2" + + #expect(callCount == 2) +} + +@Test +@MainActor +func nsTextDiffViewRecomputesWhenModeChanges() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + view.mode = .character + + #expect(callCount == 2) +} + +@Test +@MainActor +func nsTextDiffViewStyleChangeDoesNotRecomputeDiff() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + var style = TextDiffStyle.default + style.deletionStrikethrough = true + view.style = style + + #expect(callCount == 1) +} + +@Test +@MainActor +func nsTextDiffViewSetContentBatchesRecompute() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + var style = TextDiffStyle.default + style.deletionStrikethrough = true + view.setContent( + original: "old-2", + updated: "new-2", + style: style, + mode: .character + ) + + #expect(callCount == 2) +} + +@Test +@MainActor +func nsTextDiffViewSetContentStyleOnlyDoesNotRecomputeDiff() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + var style = TextDiffStyle.default + style.deletionStrikethrough = true + view.setContent( + original: "old", + updated: "new", + style: style, + mode: .token + ) + + #expect(callCount == 1) +} diff --git a/Tests/TextDiffTests/SnapshotTestSupport.swift b/Tests/TextDiffTests/SnapshotTestSupport.swift index c8be695..795e220 100644 --- a/Tests/TextDiffTests/SnapshotTestSupport.swift +++ b/Tests/TextDiffTests/SnapshotTestSupport.swift @@ -51,6 +51,54 @@ func assertTextDiffSnapshot( ) } +@MainActor +func assertNSTextDiffSnapshot( + original: String, + updated: String, + mode: TextDiffComparisonMode = .token, + style: TextDiffStyle = .default, + size: CGSize, + named name: String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + testName: String = #function, + line: UInt = #line, + column: UInt = #column +) { + let diffView = NSTextDiffView( + original: original, + updated: updated, + style: style, + mode: mode + ) + + let container = NSView(frame: CGRect(origin: .zero, size: size)) + container.wantsLayer = true + container.layer?.backgroundColor = NSColor.white.cgColor + container.appearance = NSAppearance(named: .aqua) + + diffView.frame = container.bounds + diffView.autoresizingMask = [.width, .height] + container.addSubview(diffView) + container.layoutSubtreeIfNeeded() + + let snapshotImage = renderSnapshotImage1x(view: container, size: size) + + assertSnapshot( + of: snapshotImage, + as: .image( + precision: snapshotPrecision, + perceptualPrecision: snapshotPerceptualPrecision + ), + named: name, + fileID: fileID, + file: filePath, + testName: testName, + line: line, + column: column + ) +} + @MainActor private func renderSnapshotImage1x(view: NSView, size: CGSize) -> NSImage { let rep = NSBitmapImageRep( diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png new file mode 100644 index 0000000..2589385 Binary files /dev/null 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 new file mode 100644 index 0000000..dc0b727 Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.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 new file mode 100644 index 0000000..3006387 Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png new file mode 100644 index 0000000..8b47eda Binary files /dev/null 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 new file mode 100644 index 0000000..86ca2b5 Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png differ