Skip to content

Commit b5815ad

Browse files
authored
Merge pull request #7 from iSapozhnik/feature/decouple-text-engine
Decouple TextDiff engine and add reusable diff results
2 parents 167064a + 0ae38e4 commit b5815ad

15 files changed

Lines changed: 724 additions & 97 deletions

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,66 @@ TextDiffView(
8080
- `.token` (default): token-level diff behavior.
8181
- `.character`: refines adjacent word replacements by character so shared parts remain unchanged text (for example `Add` -> `Added` shows unchanged `Add` and inserted `ed`).
8282

83+
## Engine-Only Results
84+
85+
You can compute a reusable diff result without rendering a view:
86+
87+
```swift
88+
import TextDiff
89+
90+
let result = TextDiffEngine.result(
91+
original: "Track old values in storage.",
92+
updated: "Track new values in storage.",
93+
mode: .token
94+
)
95+
96+
for change in result.changes {
97+
print(change.kind, change.text)
98+
}
99+
100+
print(result.summary.insertedCharacters)
101+
print(result.summary.deletedCharacters)
102+
```
103+
104+
`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.
105+
106+
## Precomputed Rendering
107+
108+
If you already computed a diff result for storage or analytics, you can render it later without recomputing:
109+
110+
```swift
111+
import SwiftUI
112+
import TextDiff
113+
114+
let result = TextDiffEngine.result(
115+
original: "Track old values in storage.",
116+
updated: "Track new values in storage.",
117+
mode: .token
118+
)
119+
120+
struct StoredDiffView: View {
121+
var body: some View {
122+
TextDiffView(result: result)
123+
.padding()
124+
}
125+
}
126+
```
127+
128+
AppKit has the same precomputed rendering path:
129+
130+
```swift
131+
import AppKit
132+
import TextDiff
133+
134+
let result = TextDiffEngine.result(
135+
original: "Track old values in storage.",
136+
updated: "Track new values in storage.",
137+
mode: .token
138+
)
139+
140+
let diffView = NSTextDiffView(result: result)
141+
```
142+
83143
## Custom Styling
84144

85145
```swift
@@ -123,8 +183,10 @@ Change-specific colors and text treatment live under `additionsStyle` and `remov
123183
- Matching is exact (case-sensitive and punctuation-sensitive).
124184
- Replacements are rendered as adjacent delete then insert segments.
125185
- Character mode refines adjacent word replacements only; punctuation and whitespace keep token-level behavior.
186+
- `TextDiffResult.changes` and `TextDiffResult.summary` are mode-specific outputs; `.token` and `.character` results are not normalized to each other.
126187
- Whitespace changes preserve the `updated` layout and stay visually neutral (no chips).
127188
- Rendering is display-only (not selectable) to keep chip geometry deterministic.
189+
- Result-driven rendering (`TextDiffView(result:)`, `NSTextDiffView(result:)`) is display-only and does not enable revert actions.
128190
- `interChipSpacing` controls spacing between adjacent changed lexical chips (words or punctuation).
129191
- `lineSpacing` controls vertical spacing between wrapped lines.
130192
- Chip horizontal padding is preserved with a minimum effective floor of 3pt per side.

Sources/TextDiff/AppKit/DiffRevertActionResolver.swift

Lines changed: 2 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
import CoreGraphics
22
import Foundation
33

4-
struct IndexedSegment {
5-
let segmentIndex: Int
6-
let segment: DiffSegment
7-
let originalCursor: Int
8-
let updatedCursor: Int
9-
let originalRange: NSRange
10-
let updatedRange: NSRange
11-
}
12-
134
enum DiffRevertCandidateKind: Equatable {
145
case singleInsertion
156
case singleDeletion
@@ -35,67 +26,6 @@ struct DiffRevertInteractionContext {
3526
}
3627

3728
enum DiffRevertActionResolver {
38-
static func indexedSegments(
39-
from segments: [DiffSegment],
40-
original: String,
41-
updated: String
42-
) -> [IndexedSegment] {
43-
var output: [IndexedSegment] = []
44-
output.reserveCapacity(segments.count)
45-
46-
let originalNSString = original as NSString
47-
let updatedNSString = updated as NSString
48-
var originalCursor = 0
49-
var updatedCursor = 0
50-
51-
for (index, segment) in segments.enumerated() {
52-
let textLength = segment.text.utf16.count
53-
let originalRange: NSRange
54-
let updatedRange: NSRange
55-
56-
switch segment.kind {
57-
case .equal:
58-
originalRange = NSRange(location: originalCursor, length: textLength)
59-
updatedRange = NSRange(location: updatedCursor, length: textLength)
60-
let originalMatches = textMatches(segment.text, source: originalNSString, at: originalCursor)
61-
let updatedMatches = textMatches(segment.text, source: updatedNSString, at: updatedCursor)
62-
#if !TESTING
63-
assert(
64-
originalMatches,
65-
"Equal segment text mismatch in original at \(originalCursor) for segment \(index): \(segment.text)"
66-
)
67-
assert(
68-
updatedMatches,
69-
"Equal segment text mismatch in updated at \(updatedCursor) for segment \(index): \(segment.text)"
70-
)
71-
#endif
72-
originalCursor += textLength
73-
updatedCursor += textLength
74-
case .delete:
75-
originalRange = NSRange(location: originalCursor, length: textLength)
76-
updatedRange = NSRange(location: updatedCursor, length: 0)
77-
originalCursor += textLength
78-
case .insert:
79-
originalRange = NSRange(location: originalCursor, length: 0)
80-
updatedRange = NSRange(location: updatedCursor, length: textLength)
81-
updatedCursor += textLength
82-
}
83-
84-
output.append(
85-
IndexedSegment(
86-
segmentIndex: index,
87-
segment: segment,
88-
originalCursor: originalRange.location,
89-
updatedCursor: updatedRange.location,
90-
originalRange: originalRange,
91-
updatedRange: updatedRange
92-
)
93-
)
94-
}
95-
96-
return output
97-
}
98-
9929
static func candidates(
10030
from segments: [DiffSegment],
10131
mode: TextDiffComparisonMode
@@ -121,7 +51,7 @@ enum DiffRevertActionResolver {
12151
return []
12252
}
12353

124-
let indexed = indexedSegments(from: segments, original: original, updated: updated)
54+
let indexed = DiffSegmentIndexer.indexedSegments(from: segments, original: original, updated: updated)
12555
guard !indexed.isEmpty else {
12656
return []
12757
}
@@ -181,7 +111,7 @@ enum DiffRevertActionResolver {
181111
kind: .singleDeletion,
182112
tokenKind: current.segment.tokenKind,
183113
segmentIndices: [current.segmentIndex],
184-
updatedRange: NSRange(location: current.updatedCursor, length: 0),
114+
updatedRange: NSRange(location: current.updatedRange.location, length: 0),
185115
replacementText: current.segment.text,
186116
originalTextFragment: current.segment.text,
187117
updatedTextFragment: nil
@@ -331,15 +261,6 @@ enum DiffRevertActionResolver {
331261

332262
return false
333263
}
334-
335-
private static func textMatches(_ text: String, source: NSString, at location: Int) -> Bool {
336-
let length = text.utf16.count
337-
guard location >= 0, location + length <= source.length else {
338-
return false
339-
}
340-
return source.substring(with: NSRange(location: location, length: length)) == text
341-
}
342-
343264
private static func adjustedStandaloneWordDeletionReplacement(
344265
_ replacement: String,
345266
insertionLocation: Int,

Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AppKit
22
import SwiftUI
33

44
struct DiffTextViewRepresentable: NSViewRepresentable {
5+
let result: TextDiffResult?
56
let original: String
67
let updated: String
78
let updatedBinding: Binding<String>?
@@ -16,20 +17,25 @@ struct DiffTextViewRepresentable: NSViewRepresentable {
1617
}
1718

1819
func makeNSView(context: Context) -> NSTextDiffView {
19-
let view = NSTextDiffView(
20-
original: original,
21-
updated: updated,
22-
style: style,
23-
mode: mode
24-
)
20+
let view: NSTextDiffView
21+
if let result {
22+
view = NSTextDiffView(result: result, style: style)
23+
} else {
24+
view = NSTextDiffView(
25+
original: original,
26+
updated: updated,
27+
style: style,
28+
mode: mode
29+
)
30+
}
2531
view.setContentCompressionResistancePriority(.required, for: .vertical)
2632
view.setContentHuggingPriority(.required, for: .vertical)
2733
context.coordinator.update(
2834
updatedBinding: updatedBinding,
2935
onRevertAction: onRevertAction
3036
)
3137
view.showsInvisibleCharacters = showsInvisibleCharacters
32-
view.isRevertActionsEnabled = isRevertActionsEnabled
38+
view.isRevertActionsEnabled = result == nil ? isRevertActionsEnabled : false
3339
view.onRevertAction = { [coordinator = context.coordinator] action in
3440
coordinator.handle(action)
3541
}
@@ -45,13 +51,17 @@ struct DiffTextViewRepresentable: NSViewRepresentable {
4551
coordinator.handle(action)
4652
}
4753
view.showsInvisibleCharacters = showsInvisibleCharacters
48-
view.isRevertActionsEnabled = isRevertActionsEnabled
49-
view.setContent(
50-
original: original,
51-
updated: updated,
52-
style: style,
53-
mode: mode
54-
)
54+
view.isRevertActionsEnabled = result == nil ? isRevertActionsEnabled : false
55+
if let result {
56+
view.setContent(result: result, style: style)
57+
} else {
58+
view.setContent(
59+
original: original,
60+
updated: updated,
61+
style: style,
62+
mode: mode
63+
)
64+
}
5565
}
5666

5767
final class Coordinator {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
enum NSTextDiffContentSource {
4+
case text
5+
case result(TextDiffResult)
6+
7+
var isResultDriven: Bool {
8+
if case .result = self {
9+
return true
10+
}
11+
return false
12+
}
13+
}

0 commit comments

Comments
 (0)