From 868a1adf950d8d5dae2e55d35391f8ea7117ff18 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sat, 18 Apr 2026 20:42:36 +0200 Subject: [PATCH 1/8] initial split --- Package.swift | 40 ++- Sources/TextDiff/TextDiff.swift | 3 +- .../DiffSegmentIndexer.swift | 14 +- .../DiffTypes.swift | 0 .../MyersDiff.swift | 0 Sources/TextDiffCore/TextDiff.swift | 1 + .../TextDiffEngine.swift | 0 .../TextDiffResult.swift | 0 .../Tokenizer.swift | 0 .../AppKit/DiffRevertActionResolver.swift | 1 + .../AppKit/DiffTextLayoutMetrics.swift | 0 .../AppKit/DiffTextViewRepresentable.swift | 1 + .../AppKit/DiffTokenLayouter.swift | 1 + .../AppKit/NSTextDiffContentSource.swift | 1 + .../AppKit/NSTextDiffView.swift | 1 + .../TextDiffChangeStyle.swift | 0 .../TextDiffChangeStyleDefaults.swift | 0 .../TextDiffGroupStrokeStyle.swift | 0 .../TextDiffStyle.swift | 0 .../TextDiffStyling.swift | 0 .../TextDiffView.swift | 1 + .../TextDiffViewModel.swift | 1 + .../TextDiffEngineTests.swift | 286 +---------------- .../DiffLayouterPerformanceTests.swift | 3 +- .../DiffRevertActionResolverTests.swift | 3 +- .../NSTextDiffSnapshotTests.swift | 3 +- .../NSTextDiffViewTests.swift | 3 +- .../SnapshotTestSupport.swift | 0 .../TextDiffSnapshotTests.swift | 0 .../TextDiffStyleAndLayouterTests.swift | 287 ++++++++++++++++++ .../TextDiffViewModelTests.swift | 3 +- .../character_mode_no_affordance.1.png | Bin .../character_suffix_refinement.1.png | Bin .../custom_style_spacing_strikethrough.1.png | Bin .../hover_pair_affordance.1.png | Bin .../hover_single_addition_affordance.1.png | Bin .../hover_single_deletion_affordance.1.png | Bin .../invisible_characters_debug_overlay.1.png | Bin .../multiline_insertion_wrap.1.png | Bin .../narrow_width_wrapping.1.png | Bin .../punctuation_replacement.1.png | Bin .../token_basic_replacement.1.png | Bin .../character_suffix_refinement.1.png | Bin .../custom_style_spacing_strikethrough.1.png | Bin .../multiline_insertion_wrap.1.png | Bin .../precomputed_result_rendering.1.png | Bin .../punctuation_replacement.1.png | Bin .../token_basic_replacement.1.png | Bin .../whitespace_only_layout_change.1.png | Bin 49 files changed, 349 insertions(+), 304 deletions(-) rename Sources/{TextDiff => TextDiffCore}/DiffSegmentIndexer.swift (91%) rename Sources/{TextDiff => TextDiffCore}/DiffTypes.swift (100%) rename Sources/{TextDiff => TextDiffCore}/MyersDiff.swift (100%) create mode 100644 Sources/TextDiffCore/TextDiff.swift rename Sources/{TextDiff => TextDiffCore}/TextDiffEngine.swift (100%) rename Sources/{TextDiff => TextDiffCore}/TextDiffResult.swift (100%) rename Sources/{TextDiff => TextDiffCore}/Tokenizer.swift (100%) rename Sources/{TextDiff => TextDiffMacOSUI}/AppKit/DiffRevertActionResolver.swift (99%) rename Sources/{TextDiff => TextDiffMacOSUI}/AppKit/DiffTextLayoutMetrics.swift (100%) rename Sources/{TextDiff => TextDiffMacOSUI}/AppKit/DiffTextViewRepresentable.swift (99%) rename Sources/{TextDiff => TextDiffMacOSUI}/AppKit/DiffTokenLayouter.swift (99%) rename Sources/{TextDiff => TextDiffMacOSUI}/AppKit/NSTextDiffContentSource.swift (91%) rename Sources/{TextDiff => TextDiffMacOSUI}/AppKit/NSTextDiffView.swift (99%) rename Sources/{TextDiff => TextDiffMacOSUI}/TextDiffChangeStyle.swift (100%) rename Sources/{TextDiff => TextDiffMacOSUI}/TextDiffChangeStyleDefaults.swift (100%) rename Sources/{TextDiff => TextDiffMacOSUI}/TextDiffGroupStrokeStyle.swift (100%) rename Sources/{TextDiff => TextDiffMacOSUI}/TextDiffStyle.swift (100%) rename Sources/{TextDiff => TextDiffMacOSUI}/TextDiffStyling.swift (100%) rename Sources/{TextDiff => TextDiffMacOSUI}/TextDiffView.swift (99%) rename Sources/{TextDiff => TextDiffMacOSUI}/TextDiffViewModel.swift (98%) rename Tests/{TextDiffTests => TextDiffCoreTests}/TextDiffEngineTests.swift (52%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/DiffLayouterPerformanceTests.swift (98%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/DiffRevertActionResolverTests.swift (99%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/NSTextDiffSnapshotTests.swift (98%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/NSTextDiffViewTests.swift (99%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/SnapshotTestSupport.swift (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/TextDiffSnapshotTests.swift (100%) create mode 100644 Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift rename Tests/{TextDiffTests => TextDiffMacOSUITests}/TextDiffViewModelTests.swift (98%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/character_suffix_refinement.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/multiline_insertion_wrap.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/punctuation_replacement.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/NSTextDiffSnapshotTests/token_basic_replacement.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/TextDiffSnapshotTests/character_suffix_refinement.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/TextDiffSnapshotTests/multiline_insertion_wrap.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/TextDiffSnapshotTests/punctuation_replacement.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/TextDiffSnapshotTests/token_basic_replacement.1.png (100%) rename Tests/{TextDiffTests => TextDiffMacOSUITests}/__Snapshots__/TextDiffSnapshotTests/whitespace_only_layout_change.1.png (100%) diff --git a/Package.swift b/Package.swift index 0fee76c..ed44955 100644 --- a/Package.swift +++ b/Package.swift @@ -9,10 +9,18 @@ let package = Package( .macOS(.v14) ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "TextDiffCore", + targets: ["TextDiffCore"] + ), + .library( + name: "TextDiffMacOSUI", + targets: ["TextDiffMacOSUI"] + ), .library( name: "TextDiff", - targets: ["TextDiff"]), + targets: ["TextDiff"] + ), ], dependencies: [ .package( @@ -21,18 +29,38 @@ 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: "TextDiffMacOSUI", + dependencies: ["TextDiffCore"], swiftSettings: [ .define("TESTING", .when(configuration: .debug)) ] ), + .target( + name: "TextDiff", + dependencies: [ + "TextDiffCore", + "TextDiffMacOSUI" + ] + ), + .testTarget( + name: "TextDiffCoreTests", + dependencies: [ + "TextDiffCore" + ] + ), .testTarget( - name: "TextDiffTests", + name: "TextDiffMacOSUITests", dependencies: [ "TextDiff", + "TextDiffCore", + "TextDiffMacOSUI", .product(name: "SnapshotTesting", package: "swift-snapshot-testing") ] ), diff --git a/Sources/TextDiff/TextDiff.swift b/Sources/TextDiff/TextDiff.swift index d118d3c..bee4801 100644 --- a/Sources/TextDiff/TextDiff.swift +++ b/Sources/TextDiff/TextDiff.swift @@ -1 +1,2 @@ -// TextDiff public API is split across dedicated files. +@_exported import TextDiffCore +@_exported import TextDiffMacOSUI 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/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..5726b92 100644 --- a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift +++ b/Sources/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift @@ -1,5 +1,6 @@ import CoreGraphics import Foundation +import TextDiffCore enum DiffRevertCandidateKind: Equatable { case singleInsertion diff --git a/Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift b/Sources/TextDiffMacOSUI/AppKit/DiffTextLayoutMetrics.swift similarity index 100% rename from Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift rename to Sources/TextDiffMacOSUI/AppKit/DiffTextLayoutMetrics.swift diff --git a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift similarity index 99% rename from Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift rename to Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift index 1efd189..3499128 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import TextDiffCore struct DiffTextViewRepresentable: NSViewRepresentable { let result: TextDiffResult? diff --git a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift b/Sources/TextDiffMacOSUI/AppKit/DiffTokenLayouter.swift similarity index 99% rename from Sources/TextDiff/AppKit/DiffTokenLayouter.swift rename to Sources/TextDiffMacOSUI/AppKit/DiffTokenLayouter.swift index 2139d79..428df6a 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiffMacOSUI/AppKit/DiffTokenLayouter.swift @@ -1,6 +1,7 @@ import AppKit import CoreText import Foundation +import TextDiffCore struct LaidOutRun { let segmentIndex: Int 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..d61383d 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import TextDiffCore /// An AppKit view that renders a merged visual diff between two strings. public final class NSTextDiffView: NSView { diff --git a/Sources/TextDiff/TextDiffChangeStyle.swift b/Sources/TextDiffMacOSUI/TextDiffChangeStyle.swift similarity index 100% rename from Sources/TextDiff/TextDiffChangeStyle.swift rename to Sources/TextDiffMacOSUI/TextDiffChangeStyle.swift diff --git a/Sources/TextDiff/TextDiffChangeStyleDefaults.swift b/Sources/TextDiffMacOSUI/TextDiffChangeStyleDefaults.swift similarity index 100% rename from Sources/TextDiff/TextDiffChangeStyleDefaults.swift rename to Sources/TextDiffMacOSUI/TextDiffChangeStyleDefaults.swift diff --git a/Sources/TextDiff/TextDiffGroupStrokeStyle.swift b/Sources/TextDiffMacOSUI/TextDiffGroupStrokeStyle.swift similarity index 100% rename from Sources/TextDiff/TextDiffGroupStrokeStyle.swift rename to Sources/TextDiffMacOSUI/TextDiffGroupStrokeStyle.swift diff --git a/Sources/TextDiff/TextDiffStyle.swift b/Sources/TextDiffMacOSUI/TextDiffStyle.swift similarity index 100% rename from Sources/TextDiff/TextDiffStyle.swift rename to Sources/TextDiffMacOSUI/TextDiffStyle.swift diff --git a/Sources/TextDiff/TextDiffStyling.swift b/Sources/TextDiffMacOSUI/TextDiffStyling.swift similarity index 100% rename from Sources/TextDiff/TextDiffStyling.swift rename to Sources/TextDiffMacOSUI/TextDiffStyling.swift diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiffMacOSUI/TextDiffView.swift similarity index 99% rename from Sources/TextDiff/TextDiffView.swift rename to Sources/TextDiffMacOSUI/TextDiffView.swift index 11ec750..a4c3b43 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiffMacOSUI/TextDiffView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import TextDiffCore /// A SwiftUI view that renders a merged visual diff between two strings. public struct TextDiffView: View { diff --git a/Sources/TextDiff/TextDiffViewModel.swift b/Sources/TextDiffMacOSUI/TextDiffViewModel.swift similarity index 98% rename from Sources/TextDiff/TextDiffViewModel.swift rename to Sources/TextDiffMacOSUI/TextDiffViewModel.swift index 2af1b7a..4e7bffe 100644 --- a/Sources/TextDiff/TextDiffViewModel.swift +++ b/Sources/TextDiffMacOSUI/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 98% rename from Tests/TextDiffTests/DiffLayouterPerformanceTests.swift rename to Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift index 0d42ef4..74e606b 100644 --- a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift +++ b/Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift @@ -1,6 +1,7 @@ import AppKit import XCTest -@testable import TextDiff +import TextDiffCore +@testable import TextDiffMacOSUI // swift test --filter DiffLayouterPerformanceTests 2>&1 | xcsift 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..d94289a 100644 --- a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift +++ b/Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift @@ -1,7 +1,8 @@ import AppKit import SnapshotTesting import XCTest -@testable import TextDiff +import TextDiffCore +@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..2e9f371 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift @@ -1,6 +1,7 @@ import CoreGraphics import Testing -@testable import TextDiff +import TextDiffCore +@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..9f0abe0 --- /dev/null +++ b/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift @@ -0,0 +1,287 @@ +import AppKit +import Testing +import TextDiffCore +@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 = 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 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/TextDiffViewModelTests.swift b/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift similarity index 98% rename from Tests/TextDiffTests/TextDiffViewModelTests.swift rename to Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift index 4edd08f..5696407 100644 --- a/Tests/TextDiffTests/TextDiffViewModelTests.swift +++ b/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift @@ -1,5 +1,6 @@ import Testing -@testable import TextDiff +import TextDiffCore +@testable import TextDiffMacOSUI @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/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png 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/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png 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/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png similarity index 100% rename from Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png rename to Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png 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 From 5162e18efae1e99033adb79ce708455e553bc7b9 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sat, 18 Apr 2026 21:25:08 +0200 Subject: [PATCH 2/8] iOS support --- Package.swift | 38 ++- Sources/TextDiff/TextDiff.swift | 6 + .../TextDiffView.swift | 274 ++++++++++++----- Sources/TextDiffIOSUI/UITextDiffView.swift | 291 ++++++++++++++++++ .../AppKit/DiffRevertActionResolver.swift | 1 + .../AppKit/DiffTextViewRepresentable.swift | 1 + .../AppKit/NSTextDiffView.swift | 3 +- .../TextDiffChangeStyleDefaults.swift | 17 - .../DiffTextLayoutMetrics.swift | 8 +- .../DiffTokenLayouter.swift | 83 +++-- .../TextDiffChangeStyle.swift | 13 +- .../TextDiffChangeStyleDefaults.swift | 27 ++ .../TextDiffGroupStrokeStyle.swift | 0 .../TextDiffPlatformTypes.swift | 25 ++ .../TextDiffStyle.swift | 27 +- .../TextDiffStyling.swift | 8 +- .../TextDiffViewModel.swift | 1 + .../DiffLayouterPerformanceTests.swift | 5 +- .../NSTextDiffSnapshotTests.swift | 1 + .../NSTextDiffViewTests.swift | 1 + .../TextDiffStyleAndLayouterTests.swift | 11 +- .../TextDiffViewModelTests.swift | 2 +- 22 files changed, 684 insertions(+), 159 deletions(-) rename Sources/{TextDiffMacOSUI => TextDiff}/TextDiffView.swift (53%) create mode 100644 Sources/TextDiffIOSUI/UITextDiffView.swift delete mode 100644 Sources/TextDiffMacOSUI/TextDiffChangeStyleDefaults.swift rename Sources/{TextDiffMacOSUI/AppKit => TextDiffUICommon}/DiffTextLayoutMetrics.swift (60%) rename Sources/{TextDiffMacOSUI/AppKit => TextDiffUICommon}/DiffTokenLayouter.swift (82%) rename Sources/{TextDiffMacOSUI => TextDiffUICommon}/TextDiffChangeStyle.swift (73%) create mode 100644 Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift rename Sources/{TextDiffMacOSUI => TextDiffUICommon}/TextDiffGroupStrokeStyle.swift (100%) create mode 100644 Sources/TextDiffUICommon/TextDiffPlatformTypes.swift rename Sources/{TextDiffMacOSUI => TextDiffUICommon}/TextDiffStyle.swift (79%) rename Sources/{TextDiffMacOSUI => TextDiffUICommon}/TextDiffStyling.swift (73%) rename Sources/{TextDiffMacOSUI => TextDiffUICommon}/TextDiffViewModel.swift (98%) diff --git a/Package.swift b/Package.swift index ed44955..b700b05 100644 --- a/Package.swift +++ b/Package.swift @@ -6,17 +6,26 @@ import PackageDescription let package = Package( name: "TextDiff", platforms: [ - .macOS(.v14) + .macOS(.v14), + .iOS(.v18) ], products: [ .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"] @@ -36,17 +45,39 @@ let package = Package( ] ), .target( - name: "TextDiffMacOSUI", + 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", - "TextDiffMacOSUI" + "TextDiffUICommon", + .target(name: "TextDiffMacOSUI", condition: .when(platforms: [.macOS])), + .target(name: "TextDiffIOSUI", condition: .when(platforms: [.iOS])) ] ), .testTarget( @@ -60,6 +91,7 @@ let package = Package( dependencies: [ "TextDiff", "TextDiffCore", + "TextDiffUICommon", "TextDiffMacOSUI", .product(name: "SnapshotTesting", package: "swift-snapshot-testing") ] diff --git a/Sources/TextDiff/TextDiff.swift b/Sources/TextDiff/TextDiff.swift index bee4801..0b6fefb 100644 --- a/Sources/TextDiff/TextDiff.swift +++ b/Sources/TextDiff/TextDiff.swift @@ -1,2 +1,8 @@ @_exported import TextDiffCore +@_exported import TextDiffUICommon + +#if os(macOS) @_exported import TextDiffMacOSUI +#elseif os(iOS) +@_exported import TextDiffIOSUI +#endif diff --git a/Sources/TextDiffMacOSUI/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift similarity index 53% rename from Sources/TextDiffMacOSUI/TextDiffView.swift rename to Sources/TextDiff/TextDiffView.swift index a4c3b43..e745892 100644 --- a/Sources/TextDiffMacOSUI/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -1,55 +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 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 } - /// 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. + #if os(macOS) + /// Creates a macOS-only text diff view backed by a mutable updated binding. public init( original: String, updated: Binding, @@ -69,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, @@ -107,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) + } } } @@ -121,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), @@ -137,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 @@ -146,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", @@ -163,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", @@ -220,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), @@ -236,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!", @@ -259,17 +348,54 @@ 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 + ) + } } } +#endif 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/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift index 5726b92..d437964 100644 --- a/Sources/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift +++ b/Sources/TextDiffMacOSUI/AppKit/DiffRevertActionResolver.swift @@ -1,6 +1,7 @@ import CoreGraphics import Foundation import TextDiffCore +import TextDiffUICommon enum DiffRevertCandidateKind: Equatable { case singleInsertion diff --git a/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift index 3499128..e28d75e 100644 --- a/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiffMacOSUI/AppKit/DiffTextViewRepresentable.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import TextDiffCore +import TextDiffUICommon struct DiffTextViewRepresentable: NSViewRepresentable { let result: TextDiffResult? diff --git a/Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift b/Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift index d61383d..8670fba 100644 --- a/Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiffMacOSUI/AppKit/NSTextDiffView.swift @@ -1,6 +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 { @@ -353,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/TextDiffMacOSUI/TextDiffChangeStyleDefaults.swift b/Sources/TextDiffMacOSUI/TextDiffChangeStyleDefaults.swift deleted file mode 100644 index 3822447..0000000 --- a/Sources/TextDiffMacOSUI/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/TextDiffMacOSUI/AppKit/DiffTextLayoutMetrics.swift b/Sources/TextDiffUICommon/DiffTextLayoutMetrics.swift similarity index 60% rename from Sources/TextDiffMacOSUI/AppKit/DiffTextLayoutMetrics.swift rename to Sources/TextDiffUICommon/DiffTextLayoutMetrics.swift index fe2ab50..dbeb337 100644 --- a/Sources/TextDiffMacOSUI/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/TextDiffMacOSUI/AppKit/DiffTokenLayouter.swift b/Sources/TextDiffUICommon/DiffTokenLayouter.swift similarity index 82% rename from Sources/TextDiffMacOSUI/AppKit/DiffTokenLayouter.swift rename to Sources/TextDiffUICommon/DiffTokenLayouter.swift index 428df6a..4e6ed85 100644 --- a/Sources/TextDiffMacOSUI/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiffUICommon/DiffTokenLayouter.swift @@ -1,34 +1,38 @@ +#if canImport(AppKit) import AppKit +#elseif canImport(UIKit) +import UIKit +#endif import CoreText import Foundation import TextDiffCore -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 +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) @@ -109,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) @@ -154,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 } @@ -191,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, @@ -200,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 @@ -217,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 @@ -228,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 @@ -239,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] { @@ -312,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/TextDiffMacOSUI/TextDiffChangeStyle.swift b/Sources/TextDiffUICommon/TextDiffChangeStyle.swift similarity index 73% rename from Sources/TextDiffMacOSUI/TextDiffChangeStyle.swift rename to Sources/TextDiffUICommon/TextDiffChangeStyle.swift index 60a6629..8be6145 100644 --- a/Sources/TextDiffMacOSUI/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..eecd8df --- /dev/null +++ b/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift @@ -0,0 +1,27 @@ +public extension TextDiffChangeStyle { + static let defaultAddition = TextDiffChangeStyle( + fillColor: defaultAdditionFillColorValue, + strokeColor: defaultAdditionStrokeColorValue, + textColorOverride: nil, + strikethrough: false + ) + + static let defaultRemoval = TextDiffChangeStyle( + fillColor: defaultRemovalFillColorValue, + strokeColor: defaultRemovalStrokeColorValue, + textColorOverride: nil, + 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 defaultRemovalFillColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.22) } +private var defaultRemovalStrokeColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.65) } +#elseif canImport(UIKit) +private var defaultAdditionFillColorValue: PlatformColor { PlatformColor.systemGreen.withAlphaComponent(0.22) } +private var defaultAdditionStrokeColorValue: PlatformColor { PlatformColor.systemGreen.withAlphaComponent(0.65) } +private var defaultRemovalFillColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.22) } +private var defaultRemovalStrokeColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.65) } +#endif diff --git a/Sources/TextDiffMacOSUI/TextDiffGroupStrokeStyle.swift b/Sources/TextDiffUICommon/TextDiffGroupStrokeStyle.swift similarity index 100% rename from Sources/TextDiffMacOSUI/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/TextDiffMacOSUI/TextDiffStyle.swift b/Sources/TextDiffUICommon/TextDiffStyle.swift similarity index 79% rename from Sources/TextDiffMacOSUI/TextDiffStyle.swift rename to Sources/TextDiffUICommon/TextDiffStyle.swift index 5653acf..83c610e 100644 --- a/Sources/TextDiffMacOSUI/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/TextDiffMacOSUI/TextDiffStyling.swift b/Sources/TextDiffUICommon/TextDiffStyling.swift similarity index 73% rename from Sources/TextDiffMacOSUI/TextDiffStyling.swift rename to Sources/TextDiffUICommon/TextDiffStyling.swift index ecfbc22..266956d 100644 --- a/Sources/TextDiffMacOSUI/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/TextDiffMacOSUI/TextDiffViewModel.swift b/Sources/TextDiffUICommon/TextDiffViewModel.swift similarity index 98% rename from Sources/TextDiffMacOSUI/TextDiffViewModel.swift rename to Sources/TextDiffUICommon/TextDiffViewModel.swift index 4e7bffe..45b5130 100644 --- a/Sources/TextDiffMacOSUI/TextDiffViewModel.swift +++ b/Sources/TextDiffUICommon/TextDiffViewModel.swift @@ -1,6 +1,7 @@ import Combine import Foundation import TextDiffCore +import TextDiffCore @MainActor final class TextDiffViewModel: ObservableObject { diff --git a/Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift b/Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift index 74e606b..0b45430 100644 --- a/Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift +++ b/Tests/TextDiffMacOSUITests/DiffLayouterPerformanceTests.swift @@ -1,6 +1,7 @@ import AppKit import XCTest import TextDiffCore +import TextDiffUICommon @testable import TextDiffMacOSUI // swift test --filter DiffLayouterPerformanceTests 2>&1 | xcsift @@ -25,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) @@ -47,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/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift b/Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift index d94289a..2c32148 100644 --- a/Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift +++ b/Tests/TextDiffMacOSUITests/NSTextDiffSnapshotTests.swift @@ -2,6 +2,7 @@ import AppKit import SnapshotTesting import XCTest import TextDiffCore +import TextDiffUICommon @testable import TextDiffMacOSUI final class NSTextDiffSnapshotTests: XCTestCase { diff --git a/Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift b/Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift index 2e9f371..7b34348 100644 --- a/Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffMacOSUITests/NSTextDiffViewTests.swift @@ -1,6 +1,7 @@ import CoreGraphics import Testing import TextDiffCore +import TextDiffUICommon @testable import TextDiffMacOSUI @Test diff --git a/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift b/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift index 9f0abe0..acbd051 100644 --- a/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift +++ b/Tests/TextDiffMacOSUITests/TextDiffStyleAndLayouterTests.swift @@ -1,6 +1,7 @@ import AppKit import Testing import TextDiffCore +import TextDiffUICommon @testable import TextDiffMacOSUI @Test @@ -78,7 +79,7 @@ func layouterEnforcesGapForAdjacentChangedLexicalRuns() { @Test func layouterPreservesMinimumHorizontalPaddingFloor() throws { var style = TextDiffStyle.default - style.chipInsets = NSEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) + style.chipInsets = TextDiffEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) let layout = DiffTokenLayouter.layout( segments: [DiffSegment(kind: .delete, tokenKind: .word, text: "token")], @@ -235,17 +236,17 @@ func layouterUsesRemovalStrikethroughFromRemovalStyle() throws { @Test func verticalInsetIsNonNegativeAndMonotonic() { var base = TextDiffStyle.default - base.chipInsets = NSEdgeInsets(top: 0, left: 2, bottom: 0, right: 2) + 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 = NSEdgeInsets(top: 6, left: 2, bottom: 0, right: 2) + 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 = NSEdgeInsets(top: 0, left: 2, bottom: 7, right: 2) + largerBottom.chipInsets = TextDiffEdgeInsets(top: 0, left: 2, bottom: 7, right: 2) let largerBottomInset = DiffTextLayoutMetrics.verticalTextInset(for: largerBottom) #expect(largerBottomInset >= baseInset) } @@ -284,4 +285,4 @@ private struct TestStyling: TextDiffStyling { let strikethrough: Bool } -private let zeroInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) +private let zeroInsets = TextDiffEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) diff --git a/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift b/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift index 5696407..3406eae 100644 --- a/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift +++ b/Tests/TextDiffMacOSUITests/TextDiffViewModelTests.swift @@ -1,6 +1,6 @@ import Testing import TextDiffCore -@testable import TextDiffMacOSUI +@testable import TextDiffUICommon @Test @MainActor From c5171e7539624c0c7ba66481c6e0bcbfa4582009 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sat, 18 Apr 2026 21:44:54 +0200 Subject: [PATCH 3/8] default text color --- Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift b/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift index eecd8df..e071cee 100644 --- a/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift +++ b/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift @@ -9,7 +9,7 @@ public extension TextDiffChangeStyle { static let defaultRemoval = TextDiffChangeStyle( fillColor: defaultRemovalFillColorValue, strokeColor: defaultRemovalStrokeColorValue, - textColorOverride: nil, + textColorOverride: defaultRemovalTextColorValue, strikethrough: false ) } @@ -19,9 +19,11 @@ private var defaultAdditionFillColorValue: PlatformColor { PlatformColor.systemG private var defaultAdditionStrokeColorValue: PlatformColor { PlatformColor.systemGreen.withAlphaComponent(0.65) } 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 defaultRemovalFillColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.22) } private var defaultRemovalStrokeColorValue: PlatformColor { PlatformColor.systemRed.withAlphaComponent(0.65) } +private var defaultRemovalTextColorValue: PlatformColor { PlatformColor.label } #endif From 34f69f0b2515e340ee7779e827883222c3428bce Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sat, 18 Apr 2026 21:53:00 +0200 Subject: [PATCH 4/8] default color for additions --- Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift b/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift index e071cee..47cd487 100644 --- a/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift +++ b/Sources/TextDiffUICommon/TextDiffChangeStyleDefaults.swift @@ -2,7 +2,7 @@ public extension TextDiffChangeStyle { static let defaultAddition = TextDiffChangeStyle( fillColor: defaultAdditionFillColorValue, strokeColor: defaultAdditionStrokeColorValue, - textColorOverride: nil, + textColorOverride: defaultAdditionTextColorValue, strikethrough: false ) @@ -17,12 +17,14 @@ public extension TextDiffChangeStyle { #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 } From 465e1712371f98c34e8e502df279d5773ee9ba1a Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sun, 19 Apr 2026 00:29:47 +0200 Subject: [PATCH 5/8] updating snapshot tests --- .../custom_style_spacing_strikethrough.1.png | Bin 9816 -> 9720 bytes .../hover_pair_affordance.1.png | Bin 4688 -> 4628 bytes .../narrow_width_wrapping.1.png | Bin 9502 -> 9389 bytes .../custom_style_spacing_strikethrough.1.png | Bin 9816 -> 9720 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png index 494ee13d508878dea2e5366e6e2c327685bd585e..f504191d98b4747202995bfa2c25e04b68cc1521 100644 GIT binary patch literal 9720 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 YM~{n^u!+C+|NV3U`l=vVF0SwYe;&prb^rhX literal 9816 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 W8G%QkMF00whnkYs`)Y+xq5lVR^3sI> diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png index 771a14dec89cac90ef7d18acb7a2758571f07d8f..f451aa237a9f509b0bb90c42af7ced1b0b7df213 100644 GIT binary patch delta 3509 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 Cu9;^5 delta 3569 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 diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/NSTextDiffSnapshotTests/narrow_width_wrapping.1.png index 96d2422e5cdce7d68b1f8b32a546bd514e4b3b2e..c913c910bba532214e92d5349896d137a9fe8736 100644 GIT binary patch delta 8307 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 E00sITF8}}l delta 8421 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 diff --git a/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png b/Tests/TextDiffMacOSUITests/__Snapshots__/TextDiffSnapshotTests/custom_style_spacing_strikethrough.1.png index 494ee13d508878dea2e5366e6e2c327685bd585e..f504191d98b4747202995bfa2c25e04b68cc1521 100644 GIT binary patch literal 9720 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 YM~{n^u!+C+|NV3U`l=vVF0SwYe;&prb^rhX literal 9816 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 W8G%QkMF00whnkYs`)Y+xq5lVR^3sI> From 5946bbdbe8cb1631026132725f99a2ce073a1efb Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sun, 19 Apr 2026 00:32:05 +0200 Subject: [PATCH 6/8] remove dublicated import --- Sources/TextDiffUICommon/TextDiffViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/TextDiffUICommon/TextDiffViewModel.swift b/Sources/TextDiffUICommon/TextDiffViewModel.swift index 45b5130..4e7bffe 100644 --- a/Sources/TextDiffUICommon/TextDiffViewModel.swift +++ b/Sources/TextDiffUICommon/TextDiffViewModel.swift @@ -1,7 +1,6 @@ import Combine import Foundation import TextDiffCore -import TextDiffCore @MainActor final class TextDiffViewModel: ObservableObject { From b8a7df0ca835aba2adc393ecf8b9ae90d10229be Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sun, 19 Apr 2026 00:43:47 +0200 Subject: [PATCH 7/8] readme --- README.md | 87 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 17 deletions(-) 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. From 4ebeca0aa884a6aebc14df713c75bed1ccc7f42e Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Sun, 19 Apr 2026 00:48:13 +0200 Subject: [PATCH 8/8] previews --- Sources/TextDiff/TextDiffView.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index e745892..08b25f5 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -398,4 +398,32 @@ private struct TextDiffIOSRepresentable: UIViewRepresentable { } } } + +#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