Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,66 @@ TextDiffView(
- `.token` (default): token-level diff behavior.
- `.character`: refines adjacent word replacements by character so shared parts remain unchanged text (for example `Add` -> `Added` shows unchanged `Add` and inserted `ed`).

## Engine-Only Results

You can compute a reusable diff result without rendering a view:

```swift
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.

## Precomputed Rendering

If you already computed a diff result for storage or analytics, you can render it later without recomputing:

```swift
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:

```swift
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)
```

## Custom Styling

```swift
Expand Down Expand Up @@ -123,8 +183,10 @@ Change-specific colors and text treatment live under `additionsStyle` and `remov
- 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.changes` and `TextDiffResult.summary` are mode-specific outputs; `.token` and `.character` results are not normalized to each other.
- Whitespace changes preserve the `updated` layout 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.
- `interChipSpacing` controls spacing between adjacent changed lexical chips (words or punctuation).
- `lineSpacing` controls vertical spacing between wrapped lines.
- Chip horizontal padding is preserved with a minimum effective floor of 3pt per side.
Expand Down
83 changes: 2 additions & 81 deletions Sources/TextDiff/AppKit/DiffRevertActionResolver.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
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
Expand All @@ -35,67 +26,6 @@ struct DiffRevertInteractionContext {
}

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
Expand All @@ -121,7 +51,7 @@ enum DiffRevertActionResolver {
return []
}

let indexed = indexedSegments(from: segments, original: original, updated: updated)
let indexed = DiffSegmentIndexer.indexedSegments(from: segments, original: original, updated: updated)
guard !indexed.isEmpty else {
return []
}
Expand Down Expand Up @@ -181,7 +111,7 @@ enum DiffRevertActionResolver {
kind: .singleDeletion,
tokenKind: current.segment.tokenKind,
segmentIndices: [current.segmentIndex],
updatedRange: NSRange(location: current.updatedCursor, length: 0),
updatedRange: NSRange(location: current.updatedRange.location, length: 0),
replacementText: current.segment.text,
originalTextFragment: current.segment.text,
updatedTextFragment: nil
Expand Down Expand Up @@ -331,15 +261,6 @@ enum DiffRevertActionResolver {

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,
Expand Down
38 changes: 24 additions & 14 deletions Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit
import SwiftUI

struct DiffTextViewRepresentable: NSViewRepresentable {
let result: TextDiffResult?
let original: String
let updated: String
let updatedBinding: Binding<String>?
Expand All @@ -16,20 +17,25 @@ struct DiffTextViewRepresentable: NSViewRepresentable {
}

func makeNSView(context: Context) -> NSTextDiffView {
let view = NSTextDiffView(
original: original,
updated: updated,
style: style,
mode: mode
)
let view: NSTextDiffView
if let result {
view = NSTextDiffView(result: result, style: style)
} else {
view = NSTextDiffView(
original: original,
updated: updated,
style: style,
mode: mode
)
}
view.setContentCompressionResistancePriority(.required, for: .vertical)
view.setContentHuggingPriority(.required, for: .vertical)
context.coordinator.update(
updatedBinding: updatedBinding,
onRevertAction: onRevertAction
)
view.showsInvisibleCharacters = showsInvisibleCharacters
view.isRevertActionsEnabled = isRevertActionsEnabled
view.isRevertActionsEnabled = result == nil ? isRevertActionsEnabled : false
view.onRevertAction = { [coordinator = context.coordinator] action in
coordinator.handle(action)
}
Expand All @@ -45,13 +51,17 @@ struct DiffTextViewRepresentable: NSViewRepresentable {
coordinator.handle(action)
}
view.showsInvisibleCharacters = showsInvisibleCharacters
view.isRevertActionsEnabled = isRevertActionsEnabled
view.setContent(
original: original,
updated: updated,
style: style,
mode: mode
)
view.isRevertActionsEnabled = result == nil ? isRevertActionsEnabled : false
if let result {
view.setContent(result: result, style: style)
} else {
view.setContent(
original: original,
updated: updated,
style: style,
mode: mode
)
}
}

final class Coordinator {
Expand Down
13 changes: 13 additions & 0 deletions Sources/TextDiff/AppKit/NSTextDiffContentSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

enum NSTextDiffContentSource {
case text
case result(TextDiffResult)

var isResultDriven: Bool {
if case .result = self {
return true
}
return false
}
}
Loading
Loading