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.
- macOS 14+
- Swift tools 6.1+
Add TextDiff as a Swift Package dependency in Xcode or in Package.swift:
dependencies: [
.package(url: "https://github.com/iSapozhnik/TextDiff.git", from: "1.0.0")
]Then import:
import TextDiffimport SwiftUI
import TextDiff
struct DemoView: View {
var body: some View {
TextDiffView(
original: "This is teh old sentence.",
updated: "This is the updated sentence!",
mode: .token
)
.padding()
}
}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 = falseYou can update content in place:
diffView.mode = .character
diffView.original = "Add a diff"
diffView.updated = "Added a diff"TextDiffView(
original: "Add",
updated: "Added",
mode: .character
).token(default): token-level diff behavior..character: refines adjacent word replacements by character so shared parts remain unchanged text (for exampleAdd->Addedshows unchangedAddand inserteded).
You can compute a reusable diff result without rendering a view:
import TextDiff
let result = TextDiffEngine.result(
original: "Track old values in storage.",
updated: "Track new values in storage.",
mode: .token
)
for change in result.changes {
print(change.kind, change.text)
}
print(result.summary.insertedCharacters)
print(result.summary.deletedCharacters)TextDiffResult.changes preserves the computed diff order for the selected mode and uses UTF-16 offsets/lengths so it can be stored and replayed consistently later. Summaries are derived from those mode-specific change records.
If you already computed a diff result for storage or analytics, you can render it later without recomputing:
import SwiftUI
import TextDiff
let result = TextDiffEngine.result(
original: "Track old values in storage.",
updated: "Track new values in storage.",
mode: .token
)
struct StoredDiffView: View {
var body: some View {
TextDiffView(result: result)
.padding()
}
}AppKit has the same precomputed rendering path:
import AppKit
import TextDiff
let result = TextDiffEngine.result(
original: "Track old values in storage.",
updated: "Track new values in storage.",
mode: .token
)
let diffView = NSTextDiffView(result: result)import SwiftUI
import TextDiff
let customStyle = TextDiffStyle(
additionsStyle: TextDiffChangeStyle(
fillColor: NSColor.systemGreen.withAlphaComponent(0.28),
strokeColor: NSColor.systemGreen.withAlphaComponent(0.75)
),
removalsStyle: TextDiffChangeStyle(
fillColor: NSColor.systemRed.withAlphaComponent(0.24),
strokeColor: NSColor.systemRed.withAlphaComponent(0.75),
strikethrough: true
),
textColor: .labelColor,
font: .monospacedSystemFont(ofSize: 15, weight: .regular),
chipCornerRadius: 5,
chipInsets: NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3),
interChipSpacing: 4,
lineSpacing: 2
)
struct StyledDemoView: View {
var body: some View {
TextDiffView(
original: "A quick brown fox jumps over a lazy dog.",
updated: "A quick fox hops over the lazy dog!",
style: customStyle
)
}
}Change-specific colors and text treatment live under additionsStyle and removalsStyle. Shared layout and typography stay on TextDiffStyle (font, chipInsets, interChipSpacing, lineSpacing, etc.).
- Tokenization uses
NLTokenizer(.word) and reconstructs punctuation/whitespace by filling range gaps. - Matching is exact (case-sensitive and punctuation-sensitive).
- Replacements are rendered as adjacent delete then insert segments.
- Character mode refines adjacent word replacements only; punctuation and whitespace keep token-level behavior.
TextDiffResult.changesandTextDiffResult.summaryare mode-specific outputs;.tokenand.characterresults are not normalized to each other.- Whitespace changes preserve the
updatedlayout and stay visually neutral (no chips). - Rendering is display-only (not selectable) to keep chip geometry deterministic.
- Result-driven rendering (
TextDiffView(result:),NSTextDiffView(result:)) is display-only and does not enable revert actions. interChipSpacingcontrols spacing between adjacent changed lexical chips (words or punctuation).lineSpacingcontrols vertical spacing between wrapped lines.- Chip horizontal padding is preserved with a minimum effective floor of 3pt per side.
- 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 shared by both
TextDiffViewandNSTextDiffView.
Snapshot coverage uses Point-Free SnapshotTesting with swift-testing.
- Snapshot tests live in
Tests/TextDiffTests/TextDiffSnapshotTests.swift. - AppKit snapshot tests live in
Tests/TextDiffTests/NSTextDiffSnapshotTests.swift. - Baselines are stored under
Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/andTests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/. - The suite uses
@Suite(.snapshots(record: .missing))to record only missing baselines.
Run all tests:
swift test 2>&1 | xcsift --quietUpdate baselines intentionally:
- Temporarily switch the suite trait in snapshot suites (for example,
Tests/TextDiffTests/TextDiffSnapshotTests.swiftandTests/TextDiffTests/NSTextDiffSnapshotTests.swift) from.missingto.all. - Run
swift test 2>&1 | xcsift --quietonce to rewrite baselines. - Switch the suite trait back to
.missing. - Review snapshot image diffs in your PR before merging.
- Performance baselines for
DiffLayouterPerformanceTestsare stored under.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/. swift testruns 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
TextDiffscheme.
Run the layouter performance suite with Xcode:
xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -scheme TextDiff -destination 'platform=macOS' -configuration Debug test -only-testing:TextDiffTests/DiffLayouterPerformanceTests 2>&1 | xcsiftIf 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.
