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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.snapshot-artifacts/
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>classNames</key>
<dict>
<key>DiffLayouterPerformanceTests</key>
<dict>
<key>testLayoutPerformance1000Words()</key>
<dict>
<key>com.apple.dt.XCTMetric_Clock.time.monotonic</key>
<dict>
<key>baselineAverage</key>
<real>0.058013</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
<key>testLayoutPerformance200Words()</key>
<dict>
<key>com.apple.dt.XCTMetric_Clock.time.monotonic</key>
<dict>
<key>baselineAverage</key>
<real>0.013204</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
<key>testLayoutPerformance500Words()</key>
<dict>
<key>com.apple.dt.XCTMetric_Clock.time.monotonic</key>
<dict>
<key>baselineAverage</key>
<real>0.027967</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
</dict>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>runDestinationsByUUID</key>
<dict>
<key>BF20AB95-BD61-4DE5-BFD2-9B7DB182F4A1</key>
<dict>
<key>localComputer</key>
<dict>
<key>busSpeedInMHz</key>
<integer>0</integer>
<key>cpuCount</key>
<integer>1</integer>
<key>cpuKind</key>
<string>Apple M1</string>
<key>cpuSpeedInMHz</key>
<integer>0</integer>
<key>logicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>modelCode</key>
<string>MacBookPro17,1</string>
<key>physicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>platformIdentifier</key>
<string>com.apple.platform.macosx</string>
</dict>
<key>targetArchitecture</key>
<string>arm64</string>
</dict>
</dict>
</dict>
</plist>
4 changes: 2 additions & 2 deletions Sources/TextDiff/AppKit/DiffTextLayoutMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import AppKit

