diff --git a/README.md b/README.md index c8b2d25..5694b78 100644 --- a/README.md +++ b/README.md @@ -87,15 +87,19 @@ import SwiftUI import TextDiff let customStyle = TextDiffStyle( - additionFillColor: NSColor.systemGreen.withAlphaComponent(0.28), - additionStrokeColor: NSColor.systemGreen.withAlphaComponent(0.75), - deletionFillColor: NSColor.systemRed.withAlphaComponent(0.24), - deletionStrokeColor: NSColor.systemRed.withAlphaComponent(0.75), - unchangedTextColor: .labelColor, + additionsStyle: TextDiffChangeStyle( + fillColor: NSColor.systemGreen.withAlphaComponent(0.28), + strokeColor: NSColor.systemGreen.withAlphaComponent(0.75) + ), + removalsStyle: TextDiffChangeStyle( + fillColor: NSColor.systemRed.withAlphaComponent(0.24), + strokeColor: NSColor.systemRed.withAlphaComponent(0.75), + strikethrough: true + ), + textColor: .labelColor, font: .monospacedSystemFont(ofSize: 15, weight: .regular), chipCornerRadius: 5, chipInsets: NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), - deletionStrikethrough: true, interChipSpacing: 4, lineSpacing: 2 ) @@ -111,6 +115,8 @@ 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.). + ## Behavior Notes - Tokenization uses `NLTokenizer` (`.word`) and reconstructs punctuation/whitespace by filling range gaps. diff --git a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift index 88143b4..a7f2676 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift @@ -139,7 +139,7 @@ enum DiffTokenLayouter { .foregroundColor: textColor(for: segment, style: style) ] - if style.deletionStrikethrough, segment.kind == .delete, segment.tokenKind != .whitespace { + if style.removalsStyle.strikethrough, segment.kind == .delete, segment.tokenKind != .whitespace { attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } @@ -163,26 +163,26 @@ enum DiffTokenLayouter { private static func textColor(for segment: DiffSegment, style: TextDiffStyle) -> NSColor { switch segment.kind { case .equal: - return style.unchangedTextColor + return style.textColor case .delete: - if let override = style.deletionTextColorOverride { + if let override = style.removalsStyle.textColorOverride { return override } - return adaptiveChipTextColor(for: style.deletionFillColor) + return adaptiveChipTextColor(for: style.removalsStyle.fillColor) case .insert: - if let override = style.additionTextColorOverride { + if let override = style.additionsStyle.textColorOverride { return override } - return adaptiveChipTextColor(for: style.additionFillColor) + return adaptiveChipTextColor(for: style.additionsStyle.fillColor) } } private static func chipFillColorForOperation(_ kind: DiffOperationKind, style: TextDiffStyle) -> NSColor? { switch kind { case .delete: - return style.deletionFillColor + return style.removalsStyle.fillColor case .insert: - return style.additionFillColor + return style.additionsStyle.fillColor case .equal: return nil } @@ -191,9 +191,9 @@ enum DiffTokenLayouter { private static func chipStrokeColorForOperation(_ kind: DiffOperationKind, style: TextDiffStyle) -> NSColor? { switch kind { case .delete: - return style.deletionStrokeColor + return style.removalsStyle.strokeColor case .insert: - return style.additionStrokeColor + return style.additionsStyle.strokeColor case .equal: return nil } diff --git a/Sources/TextDiff/TextDiffChangeStyle.swift b/Sources/TextDiff/TextDiffChangeStyle.swift new file mode 100644 index 0000000..60a6629 --- /dev/null +++ b/Sources/TextDiff/TextDiffChangeStyle.swift @@ -0,0 +1,29 @@ +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 strikethrough: Bool + + public init( + fillColor: NSColor, + strokeColor: NSColor, + textColorOverride: NSColor? = nil, + strikethrough: Bool = false + ) { + self.fillColor = fillColor + self.strokeColor = strokeColor + self.textColorOverride = textColorOverride + self.strikethrough = strikethrough + } + + public init(_ styling: some TextDiffStyling) { + self.fillColor = styling.fillColor + self.strokeColor = styling.strokeColor + self.textColorOverride = styling.textColorOverride + self.strikethrough = styling.strikethrough + } +} diff --git a/Sources/TextDiff/TextDiffChangeStyleDefaults.swift b/Sources/TextDiff/TextDiffChangeStyleDefaults.swift new file mode 100644 index 0000000..3822447 --- /dev/null +++ b/Sources/TextDiff/TextDiffChangeStyleDefaults.swift @@ -0,0 +1,17 @@ +import AppKit + +public extension TextDiffChangeStyle { + static let defaultAddition = TextDiffChangeStyle( + fillColor: NSColor.systemGreen.withAlphaComponent(0.22), + strokeColor: NSColor.systemGreen.withAlphaComponent(0.65), + textColorOverride: nil, + strikethrough: false + ) + + static let defaultRemoval = TextDiffChangeStyle( + fillColor: NSColor.systemRed.withAlphaComponent(0.22), + strokeColor: NSColor.systemRed.withAlphaComponent(0.65), + textColorOverride: nil, + strikethrough: false + ) +} diff --git a/Sources/TextDiff/TextDiffStyle.swift b/Sources/TextDiff/TextDiffStyle.swift index 26d681e..ada66cb 100644 --- a/Sources/TextDiff/TextDiffStyle.swift +++ b/Sources/TextDiff/TextDiffStyle.swift @@ -3,30 +3,19 @@ import Foundation /// Visual configuration for rendering text diff segments. public struct TextDiffStyle: @unchecked Sendable { - /// Fill color used for inserted token chips. - public var additionFillColor: NSColor - /// Stroke color used for inserted token chips. - public var additionStrokeColor: NSColor - /// Optional text color override for inserted tokens. - public var additionTextColorOverride: NSColor? - - /// Fill color used for deleted token chips. - public var deletionFillColor: NSColor - /// Stroke color used for deleted token chips. - public var deletionStrokeColor: NSColor - /// Optional text color override for deleted tokens. - public var deletionTextColorOverride: NSColor? + /// Visual style used for inserted token chips. + public var additionsStyle: TextDiffChangeStyle + /// Visual style used for deleted token chips. + public var removalsStyle: TextDiffChangeStyle /// Text color used for unchanged tokens. - public var unchangedTextColor: NSColor + public var textColor: NSColor /// Font used for all rendered tokens. public var font: NSFont /// 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 - /// Controls whether deleted lexical tokens are drawn with a strikethrough. - public var deletionStrikethrough: Bool /// Minimum visual gap between adjacent changed lexical chips. public var interChipSpacing: CGFloat /// Additional vertical spacing between wrapped lines. @@ -35,54 +24,67 @@ public struct TextDiffStyle: @unchecked Sendable { /// Creates a style for rendering text diffs. /// /// - Parameters: - /// - additionFillColor: Fill color used for inserted token chips. - /// - additionStrokeColor: Stroke color used for inserted token chips. - /// - additionTextColorOverride: Optional text color override for inserted tokens. - /// - deletionFillColor: Fill color used for deleted token chips. - /// - deletionStrokeColor: Stroke color used for deleted token chips. - /// - deletionTextColorOverride: Optional text color override for deleted tokens. - /// - unchangedTextColor: Text color used for unchanged tokens. + /// - additionsStyle: Change style used for inserted token chips. + /// - removalsStyle: Change style used for deleted token chips. + /// - textColor: Text color used for unchanged tokens. /// - font: Font used for all rendered tokens. /// - chipCornerRadius: Corner radius applied to changed-token chips. /// - chipInsets: Insets applied around changed-token text when drawing chips. - /// - deletionStrikethrough: Whether deleted lexical tokens use a strikethrough. /// - interChipSpacing: Gap between adjacent changed lexical chips. /// - lineSpacing: Additional vertical spacing between wrapped lines. public init( - additionFillColor: NSColor, - additionStrokeColor: NSColor, - additionTextColorOverride: NSColor? = nil, - deletionFillColor: NSColor, - deletionStrokeColor: NSColor, - deletionTextColorOverride: NSColor? = nil, - unchangedTextColor: NSColor = .labelColor, + additionsStyle: TextDiffChangeStyle = .defaultAddition, + removalsStyle: TextDiffChangeStyle = .defaultRemoval, + textColor: NSColor = .labelColor, font: NSFont = .monospacedSystemFont(ofSize: 14, weight: .regular), chipCornerRadius: CGFloat = 4, chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), - deletionStrikethrough: Bool = false, interChipSpacing: CGFloat = 0, lineSpacing: CGFloat = 2 ) { - self.additionFillColor = additionFillColor - self.additionStrokeColor = additionStrokeColor - self.additionTextColorOverride = additionTextColorOverride - self.deletionFillColor = deletionFillColor - self.deletionStrokeColor = deletionStrokeColor - self.deletionTextColorOverride = deletionTextColorOverride - self.unchangedTextColor = unchangedTextColor + self.additionsStyle = additionsStyle + self.removalsStyle = removalsStyle + self.textColor = textColor self.font = font self.chipCornerRadius = chipCornerRadius self.chipInsets = chipInsets - self.deletionStrikethrough = deletionStrikethrough self.interChipSpacing = interChipSpacing self.lineSpacing = lineSpacing } + /// Creates a style by converting protocol-based operation styles to concrete change styles. + /// + /// - Parameters: + /// - additionsStyle: Protocol-based style for inserted token chips. + /// - removalsStyle: Protocol-based style for deleted token chips. + /// - textColor: Text color used for unchanged tokens. + /// - font: Font used for all rendered tokens. + /// - chipCornerRadius: Corner radius applied to changed-token chips. + /// - chipInsets: Insets applied around changed-token text when drawing chips. + /// - interChipSpacing: Gap between adjacent changed lexical chips. + /// - lineSpacing: Additional vertical spacing between wrapped lines. + public init( + additionsStyle: some TextDiffStyling, + removalsStyle: some TextDiffStyling, + textColor: NSColor = .labelColor, + font: NSFont = .monospacedSystemFont(ofSize: 14, weight: .regular), + chipCornerRadius: CGFloat = 4, + chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), + interChipSpacing: CGFloat = 0, + lineSpacing: CGFloat = 2 + ) { + self.init( + additionsStyle: TextDiffChangeStyle(additionsStyle), + removalsStyle: TextDiffChangeStyle(removalsStyle), + textColor: textColor, + font: font, + chipCornerRadius: chipCornerRadius, + chipInsets: chipInsets, + interChipSpacing: interChipSpacing, + lineSpacing: lineSpacing + ) + } + /// The default style tuned for system green insertions and system red deletions. - public static let `default` = TextDiffStyle( - additionFillColor: NSColor.systemGreen.withAlphaComponent(0.22), - additionStrokeColor: NSColor.systemGreen.withAlphaComponent(0.65), - deletionFillColor: NSColor.systemRed.withAlphaComponent(0.22), - deletionStrokeColor: NSColor.systemRed.withAlphaComponent(0.65) - ) + public static let `default` = TextDiffStyle() } diff --git a/Sources/TextDiff/TextDiffStyling.swift b/Sources/TextDiff/TextDiffStyling.swift new file mode 100644 index 0000000..ecfbc22 --- /dev/null +++ b/Sources/TextDiff/TextDiffStyling.swift @@ -0,0 +1,13 @@ +import AppKit + +/// Change-specific visual configuration used for addition/removal rendering. +public protocol TextDiffStyling { + /// Fill color used for chip backgrounds. + var fillColor: NSColor { get } + /// Stroke color used for chip outlines. + var strokeColor: NSColor { get } + /// Optional text color override for chip text. + var textColorOverride: NSColor? { get } + /// Whether changed lexical content should render with a strikethrough. + var strikethrough: Bool { get } +} diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index 2548056..ae188ae 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -50,17 +50,21 @@ public struct TextDiffView: View { #Preview("TextDiffView") { let style = TextDiffStyle( - additionFillColor: .systemGreen.withAlphaComponent(0.28), - additionStrokeColor: .systemGreen.withAlphaComponent(0.75), - additionTextColorOverride: .labelColor, - deletionFillColor: .systemRed.withAlphaComponent(0.24), - deletionStrokeColor: .systemRed.withAlphaComponent(0.75), - deletionTextColorOverride: .secondaryLabelColor, - unchangedTextColor: .labelColor, + additionsStyle: TextDiffChangeStyle( + fillColor: .systemGreen.withAlphaComponent(0.28), + strokeColor: .systemGreen.withAlphaComponent(0.75), + textColorOverride: .labelColor + ), + removalsStyle: TextDiffChangeStyle( + fillColor: .systemRed.withAlphaComponent(0.24), + strokeColor: .systemRed.withAlphaComponent(0.75), + textColorOverride: .secondaryLabelColor, + strikethrough: true + ), + textColor: .labelColor, font: .systemFont(ofSize: 16, weight: .regular), chipCornerRadius: 3, chipInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), - deletionStrikethrough: true, interChipSpacing: 1, lineSpacing: 2 ) diff --git a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift index 7af8152..6b283e7 100644 --- a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift +++ b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift @@ -49,7 +49,7 @@ struct NSTextDiffSnapshotTests { @Test func custom_style_spacing_strikethrough() { var style = TextDiffStyle.default - style.deletionStrikethrough = true + style.removalsStyle.strikethrough = true style.interChipSpacing = 1 assertNSTextDiffSnapshot( diff --git a/Tests/TextDiffTests/NSTextDiffViewTests.swift b/Tests/TextDiffTests/NSTextDiffViewTests.swift index 85968c3..014daee 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -86,7 +86,7 @@ func nsTextDiffViewStyleChangeDoesNotRecomputeDiff() { } var style = TextDiffStyle.default - style.deletionStrikethrough = true + style.removalsStyle.strikethrough = true view.style = style #expect(callCount == 1) @@ -106,7 +106,7 @@ func nsTextDiffViewSetContentBatchesRecompute() { } var style = TextDiffStyle.default - style.deletionStrikethrough = true + style.removalsStyle.strikethrough = true view.setContent( original: "old-2", updated: "new-2", @@ -131,7 +131,7 @@ func nsTextDiffViewSetContentStyleOnlyDoesNotRecomputeDiff() { } var style = TextDiffStyle.default - style.deletionStrikethrough = true + style.removalsStyle.strikethrough = true view.setContent( original: "old", updated: "new", diff --git a/Tests/TextDiffTests/TextDiffEngineTests.swift b/Tests/TextDiffTests/TextDiffEngineTests.swift index e545daa..52e8c95 100644 --- a/Tests/TextDiffTests/TextDiffEngineTests.swift +++ b/Tests/TextDiffTests/TextDiffEngineTests.swift @@ -168,6 +168,46 @@ func defaultStyleInterChipSpacingMatchesCurrentDefault() { #expect(TextDiffStyle.default.interChipSpacing == 0) } +@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 + ) + + 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) +} + @Test func layouterEnforcesGapForAdjacentChangedLexicalRuns() { var style = TextDiffStyle.default @@ -274,6 +314,23 @@ func layouterWrapsByTokenAndRespectsExplicitNewlines() { #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 verticalInsetScalesWithChipInsets() { #expect(DiffTextLayoutMetrics.verticalTextInset(for: .default) == 3) @@ -300,4 +357,25 @@ 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/TextDiffSnapshotTests.swift b/Tests/TextDiffTests/TextDiffSnapshotTests.swift index a87c76d..dbdbc54 100644 --- a/Tests/TextDiffTests/TextDiffSnapshotTests.swift +++ b/Tests/TextDiffTests/TextDiffSnapshotTests.swift @@ -69,7 +69,7 @@ struct TextDiffSnapshotTests { @Test func custom_style_spacing_strikethrough() { var style = TextDiffStyle.default - style.deletionStrikethrough = true + style.removalsStyle.strikethrough = true style.interChipSpacing = 1 assertTextDiffSnapshot(