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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions Sources/TextDiff/AppKit/DiffTokenLayouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
29 changes: 29 additions & 0 deletions Sources/TextDiff/TextDiffChangeStyle.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
17 changes: 17 additions & 0 deletions Sources/TextDiff/TextDiffChangeStyleDefaults.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
94 changes: 48 additions & 46 deletions Sources/TextDiff/TextDiffStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
}
13 changes: 13 additions & 0 deletions Sources/TextDiff/TextDiffStyling.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
20 changes: 12 additions & 8 deletions Sources/TextDiff/TextDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 1 addition & 1 deletion Tests/TextDiffTests/NSTextDiffSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions Tests/TextDiffTests/NSTextDiffViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func nsTextDiffViewStyleChangeDoesNotRecomputeDiff() {
}

var style = TextDiffStyle.default
style.deletionStrikethrough = true
style.removalsStyle.strikethrough = true
view.style = style

#expect(callCount == 1)
Expand All @@ -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",
Expand All @@ -131,7 +131,7 @@ func nsTextDiffViewSetContentStyleOnlyDoesNotRecomputeDiff() {
}

var style = TextDiffStyle.default
style.deletionStrikethrough = true
style.removalsStyle.strikethrough = true
view.setContent(
original: "old",
updated: "new",
Expand Down
Loading
Loading