From b6bd365adc262405c270526596d2eaa6a679ed63 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 17 Feb 2026 21:17:22 +0100 Subject: [PATCH 1/3] Add AppKit NSTextDiffView API and unify SwiftUI rendering --- README.md | 33 ++- Sources/TextDiff/AppKit/DiffCanvasView.swift | 104 --------- .../AppKit/DiffTextLayoutMetrics.swift | 13 ++ .../AppKit/DiffTextViewRepresentable.swift | 33 ++- Sources/TextDiff/AppKit/NSTextDiffView.swift | 210 ++++++++++++++++++ Sources/TextDiff/TextDiffView.swift | 29 +-- .../NSTextDiffSnapshotTests.swift | 66 ++++++ Tests/TextDiffTests/NSTextDiffViewTests.swift | 93 ++++++++ Tests/TextDiffTests/SnapshotTestSupport.swift | 48 ++++ .../character_suffix_refinement.1.png | Bin 0 -> 3217 bytes .../custom_style_spacing_strikethrough.1.png | Bin 0 -> 8339 bytes .../multiline_insertion_wrap.1.png | Bin 0 -> 4414 bytes .../punctuation_replacement.1.png | Bin 0 -> 2698 bytes .../token_basic_replacement.1.png | Bin 0 -> 6295 bytes 14 files changed, 480 insertions(+), 149 deletions(-) delete mode 100644 Sources/TextDiff/AppKit/DiffCanvasView.swift create mode 100644 Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift create mode 100644 Sources/TextDiff/AppKit/NSTextDiffView.swift create mode 100644 Tests/TextDiffTests/NSTextDiffSnapshotTests.swift create mode 100644 Tests/TextDiffTests/NSTextDiffViewTests.swift create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png 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..c2d6e4a 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift @@ -1,31 +1,28 @@ 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.style = style + view.mode = mode + view.original = original + view.updated = updated } } diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift new file mode 100644 index 0000000..115904a --- /dev/null +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -0,0 +1,210 @@ +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 { + updateSegmentsIfNeeded() + } + } + + /// The source text after edits. + /// Setting this value updates rendered diff output when content changes. + public var updated: String { + didSet { + 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 { + 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 { + updateSegmentsIfNeeded() + } + } + + private var segments: [DiffSegment] + private let diffProvider: DiffProvider + + private var lastOriginal: String + private var lastUpdated: String + private var lastModeKey: Int + + 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) + } + + 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) + } + + @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) + } + } + + private func updateSegmentsIfNeeded() { + let newModeKey = Self.modeKey(for: mode) + guard original != lastOriginal || updated != lastUpdated || newModeKey != lastModeKey else { + return + } + + lastOriginal = original + lastUpdated = updated + lastModeKey = newModeKey + segments = diffProvider(original, updated, mode) + invalidateCachedLayout() + } + + 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..c8aa952 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 - } } } 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..84cf587 --- /dev/null +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -0,0 +1,93 @@ +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) +} 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 0000000000000000000000000000000000000000..2589385780cf5365f33d1a3ae57154780a7fc481 GIT binary patch literal 3217 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dc0b727b52fe456e215a51d4b7119003c1c2c88b GIT binary patch literal 8339 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3006387a4a1aea0004dce1cd227a3f0814fa4fcf GIT binary patch literal 4414 zcmeI0`9GB3`^RU-(v&?R#3)OPExc_>Xpk|MtYaHgmW(}yEHn1BkO&jKEm^XL?1n+_ zveS^I$W}~aYb+TInLfAocqs|83KWL>k;~T*7w0bXSi3DqO?cbcY_Q71qT<16iD-$(&{(K8`TeY<;MRK)_LorKu8IaA@o0<#m#e?_2wB z(iyD~lkOy8$-uzC+K`_PWHPc8H8*$G=M?=(^vrYu!DaLPo!hsi zG&SuFlSQyyj2xx6?d{I9_ygatrB{iI6oLya$)=VY2%Qg1N}wSm38GvW5FuV2e#=|r_VlNVBS6sp_z%f}`rh`!dY z#YIKEt?TWwEXTNmOH^V(5agriEJ{2&;G)85up)O6Ftb)$Nqz zofmGIGRK1OdmG<~edRV4SA0ao#T6=0)q*-YIwbF`XU`-U42JT(MI(!-J0#peNR~!m zfk~FKLA9iuoXL-mIkHr8-siKFb^r7{G5j?Zz-bd(5@aAVc8o{7vSbqmfl3Ug>Xxu)PTj_6mBvwXU$wN@ zr|hhLHXoZbRNemh74?8q_*`mg>b~}$vp{-L{7Yk<#HnLtH!WKu4-a+;+uOl2*1qU6 zmFJ{zI&rFC5Do&f)<<(!_V*j(0;YowGl&XEUwQh{2;4>20fjm*ARrJP^WXuyX3&qM zQvw3ZGaXG(u)Ca{>2};xu9sVZ6K{2a3ee7ni({ z8*(UW;Lo^NYX57DR|s?R>Yi3q-Hg_}g77IpS@W4xIX!%g8Ug7GqzVDSa}6cS>&WBa z|NMqao&D`?#;dOpILyp0H`{MBvp21)*Lde3{nFe&bcH)3OiB2p$zAMYd0D+q5AdHk zEk|4W5PfSV_m0%1{3iOX;>$Mq@@*UwIRObcfG`uVQ5j=e)$5r(Jv?I1 zr?TsstP#EfkRj8BnH3mrfTkSO18?^2%nDr}%JvZv5|#t<}#cWA`wM zfIz|R0*a_4ztpYZ?*i1h-VfQMs%iVZc43F(34}L4He%q)=hQ4hn70zO(Bpmw2uPEi zA+PTI1JpuQZvK*_NT$DZC3ec*Tcf<&A!B_)TY|mfS0&~$x~rJ4i=_& zF>W6R>fC$pz>Aiu4JZd)a@gYvQ3*zB%6htT&-u(%mKUBH1)TFZOyYePX1gjykQ#c%q41H19YljFk0Fzxu0s zf$}`o-0fXbR1mcyRrr_6Vp|X@aZM=d+)I4J$nkm@dh`C2JI5wFkO15H?53J+BA#l}BK z=>Ih7f5p*}Dy>mZFVp0+;7iNhEXWln{AHcieyE?A*Tebm67lAivr23J2A_GkOMgm} z=d&PD+$IsE<|&94%Bq5At%LF0Alw0)5tKj*8dxN!;1u5SIEBmH`u6A43d7}$&a0F= zZ0vzHtzNKUZmV;|0dkmx!C-W|GOf!KJNm>_sCfE# zvDP|q6jvR>H(r&G84YN+xEIx;mwO)*?@Yt2dWu7!d#`m{`BZl@EVQUJjt*?TfQS>UrCP939hn`ctHi5RAp-h^ zre{C7-Xtvuaed;G4?`oW`u^)O;U12Z{)y>1q^@gpTd0P4lkdU&aqV}EYI@A6+|W0k zpeW`7qc-5j)j)9$`4QZD=2{dvwPxe8#kXd^WlR_|0})PixO73o$hj};ViuYF?6(fn z{`-xqRj3Jvh}LP+wq6pTt7PcjkUV-yx&8npedY4^%*e7|SSr16C1PhK44vmPVttX( zaIn~L?TzE@&kZL9uYenso$8Ti+Z#wjlA1}1e`N27^wd|Z9Aj^wcdD5a>-a0&wxOw%Z*Jt_86 zEu_u59>Dl37W`7dBz<48@=nSRoI3MKrwH!`J2)4)#6{9ihDzkKX^^@VArR>OWKax6 z6>abeDgh9X*8e3Sryy6cYms&s%+wqUhcQr-=RG)<>f*IRY3Y(T6tu%+3pj6XZlX9VU%WVtFf*I@_%$c?@MsH#9U< z`I(tL^_i?Su(Yyr-&*Xe^(2e;_4Wo(D*~)qqjJBkP1Y6sgV~sWmy7rf6w0r5op(8q zV$rZ`ke#uYte znUL1;YyJ`Q@BkSePV_5ptTIL-i&53iw1R`3pU*2R<+9X#vx|y~nn#88fS`k2ZES5< z;ua_rii19yP~4a~R`ucv9w5+v2`V>y<3pB;=XOHQ%C(;??3|Y^IZvE8iJedc_-Tw* zFvVuw^L}e zjl=D0qc5$=+EE>I)AOgGqO;sbx<&Ytp3(W?SMXY|QJLeMoaq1>FZGG(-2aDM_oB2e zKDRD;MWSs=)v4C)QSkaKy2(PdbirwbGI_#EWq*6Q5I%(FlLThL@x*kR=w6xZ%6P@k zs(IfsGQkiO<9!k!C#O8 z#vQJBlLZ1jeVo_M(Bv;*q$n#?PBc751i)XT=uYL^1$+?b7htJnrtE(q{%;olpRC0Vbr!ok3et1Y`{+Lg!oWbh&} Fe*h`mr_lfa literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8b47edaeff898401a7f46454195f45f56a1d412d GIT binary patch literal 2698 zcmeHJ`9IX#AO6fV)il0plr76OB}+GBscr*t*jX_fPo#c7J%E^PKZO=kJZjkg^U`H&b1<@f+x~DtpH)=)>uNp9ZqkQm9@h1 zk6}jUG(E#J9=~FX^9>DJP43vlGEq&FJ^|75CLwcq3>{SPpvjd*zgq`kU??K%Cis!c z$q?uLAPC$J0!44xqJgA5IRtQ-He+%L0)KtQc4*bHQhYN;>|R0fJl$gTC|65#B-^H4 zZ{UfZoUCTqg21|%_+$Pqp<=5(?8#Q?7omS_zjkr`Mr~L`KMpB4W}AFx#ilrt5ZkA?_icP`0(`N^^*&a1DV5GOnUo1 zo4C#4ZpPz~LLASl`axRSJ()m)qS!2ySfnvO$Vw1jsQ3_)iqYmS&*xn8>N%i;e`RL@ zB#p_gGPgaisIdRa$Y*{3%4pGDThX80EH4W0bN*!|fLxq-Dg;q6+nA6xg!X%nL5d;r^zE~stPG0Bx4;hPIXHHN<>#;{l z5F~UYET}hAwaUHBx%Am)inKD7xVbihS8X-9wiYw+(7-ZV)9Lj1-^@}6ZqwxuKJ9ag zE!VKUUVj{^rY@Qr-};S*8q=;*{w0*P!BrFTRe7jcoBk@ZRVff8z8)Pcoa$_joyA0F z4h#&0;h@F`{T|G)YQsOj{u8M;`EBL?VRvc>~<|~9|$sW8gVRs3CAh_>kX$NBzyAf;M3u!C$8KpsGk*yM3|=N z(s&WSG(*|Al1e>mWJX*Sp097;Y&h3>BZ$X;UqzT1MVckZKl>W)F5#4H7(R*{t@g1u zTc&STV;U<5{8TqMdr8Rl6YLDSj6~@a=pprv&Gi+nK5yYbp2@LYU0q$LIE<_VmP@M( zpMsW*3H-A;_wVE8#+pp8E|z4fp(q*-4-WNl8?Dxx5>_JlU-u~Lqvhn}-rSn5&R7-9 z8|xxF`v74^KU)R_@zcwK6l8p+{N8lW(Ipq@OKweA37hIjQZi4BJBn$T7QVRjDMmPD zfo)4P*=3b5)
$6#(e_eI^`V+kZZ`!#F7X$KTTXnP|EZ|#)o?lXW0j4qo)lT_fZ*#tA?X%8~6fka|kMCD6~MmXB2t2+)1wy7#l~KEX#*tvqA{dIGTN(xA|^&h%ac4Yd3nn*sCR8a>mnnTd^wJCD!F$ z;84}c{+=KozbtrGQ!;4;Os3QtIELcG#xab{8YXp5N&O1fD`bx)udzemw$il38eZcp zx?S^LBV4ONBpT{gvA0dcu9&nWj-IV@+4^qmv3u2GS<_FQgx(FMx+FV0H)~hF4EPXI zYDhKZ{2)Am!C+hTXN%9efx}&lSgJ-{1Qso^ z^3P;+E?D36-OIZrS|a8=QT;vffw?NphD~MB)OJlG4#UECrdXdj()E!nI&V_#&zfVQ zlxN(^x?{_o67cJVJNvq#y?gqL%9qW5_UlsM;64@kq0agY9*g!>dh3~LpRmlJ$Sq4( z^|EijJJm3iekRn?TZTKjomyHKL3l__-Yt0S|6`-AUy{x4acwE}62l?C!&arxxsJzS z5??AJ)gHd+BKsu|j_#-dK;LNLI7Kk@l{^9nlyXObp)VxQ&Umb}v_$%BFyvgsdA=)An*{LHKY%L-$tT=UU@PR0>5|!4K&XxDWw45y6w;ocGt*V035j;il$>N tyWwaA4FD8o^dBq2`2746+FLtYQBIw^@eh7>!HWO@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..86ca2b50238d891685f49e6848ea85b551ed1aed GIT binary patch literal 6295 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 Date: Tue, 17 Feb 2026 23:01:28 +0100 Subject: [PATCH 2/3] prevent recomputations --- Package.swift | 6 ++- .../AppKit/DiffTextViewRepresentable.swift | 10 ++-- Sources/TextDiff/AppKit/NSTextDiffView.swift | 54 +++++++++++++++++-- Tests/TextDiffTests/NSTextDiffViewTests.swift | 50 +++++++++++++++++ 4 files changed, 110 insertions(+), 10 deletions(-) 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/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift index c2d6e4a..73d59f0 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift @@ -20,9 +20,11 @@ struct DiffTextViewRepresentable: NSViewRepresentable { } func updateNSView(_ view: NSTextDiffView, context: Context) { - view.style = style - view.mode = mode - view.original = original - view.updated = updated + view.setContent( + original: original, + updated: updated, + style: style, + mode: mode + ) } } diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index 115904a..00920a3 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -9,7 +9,10 @@ public final class NSTextDiffView: NSView { /// Setting this value updates rendered diff output when content changes. public var original: String { didSet { - updateSegmentsIfNeeded() + guard !isBatchUpdating else { + return + } + _ = updateSegmentsIfNeeded() } } @@ -17,7 +20,10 @@ public final class NSTextDiffView: NSView { /// Setting this value updates rendered diff output when content changes. public var updated: String { didSet { - updateSegmentsIfNeeded() + guard !isBatchUpdating else { + return + } + _ = updateSegmentsIfNeeded() } } @@ -25,6 +31,10 @@ public final class NSTextDiffView: NSView { /// Setting this value redraws the view without recomputing diff segments. public var style: TextDiffStyle { didSet { + guard !isBatchUpdating else { + pendingStyleInvalidation = true + return + } invalidateCachedLayout() } } @@ -33,7 +43,10 @@ public final class NSTextDiffView: NSView { /// Setting this value updates rendered diff output when mode changes. public var mode: TextDiffComparisonMode { didSet { - updateSegmentsIfNeeded() + guard !isBatchUpdating else { + return + } + _ = updateSegmentsIfNeeded() } } @@ -43,6 +56,8 @@ public final class NSTextDiffView: NSView { 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? @@ -83,6 +98,7 @@ public final class NSTextDiffView: NSView { super.init(frame: .zero) } + #if TESTING init( original: String, updated: String, @@ -101,6 +117,7 @@ public final class NSTextDiffView: NSView { self.segments = diffProvider(original, updated, mode) super.init(frame: .zero) } + #endif @available(*, unavailable, message: "Use init(original:updated:style:mode:)") required init?(coder: NSCoder) { @@ -133,10 +150,36 @@ public final class NSTextDiffView: NSView { } } - private func updateSegmentsIfNeeded() { + /// 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 + return false } lastOriginal = original @@ -144,6 +187,7 @@ public final class NSTextDiffView: NSView { lastModeKey = newModeKey segments = diffProvider(original, updated, mode) invalidateCachedLayout() + return true } private func layoutForCurrentWidth() -> DiffLayout { diff --git a/Tests/TextDiffTests/NSTextDiffViewTests.swift b/Tests/TextDiffTests/NSTextDiffViewTests.swift index 84cf587..85968c3 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -91,3 +91,53 @@ func nsTextDiffViewStyleChangeDoesNotRecomputeDiff() { #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) +} From df5ea5b1007fe6075c7efd6d32ef95b1503bd954 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 17 Feb 2026 23:01:34 +0100 Subject: [PATCH 3/3] ui --- Sources/TextDiff/TextDiffView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index c8aa952..2548056 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -48,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),