diff --git a/Package.swift b/Package.swift index 0fee76c..b700b05 100644 --- a/Package.swift +++ b/Package.swift @@ -6,13 +6,30 @@ import PackageDescription let package = Package( name: "TextDiff", platforms: [ - .macOS(.v14) + .macOS(.v14), + .iOS(.v18) ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "TextDiffCore", + targets: ["TextDiffCore"] + ), + .library( + name: "TextDiffUICommon", + targets: ["TextDiffUICommon"] + ), + .library( + name: "TextDiffMacOSUI", + targets: ["TextDiffMacOSUI"] + ), + .library( + name: "TextDiffIOSUI", + targets: ["TextDiffIOSUI"] + ), .library( name: "TextDiff", - targets: ["TextDiff"]), + targets: ["TextDiff"] + ), ], dependencies: [ .package( @@ -21,18 +38,61 @@ let package = Package( ) ], targets: [ - // 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: "TextDiffCore", + swiftSettings: [ + .define("TESTING", .when(configuration: .debug)) + ] + ), + .target( + name: "TextDiffUICommon", + dependencies: ["TextDiffCore"], + swiftSettings: [ + .define("TESTING", .when(configuration: .debug)) + ] + ), + .target( + name: "TextDiffMacOSUI", + dependencies: [ + "TextDiffCore", + "TextDiffUICommon" + ], + swiftSettings: [ + .define("TESTING", .when(configuration: .debug)) + ] + ), + .target( + name: "TextDiffIOSUI", + dependencies: [ + "TextDiffCore", + "TextDiffUICommon" + ], swiftSettings: [ .define("TESTING", .when(configuration: .debug)) ] ), + .target( + name: "TextDiff", + dependencies: [ + "TextDiffCore", + "TextDiffUICommon", + .target(name: "TextDiffMacOSUI", condition: .when(platforms: [.macOS])), + .target(name: "TextDiffIOSUI", condition: .when(platforms: [.iOS])) + ] + ), + .testTarget( + name: "TextDiffCoreTests", + dependencies: [ + "TextDiffCore" + ] + ), .testTarget( - name: "TextDiffTests", + name: "TextDiffMacOSUITests", dependencies: [ "TextDiff", + "TextDiffCore", + "TextDiffUICommon", + "TextDiffMacOSUI", .product(name: "SnapshotTesting", package: "swift-snapshot-testing") ] ), diff --git a/README.md b/README.md index b6fce09..87cd30d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # TextDiff -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 is a Swift package that computes token-level diffs and renders a merged, display-only diff view across Apple platforms. It supports SwiftUI on macOS and iOS, AppKit on macOS (`NSTextDiffView`), and UIKit on iOS (`UITextDiffView`). ![TextDiff preview](Resources/textdiff-preview.png) ## Requirements - macOS 14+ +- iOS 18+ - Swift tools 6.1+ ## Installation @@ -19,13 +20,30 @@ dependencies: [ ] ``` -Then import: +Choose the product that matches how you want to use the package: + +| Product | Use when | +| --- | --- | +| `TextDiff` | Default umbrella product for app code. | +| `TextDiffCore` | Engine-only diff computation with no UI. | +| `TextDiffUICommon` | Shared UI types for advanced module-level integrations. | +| `TextDiffMacOSUI` | Direct dependency on macOS UI APIs. | +| `TextDiffIOSUI` | Direct dependency on iOS UI APIs. | + +## Which Product Should I Import? + +- Use `import TextDiff` by default. It re-exports the common public API plus the matching platform UI module. +- Use `import TextDiffCore` when you only need diffing and result types without UI. +- Use `import TextDiffMacOSUI` or `import TextDiffIOSUI` only when you intentionally want direct platform module dependencies. +- Most app code should not need to import `TextDiffUICommon` separately. + +Then import the module you chose: ```swift import TextDiff ``` -## Basic Usage +## SwiftUI Usage ```swift import SwiftUI @@ -43,7 +61,7 @@ struct DemoView: View { } ``` -## AppKit Usage +## AppKit Usage (macOS) ```swift import AppKit @@ -67,6 +85,19 @@ diffView.original = "Add a diff" diffView.updated = "Added a diff" ``` +## UIKit Usage (iOS) + +```swift +import UIKit +import TextDiff + +let diffView = UITextDiffView( + original: "This is teh old sentence.", + updated: "This is the updated sentence!", + mode: .token +) +``` + ## Comparison Modes ```swift @@ -140,6 +171,21 @@ let result = TextDiffEngine.result( let diffView = NSTextDiffView(result: result) ``` +UIKit has the same precomputed rendering path: + +```swift +import UIKit +import TextDiff + +let result = TextDiffEngine.result( + original: "Track old values in storage.", + updated: "Track new values in storage.", + mode: .token +) + +let diffView = UITextDiffView(result: result) +``` + ## Custom Styling ```swift @@ -148,18 +194,18 @@ import TextDiff let customStyle = TextDiffStyle( additionsStyle: TextDiffChangeStyle( - fillColor: NSColor.systemGreen.withAlphaComponent(0.28), - strokeColor: NSColor.systemGreen.withAlphaComponent(0.75) + fillColor: PlatformColor.systemGreen.withAlphaComponent(0.28), + strokeColor: PlatformColor.systemGreen.withAlphaComponent(0.75) ), removalsStyle: TextDiffChangeStyle( - fillColor: NSColor.systemRed.withAlphaComponent(0.24), - strokeColor: NSColor.systemRed.withAlphaComponent(0.75), + fillColor: PlatformColor.systemRed.withAlphaComponent(0.24), + strokeColor: PlatformColor.systemRed.withAlphaComponent(0.75), strikethrough: true ), - textColor: .labelColor, - font: .monospacedSystemFont(ofSize: 15, weight: .regular), + textColor: PlatformColor.label, + font: PlatformFont.monospacedSystemFont(ofSize: 15, weight: .regular), chipCornerRadius: 5, - chipInsets: NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), + chipInsets: TextDiffEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), interChipSpacing: 4, lineSpacing: 2 ) @@ -177,6 +223,12 @@ struct StyledDemoView: View { Change-specific colors and text treatment live under `additionsStyle` and `removalsStyle`. Shared layout and typography stay on `TextDiffStyle` (`font`, `chipInsets`, `interChipSpacing`, `lineSpacing`, etc.). +## macOS-Only Features + +- Revert actions are available on macOS only. +- The invisible characters debug overlay is available on macOS only. +- The binding-based `TextDiffView` initializer is available on macOS only. + ## Behavior Notes - Tokenization uses `NLTokenizer` (`.word`) and reconstructs punctuation/whitespace by filling range gaps. @@ -199,9 +251,10 @@ Change-specific colors and text treatment live under `additionsStyle` and `remov Snapshot coverage uses [Point-Free SnapshotTesting](https://github.com/pointfreeco/swift-snapshot-testing) 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/` and `Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/`. +- Engine tests live in `Tests/TextDiffCoreTests/`. +- UI and snapshot tests currently live in `Tests/TextDiffMacOSUITests/`. +- Snapshot suites live in `Tests/TextDiffMacOSUITests/TextDiffSnapshotTests.swift` and `Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift`. +- Baselines are stored under `Tests/TextDiffMacOSUITests/__Snapshots__/`. - The suite uses `@Suite(.snapshots(record: .missing))` to record only missing baselines. Run all tests: @@ -212,21 +265,21 @@ swift test 2>&1 | xcsift --quiet Update baselines intentionally: -1. Temporarily switch the suite trait in snapshot suites (for example, `Tests/TextDiffTests/TextDiffSnapshotTests.swift` and `Tests/TextDiffTests/NSTextDiffSnapshotTests.swift`) from `.missing` to `.all`. +1. Temporarily switch the suite trait in snapshot suites (for example, `Tests/TextDiffMacOSUITests/TextDiffSnapshotTests.swift` and `Tests/TextDiffMacOSUITests/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. ## Performance Testing -- Performance baselines for `DiffLayouterPerformanceTests` are stored under `.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffTests.xcbaseline/`. +- Performance baselines for `DiffLayouterPerformanceTests` are stored under `.swiftpm/xcode/xcshareddata/xcbaselines/TextDiffMacOSUITests.xcbaseline/`. - `swift test` runs 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 `TextDiff` scheme. Run the layouter performance suite with Xcode: ```bash -xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -scheme TextDiff -destination 'platform=macOS' -configuration Debug test -only-testing:TextDiffTests/DiffLayouterPerformanceTests 2>&1 | xcsift +xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -scheme TextDiff -destination 'platform=macOS' -configuration Debug test -only-testing:TextDiffMacOSUITests/DiffLayouterPerformanceTests 2>&1 | xcsift ``` If 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. diff --git a/Sources/TextDiff/TextDiff.swift b/Sources/TextDiff/TextDiff.swift index d118d3c..0b6fefb 100644 --- a/Sources/TextDiff/TextDiff.swift +++ b/Sources/TextDiff/TextDiff.swift @@ -1 +1,8 @@ -// TextDiff public API is split across dedicated files. +@_exported import TextDiffCore +@_exported import TextDiffUICommon + +#if os(macOS) +@_exported import TextDiffMacOSUI +#elseif os(iOS) +@_exported import TextDiffIOSUI +#endif diff --git a/Sources/TextDiff/TextDiffChangeStyleDefaults.swift b/Sources/TextDiff/TextDiffChangeStyleDefaults.swift deleted file mode 100644 index 3822447..0000000 --- a/Sources/TextDiff/TextDiffChangeStyleDefaults.swift +++ /dev/null @@ -1,17 +0,0 @@ -import AppKit - -public extension TextDiffChangeStyle { - static let defaultAddition = TextDiffChangeStyle( - fillColor: NSColor.systemGreen.withAlphaComponent(0.22), - strokeColor: NSColor.systemGreen.withAlphaComponent(0.65), - textColorOverride: nil, - strikethrough: false - ) - - static let defaultRemoval = TextDiffChangeStyle( - fillColor: NSColor.systemRed.withAlphaComponent(0.22), - strokeColor: NSColor.systemRed.withAlphaComponent(0.65), - textColorOverride: nil, - strikethrough: false - ) -} diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index 11ec750..08b25f5 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -1,54 +1,70 @@ -import AppKit import SwiftUI +import TextDiffCore +import TextDiffUICommon + +#if os(macOS) +import AppKit +import TextDiffMacOSUI +#elseif os(iOS) +import UIKit +import TextDiffIOSUI +#endif /// A SwiftUI view that renders a merged visual diff between two strings. public struct TextDiffView: View { private let result: TextDiffResult? private let original: String private let updatedValue: String - private let updatedBinding: Binding? private let mode: TextDiffComparisonMode private let style: TextDiffStyle + + #if os(macOS) + private let updatedBinding: Binding? private let showsInvisibleCharacters: Bool private let isRevertActionsEnabled: Bool private let onRevertAction: ((TextDiffRevertAction) -> Void)? + #endif /// 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. - /// - showsInvisibleCharacters: Debug-only overlay that draws whitespace/newline symbols in red. public init( original: String, updated: String, style: TextDiffStyle = .default, - mode: TextDiffComparisonMode = .token, - showsInvisibleCharacters: Bool = false + mode: TextDiffComparisonMode = .token ) { self.result = nil self.original = original self.updatedValue = updated - self.updatedBinding = nil self.mode = mode self.style = style - self.showsInvisibleCharacters = showsInvisibleCharacters + #if os(macOS) + self.updatedBinding = nil + self.showsInvisibleCharacters = false self.isRevertActionsEnabled = false self.onRevertAction = nil + #endif } - /// Creates a text diff view backed by a mutable updated binding. - /// - /// - 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. - /// - showsInvisibleCharacters: Debug-only overlay that draws whitespace/newline symbols in red. - /// - isRevertActionsEnabled: Enables hover affordance and revert actions. - /// - onRevertAction: Optional callback invoked on revert clicks. + /// Creates a display-only diff view backed by a precomputed result. + public init( + result: TextDiffResult, + style: TextDiffStyle = .default + ) { + self.result = result + self.original = result.original + self.updatedValue = result.updated + self.mode = result.mode + self.style = style + #if os(macOS) + self.updatedBinding = nil + self.showsInvisibleCharacters = false + self.isRevertActionsEnabled = false + self.onRevertAction = nil + #endif + } + + #if os(macOS) + /// Creates a macOS-only text diff view backed by a mutable updated binding. public init( original: String, updated: Binding, @@ -68,33 +84,12 @@ public struct TextDiffView: View { self.isRevertActionsEnabled = isRevertActionsEnabled self.onRevertAction = onRevertAction } + #endif - /// Creates a display-only diff view backed by a precomputed result. - /// - /// - Parameters: - /// - result: A precomputed diff result to render. - /// - style: Visual style used to render additions, deletions, and unchanged text. - /// - showsInvisibleCharacters: Debug-only overlay that draws whitespace/newline symbols in red. - public init( - result: TextDiffResult, - style: TextDiffStyle = .default, - showsInvisibleCharacters: Bool = false - ) { - self.result = result - self.original = result.original - self.updatedValue = result.updated - self.updatedBinding = nil - self.mode = result.mode - self.style = style - self.showsInvisibleCharacters = showsInvisibleCharacters - self.isRevertActionsEnabled = false - self.onRevertAction = nil - } - - /// The view body that renders the current diff content. public var body: some View { + #if os(macOS) let updated = updatedBinding?.wrappedValue ?? updatedValue - DiffTextViewRepresentable( + TextDiffMacOSRepresentable( result: result, original: original, updated: updated, @@ -106,6 +101,101 @@ public struct TextDiffView: View { onRevertAction: onRevertAction ) .accessibilityLabel("Text diff") + #elseif os(iOS) + TextDiffIOSRepresentable( + result: result, + original: original, + updated: updatedValue, + style: style, + mode: mode + ) + .accessibilityLabel("Text diff") + #else + Color.clear.accessibilityHidden(true) + #endif + } +} + +#if os(macOS) +private struct TextDiffMacOSRepresentable: NSViewRepresentable { + let result: TextDiffResult? + let original: String + let updated: String + let updatedBinding: Binding? + let style: TextDiffStyle + let mode: TextDiffComparisonMode + let showsInvisibleCharacters: Bool + let isRevertActionsEnabled: Bool + let onRevertAction: ((TextDiffRevertAction) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSTextDiffView { + 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 = result == nil ? isRevertActionsEnabled : false + view.onRevertAction = { [coordinator = context.coordinator] action in + coordinator.handle(action) + } + return view + } + + func updateNSView(_ view: NSTextDiffView, context: Context) { + context.coordinator.update( + updatedBinding: updatedBinding, + onRevertAction: onRevertAction + ) + view.onRevertAction = { [coordinator = context.coordinator] action in + coordinator.handle(action) + } + view.showsInvisibleCharacters = showsInvisibleCharacters + 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 { + private var updatedBinding: Binding? + private var onRevertAction: ((TextDiffRevertAction) -> Void)? + + func update( + updatedBinding: Binding?, + onRevertAction: ((TextDiffRevertAction) -> Void)? + ) { + self.updatedBinding = updatedBinding + self.onRevertAction = onRevertAction + } + + func handle(_ action: TextDiffRevertAction) { + updatedBinding?.wrappedValue = action.resultingUpdated + onRevertAction?(action) + } } } @@ -120,7 +210,7 @@ public struct TextDiffView: View { #Preview("TextDiffView") { @Previewable @State var updatedText = "Added a diff view. It looks good!" - let font: NSFont = .systemFont(ofSize: 16, weight: .regular) + let font: PlatformFont = .systemFont(ofSize: 16, weight: .regular) let style = TextDiffStyle( additionsStyle: TextDiffChangeStyle( fillColor: .systemGreen.withAlphaComponent(0.28), @@ -136,7 +226,7 @@ public struct TextDiffView: View { textColor: .labelColor, font: font, chipCornerRadius: 3, - chipInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), + chipInsets: TextDiffEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), interChipSpacing: 1, lineSpacing: 2, groupStrokeStyle: .dashed @@ -145,13 +235,13 @@ public struct TextDiffView: View { Text("Diff by characters") .bold() TextDiffView( - original: "Add a diff view! Looks good!", - updated: "Added a diff view. It looks good!", - style: style, - mode: .character - ) + original: "Add a diff view! Looks good!", + updated: "Added a diff view. It looks good!", + style: style, + mode: .character + ) HStack { - Text("dog → fog:") + Text("dog -> fog:") TextDiffView( original: "dog", updated: "fog", @@ -162,15 +252,15 @@ public struct TextDiffView: View { Divider() Text("Diff by words and revertible") .bold() - TextDiffView( - original: "Add a diff view! Looks good!", - updated: $updatedText, - style: style, - mode: .token, - isRevertActionsEnabled: true - ) + TextDiffView( + original: "Add a diff view! Looks good!", + updated: $updatedText, + style: style, + mode: .token, + isRevertActionsEnabled: true + ) HStack { - Text("dog → fog:") + Text("dog -> fog:") TextDiffView( original: "dog", updated: "fog", @@ -219,7 +309,7 @@ public struct TextDiffView: View { } #Preview("Height diff") { - let font: NSFont = .systemFont(ofSize: 32, weight: .regular) + let font: PlatformFont = .systemFont(ofSize: 32, weight: .regular) let style = TextDiffStyle( additionsStyle: TextDiffChangeStyle( fillColor: .systemGreen.withAlphaComponent(0.28), @@ -235,15 +325,15 @@ public struct TextDiffView: View { textColor: .labelColor, font: font, chipCornerRadius: 3, - chipInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + chipInsets: TextDiffEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), interChipSpacing: 1, lineSpacing: 0 ) ZStack(alignment: .topLeading) { Text("Add ed a diff view. It looks good! Add ed a diff view. It looks good!") - .font(.system(size: 32, weight: .regular, design: nil)) + .font(.system(size: 32, weight: .regular)) .foregroundStyle(.red.opacity(0.7)) - + TextDiffView( original: "Add ed a diff view. It looks good! Add ed a diff view. It looks good.", updated: "Add ed a diff view. It looks good! Add ed a diff view. It looks good!", @@ -258,17 +348,82 @@ private struct RevertBindingPreview: View { @State private var updated = "To switch back to your computer, simply press any key on your keyboard." var body: some View { - var style = TextDiffStyle.default - style.font = .systemFont(ofSize: 13) - return TextDiffView( - original: "To switch back to your computer, just press any key on your keyboard.", + TextDiffView( + original: "To switch back to your Mac, press any key on your keyboard.", updated: $updated, - style: style, mode: .token, - showsInvisibleCharacters: false, isRevertActionsEnabled: true ) .padding() - .frame(width: 500) + .frame(width: 420) + } +} +#endif + +#if os(iOS) +private struct TextDiffIOSRepresentable: UIViewRepresentable { + let result: TextDiffResult? + let original: String + let updated: String + let style: TextDiffStyle + let mode: TextDiffComparisonMode + + func makeUIView(context: Context) -> UITextDiffView { + let view: UITextDiffView + if let result { + view = UITextDiffView(result: result, style: style) + } else { + view = UITextDiffView( + original: original, + updated: updated, + style: style, + mode: mode + ) + } + view.setContentCompressionResistancePriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .vertical) + return view + } + + func updateUIView(_ view: UITextDiffView, context: Context) { + if let result { + view.setContent(result: result, style: style) + } else { + view.setContent( + original: original, + updated: updated, + style: style, + mode: mode + ) + } } } + +#Preview("iOS Representable Default") { + TextDiffIOSRepresentable( + result: nil, + original: "Apply old value in this sentence.", + updated: "Apply new value in this sentence.", + style: .default, + mode: .token + ) + .padding() + .frame(width: 420) +} + +#Preview("iOS Representable Precomputed Result") { + TextDiffIOSRepresentable( + result: TextDiffEngine.result( + original: "Track old values in storage.", + updated: "Track new values in storage.", + mode: .token + ), + original: "", + updated: "", + style: .default, + mode: .token + ) + .padding() + .frame(width: 420) +} +#endif diff --git a/Sources/TextDiff/DiffSegmentIndexer.swift b/Sources/TextDiffCore/DiffSegmentIndexer.swift similarity index 91% rename from Sources/TextDiff/DiffSegmentIndexer.swift rename to Sources/TextDiffCore/DiffSegmentIndexer.swift index 142a3e3..45a5de0 100644 --- a/Sources/TextDiff/DiffSegmentIndexer.swift +++ b/Sources/TextDiffCore/DiffSegmentIndexer.swift @@ -1,14 +1,14 @@ import Foundation -struct IndexedDiffSegment { - let segmentIndex: Int - let segment: DiffSegment - let originalRange: NSRange - let updatedRange: NSRange +package struct IndexedDiffSegment { + package let segmentIndex: Int + package let segment: DiffSegment + package let originalRange: NSRange + package let updatedRange: NSRange } -enum DiffSegmentIndexer { - static func indexedSegments( +package enum DiffSegmentIndexer { + package static func indexedSegments( from segments: [DiffSegment], original: String, updated: String diff --git a/Sources/TextDiff/DiffTypes.swift b/Sources/TextDiffCore/DiffTypes.swift similarity index 100% rename from Sources/TextDiff/DiffTypes.swift rename to Sources/TextDiffCore/DiffTypes.swift diff --git a/Sources/TextDiff/MyersDiff.swift b/Sources/TextDiffCore/MyersDiff.swift similarity index 100% rename from Sources/TextDiff/MyersDiff.swift rename to Sources/TextDiffCore/MyersDiff.swift diff --git a/Sources/TextDiffCore/TextDiff.swift b/Sources/TextDiffCore/TextDiff.swift new file mode 100644 index 0000000..d118d3c --- /dev/null +++ b/Sources/TextDiffCore/TextDiff.swift @@ -0,0 +1 @@ +// TextDiff public API is split across dedicated files. diff --git a/Sources/TextDiff/TextDiffEngine.swift b/Sources/TextDiffCore/TextDiffEngine.swift similarity index 100% rename from Sources/TextDiff/TextDiffEngine.swift rename to Sources/TextDiffCore/TextDiffEngine.swift diff --git a/Sources/TextDiff/TextDiffResult.swift b/Sources/TextDiffCore/TextDiffResult.swift similarity index 100% rename from Sources/TextDiff/TextDiffResult.swift rename to Sources/TextDiffCore/TextDiffResult.swift diff --git a/Sources/TextDiff/Tokenizer.swift b/Sources/TextDiffCore/Tokenizer.swift similarity index 100% rename from Sources/TextDiff/Tokenizer.swift rename to Sources/TextDiffCore/Tokenizer.swift diff --git a/Sources/TextDiffIOSUI/UITextDiffView.swift b/Sources/TextDiffIOSUI/UITextDiffView.swift new file mode 100644 index 0000000..3b138ba --- /dev/null +++ b/Sources/TextDiffIOSUI/UITextDiffView.swift @@ -0,0 +1,291 @@ +import UIKit +import TextDiffCore +import TextDiffUICommon + +/// A UIKit view that renders a merged visual diff between two strings. +public final class UITextDiffView: UIView { + typealias DiffProvider = (String, String, TextDiffComparisonMode) -> [DiffSegment] + + public var original: String { + didSet { + guard !isBatchUpdating else { return } + contentSource = .text + _ = updateSegmentsIfNeeded() + } + } + + public var updated: String { + didSet { + guard !isBatchUpdating else { return } + contentSource = .text + _ = updateSegmentsIfNeeded() + } + } + + public var style: TextDiffStyle { + didSet { + guard !isBatchUpdating else { + pendingStyleInvalidation = true + return + } + invalidateCachedLayout() + } + } + + public var mode: TextDiffComparisonMode { + didSet { + guard !isBatchUpdating else { return } + contentSource = .text + _ = updateSegmentsIfNeeded() + } + } + + private var segments: [DiffSegment] + private let diffProvider: DiffProvider + private var contentSource: ContentSource + 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 intrinsicContentSize: CGSize { + let layout = layoutForCurrentWidth() + return CGSize(width: UIView.noIntrinsicMetric, height: ceil(layout.contentSize.height)) + } + + 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.contentSource = .text + self.lastOriginal = original + self.lastUpdated = updated + self.lastModeKey = Self.modeKey(for: mode) + self.segments = self.diffProvider(original, updated, mode) + super.init(frame: .zero) + commonInit() + } + + public init( + result: TextDiffResult, + style: TextDiffStyle = .default + ) { + self.original = result.original + self.updated = result.updated + self.style = style + self.mode = result.mode + self.diffProvider = { original, updated, mode in + TextDiffEngine.diff(original: original, updated: updated, mode: mode) + } + self.contentSource = .result + self.lastOriginal = result.original + self.lastUpdated = result.updated + self.lastModeKey = Self.modeKey(for: result.mode) + self.segments = result.segments + super.init(frame: .zero) + commonInit() + } + + #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.contentSource = .text + self.lastOriginal = original + self.lastUpdated = updated + self.lastModeKey = Self.modeKey(for: mode) + self.segments = diffProvider(original, updated, mode) + super.init(frame: .zero) + commonInit() + } + #endif + + @available(*, unavailable, message: "Use init(original:updated:style:mode:)") + required init?(coder: NSCoder) { + fatalError("Use init(original:updated:style:mode:)") + } + + public override func layoutSubviews() { + super.layoutSubviews() + let width = max(bounds.width, 1) + if abs(cachedWidth - width) > 0.5 { + invalidateCachedLayout() + } + } + + public override func draw(_ rect: CGRect) { + 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) + } + } + + public func setContent( + original: String, + updated: String, + style: TextDiffStyle, + mode: TextDiffComparisonMode + ) { + isBatchUpdating = true + defer { + isBatchUpdating = false + let needsStyleInvalidation = pendingStyleInvalidation + pendingStyleInvalidation = false + + contentSource = .text + let didRecompute = updateSegmentsIfNeeded() + if needsStyleInvalidation, !didRecompute { + invalidateCachedLayout() + } + } + + self.style = style + self.mode = mode + self.original = original + self.updated = updated + } + + public func setContent( + result: TextDiffResult, + style: TextDiffStyle + ) { + isBatchUpdating = true + defer { + isBatchUpdating = false + pendingStyleInvalidation = false + } + + self.style = style + apply(result: result) + } + + @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) + contentSource = .text + invalidateCachedLayout() + return true + } + + private func apply(result: TextDiffResult) { + contentSource = .result + original = result.original + updated = result.updated + mode = result.mode + lastOriginal = result.original + lastUpdated = result.updated + lastModeKey = Self.modeKey(for: result.mode) + segments = result.segments + 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 = TextDiffEdgeInsets(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 + setNeedsDisplay() + invalidateIntrinsicContentSize() + } + + private func drawChip( + chipRect: CGRect, + fillColor: PlatformColor?, + strokeColor: PlatformColor?, + cornerRadius: CGFloat + ) { + guard chipRect.width > 0, chipRect.height > 0 else { + return + } + + let fillPath = UIBezierPath(roundedRect: chipRect, cornerRadius: 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 = UIBezierPath(roundedRect: strokeRect, cornerRadius: cornerRadius) + strokeColor?.setStroke() + strokePath.lineWidth = 1 + strokePath.stroke() + } + + private func commonInit() { + backgroundColor = .clear + contentMode = .redraw + isOpaque = false + } + + private static func modeKey(for mode: TextDiffComparisonMode) -> Int { + switch mode { + case .token: + return 0 + case .character: + return 1 + } + } +} + +private enum ContentSource { + case text + case result +} diff --git a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift similarity index 99% rename from Sources/TextDiff/AppKit/DiffRevertActionResolver.swift rename to Sources/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift index 2b538d4..d437964 100644 --- a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift +++ b/Sources/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift @@ -1,5 +1,7 @@ import CoreGraphics import Foundation +import TextDiffCore +import TextDiffUICommon enum DiffRevertCandidateKind: Equatable { case singleInsertion diff --git a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift similarity index 98% rename from Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift rename to Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift index 1efd189..e28d75e 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift @@ -1,5 +1,7 @@ import AppKit import SwiftUI +import TextDiffCore +import TextDiffUICommon struct DiffTextViewRepresentable: NSViewRepresentable { let result: TextDiffResult? diff --git a/Sources/TextDiff/AppKit/NSTextDiffContentSource.swift b/Sources/TextDiffMacOSUI/AppKit/NSTextDiffContentSource.swift similarity index 91% rename from Sources/TextDiff/AppKit/NSTextDiffContentSource.swift rename to Sources/TextDiffMacOSUI/AppKit/NSTextDiffContentSource.swift index 21cabb3..d63f4e5 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffContentSource.swift +++ b/Sources/TextDiffMacOSUI/AppKit/NSTextDiffContentSource.swift @@ -1,4 +1,5 @@ import Foundation +import TextDiffCore enum NSTextDiffContentSource { case text diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift similarity index 99% rename from Sources/TextDiff/AppKit/NSTextDiffView.swift rename to Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift index 0802051..8670fba 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift @@ -1,5 +1,7 @@ import AppKit import Foundation +import TextDiffCore +import TextDiffUICommon /// An AppKit view that renders a merged visual diff between two strings. public final class NSTextDiffView: NSView { @@ -352,7 +354,7 @@ public final class NSTextDiffView: NSView { } let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) - let contentInsets = NSEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) + let contentInsets = TextDiffEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) let availableWidth = max(1, width - contentInsets.left - contentInsets.right) let layout = DiffTokenLayouter.layout( segments: segments, diff --git a/Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift b/Sources/TextDiffUICommon/DiffTextLayoutMetrics.swift similarity index 60% rename from Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift rename to Sources/TextDiffUICommon/DiffTextLayoutMetrics.swift index fe2ab50..dbeb337 100644 --- a/Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift +++ b/Sources/TextDiffUICommon/DiffTextLayoutMetrics.swift @@ -1,11 +1,11 @@ -import AppKit +import CoreGraphics -enum DiffTextLayoutMetrics { - static func verticalTextInset(for style: TextDiffStyle) -> CGFloat { +package enum DiffTextLayoutMetrics { + package static func verticalTextInset(for style: TextDiffStyle) -> CGFloat { ceil(max(0, style.chipInsets.top, style.chipInsets.bottom)) } - static func lineHeight(for style: TextDiffStyle) -> CGFloat { + package static func lineHeight(for style: TextDiffStyle) -> CGFloat { let textHeight = 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/DiffTokenLayouter.swift b/Sources/TextDiffUICommon/DiffTokenLayouter.swift similarity index 82% rename from Sources/TextDiff/AppKit/DiffTokenLayouter.swift rename to Sources/TextDiffUICommon/DiffTokenLayouter.swift index 2139d79..4e6ed85 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiffUICommon/DiffTokenLayouter.swift @@ -1,33 +1,38 @@ +#if canImport(AppKit) import AppKit +#elseif canImport(UIKit) +import UIKit +#endif import CoreText import Foundation - -struct LaidOutRun { - let segmentIndex: Int - let segment: DiffSegment - let attributedText: NSAttributedString - let textRect: CGRect - let chipRect: CGRect? - let chipFillColor: NSColor? - let chipStrokeColor: NSColor? - let chipCornerRadius: CGFloat - let isChangedLexical: Bool +import TextDiffCore + +package struct LaidOutRun { + package let segmentIndex: Int + package let segment: DiffSegment + package let attributedText: NSAttributedString + package let textRect: CGRect + package let chipRect: CGRect? + package let chipFillColor: PlatformColor? + package let chipStrokeColor: PlatformColor? + package let chipCornerRadius: CGFloat + package let isChangedLexical: Bool } -struct DiffLayout { - let runs: [LaidOutRun] - let lineBreakMarkers: [CGPoint] - let contentSize: CGSize +package struct DiffLayout { + package let runs: [LaidOutRun] + package let lineBreakMarkers: [CGPoint] + package let contentSize: CGSize } -enum DiffTokenLayouter { +package enum DiffTokenLayouter { private static let minimumHorizontalChipPadding: CGFloat = 3 - static func layout( + package static func layout( segments: [DiffSegment], style: TextDiffStyle, availableWidth: CGFloat, - contentInsets: NSEdgeInsets + contentInsets: TextDiffEdgeInsets ) -> DiffLayout { let lineHeight = DiffTextLayoutMetrics.lineHeight(for: style) let textHeight = ceil(style.font.ascender - style.font.descender + style.font.leading) @@ -108,8 +113,8 @@ enum DiffTokenLayouter { let textRect = CGRect(origin: CGPoint(x: textX, y: textY), size: textSize) var chipRect: CGRect? - var chipFillColor: NSColor? - var chipStrokeColor: NSColor? + var chipFillColor: PlatformColor? + var chipStrokeColor: PlatformColor? if isChangedLexical { let chipHeight = textSize.height + chipInsets.top + chipInsets.bottom let chipY = lineTop + ((lineHeight - chipHeight) / 2) @@ -153,7 +158,7 @@ enum DiffTokenLayouter { private static func measuredTextWidth( for text: String, - font: NSFont, + font: PlatformFont, cache: inout [WidthCacheKey: CGFloat] ) -> CGFloat { guard !text.isEmpty else { return 0 } @@ -190,8 +195,8 @@ enum DiffTokenLayouter { return NSAttributedString(string: segment.text, attributes: attributes) } - private static func effectiveChipInsets(for style: TextDiffStyle) -> NSEdgeInsets { - NSEdgeInsets( + private static func effectiveChipInsets(for style: TextDiffStyle) -> TextDiffEdgeInsets { + TextDiffEdgeInsets( top: style.chipInsets.top, left: max(style.chipInsets.left, minimumHorizontalChipPadding), bottom: style.chipInsets.bottom, @@ -199,7 +204,7 @@ enum DiffTokenLayouter { ) } - private static func textColor(for segment: DiffSegment, style: TextDiffStyle) -> NSColor { + private static func textColor(for segment: DiffSegment, style: TextDiffStyle) -> PlatformColor { switch segment.kind { case .equal: return style.textColor @@ -216,7 +221,7 @@ enum DiffTokenLayouter { } } - private static func chipFillColorForOperation(_ kind: DiffOperationKind, style: TextDiffStyle) -> NSColor? { + private static func chipFillColorForOperation(_ kind: DiffOperationKind, style: TextDiffStyle) -> PlatformColor? { switch kind { case .delete: return style.removalsStyle.fillColor @@ -227,7 +232,7 @@ enum DiffTokenLayouter { } } - private static func chipStrokeColorForOperation(_ kind: DiffOperationKind, style: TextDiffStyle) -> NSColor? { + private static func chipStrokeColorForOperation(_ kind: DiffOperationKind, style: TextDiffStyle) -> PlatformColor? { switch kind { case .delete: return style.removalsStyle.strokeColor @@ -238,13 +243,13 @@ enum DiffTokenLayouter { } } - private static func adaptiveChipTextColor(for fillColor: NSColor) -> NSColor { - let rgb = fillColor.usingColorSpace(.deviceRGB) ?? fillColor - let luminance = (0.2126 * rgb.redComponent) + (0.7152 * rgb.greenComponent) + (0.0722 * rgb.blueComponent) + private static func adaptiveChipTextColor(for fillColor: PlatformColor) -> PlatformColor { + let components = rgbaComponents(for: fillColor) + let luminance = (0.2126 * components.red) + (0.7152 * components.green) + (0.0722 * components.blue) if luminance > 0.55 { - return NSColor.black.withAlphaComponent(0.9) + return PlatformColor.black.withAlphaComponent(0.9) } - return NSColor.white.withAlphaComponent(0.95) + return PlatformColor.white.withAlphaComponent(0.95) } private static func pieces(from segments: [DiffSegment]) -> [LayoutPiece] { @@ -311,3 +316,22 @@ private struct WidthCacheKey: Hashable { let fontName: String let fontSize: CGFloat } + +private func rgbaComponents(for color: PlatformColor) -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + #if canImport(AppKit) + let rgb = color.usingColorSpace(.deviceRGB) ?? color + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (red, green, blue, alpha) + #elseif canImport(UIKit) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (red, green, blue, alpha) + #endif +} diff --git a/Sources/TextDiff/TextDiffChangeStyle.swift b/Sources/TextDiffUICommon/TextDiffChangeStyle.swift similarity index 73% rename from Sources/TextDiff/TextDiffChangeStyle.swift rename to Sources/TextDiffUICommon/TextDiffChangeStyle.swift index 60a6629..8be6145 100644 --- a/Sources/TextDiff/TextDiffChangeStyle.swift +++ b/Sources/TextDiffUICommon/TextDiffChangeStyle.swift @@ -1,17 +1,16 @@ -import AppKit import Foundation /// Concrete change style used for additions and removals. public struct TextDiffChangeStyle: TextDiffStyling, @unchecked Sendable { - public var fillColor: NSColor - public var strokeColor: NSColor - public var textColorOverride: NSColor? + public var fillColor: PlatformColor + public var strokeColor: PlatformColor + public var textColorOverride: PlatformColor? public var strikethrough: Bool public init( - fillColor: NSColor, - strokeColor: NSColor, - textColorOverride: NSColor? = nil, + fillColor: PlatformColor, + strokeColor: PlatformColor, + textColorOverride: PlatformColor? = nil, strikethrough: Bool = false ) { self.fillColor = fillColor diff --git a/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift b/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift new file mode 100644 index 0000000..47cd487 --- /dev/null +++ b/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift @@ -0,0 +1,31 @@ +public extension TextDiffChangeStyle { + static let defaultAddition = TextDiffChangeStyle( + fillColor: defaultAdditionFillColorValue, + strokeColor: defaultAdditionStrokeColorValue, + textColorOverride: defaultAdditionTextColorValue, + strikethrough: false + ) + + static let defaultRemoval = TextDiffChangeStyle( + fillColor: defaultRemovalFillColorValue, + strokeColor: defaultRemovalStrokeColorValue, + textColorOverride: defaultRemovalTextColorValue, + strikethrough: false + ) +} + +#if canImport(AppKit) +private var defaultAdditionFillColorValue: PlatformColor { PlatformColor.systemGreen.withAlphaComponent(0.22) } +private var defaultAdditionStrokeColorValue: PlatformColor { PlatformColor.systemGreen.withAlphaComponent(0.65) } +private var defaultAdditionTextColorValue: PlatformColor { PlatformColor.labelColor } +private var defaultRemovalFillColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.22) } +private var defaultRemovalStrokeColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.65) } +private var defaultRemovalTextColorValue: PlatformColor { PlatformColor.labelColor } +#elseif canImport(UIKit) +private var defaultAdditionFillColorValue: PlatformColor { PlatformColor.systemGreen.withAlphaComponent(0.22) } +private var defaultAdditionStrokeColorValue: PlatformColor { PlatformColor.systemGreen.withAlphaComponent(0.65) } +private var defaultAdditionTextColorValue: PlatformColor { PlatformColor.label } +private var defaultRemovalFillColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.22) } +private var defaultRemovalStrokeColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.65) } +private var defaultRemovalTextColorValue: PlatformColor { PlatformColor.label } +#endif diff --git a/Sources/TextDiff/TextDiffGroupStrokeStyle.swift b/Sources/TextDiffUICommon/TextDiffGroupStrokeStyle.swift similarity index 100% rename from Sources/TextDiff/TextDiffGroupStrokeStyle.swift rename to Sources/TextDiffUICommon/TextDiffGroupStrokeStyle.swift diff --git a/Sources/TextDiffUICommon/TextDiffPlatformTypes.swift b/Sources/TextDiffUICommon/TextDiffPlatformTypes.swift new file mode 100644 index 0000000..798a0db --- /dev/null +++ b/Sources/TextDiffUICommon/TextDiffPlatformTypes.swift @@ -0,0 +1,25 @@ +import CoreGraphics + +#if canImport(AppKit) +import AppKit +public typealias PlatformColor = NSColor +public typealias PlatformFont = NSFont +#elseif canImport(UIKit) +import UIKit +public typealias PlatformColor = UIColor +public typealias PlatformFont = UIFont +#endif + +public struct TextDiffEdgeInsets: Sendable, Equatable { + public var top: CGFloat + public var left: CGFloat + public var bottom: CGFloat + public var right: CGFloat + + public init(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) { + self.top = top + self.left = left + self.bottom = bottom + self.right = right + } +} diff --git a/Sources/TextDiff/TextDiffStyle.swift b/Sources/TextDiffUICommon/TextDiffStyle.swift similarity index 79% rename from Sources/TextDiff/TextDiffStyle.swift rename to Sources/TextDiffUICommon/TextDiffStyle.swift index 5653acf..83c610e 100644 --- a/Sources/TextDiff/TextDiffStyle.swift +++ b/Sources/TextDiffUICommon/TextDiffStyle.swift @@ -1,4 +1,3 @@ -import AppKit import Foundation /// Visual configuration for rendering text diff segments. @@ -9,13 +8,13 @@ public struct TextDiffStyle: @unchecked Sendable { public var removalsStyle: TextDiffChangeStyle /// Text color used for unchanged tokens. - public var textColor: NSColor + public var textColor: PlatformColor /// Font used for all rendered tokens. - public var font: NSFont + public var font: PlatformFont /// Corner radius applied to changed-token chips. public var chipCornerRadius: CGFloat /// Insets used to draw changed-token chips. Horizontal insets are floored to 3 points by the renderer. - public var chipInsets: NSEdgeInsets + public var chipInsets: TextDiffEdgeInsets /// Minimum visual gap between adjacent changed lexical chips. public var interChipSpacing: CGFloat /// Additional vertical spacing between wrapped lines. @@ -38,10 +37,10 @@ public struct TextDiffStyle: @unchecked Sendable { public init( additionsStyle: TextDiffChangeStyle = .defaultAddition, removalsStyle: TextDiffChangeStyle = .defaultRemoval, - textColor: NSColor = .labelColor, - font: NSFont = .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: PlatformColor = TextDiffStyle.defaultTextColorValue, + font: PlatformFont = TextDiffStyle.defaultTextFontValue, chipCornerRadius: CGFloat = 4, - chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), + chipInsets: TextDiffEdgeInsets = TextDiffEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), interChipSpacing: CGFloat = 0, lineSpacing: CGFloat = 2, groupStrokeStyle: TextDiffGroupStrokeStyle = .solid @@ -72,10 +71,10 @@ public struct TextDiffStyle: @unchecked Sendable { public init( additionsStyle: some TextDiffStyling, removalsStyle: some TextDiffStyling, - textColor: NSColor = .labelColor, - font: NSFont = .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: PlatformColor = TextDiffStyle.defaultTextColorValue, + font: PlatformFont = TextDiffStyle.defaultTextFontValue, chipCornerRadius: CGFloat = 4, - chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), + chipInsets: TextDiffEdgeInsets = TextDiffEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), interChipSpacing: CGFloat = 0, lineSpacing: CGFloat = 2, groupStrokeStyle: TextDiffGroupStrokeStyle = .solid @@ -95,4 +94,12 @@ public struct TextDiffStyle: @unchecked Sendable { /// The default style tuned for system green insertions and system red deletions. public static let `default` = TextDiffStyle() + + #if canImport(AppKit) + public static var defaultTextColorValue: PlatformColor { .labelColor } + public static var defaultTextFontValue: PlatformFont { .monospacedSystemFont(ofSize: 14, weight: .regular) } + #elseif canImport(UIKit) + public static var defaultTextColorValue: PlatformColor { .label } + public static var defaultTextFontValue: PlatformFont { .monospacedSystemFont(ofSize: 14, weight: .regular) } + #endif } diff --git a/Sources/TextDiff/TextDiffStyling.swift b/Sources/TextDiffUICommon/TextDiffStyling.swift similarity index 73% rename from Sources/TextDiff/TextDiffStyling.swift rename to Sources/TextDiffUICommon/TextDiffStyling.swift index ecfbc22..266956d 100644 --- a/Sources/TextDiff/TextDiffStyling.swift +++ b/Sources/TextDiffUICommon/TextDiffStyling.swift @@ -1,13 +1,11 @@ -import AppKit - /// Change-specific visual configuration used for addition/removal rendering. public protocol TextDiffStyling { /// Fill color used for chip backgrounds. - var fillColor: NSColor { get } + var fillColor: PlatformColor { get } /// Stroke color used for chip outlines. - var strokeColor: NSColor { get } + var strokeColor: PlatformColor { get } /// Optional text color override for chip text. - var textColorOverride: NSColor? { get } + var textColorOverride: PlatformColor? { get } /// Whether changed lexical content should render with a strikethrough. var strikethrough: Bool { get } } diff --git a/Sources/TextDiff/TextDiffViewModel.swift b/Sources/TextDiffUICommon/TextDiffViewModel.swift similarity index 98% rename from Sources/TextDiff/TextDiffViewModel.swift rename to Sources/TextDiffUICommon/TextDiffViewModel.swift index 2af1b7a..4e7bffe 100644 --- a/Sources/TextDiff/TextDiffViewModel.swift +++ b/Sources/TextDiffUICommon/TextDiffViewModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import TextDiffCore @MainActor final class TextDiffViewModel: ObservableObject { diff --git a/Tests/TextDiffTests/TextDiffEngineTests.swift b/Tests/TextDiffCoreTests/TextDiffEngineTests.swift similarity index 52% rename from Tests/TextDiffTests/TextDiffEngineTests.swift rename to Tests/TextDiffCoreTests/TextDiffEngineTests.swift index 08be88e..09d50df 100644 --- a/Tests/TextDiffTests/TextDiffEngineTests.swift +++ b/Tests/TextDiffCoreTests/TextDiffEngineTests.swift @@ -1,6 +1,5 @@ -import AppKit import Testing -@testable import TextDiff +@testable import TextDiffCore @Test func equalTextProducesOnlyEqualSegments() { @@ -314,289 +313,6 @@ func insertOffsetsUseUtf16AnchorsForEmoji() throws { #expect(change.updatedLength == 2) } -@Test -func defaultStyleInterChipSpacingMatchesCurrentDefault() { - #expect(TextDiffStyle.default.interChipSpacing == 0) -} - -@Test -func defaultGroupStrokeStyleIsSolid() { - #expect(TextDiffStyle.default.groupStrokeStyle == .solid) -} - -@Test -func textDiffStyleDefaultUsesDefaultAdditionAndRemovalStyles() { - let style = TextDiffStyle.default - expectColorEqual(style.additionsStyle.fillColor, TextDiffChangeStyle.defaultAddition.fillColor) - expectColorEqual(style.additionsStyle.strokeColor, TextDiffChangeStyle.defaultAddition.strokeColor) - expectColorEqual(style.removalsStyle.fillColor, TextDiffChangeStyle.defaultRemoval.fillColor) - expectColorEqual(style.removalsStyle.strokeColor, TextDiffChangeStyle.defaultRemoval.strokeColor) -} - -@Test -func textDiffStyleProtocolInitConvertsCustomConformers() { - let additions = TestStyling( - fillColor: .systemTeal, - strokeColor: .systemCyan, - textColorOverride: .black, - strikethrough: false - ) - let removals = TestStyling( - fillColor: .systemOrange, - strokeColor: .systemBrown, - textColorOverride: .white, - strikethrough: true - ) - - let style = TextDiffStyle( - additionsStyle: additions, - removalsStyle: removals, - groupStrokeStyle: .dashed - ) - - expectColorEqual(style.additionsStyle.fillColor, additions.fillColor) - expectColorEqual(style.additionsStyle.strokeColor, additions.strokeColor) - expectColorEqual(style.additionsStyle.textColorOverride ?? .clear, additions.textColorOverride ?? .clear) - #expect(style.additionsStyle.strikethrough == additions.strikethrough) - - expectColorEqual(style.removalsStyle.fillColor, removals.fillColor) - expectColorEqual(style.removalsStyle.strokeColor, removals.strokeColor) - expectColorEqual(style.removalsStyle.textColorOverride ?? .clear, removals.textColorOverride ?? .clear) - #expect(style.removalsStyle.strikethrough == removals.strikethrough) - #expect(style.groupStrokeStyle == .dashed) -} - -@Test -func layouterEnforcesGapForAdjacentChangedLexicalRuns() { - var style = TextDiffStyle.default - style.interChipSpacing = 4 - - let layout = DiffTokenLayouter.layout( - segments: [ - DiffSegment(kind: .delete, tokenKind: .word, text: "old"), - DiffSegment(kind: .insert, tokenKind: .word, text: "new") - ], - style: style, - availableWidth: 500, - contentInsets: zeroInsets - ) - - let chips = layout.runs.compactMap { $0.chipRect } - #expect(chips.count == 2) - #expect(chips[1].minX - chips[0].maxX >= 4 - 0.0001) -} - -@Test -func layouterPreservesMinimumHorizontalPaddingFloor() throws { - var style = TextDiffStyle.default - style.chipInsets = NSEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) - - let layout = DiffTokenLayouter.layout( - segments: [DiffSegment(kind: .delete, tokenKind: .word, text: "token")], - style: style, - availableWidth: 500, - contentInsets: zeroInsets - ) - - let run = try #require(layout.runs.first) - let chipRect = try #require(run.chipRect) - #expect(chipRect.minX <= run.textRect.minX - 3 + 0.0001) - #expect(chipRect.maxX >= run.textRect.maxX + 3 - 0.0001) -} - -@Test -func layouterAppliesGapForPunctuationAdjacency() { - var style = TextDiffStyle.default - style.interChipSpacing = 4 - - let layout = DiffTokenLayouter.layout( - segments: [ - DiffSegment(kind: .delete, tokenKind: .punctuation, text: "!"), - DiffSegment(kind: .insert, tokenKind: .punctuation, text: ".") - ], - style: style, - availableWidth: 500, - contentInsets: zeroInsets - ) - - let chips = layout.runs.compactMap { $0.chipRect } - #expect(chips.count == 2) - #expect(chips[1].minX - chips[0].maxX >= 4 - 0.0001) -} - -@Test -func layouterRendersDeletedWhitespaceAsChipWhenReplacedByPunctuation() throws { - let style = TextDiffStyle.default - let layout = DiffTokenLayouter.layout( - segments: [ - DiffSegment(kind: .equal, tokenKind: .word, text: "in"), - DiffSegment(kind: .delete, tokenKind: .whitespace, text: " "), - DiffSegment(kind: .insert, tokenKind: .punctuation, text: "-"), - DiffSegment(kind: .equal, tokenKind: .word, text: "app") - ], - style: style, - availableWidth: 500, - contentInsets: zeroInsets - ) - - let deletedWhitespaceRun = layout.runs.first { - $0.segment.kind == .delete && $0.segment.tokenKind == .whitespace - } - let insertedHyphenRun = layout.runs.first { - $0.segment.kind == .insert && $0.segment.tokenKind == .punctuation && $0.segment.text == "-" - } - - let deletedWhitespaceChip = try #require(deletedWhitespaceRun?.chipRect) - let insertedHyphenChip = try #require(insertedHyphenRun?.chipRect) - #expect(deletedWhitespaceChip.width > 0) - #expect(insertedHyphenChip.width > 0) -} - -@Test -func layouterDoesNotInjectAdjacencyGapAcrossUnchangedWhitespace() throws { - let style = TextDiffStyle.default - let layout = DiffTokenLayouter.layout( - segments: [ - DiffSegment(kind: .delete, tokenKind: .word, text: "old"), - DiffSegment(kind: .equal, tokenKind: .whitespace, text: " "), - DiffSegment(kind: .insert, tokenKind: .word, text: "new") - ], - style: style, - availableWidth: 500, - contentInsets: zeroInsets - ) - - let deleteRun = layout.runs[0] - let whitespaceRun = layout.runs[1] - let insertRun = layout.runs[2] - - let deleteChip = try #require(deleteRun.chipRect) - let insertChip = try #require(insertRun.chipRect) - let actualGap = insertChip.minX - deleteChip.maxX - #expect(abs(actualGap - whitespaceRun.textRect.width) < 0.0001) -} - -@Test -func layouterPreventsInsertedTokenClipWithProportionalSystemFont() throws { - var style = TextDiffStyle.default - style.font = .systemFont(ofSize: 13) - - let layout = DiffTokenLayouter.layout( - segments: [ - DiffSegment(kind: .delete, tokenKind: .word, text: "just"), - DiffSegment(kind: .insert, tokenKind: .word, text: "simply") - ], - style: style, - availableWidth: 500, - contentInsets: zeroInsets - ) - - let insertedRunCandidate = layout.runs.first(where: { - $0.segment.kind == .insert && $0.segment.tokenKind == .word && $0.segment.text == "simply" - }) - let insertedRun = try #require(insertedRunCandidate) - let insertedChip = try #require(insertedRun.chipRect) - let standaloneWidth = ("simply" as NSString).size(withAttributes: [.font: style.font]).width - - #expect(insertedRun.textRect.width >= standaloneWidth - 0.0001) - #expect(insertedChip.maxX >= insertedRun.textRect.maxX - 0.0001) -} - -@Test -func layouterWrapsByTokenAndRespectsExplicitNewlines() { - let layout = DiffTokenLayouter.layout( - segments: [ - DiffSegment(kind: .equal, tokenKind: .word, text: "alpha"), - DiffSegment(kind: .equal, tokenKind: .whitespace, text: " "), - DiffSegment(kind: .insert, tokenKind: .word, text: "beta"), - DiffSegment(kind: .equal, tokenKind: .whitespace, text: "\n"), - DiffSegment(kind: .equal, tokenKind: .word, text: "gamma") - ], - style: .default, - availableWidth: 45, - contentInsets: zeroInsets - ) - - #expect(layout.runs.contains { $0.segment.text == "alpha" }) - #expect(layout.runs.contains { $0.segment.text == "beta" }) - #expect(layout.runs.contains { $0.segment.text == "gamma" }) - #expect(layout.runs.allSatisfy { !$0.segment.text.contains("\n") }) - - let linePositions = Set(layout.runs.map { Int($0.textRect.minY.rounded()) }) - #expect(linePositions.count >= 2) -} - -@Test -func layouterUsesRemovalStrikethroughFromRemovalStyle() throws { - var style = TextDiffStyle.default - style.removalsStyle.strikethrough = true - - let layout = DiffTokenLayouter.layout( - segments: [DiffSegment(kind: .delete, tokenKind: .word, text: "old")], - style: style, - availableWidth: 500, - contentInsets: zeroInsets - ) - - let run = try #require(layout.runs.first) - let value = run.attributedText.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int - #expect(value == NSUnderlineStyle.single.rawValue) -} - -@Test -func verticalInsetIsNonNegativeAndMonotonic() { - var base = TextDiffStyle.default - base.chipInsets = NSEdgeInsets(top: 0, left: 2, bottom: 0, right: 2) - let baseInset = DiffTextLayoutMetrics.verticalTextInset(for: base) - #expect(baseInset >= 0) - - var largerTop = base - largerTop.chipInsets = NSEdgeInsets(top: 6, left: 2, bottom: 0, right: 2) - let largerTopInset = DiffTextLayoutMetrics.verticalTextInset(for: largerTop) - #expect(largerTopInset >= baseInset) - - var largerBottom = base - largerBottom.chipInsets = NSEdgeInsets(top: 0, left: 2, bottom: 7, right: 2) - let largerBottomInset = DiffTextLayoutMetrics.verticalTextInset(for: largerBottom) - #expect(largerBottomInset >= baseInset) -} - -@Test -func lineHeightUsesConfigurableLineSpacing() { - var compact = TextDiffStyle.default - compact.lineSpacing = 0 - - var roomy = TextDiffStyle.default - roomy.lineSpacing = 6 - - let compactHeight = DiffTextLayoutMetrics.lineHeight(for: compact) - let roomyHeight = DiffTextLayoutMetrics.lineHeight(for: roomy) - #expect(roomyHeight - compactHeight >= 6 - 0.0001) -} - private func joinedText(_ segments: [DiffSegment]) -> String { segments.map(\.text).joined() } - -private func expectColorEqual(_ lhs: NSColor, _ rhs: NSColor, tolerance: CGFloat = 0.0001) { - let left = rgba(lhs) - let right = rgba(rhs) - #expect(abs(left.0 - right.0) <= tolerance) - #expect(abs(left.1 - right.1) <= tolerance) - #expect(abs(left.2 - right.2) <= tolerance) - #expect(abs(left.3 - right.3) <= tolerance) -} - -private func rgba(_ color: NSColor) -> (CGFloat, CGFloat, CGFloat, CGFloat) { - let rgb = color.usingColorSpace(.deviceRGB) ?? color - return (rgb.redComponent, rgb.greenComponent, rgb.blueComponent, rgb.alphaComponent) -} - -private struct TestStyling: TextDiffStyling { - let fillColor: NSColor - let strokeColor: NSColor - let textColorOverride: NSColor? - let strikethrough: Bool -} - -private let zeroInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) diff --git a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift b/Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift similarity index 92% rename from Tests/TextDiffTests/DiffLayouterPerformanceTests.swift rename to Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift index 0d42ef4..0b45430 100644 --- a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift +++ b/Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift @@ -1,6 +1,8 @@ import AppKit import XCTest -@testable import TextDiff +import TextDiffCore +import TextDiffUICommon +@testable import TextDiffMacOSUI // swift test --filter DiffLayouterPerformanceTests 2>&1 | xcsift @@ -24,7 +26,7 @@ final class DiffLayouterPerformanceTests: XCTestCase { private func runLayoutPerformanceTest(wordCount: Int) { let style = TextDiffStyle.default let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) - let contentInsets = NSEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) + let contentInsets = TextDiffEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) let availableWidth: CGFloat = 520 let original = Self.largeText(wordCount: wordCount) @@ -46,7 +48,7 @@ final class DiffLayouterPerformanceTests: XCTestCase { private func runLayoutWithRevertInteractionsPerformanceTest(wordCount: Int) { let style = TextDiffStyle.default let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) - let contentInsets = NSEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) + let contentInsets = TextDiffEdgeInsets(top: verticalInset, left: 0, bottom: verticalInset, right: 0) let availableWidth: CGFloat = 520 let original = Self.largeText(wordCount: wordCount) diff --git a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift b/Tests/TextDiffMacOSUITests/DiffRevertActionResolverTests.swift similarity index 99% rename from Tests/TextDiffTests/DiffRevertActionResolverTests.swift rename to Tests/TextDiffMacOSUITests/DiffRevertActionResolverTests.swift index dd607c6..01adfaa 100644 --- a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift +++ b/Tests/TextDiffMacOSUITests/DiffRevertActionResolverTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing -@testable import TextDiff +import TextDiffCore +@testable import TextDiffMacOSUI @Test func candidatesBuildPairedReplacementForAdjacentDeleteInsert() throws { diff --git a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift b/Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift similarity index 98% rename from Tests/TextDiffTests/NSTextDiffSnapshotTests.swift rename to Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift index 91dcd3a..2c32148 100644 --- a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift +++ b/Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift @@ -1,7 +1,9 @@ import AppKit import SnapshotTesting import XCTest -@testable import TextDiff +import TextDiffCore +import TextDiffUICommon +@testable import TextDiffMacOSUI final class NSTextDiffSnapshotTests: XCTestCase { override func invokeTest() { diff --git a/Tests/TextDiffTests/NSTextDiffViewTests.swift b/Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift similarity index 99% rename from Tests/TextDiffTests/NSTextDiffViewTests.swift rename to Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift index 9edbb9a..7b34348 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift @@ -1,6 +1,8 @@ import CoreGraphics import Testing -@testable import TextDiff +import TextDiffCore +import TextDiffUICommon +@testable import TextDiffMacOSUI @Test @MainActor diff --git a/Tests/TextDiffTests/SnapshotTestSupport.swift b/Tests/TextDiffMacOSUITests/SnapshotTestSupport.swift similarity index 100% rename from Tests/TextDiffTests/SnapshotTestSupport.swift rename to Tests/TextDiffMacOSUITests/SnapshotTestSupport.swift diff --git a/Tests/TextDiffTests/TextDiffSnapshotTests.swift b/Tests/TextDiffMacOSUITests/TextDiffSnapshotTests.swift similarity index 100% rename from Tests/TextDiffTests/TextDiffSnapshotTests.swift rename to Tests/TextDiffMacOSUITests/TextDiffSnapshotTests.swift diff --git a/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift b/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift new file mode 100644 index 0000000..acbd051 --- /dev/null +++ b/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift @@ -0,0 +1,288 @@ +import AppKit +import Testing +import TextDiffCore +import TextDiffUICommon +@testable import TextDiffMacOSUI + +@Test +func defaultStyleInterChipSpacingMatchesCurrentDefault() { + #expect(TextDiffStyle.default.interChipSpacing == 0) +} + +@Test +func defaultGroupStrokeStyleIsSolid() { + #expect(TextDiffStyle.default.groupStrokeStyle == .solid) +} + +@Test +func textDiffStyleDefaultUsesDefaultAdditionAndRemovalStyles() { + let style = TextDiffStyle.default + expectColorEqual(style.additionsStyle.fillColor, TextDiffChangeStyle.defaultAddition.fillColor) + expectColorEqual(style.additionsStyle.strokeColor, TextDiffChangeStyle.defaultAddition.strokeColor) + expectColorEqual(style.removalsStyle.fillColor, TextDiffChangeStyle.defaultRemoval.fillColor) + expectColorEqual(style.removalsStyle.strokeColor, TextDiffChangeStyle.defaultRemoval.strokeColor) +} + +@Test +func textDiffStyleProtocolInitConvertsCustomConformers() { + let additions = TestStyling( + fillColor: .systemTeal, + strokeColor: .systemCyan, + textColorOverride: .black, + strikethrough: false + ) + let removals = TestStyling( + fillColor: .systemOrange, + strokeColor: .systemBrown, + textColorOverride: .white, + strikethrough: true + ) + + let style = TextDiffStyle( + additionsStyle: additions, + removalsStyle: removals, + groupStrokeStyle: .dashed + ) + + expectColorEqual(style.additionsStyle.fillColor, additions.fillColor) + expectColorEqual(style.additionsStyle.strokeColor, additions.strokeColor) + expectColorEqual(style.additionsStyle.textColorOverride ?? .clear, additions.textColorOverride ?? .clear) + #expect(style.additionsStyle.strikethrough == additions.strikethrough) + + expectColorEqual(style.removalsStyle.fillColor, removals.fillColor) + expectColorEqual(style.removalsStyle.strokeColor, removals.strokeColor) + expectColorEqual(style.removalsStyle.textColorOverride ?? .clear, removals.textColorOverride ?? .clear) + #expect(style.removalsStyle.strikethrough == removals.strikethrough) + #expect(style.groupStrokeStyle == .dashed) +} + +@Test +func layouterEnforcesGapForAdjacentChangedLexicalRuns() { + var style = TextDiffStyle.default + style.interChipSpacing = 4 + + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .delete, tokenKind: .word, text: "old"), + DiffSegment(kind: .insert, tokenKind: .word, text: "new") + ], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let chips = layout.runs.compactMap { $0.chipRect } + #expect(chips.count == 2) + #expect(chips[1].minX - chips[0].maxX >= 4 - 0.0001) +} + +@Test +func layouterPreservesMinimumHorizontalPaddingFloor() throws { + var style = TextDiffStyle.default + style.chipInsets = TextDiffEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) + + let layout = DiffTokenLayouter.layout( + segments: [DiffSegment(kind: .delete, tokenKind: .word, text: "token")], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let run = try #require(layout.runs.first) + let chipRect = try #require(run.chipRect) + #expect(chipRect.minX <= run.textRect.minX - 3 + 0.0001) + #expect(chipRect.maxX >= run.textRect.maxX + 3 - 0.0001) +} + +@Test +func layouterAppliesGapForPunctuationAdjacency() { + var style = TextDiffStyle.default + style.interChipSpacing = 4 + + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .delete, tokenKind: .punctuation, text: "!"), + DiffSegment(kind: .insert, tokenKind: .punctuation, text: ".") + ], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let chips = layout.runs.compactMap { $0.chipRect } + #expect(chips.count == 2) + #expect(chips[1].minX - chips[0].maxX >= 4 - 0.0001) +} + +@Test +func layouterRendersDeletedWhitespaceAsChipWhenReplacedByPunctuation() throws { + let style = TextDiffStyle.default + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .equal, tokenKind: .word, text: "in"), + DiffSegment(kind: .delete, tokenKind: .whitespace, text: " "), + DiffSegment(kind: .insert, tokenKind: .punctuation, text: "-"), + DiffSegment(kind: .equal, tokenKind: .word, text: "app") + ], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let deletedWhitespaceRun = layout.runs.first { + $0.segment.kind == .delete && $0.segment.tokenKind == .whitespace + } + let insertedHyphenRun = layout.runs.first { + $0.segment.kind == .insert && $0.segment.tokenKind == .punctuation && $0.segment.text == "-" + } + + let deletedWhitespaceChip = try #require(deletedWhitespaceRun?.chipRect) + let insertedHyphenChip = try #require(insertedHyphenRun?.chipRect) + #expect(deletedWhitespaceChip.width > 0) + #expect(insertedHyphenChip.width > 0) +} + +@Test +func layouterDoesNotInjectAdjacencyGapAcrossUnchangedWhitespace() throws { + let style = TextDiffStyle.default + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .delete, tokenKind: .word, text: "old"), + DiffSegment(kind: .equal, tokenKind: .whitespace, text: " "), + DiffSegment(kind: .insert, tokenKind: .word, text: "new") + ], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let deleteRun = layout.runs[0] + let whitespaceRun = layout.runs[1] + let insertRun = layout.runs[2] + + let deleteChip = try #require(deleteRun.chipRect) + let insertChip = try #require(insertRun.chipRect) + let actualGap = insertChip.minX - deleteChip.maxX + #expect(abs(actualGap - whitespaceRun.textRect.width) < 0.0001) +} + +@Test +func layouterPreventsInsertedTokenClipWithProportionalSystemFont() throws { + var style = TextDiffStyle.default + style.font = .systemFont(ofSize: 13) + + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .delete, tokenKind: .word, text: "just"), + DiffSegment(kind: .insert, tokenKind: .word, text: "simply") + ], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let insertedRunCandidate = layout.runs.first(where: { + $0.segment.kind == .insert && $0.segment.tokenKind == .word && $0.segment.text == "simply" + }) + let insertedRun = try #require(insertedRunCandidate) + let insertedChip = try #require(insertedRun.chipRect) + let standaloneWidth = ("simply" as NSString).size(withAttributes: [.font: style.font]).width + + #expect(insertedRun.textRect.width >= standaloneWidth - 0.0001) + #expect(insertedChip.maxX >= insertedRun.textRect.maxX - 0.0001) +} + +@Test +func layouterWrapsByTokenAndRespectsExplicitNewlines() { + let layout = DiffTokenLayouter.layout( + segments: [ + DiffSegment(kind: .equal, tokenKind: .word, text: "alpha"), + DiffSegment(kind: .equal, tokenKind: .whitespace, text: " "), + DiffSegment(kind: .insert, tokenKind: .word, text: "beta"), + DiffSegment(kind: .equal, tokenKind: .whitespace, text: "\n"), + DiffSegment(kind: .equal, tokenKind: .word, text: "gamma") + ], + style: .default, + availableWidth: 45, + contentInsets: zeroInsets + ) + + #expect(layout.runs.contains { $0.segment.text == "alpha" }) + #expect(layout.runs.contains { $0.segment.text == "beta" }) + #expect(layout.runs.contains { $0.segment.text == "gamma" }) + #expect(layout.runs.allSatisfy { !$0.segment.text.contains("\n") }) + + let linePositions = Set(layout.runs.map { Int($0.textRect.minY.rounded()) }) + #expect(linePositions.count >= 2) +} + +@Test +func layouterUsesRemovalStrikethroughFromRemovalStyle() throws { + var style = TextDiffStyle.default + style.removalsStyle.strikethrough = true + + let layout = DiffTokenLayouter.layout( + segments: [DiffSegment(kind: .delete, tokenKind: .word, text: "old")], + style: style, + availableWidth: 500, + contentInsets: zeroInsets + ) + + let run = try #require(layout.runs.first) + let value = run.attributedText.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int + #expect(value == NSUnderlineStyle.single.rawValue) +} + +@Test +func verticalInsetIsNonNegativeAndMonotonic() { + var base = TextDiffStyle.default + base.chipInsets = TextDiffEdgeInsets(top: 0, left: 2, bottom: 0, right: 2) + let baseInset = DiffTextLayoutMetrics.verticalTextInset(for: base) + #expect(baseInset >= 0) + + var largerTop = base + largerTop.chipInsets = TextDiffEdgeInsets(top: 6, left: 2, bottom: 0, right: 2) + let largerTopInset = DiffTextLayoutMetrics.verticalTextInset(for: largerTop) + #expect(largerTopInset >= baseInset) + + var largerBottom = base + largerBottom.chipInsets = TextDiffEdgeInsets(top: 0, left: 2, bottom: 7, right: 2) + let largerBottomInset = DiffTextLayoutMetrics.verticalTextInset(for: largerBottom) + #expect(largerBottomInset >= baseInset) +} + +@Test +func lineHeightUsesConfigurableLineSpacing() { + var compact = TextDiffStyle.default + compact.lineSpacing = 0 + + var roomy = TextDiffStyle.default + roomy.lineSpacing = 6 + + let compactHeight = DiffTextLayoutMetrics.lineHeight(for: compact) + let roomyHeight = DiffTextLayoutMetrics.lineHeight(for: roomy) + #expect(roomyHeight - compactHeight >= 6 - 0.0001) +} + +private func expectColorEqual(_ lhs: NSColor, _ rhs: NSColor, tolerance: CGFloat = 0.0001) { + let left = rgba(lhs) + let right = rgba(rhs) + #expect(abs(left.0 - right.0) <= tolerance) + #expect(abs(left.1 - right.1) <= tolerance) + #expect(abs(left.2 - right.2) <= tolerance) + #expect(abs(left.3 - right.3) <= tolerance) +} + +private func rgba(_ color: NSColor) -> (CGFloat, CGFloat, CGFloat, CGFloat) { + let rgb = color.usingColorSpace(.deviceRGB) ?? color + return (rgb.redComponent, rgb.greenComponent, rgb.blueComponent, rgb.alphaComponent) +} + +private struct TestStyling: TextDiffStyling { + let fillColor: NSColor + let strokeColor: NSColor + let textColorOverride: NSColor? + let strikethrough: Bool +} + +private let zeroInsets = TextDiffEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) diff --git a/Tests/TextDiffTests/TextDiffViewModelTests.swift b/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift similarity index 98% rename from Tests/TextDiffTests/TextDiffViewModelTests.swift rename to Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift index 4edd08f..3406eae 100644 --- a/Tests/TextDiffTests/TextDiffViewModelTests.swift +++ b/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift @@ -1,5 +1,6 @@ import Testing -@testable import TextDiff +import TextDiffCore +@testable import TextDiffUICommon @Test @MainActor diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png new file mode 100644 index 0000000..f504191 Binary files /dev/null and b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png differ diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png new file mode 100644 index 0000000..f451aa2 Binary files /dev/null and b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png new file mode 100644 index 0000000..c913c91 Binary files /dev/null and b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png new file mode 100644 index 0000000..f504191 Binary files /dev/null and b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png deleted file mode 100644 index 494ee13..0000000 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png and /dev/null differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png deleted file mode 100644 index 771a14d..0000000 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png and /dev/null differ diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png deleted file mode 100644 index 96d2422..0000000 Binary files a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png and /dev/null differ diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png deleted file mode 100644 index 494ee13..0000000 Binary files a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png and /dev/null differ