enum DiffTextLayoutMetrics {
static func verticalTextInset(for style: TextDiffStyle) -> CGFloat {
ceil(max(2, style.chipInsets.top + 2, style.chipInsets.bottom + 2))
ceil(max(0, style.chipInsets.top, style.chipInsets.bottom))
}

static func lineHeight(for style: TextDiffStyle) -> CGFloat {
let textHeight = ceil(style.font.ascender - style.font.descender + style.font.leading)
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))
}
Expand Down
55 changes: 50 additions & 5 deletions Sources/TextDiff/AppKit/DiffTokenLayouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ enum DiffTokenLayouter {
contentInsets: NSEdgeInsets
) -> DiffLayout {
let lineHeight = DiffTextLayoutMetrics.lineHeight(for: style)
let textHeight = ceil(style.font.ascender - style.font.descender + style.font.leading)
let maxLineWidth = availableWidth > 0 ? availableWidth : .greatestFiniteMagnitude
let lineStartX = contentInsets.left
let maxLineX = lineStartX + maxLineWidth
Expand All @@ -37,12 +38,16 @@ enum DiffTokenLayouter {
var maxUsedX = lineStartX
var lineCount = 1
var lineHasContent = false
let lineText = NSMutableString()
var lineTextWidth: CGFloat = 0
var previousChangedLexical = false

func moveToNewLine() {
lineTop += lineHeight
cursorX = lineStartX
lineHasContent = false
lineText.setString("")
lineTextWidth = 0
previousChangedLexical = false
lineCount += 1
}
Expand All @@ -65,9 +70,15 @@ enum DiffTokenLayouter {
}

let attributedText = attributedToken(for: segment, style: style)
let textSize = measuredTextSize(for: piece.text, font: style.font)
var textMeasurement = measuredIncrementalTextWidth(
for: piece.text,
font: style.font,
lineText: lineText,
lineTextWidth: lineTextWidth
)
var textSize = CGSize(width: textMeasurement.textWidth, height: textHeight)
let chipInsets = effectiveChipInsets(for: style)
let runWidth = isChangedLexical ? textSize.width + chipInsets.left + chipInsets.right : textSize.width
var runWidth = isChangedLexical ? textSize.width + chipInsets.left + chipInsets.right : textSize.width
let requiredWidth = leadingGap + runWidth

let wrapped = lineHasContent && cursorX + requiredWidth > maxLineX
Expand All @@ -79,6 +90,15 @@ enum DiffTokenLayouter {
if piece.tokenKind == .whitespace {
continue
}

textMeasurement = measuredIncrementalTextWidth(
for: piece.text,
font: style.font,
lineText: lineText,
lineTextWidth: lineTextWidth
)
textSize = CGSize(width: textMeasurement.textWidth, height: textHeight)
runWidth = isChangedLexical ? textSize.width + chipInsets.left + chipInsets.right : textSize.width
}

cursorX += leadingGap
Expand Down Expand Up @@ -118,6 +138,7 @@ enum DiffTokenLayouter {
cursorX += runWidth
maxUsedX = max(maxUsedX, cursorX)
lineHasContent = true
lineTextWidth = textMeasurement.combinedLineWidth
previousChangedLexical = isChangedLexical
}

Expand Down Expand Up @@ -146,9 +167,28 @@ enum DiffTokenLayouter {
return NSAttributedString(string: segment.text, attributes: attributes)
}

private static func measuredTextSize(for text: String, font: NSFont) -> CGSize {
let measured = (text as NSString).size(withAttributes: [.font: font])
return CGSize(width: ceil(measured.width), height: ceil(measured.height))
private static func measuredIncrementalTextWidth(
for text: String,
font: NSFont,
lineText: NSMutableString,
lineTextWidth: CGFloat
) -> IncrementalTextWidth {
guard !text.isEmpty else {
return IncrementalTextWidth(
textWidth: 0,
combinedLineWidth: lineTextWidth
)
}

lineText.append(text)
// TODO: Fix this later
// This now appends each token to lineText and calls size(withAttributes:) on the entire accumulated line every iteration, which makes layout cost grow quadratically with line length. On long unwrapped diffs (hundreds/thousands of tokens), this is a significant regression from the prior per-token measurement approach and can noticeably slow rendering even though the new performance tests only capture baselines and do not enforce thresholds.
let combinedWidth = lineText.size(withAttributes: [.font: font]).width
let textWidth = max(0, combinedWidth - lineTextWidth)
return IncrementalTextWidth(
textWidth: textWidth,
combinedLineWidth: combinedWidth
)
}

private static func effectiveChipInsets(for style: TextDiffStyle) -> NSEdgeInsets {
Expand Down Expand Up @@ -262,3 +302,8 @@ private struct LayoutPiece {
let text: String
let isLineBreak: Bool
}

private struct IncrementalTextWidth {
let textWidth: CGFloat
let combinedLineWidth: CGFloat
}
63 changes: 50 additions & 13 deletions Sources/TextDiff/TextDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public struct TextDiffView: View {
style: style,
mode: mode
)
.accessibilityLabel("Text diff")
.accessibilityLabel("Text diff")
}
}

Expand All @@ -49,6 +49,7 @@ public struct TextDiffView: View {
}

#Preview("TextDiffView") {
let font: NSFont = .systemFont(ofSize: 16, weight: .regular)
let style = TextDiffStyle(
additionsStyle: TextDiffChangeStyle(
fillColor: .systemGreen.withAlphaComponent(0.28),
Expand All @@ -62,7 +63,7 @@ public struct TextDiffView: View {
strikethrough: true
),
textColor: .labelColor,
font: .systemFont(ofSize: 16, weight: .regular),
font: font,
chipCornerRadius: 3,
chipInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0),
interChipSpacing: 1,
Expand All @@ -72,11 +73,11 @@ 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:")
TextDiffView(
Expand All @@ -89,12 +90,12 @@ public struct TextDiffView: View {
Divider()
Text("Diff by words")
.bold()
TextDiffView(
original: "Add a diff view! Looks good!",
updated: "Added a diff view. It looks good!",
style: style,
mode: .token
)
TextDiffView(
original: "Add a diff view! Looks good!",
updated: "Added a diff view. It looks good!",
style: style,
mode: .token
)
HStack {
Text("dog → fog:")
TextDiffView(
Expand Down Expand Up @@ -127,3 +128,39 @@ public struct TextDiffView: View {
.padding()
.frame(width: 320)
}

#Preview("Height diff") {
let font: NSFont = .systemFont(ofSize: 32, weight: .regular)
let style = TextDiffStyle(
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: font,
chipCornerRadius: 3,
chipInsets: NSEdgeInsets(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))
.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!",
style: style,
mode: .character
)
}
.padding()
}
62 changes: 62 additions & 0 deletions Tests/TextDiffTests/DiffLayouterPerformanceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import AppKit
import XCTest
@testable import TextDiff

// swift test --filter DiffLayouterPerformanceTests 2>&1 | xcsift

final class DiffLayouterPerformanceTests: XCTestCase {
func testLayoutPerformance200Words() {
runLayoutPerformanceTest(wordCount: 200)
}

func testLayoutPerformance500Words() {
runLayoutPerformanceTest(wordCount: 500)
}

func testLayoutPerformance1000Words() {
runLayoutPerformanceTest(wordCount: 1000)
}

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 availableWidth: CGFloat = 520

let original = Self.largeText(wordCount: wordCount)
let updated = Self.replacingLastWord(in: original)
let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .character)

measure(metrics: [XCTClockMetric()]) {
let layout = DiffTokenLayouter.layout(
segments: segments,
style: style,
availableWidth: availableWidth,
contentInsets: contentInsets
)
XCTAssertFalse(layout.runs.isEmpty)
}
}

private static func largeText(wordCount: Int) -> String {
let vocabulary = [
"alpha", "beta", "gamma", "delta", "epsilon", "theta", "lambda", "sigma",
"swift", "layout", "render", "token", "word", "segment", "measure", "width"
]
var words: [String] = []
words.reserveCapacity(wordCount)

for index in 0..<wordCount {
words.append(vocabulary[index % vocabulary.count])
}

return words.joined(separator: " ")
}

private static func replacingLastWord(in text: String) -> String {
guard let lastSpace = text.lastIndex(of: " ") else {
return "changed"
}
return String(text[..<lastSpace]) + " changed"
}
}
Loading
Loading