From c92016f50b68d54bc8a33974de0269f21c5cfa8b Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 24 Feb 2026 00:26:54 +0100 Subject: [PATCH 1/8] initial impl --- .../AppKit/DiffRevertActionResolver.swift | 259 ++++++++++ .../AppKit/DiffTextViewRepresentable.swift | 41 ++ .../TextDiff/AppKit/DiffTokenLayouter.swift | 8 +- Sources/TextDiff/AppKit/NSTextDiffView.swift | 463 ++++++++++++++++++ Sources/TextDiff/DiffTypes.swift | 42 ++ Sources/TextDiff/TextDiffView.swift | 71 ++- .../DiffLayouterPerformanceTests.swift | 31 ++ .../DiffRevertActionResolverTests.swift | 76 +++ .../NSTextDiffSnapshotTests.swift | 62 ++- Tests/TextDiffTests/NSTextDiffViewTests.swift | 210 ++++++++ Tests/TextDiffTests/SnapshotTestSupport.swift | 3 + .../character_mode_no_affordance.1.png | Bin 0 -> 3218 bytes .../hover_pair_affordance.1.png | Bin 0 -> 3721 bytes .../hover_single_addition_affordance.1.png | Bin 0 -> 2748 bytes .../hover_single_deletion_affordance.1.png | Bin 0 -> 2673 bytes 15 files changed, 1257 insertions(+), 9 deletions(-) create mode 100644 Sources/TextDiff/AppKit/DiffRevertActionResolver.swift create mode 100644 Tests/TextDiffTests/DiffRevertActionResolverTests.swift create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_pair_affordance.1.png create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_deletion_affordance.1.png diff --git a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift new file mode 100644 index 0000000..093974e --- /dev/null +++ b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift @@ -0,0 +1,259 @@ +import CoreGraphics +import Foundation + +struct IndexedSegment { + let segmentIndex: Int + let segment: DiffSegment + let originalCursor: Int + let updatedCursor: Int + let originalRange: NSRange + let updatedRange: NSRange +} + +enum DiffRevertCandidateKind: Equatable { + case singleInsertion + case singleDeletion + case pairedReplacement +} + +struct DiffRevertCandidate: Equatable { + let id: Int + let kind: DiffRevertCandidateKind + let segmentIndices: [Int] + let updatedRange: NSRange + let replacementText: String + let originalTextFragment: String? + let updatedTextFragment: String? +} + +struct DiffRevertInteractionContext { + let candidatesByID: [Int: DiffRevertCandidate] + let runIndicesByActionID: [Int: [Int]] + let chipRectsByActionID: [Int: [CGRect]] + let unionChipRectByActionID: [Int: CGRect] +} + +enum DiffRevertActionResolver { + static func indexedSegments(from segments: [DiffSegment]) -> [IndexedSegment] { + var output: [IndexedSegment] = [] + output.reserveCapacity(segments.count) + + var originalCursor = 0 + var updatedCursor = 0 + + for (index, segment) in segments.enumerated() { + let textLength = segment.text.utf16.count + let originalRange: NSRange + let updatedRange: NSRange + + switch segment.kind { + case .equal: + originalRange = NSRange(location: originalCursor, length: textLength) + updatedRange = NSRange(location: updatedCursor, length: textLength) + originalCursor += textLength + updatedCursor += textLength + case .delete: + originalRange = NSRange(location: originalCursor, length: textLength) + updatedRange = NSRange(location: updatedCursor, length: 0) + originalCursor += textLength + case .insert: + originalRange = NSRange(location: originalCursor, length: 0) + updatedRange = NSRange(location: updatedCursor, length: textLength) + updatedCursor += textLength + } + + output.append( + IndexedSegment( + segmentIndex: index, + segment: segment, + originalCursor: originalRange.location, + updatedCursor: updatedRange.location, + originalRange: originalRange, + updatedRange: updatedRange + ) + ) + } + + return output + } + + static func candidates( + from segments: [DiffSegment], + mode: TextDiffComparisonMode + ) -> [DiffRevertCandidate] { + guard mode == .token else { + return [] + } + + let indexed = indexedSegments(from: segments) + guard !indexed.isEmpty else { + return [] + } + + var output: [DiffRevertCandidate] = [] + output.reserveCapacity(indexed.count) + + var candidateID = 0 + var index = 0 + while index < indexed.count { + let current = indexed[index] + let isCurrentLexical = isLexicalChange(current.segment) + + if index + 1 < indexed.count { + let next = indexed[index + 1] + if current.segment.kind == .delete, + next.segment.kind == .insert, + isCurrentLexical, + isLexicalChange(next.segment) { + output.append( + DiffRevertCandidate( + id: candidateID, + kind: .pairedReplacement, + segmentIndices: [current.segmentIndex, next.segmentIndex], + updatedRange: next.updatedRange, + replacementText: current.segment.text, + originalTextFragment: current.segment.text, + updatedTextFragment: next.segment.text + ) + ) + candidateID += 1 + index += 2 + continue + } + } + + if isCurrentLexical { + switch current.segment.kind { + case .insert: + output.append( + DiffRevertCandidate( + id: candidateID, + kind: .singleInsertion, + segmentIndices: [current.segmentIndex], + updatedRange: current.updatedRange, + replacementText: "", + originalTextFragment: nil, + updatedTextFragment: current.segment.text + ) + ) + candidateID += 1 + case .delete: + output.append( + DiffRevertCandidate( + id: candidateID, + kind: .singleDeletion, + segmentIndices: [current.segmentIndex], + updatedRange: NSRange(location: current.updatedCursor, length: 0), + replacementText: current.segment.text, + originalTextFragment: current.segment.text, + updatedTextFragment: nil + ) + ) + candidateID += 1 + case .equal: + break + } + } + + index += 1 + } + + return output + } + + static func interactionContext( + segments: [DiffSegment], + runs: [LaidOutRun], + mode: TextDiffComparisonMode + ) -> DiffRevertInteractionContext? { + let candidates = candidates(from: segments, mode: mode) + guard !candidates.isEmpty else { + return nil + } + + var actionIDBySegmentIndex: [Int: Int] = [:] + actionIDBySegmentIndex.reserveCapacity(candidates.count * 2) + var candidatesByID: [Int: DiffRevertCandidate] = [:] + candidatesByID.reserveCapacity(candidates.count) + + for candidate in candidates { + candidatesByID[candidate.id] = candidate + for segmentIndex in candidate.segmentIndices { + actionIDBySegmentIndex[segmentIndex] = candidate.id + } + } + + var runIndicesByActionID: [Int: [Int]] = [:] + var chipRectsByActionID: [Int: [CGRect]] = [:] + var unionChipRectByActionID: [Int: CGRect] = [:] + + for (runIndex, run) in runs.enumerated() { + guard let chipRect = run.chipRect else { + continue + } + guard let actionID = actionIDBySegmentIndex[run.segmentIndex] else { + continue + } + runIndicesByActionID[actionID, default: []].append(runIndex) + chipRectsByActionID[actionID, default: []].append(chipRect) + if let currentUnion = unionChipRectByActionID[actionID] { + unionChipRectByActionID[actionID] = currentUnion.union(chipRect) + } else { + unionChipRectByActionID[actionID] = chipRect + } + } + + guard !runIndicesByActionID.isEmpty else { + return nil + } + + candidatesByID = candidatesByID.filter { runIndicesByActionID[$0.key] != nil } + + return DiffRevertInteractionContext( + candidatesByID: candidatesByID, + runIndicesByActionID: runIndicesByActionID, + chipRectsByActionID: chipRectsByActionID, + unionChipRectByActionID: unionChipRectByActionID + ) + } + + static func action( + from candidate: DiffRevertCandidate, + updated: String + ) -> TextDiffRevertAction? { + let nsUpdated = updated as NSString + guard candidate.updatedRange.location >= 0 else { + return nil + } + guard NSMaxRange(candidate.updatedRange) <= nsUpdated.length else { + return nil + } + + let resultingUpdated = nsUpdated.replacingCharacters( + in: candidate.updatedRange, + with: candidate.replacementText + ) + let actionKind: TextDiffRevertActionKind + switch candidate.kind { + case .singleInsertion: + actionKind = .singleInsertion + case .singleDeletion: + actionKind = .singleDeletion + case .pairedReplacement: + actionKind = .pairedReplacement + } + + return TextDiffRevertAction( + kind: actionKind, + updatedRange: candidate.updatedRange, + replacementText: candidate.replacementText, + originalTextFragment: candidate.originalTextFragment, + updatedTextFragment: candidate.updatedTextFragment, + resultingUpdated: resultingUpdated + ) + } + + private static func isLexicalChange(_ segment: DiffSegment) -> Bool { + segment.tokenKind != .whitespace && segment.kind != .equal + } +} diff --git a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift index 73d59f0..d8407f0 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift @@ -4,8 +4,15 @@ import SwiftUI struct DiffTextViewRepresentable: NSViewRepresentable { let original: String let updated: String + let updatedBinding: Binding? let style: TextDiffStyle let mode: TextDiffComparisonMode + let isRevertActionsEnabled: Bool + let onRevertAction: ((TextDiffRevertAction) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator() + } func makeNSView(context: Context) -> NSTextDiffView { let view = NSTextDiffView( @@ -16,10 +23,26 @@ struct DiffTextViewRepresentable: NSViewRepresentable { ) view.setContentCompressionResistancePriority(.required, for: .vertical) view.setContentHuggingPriority(.required, for: .vertical) + context.coordinator.update( + updatedBinding: updatedBinding, + onRevertAction: onRevertAction + ) + view.isRevertActionsEnabled = isRevertActionsEnabled + 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.isRevertActionsEnabled = isRevertActionsEnabled view.setContent( original: original, updated: updated, @@ -27,4 +50,22 @@ struct DiffTextViewRepresentable: NSViewRepresentable { 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) + } + } } diff --git a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift index 75ed788..191562c 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift @@ -2,6 +2,7 @@ import AppKit import Foundation struct LaidOutRun { + let segmentIndex: Int let segment: DiffSegment let attributedText: NSAttributedString let textRect: CGRect @@ -124,6 +125,7 @@ enum DiffTokenLayouter { runs.append( LaidOutRun( + segmentIndex: piece.segmentIndex, segment: segment, attributedText: attributedText, textRect: textRect, @@ -252,13 +254,14 @@ enum DiffTokenLayouter { var output: [LayoutPiece] = [] output.reserveCapacity(segments.count) - for segment in segments { + for (segmentIndex, segment) in segments.enumerated() { var buffer = "" for scalar in segment.text.unicodeScalars { if scalar == "\n" { if !buffer.isEmpty { output.append( LayoutPiece( + segmentIndex: segmentIndex, kind: segment.kind, tokenKind: segment.tokenKind, text: buffer, @@ -269,6 +272,7 @@ enum DiffTokenLayouter { } output.append( LayoutPiece( + segmentIndex: segmentIndex, kind: segment.kind, tokenKind: .whitespace, text: "", @@ -283,6 +287,7 @@ enum DiffTokenLayouter { if !buffer.isEmpty { output.append( LayoutPiece( + segmentIndex: segmentIndex, kind: segment.kind, tokenKind: segment.tokenKind, text: buffer, @@ -297,6 +302,7 @@ enum DiffTokenLayouter { } private struct LayoutPiece { + let segmentIndex: Int let kind: DiffOperationKind let tokenKind: DiffTokenKind let text: String diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index 00920a3..cdfebf7 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -50,6 +50,19 @@ public final class NSTextDiffView: NSView { } } + /// Enables hover affordances and revert action hit-testing. + public var isRevertActionsEnabled: Bool = false { + didSet { + guard oldValue != isRevertActionsEnabled else { + return + } + invalidateCachedLayout() + } + } + + /// Callback invoked when user clicks the revert icon. + public var onRevertAction: ((TextDiffRevertAction) -> Void)? + private var segments: [DiffSegment] private let diffProvider: DiffProvider @@ -58,10 +71,35 @@ public final class NSTextDiffView: NSView { private var lastModeKey: Int private var isBatchUpdating = false private var pendingStyleInvalidation = false + private var segmentGeneration: Int = 0 private var cachedWidth: CGFloat = -1 private var cachedLayout: DiffLayout? + private var cachedInteractionContext: DiffRevertInteractionContext? + private var cachedInteractionWidth: CGFloat = -1 + private var cachedInteractionGeneration: Int = -1 + + private var trackedArea: NSTrackingArea? + private var hoveredActionID: Int? + private var hoveredIconRect: CGRect? + private let hoverDismissDelay: TimeInterval = 0.5 + private var pendingHoverDismissWorkItem: DispatchWorkItem? + private var hoverDismissGeneration: Int = 0 + private var isPointingHandCursorActive = false + + #if TESTING + private var testingHoverDismissScheduler: ((TimeInterval, @escaping () -> Void) -> Void)? + private var testingScheduledHoverDismissBlocks: [() -> Void] = [] + #endif + + private let hoverOutlineColor = NSColor.controlAccentColor.withAlphaComponent(0.9) + private let hoverButtonFillColor = NSColor.black//NSColor.windowBackgroundColor.withAlphaComponent(0.95) + private let hoverButtonStrokeColor = NSColor.clear//NSColor.controlAccentColor.withAlphaComponent(0.8) + private let hoverIconName = "arrow.turn.down.left" + private let hoverButtonSize = CGSize(width: 16, height: 16) + private let hoverButtonGap: CGFloat = 4 + override public var isFlipped: Bool { true } @@ -124,6 +162,22 @@ public final class NSTextDiffView: NSView { fatalError("Use init(original:updated:style:mode:)") } + override public func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackedArea { + removeTrackingArea(trackedArea) + } + let options: NSTrackingArea.Options = [ + .mouseMoved, + .mouseEnteredAndExited, + .activeInKeyWindow, + .inVisibleRect + ] + let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(area) + trackedArea = area + } + override public func setFrameSize(_ newSize: NSSize) { let previousWidth = frame.width super.setFrameSize(newSize) @@ -148,6 +202,27 @@ public final class NSTextDiffView: NSView { run.attributedText.draw(in: run.textRect) } + + drawHoveredRevertAffordance(layout: layout) + } + + override public func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + let location = convert(event.locationInWindow, from: nil) + updateHoverState(location: location) + } + + override public func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + scheduleHoverDismiss() + } + + override public func mouseDown(with event: NSEvent) { + let location = convert(event.locationInWindow, from: nil) + if handleIconClick(at: location) { + return + } + super.mouseDown(with: event) } /// Atomically updates view inputs and recomputes diff segments at most once. @@ -186,6 +261,7 @@ public final class NSTextDiffView: NSView { lastUpdated = updated lastModeKey = newModeKey segments = diffProvider(original, updated, mode) + segmentGeneration += 1 invalidateCachedLayout() return true } @@ -208,16 +284,319 @@ public final class NSTextDiffView: NSView { cachedWidth = width cachedLayout = layout + invalidateInteractionCache() return layout } + private func interactionContext(for layout: DiffLayout) -> DiffRevertInteractionContext? { + guard isRevertActionsEnabled, mode == .token else { + return nil + } + + let width = max(bounds.width, 1) + if let cachedInteractionContext, + abs(cachedInteractionWidth - width) <= 0.5, + cachedInteractionGeneration == segmentGeneration { + return cachedInteractionContext + } + + let context = DiffRevertActionResolver.interactionContext( + segments: segments, + runs: layout.runs, + mode: mode + ) + cachedInteractionContext = context + cachedInteractionWidth = width + cachedInteractionGeneration = segmentGeneration + return context + } + private func invalidateCachedLayout() { cachedLayout = nil cachedWidth = -1 + invalidateInteractionCache() + cancelPendingHoverDismiss() + clearHoverStateNow() needsDisplay = true invalidateIntrinsicContentSize() } + private func invalidateInteractionCache() { + cachedInteractionContext = nil + cachedInteractionWidth = -1 + cachedInteractionGeneration = -1 + } + + private func updateHoverState(location: CGPoint) { + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout) else { + cancelPendingHoverDismiss() + clearHoverStateNow() + return + } + + if let actionID = actionIDForHitTarget(at: location, layout: layout, context: context) { + let iconRect = iconRect(for: actionID, context: context) + if hoveredActionID == actionID { + cancelPendingHoverDismiss() + applyImmediateHover(actionID: actionID, iconRect: iconRect) + } else { + switchHoverImmediately(to: actionID, iconRect: iconRect) + } + setPointingHandCursorActive(iconRect?.contains(location) == true) + return + } + + setPointingHandCursorActive(false) + scheduleHoverDismiss() + } + + private func clearHoverState() { + cancelPendingHoverDismiss() + clearHoverStateNow() + } + + private func clearHoverStateNow() { + guard hoveredActionID != nil || hoveredIconRect != nil || isPointingHandCursorActive else { + return + } + hoveredActionID = nil + hoveredIconRect = nil + setPointingHandCursorActive(false) + needsDisplay = true + } + + private func applyImmediateHover(actionID: Int, iconRect: CGRect?) { + let didChangeHover = hoveredActionID != actionID || hoveredIconRect != iconRect + hoveredActionID = actionID + hoveredIconRect = iconRect + if didChangeHover { + needsDisplay = true + } + } + + private func switchHoverImmediately(to actionID: Int, iconRect: CGRect?) { + cancelPendingHoverDismiss() + applyImmediateHover(actionID: actionID, iconRect: iconRect) + } + + private func cancelPendingHoverDismiss() { + pendingHoverDismissWorkItem?.cancel() + pendingHoverDismissWorkItem = nil + hoverDismissGeneration += 1 + } + + private func scheduleHoverDismiss() { + guard pendingHoverDismissWorkItem == nil else { + return + } + + hoverDismissGeneration += 1 + let generation = hoverDismissGeneration + let workItem = DispatchWorkItem { [weak self] in + guard let self else { + return + } + guard self.hoverDismissGeneration == generation else { + return + } + self.pendingHoverDismissWorkItem = nil + self.clearHoverStateNow() + } + pendingHoverDismissWorkItem = workItem + + #if TESTING + if let testingHoverDismissScheduler { + testingHoverDismissScheduler(hoverDismissDelay) { + workItem.perform() + } + return + } + #endif + + DispatchQueue.main.asyncAfter(deadline: .now() + hoverDismissDelay) { + workItem.perform() + } + } + + private func setPointingHandCursorActive(_ isActive: Bool) { + guard isPointingHandCursorActive != isActive else { + return + } + isPointingHandCursorActive = isActive + if isActive { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + @discardableResult + private func handleIconClick(at location: CGPoint) -> Bool { + guard isRevertActionsEnabled, mode == .token else { + return false + } + + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout), + let actionID = actionIDForHitTarget(at: location, layout: layout, context: context) else { + return false + } + + guard let candidate = context.candidatesByID[actionID] else { + return false + } + + if let action = DiffRevertActionResolver.action(from: candidate, updated: updated) { + onRevertAction?(action) + } + return true + } + + private func actionIDForHitTarget( + at point: CGPoint, + layout: DiffLayout, + context: DiffRevertInteractionContext + ) -> Int? { + for actionID in context.candidatesByID.keys.sorted() { + let includeIcon = hoveredActionID == actionID + if isPointWithinActionHitTarget( + point, + actionID: actionID, + layout: layout, + context: context, + includeIcon: includeIcon + ) { + return actionID + } + } + return nil + } + + private func isPointWithinActionHitTarget( + _ point: CGPoint, + actionID: Int, + layout: DiffLayout, + context: DiffRevertInteractionContext, + includeIcon: Bool + ) -> Bool { + if let runIndices = context.runIndicesByActionID[actionID] { + for runIndex in runIndices { + guard layout.runs.indices.contains(runIndex), + let chipRect = layout.runs[runIndex].chipRect else { + continue + } + if chipRect.contains(point) { + return true + } + } + } + + if includeIcon, let iconRect = iconRect(for: actionID, context: context), iconRect.contains(point) { + return true + } + + return false + } + + private func actionID(at point: CGPoint, layout: DiffLayout, context: DiffRevertInteractionContext) -> Int? { + for actionID in context.runIndicesByActionID.keys.sorted() { + guard let runIndices = context.runIndicesByActionID[actionID] else { + continue + } + for runIndex in runIndices { + guard layout.runs.indices.contains(runIndex), + let chipRect = layout.runs[runIndex].chipRect else { + continue + } + if chipRect.contains(point) { + return actionID + } + } + } + return nil + } + + private func iconRect(for actionID: Int, context: DiffRevertInteractionContext) -> CGRect? { + guard let unionRect = context.unionChipRectByActionID[actionID] else { + return nil + } + + let maxX = bounds.maxX - hoverButtonSize.width - 2 + var originX = unionRect.maxX + hoverButtonGap + if originX > maxX { + originX = max(bounds.minX + 2, unionRect.maxX - hoverButtonSize.width) + } + + var originY = unionRect.midY - (hoverButtonSize.height / 2) + originY = max(bounds.minY + 2, min(originY, bounds.maxY - hoverButtonSize.height - 2)) + + return CGRect(origin: CGPoint(x: originX, y: originY), size: hoverButtonSize) + } + + private func drawHoveredRevertAffordance(layout: DiffLayout) { + guard let hoveredActionID else { + return + } + guard let context = interactionContext(for: layout), + let chipRects = context.chipRectsByActionID[hoveredActionID], + !chipRects.isEmpty else { + return + } + + hoverOutlineColor.setStroke() + if chipRects.count > 1, let unionRect = context.unionChipRectByActionID[hoveredActionID] { + let groupRect = unionRect.insetBy(dx: -1.5, dy: -1.5) + let groupPath = NSBezierPath( + roundedRect: groupRect, + xRadius: style.chipCornerRadius + 2, + yRadius: style.chipCornerRadius + 2 + ) + groupPath.lineWidth = 1.5 + groupPath.stroke() + } else { + for chipRect in chipRects { + let outlineRect = chipRect.insetBy(dx: -1.5, dy: -1.5) + let outlinePath = NSBezierPath( + roundedRect: outlineRect, + xRadius: style.chipCornerRadius + 1, + yRadius: style.chipCornerRadius + 1 + ) + outlinePath.lineWidth = 1.5 + outlinePath.stroke() + } + } + + let iconRect = hoveredIconRect ?? iconRect(for: hoveredActionID, context: context) + guard let iconRect else { + return + } + drawIconButton(in: iconRect) + } + + private func drawIconButton(in rect: CGRect) { + let buttonPath = NSBezierPath(ovalIn: rect) + hoverButtonFillColor.setFill() + buttonPath.fill() + hoverButtonStrokeColor.setStroke() + buttonPath.lineWidth = 1 + buttonPath.stroke() + + let symbolRect = rect.insetBy(dx: 4, dy: 4) + + let base = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + let white = NSImage.SymbolConfiguration(hierarchicalColor: .white) + let config = base + .applying(.preferringMonochrome()) + .applying(white) + + guard let icon = NSImage(systemSymbolName: hoverIconName, accessibilityDescription: "Revert"), + let configured = icon.withSymbolConfiguration(config) else { + return + } + configured.draw(in: symbolRect) + } + private func drawChip( chipRect: CGRect, fillColor: NSColor?, @@ -251,4 +630,88 @@ public final class NSTextDiffView: NSView { return 1 } } + + #if TESTING + @discardableResult + func _testingSetHoveredFirstRevertAction() -> Bool { + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout), + let firstActionID = context.candidatesByID.keys.sorted().first else { + return false + } + cancelPendingHoverDismiss() + hoveredActionID = firstActionID + hoveredIconRect = iconRect(for: firstActionID, context: context) + needsDisplay = true + return true + } + + @discardableResult + func _testingTriggerHoveredRevertAction() -> Bool { + guard let hoveredActionID else { + return false + } + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout), + let candidate = context.candidatesByID[hoveredActionID] else { + return false + } + if let action = DiffRevertActionResolver.action(from: candidate, updated: updated) { + onRevertAction?(action) + } + return true + } + + func _testingHasInteractionContext() -> Bool { + let layout = layoutForCurrentWidth() + return interactionContext(for: layout) != nil + } + + func _testingHoveredActionID() -> Int? { + hoveredActionID + } + + func _testingHasPendingHoverDismiss() -> Bool { + pendingHoverDismissWorkItem != nil + } + + func _testingActionCenters() -> [CGPoint] { + let layout = layoutForCurrentWidth() + guard let context = interactionContext(for: layout) else { + return [] + } + return context.candidatesByID.keys.sorted().compactMap { actionID in + guard let rect = context.unionChipRectByActionID[actionID] else { + return nil + } + return CGPoint(x: rect.midX, y: rect.midY) + } + } + + func _testingUpdateHover(location: CGPoint) { + updateHoverState(location: location) + } + + func _testingEnableManualHoverDismissScheduler() { + testingScheduledHoverDismissBlocks.removeAll() + testingHoverDismissScheduler = { [weak self] _, block in + self?.testingScheduledHoverDismissBlocks.append(block) + } + } + + @discardableResult + func _testingRunNextScheduledHoverDismiss() -> Bool { + guard !testingScheduledHoverDismissBlocks.isEmpty else { + return false + } + let block = testingScheduledHoverDismissBlocks.removeFirst() + block() + return true + } + + func _testingClearManualHoverDismissScheduler() { + testingHoverDismissScheduler = nil + testingScheduledHoverDismissBlocks.removeAll() + } + #endif } diff --git a/Sources/TextDiff/DiffTypes.swift b/Sources/TextDiff/DiffTypes.swift index f5bdf7d..4bc24d0 100644 --- a/Sources/TextDiff/DiffTypes.swift +++ b/Sources/TextDiff/DiffTypes.swift @@ -49,3 +49,45 @@ public struct DiffSegment: Sendable, Equatable { self.text = text } } + +/// The change variant represented by a user-initiated revert action. +public enum TextDiffRevertActionKind: Sendable, Equatable { + /// Revert a standalone inserted segment by removing it from updated text. + case singleInsertion + /// Revert a standalone deleted segment by inserting it into updated text. + case singleDeletion + /// Revert an adjacent delete+insert replacement pair. + case pairedReplacement +} + +/// A revert intent payload describing how to edit updated text toward original text. +public struct TextDiffRevertAction: Sendable, Equatable { + /// The semantic action kind that triggered this payload. + public let kind: TextDiffRevertActionKind + /// The UTF-16 range in pre-click updated text to replace. + public let updatedRange: NSRange + /// The text used to replace `updatedRange`. + public let replacementText: String + /// Optional source-side text fragment associated with this action. + public let originalTextFragment: String? + /// Optional updated-side text fragment associated with this action. + public let updatedTextFragment: String? + /// The resulting updated text after applying the replacement. + public let resultingUpdated: String + + public init( + kind: TextDiffRevertActionKind, + updatedRange: NSRange, + replacementText: String, + originalTextFragment: String?, + updatedTextFragment: String?, + resultingUpdated: String + ) { + self.kind = kind + self.updatedRange = updatedRange + self.replacementText = replacementText + self.originalTextFragment = originalTextFragment + self.updatedTextFragment = updatedTextFragment + self.resultingUpdated = resultingUpdated + } +} diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index c1cabb7..08229b3 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -4,9 +4,12 @@ import SwiftUI /// A SwiftUI view that renders a merged visual diff between two strings. public struct TextDiffView: View { private let original: String - private let updated: String + private let updatedValue: String + private let updatedBinding: Binding? private let mode: TextDiffComparisonMode private let style: TextDiffStyle + private let isRevertActionsEnabled: Bool + private let onRevertAction: ((TextDiffRevertAction) -> Void)? /// Creates a text diff view for two versions of content. /// @@ -22,18 +25,51 @@ public struct TextDiffView: View { mode: TextDiffComparisonMode = .token ) { self.original = original - self.updated = updated + self.updatedValue = updated + self.updatedBinding = nil self.mode = mode self.style = style + self.isRevertActionsEnabled = false + self.onRevertAction = nil + } + + /// 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. + /// - isRevertActionsEnabled: Enables hover affordance and revert actions. + /// - onRevertAction: Optional callback invoked on revert clicks. + public init( + original: String, + updated: Binding, + style: TextDiffStyle = .default, + mode: TextDiffComparisonMode = .token, + isRevertActionsEnabled: Bool = true, + onRevertAction: ((TextDiffRevertAction) -> Void)? = nil + ) { + self.original = original + self.updatedValue = updated.wrappedValue + self.updatedBinding = updated + self.mode = mode + self.style = style + self.isRevertActionsEnabled = isRevertActionsEnabled + self.onRevertAction = onRevertAction } /// The view body that renders the current diff content. public var body: some View { + let updated = updatedBinding?.wrappedValue ?? updatedValue DiffTextViewRepresentable( original: original, updated: updated, + updatedBinding: updatedBinding, style: style, - mode: mode + mode: mode, + isRevertActionsEnabled: isRevertActionsEnabled, + onRevertAction: onRevertAction ) .accessibilityLabel("Text diff") } @@ -49,6 +85,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 style = TextDiffStyle( additionsStyle: TextDiffChangeStyle( @@ -67,7 +104,7 @@ public struct TextDiffView: View { chipCornerRadius: 3, chipInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), interChipSpacing: 1, - lineSpacing: 2 + lineSpacing: 0 ) VStack(alignment: .leading, spacing: 4) { Text("Diff by characters") @@ -88,13 +125,14 @@ public struct TextDiffView: View { ) } Divider() - Text("Diff by words") + Text("Diff by words and revertable") .bold() TextDiffView( original: "Add a diff view! Looks good!", - updated: "Added a diff view. It looks good!", + updated: $updatedText, style: style, - mode: .token + mode: .token, + isRevertActionsEnabled: true ) HStack { Text("dog → fog:") @@ -129,6 +167,10 @@ public struct TextDiffView: View { .frame(width: 320) } +#Preview("Revert Binding") { + RevertBindingPreview() +} + #Preview("Height diff") { let font: NSFont = .systemFont(ofSize: 32, weight: .regular) let style = TextDiffStyle( @@ -164,3 +206,18 @@ public struct TextDiffView: View { } .padding() } + +private struct RevertBindingPreview: View { + @State private var updated = "Apply new value in this sentence." + + var body: some View { + TextDiffView( + original: "Apply old value on The sentence!", + updated: $updated, + mode: .token, + isRevertActionsEnabled: true + ) + .padding() + .frame(width: 500) + } +} diff --git a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift b/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift index a8598de..38ac3ca 100644 --- a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift +++ b/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift @@ -17,6 +17,10 @@ final class DiffLayouterPerformanceTests: XCTestCase { runLayoutPerformanceTest(wordCount: 1000) } + func testLayoutPerformance500WordsWithRevertInteractions() { + runLayoutWithRevertInteractionsPerformanceTest(wordCount: 500) + } + private func runLayoutPerformanceTest(wordCount: Int) { let style = TextDiffStyle.default let verticalInset = DiffTextLayoutMetrics.verticalTextInset(for: style) @@ -38,6 +42,33 @@ 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 availableWidth: CGFloat = 520 + + let original = Self.largeText(wordCount: wordCount) + let updated = Self.replacingLastWord(in: original) + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + measure(metrics: [XCTClockMetric()]) { + let layout = DiffTokenLayouter.layout( + segments: segments, + style: style, + availableWidth: availableWidth, + contentInsets: contentInsets + ) + let context = DiffRevertActionResolver.interactionContext( + segments: segments, + runs: layout.runs, + mode: .token + ) + XCTAssertFalse(layout.runs.isEmpty) + XCTAssertNotNil(context) + } + } + private static func largeText(wordCount: Int) -> String { let vocabulary = [ "alpha", "beta", "gamma", "delta", "epsilon", "theta", "lambda", "sigma", diff --git a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift new file mode 100644 index 0000000..566d560 --- /dev/null +++ b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import TextDiff + +@Test +func candidatesBuildPairedReplacementForAdjacentDeleteInsert() throws { + let segments = [ + DiffSegment(kind: .delete, tokenKind: .word, text: "old"), + DiffSegment(kind: .insert, tokenKind: .word, text: "new") + ] + + let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + #expect(candidates.count == 1) + #expect(candidates[0].kind == .pairedReplacement) + #expect(candidates[0].updatedRange == NSRange(location: 0, length: 3)) + #expect(candidates[0].replacementText == "old") + + let action = try #require(DiffRevertActionResolver.action(from: candidates[0], updated: "new")) + #expect(action.kind == .pairedReplacement) + #expect(action.resultingUpdated == "old") +} + +@Test +func candidatesDoNotPairWhenAnySegmentExistsBetweenDeleteAndInsert() { + let segments = [ + DiffSegment(kind: .delete, tokenKind: .word, text: "old"), + DiffSegment(kind: .equal, tokenKind: .whitespace, text: " "), + DiffSegment(kind: .insert, tokenKind: .word, text: "new") + ] + + let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + #expect(candidates.count == 2) + #expect(candidates[0].kind == .singleDeletion) + #expect(candidates[1].kind == .singleInsertion) +} + +@Test +func singleInsertionActionRemovesInsertedFragment() throws { + let segments = [ + DiffSegment(kind: .equal, tokenKind: .word, text: "a"), + DiffSegment(kind: .insert, tokenKind: .word, text: "ß"), + DiffSegment(kind: .equal, tokenKind: .word, text: "c") + ] + let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + let insertion = try #require(candidates.first(where: { $0.kind == .singleInsertion })) + + let action = try #require(DiffRevertActionResolver.action(from: insertion, updated: "aßc")) + #expect(action.kind == .singleInsertion) + #expect(action.updatedRange == NSRange(location: 1, length: 1)) + #expect(action.replacementText.isEmpty) + #expect(action.resultingUpdated == "ac") +} + +@Test +func singleDeletionActionReinsertsDeletedFragment() throws { + let segments = [ + DiffSegment(kind: .equal, tokenKind: .word, text: "a"), + DiffSegment(kind: .delete, tokenKind: .word, text: "🌍"), + DiffSegment(kind: .equal, tokenKind: .word, text: "b") + ] + let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) + + let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: "ab")) + #expect(action.kind == .singleDeletion) + #expect(action.updatedRange == NSRange(location: 1, length: 0)) + #expect(action.replacementText == "🌍") + #expect(action.resultingUpdated == "a🌍b") +} + +@Test +func candidatesAreEmptyInCharacterMode() { + let segments = TextDiffEngine.diff(original: "old value", updated: "new value", mode: .character) + let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .character) + #expect(candidates.isEmpty) +} diff --git a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift index e98ed74..e8d4f34 100644 --- a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift +++ b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift @@ -1,7 +1,7 @@ import AppKit import SnapshotTesting -import TextDiff import XCTest +@testable import TextDiff final class NSTextDiffSnapshotTests: XCTestCase { override func invokeTest() { @@ -70,6 +70,66 @@ final class NSTextDiffSnapshotTests: XCTestCase { ) } + @MainActor + func testHoverSingleAdditionShowsAffordance() { + assertNSTextDiffSnapshot( + original: "cat", + updated: "cat!", + mode: .token, + size: CGSize(width: 260, height: 90), + configureView: { view in + view.isRevertActionsEnabled = true + _ = view._testingSetHoveredFirstRevertAction() + }, + testName: "hover_single_addition_affordance()" + ) + } + + @MainActor + func testHoverSingleDeletionShowsAffordance() { + assertNSTextDiffSnapshot( + original: "cat!", + updated: "cat", + mode: .token, + size: CGSize(width: 260, height: 90), + configureView: { view in + view.isRevertActionsEnabled = true + _ = view._testingSetHoveredFirstRevertAction() + }, + testName: "hover_single_deletion_affordance()" + ) + } + + @MainActor + func testHoverPairShowsAffordance() { + assertNSTextDiffSnapshot( + original: "old value", + updated: "new value", + mode: .token, + size: CGSize(width: 280, height: 90), + configureView: { view in + view.isRevertActionsEnabled = true + _ = view._testingSetHoveredFirstRevertAction() + }, + testName: "hover_pair_affordance()" + ) + } + + @MainActor + func testCharacterModeDoesNotShowAffordance() { + assertNSTextDiffSnapshot( + original: "Add a diff", + updated: "Added a diff", + mode: .character, + size: CGSize(width: 320, height: 110), + configureView: { view in + view.isRevertActionsEnabled = true + _ = view._testingSetHoveredFirstRevertAction() + }, + testName: "character_mode_no_affordance()" + ) + } + private let sampleOriginalSentence = "A quick brown fox jumps over a lazy dog." private let sampleUpdatedSentence = "A quick fox hops over the lazy dog!" } diff --git a/Tests/TextDiffTests/NSTextDiffViewTests.swift b/Tests/TextDiffTests/NSTextDiffViewTests.swift index 014daee..9c309b5 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Testing @testable import TextDiff @@ -141,3 +142,212 @@ func nsTextDiffViewSetContentStyleOnlyDoesNotRecomputeDiff() { #expect(callCount == 1) } + +@Test +@MainActor +func nsTextDiffViewRevertDisabledDoesNotEmitAction() { + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == false) + #expect(view._testingTriggerHoveredRevertAction() == false) + #expect(captured == nil) +} + +@Test +@MainActor +func nsTextDiffViewRevertSingleInsertionEmitsExpectedAction() throws { + let view = NSTextDiffView( + original: "cat", + updated: "cat!", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.isRevertActionsEnabled = true + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == true) + #expect(view._testingTriggerHoveredRevertAction() == true) + + let action = try #require(captured) + #expect(action.kind == .singleInsertion) + #expect(action.replacementText == "") + #expect(action.resultingUpdated == "cat") +} + +@Test +@MainActor +func nsTextDiffViewRevertSingleDeletionEmitsExpectedAction() throws { + let view = NSTextDiffView( + original: "cat!", + updated: "cat", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.isRevertActionsEnabled = true + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == true) + #expect(view._testingTriggerHoveredRevertAction() == true) + + let action = try #require(captured) + #expect(action.kind == .singleDeletion) + #expect(action.replacementText == "!") + #expect(action.resultingUpdated == "cat!") +} + +@Test +@MainActor +func nsTextDiffViewRevertPairEmitsExpectedAction() throws { + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.isRevertActionsEnabled = true + + var captured: TextDiffRevertAction? + view.onRevertAction = { action in + captured = action + } + + #expect(view._testingSetHoveredFirstRevertAction() == true) + #expect(view._testingTriggerHoveredRevertAction() == true) + + let action = try #require(captured) + #expect(action.kind == .pairedReplacement) + #expect(action.replacementText == "old") + #expect(action.resultingUpdated == "old") +} + +@Test +@MainActor +func hoverLeaveSchedulesDismissNotImmediate() { + let view = NSTextDiffView( + original: "old value", + updated: "new value", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 300, height: 100) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + let centers = view._testingActionCenters() + #expect(centers.count == 1) + guard let center = centers.first else { + return + } + + view._testingUpdateHover(location: center) + #expect(view._testingHoveredActionID() != nil) + + view._testingUpdateHover(location: CGPoint(x: -10, y: -10)) + + #expect(view._testingHasPendingHoverDismiss() == true) + #expect(view._testingHoveredActionID() != nil) +} + +@Test +@MainActor +func hoverDismissesAfterDelay() { + let view = NSTextDiffView( + original: "old value", + updated: "new value", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 300, height: 100) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + guard let center = view._testingActionCenters().first else { + Issue.record("Expected at least one action center") + return + } + view._testingUpdateHover(location: center) + view._testingUpdateHover(location: CGPoint(x: -10, y: -10)) + #expect(view._testingHasPendingHoverDismiss() == true) + + #expect(view._testingRunNextScheduledHoverDismiss() == true) + #expect(view._testingHoveredActionID() == nil) + #expect(view._testingHasPendingHoverDismiss() == false) +} + +@Test +@MainActor +func hoverSwitchesImmediatelyBetweenGroups() { + let view = NSTextDiffView( + original: "old A old B", + updated: "new A new B", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 340, height: 110) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + let centers = view._testingActionCenters() + #expect(centers.count >= 2) + guard centers.count >= 2 else { + return + } + + view._testingUpdateHover(location: centers[0]) + let firstAction = view._testingHoveredActionID() + #expect(firstAction != nil) + + view._testingUpdateHover(location: centers[1]) + let secondAction = view._testingHoveredActionID() + + #expect(secondAction != nil) + #expect(secondAction != firstAction) + #expect(view._testingHasPendingHoverDismiss() == false) +} + +@Test +@MainActor +func hoverReentryCancelsPendingDismiss() { + let view = NSTextDiffView( + original: "old value", + updated: "new value", + mode: .token + ) + view.frame = CGRect(x: 0, y: 0, width: 300, height: 100) + view.isRevertActionsEnabled = true + view._testingEnableManualHoverDismissScheduler() + + guard let center = view._testingActionCenters().first else { + Issue.record("Expected at least one action center") + return + } + + view._testingUpdateHover(location: center) + let hovered = view._testingHoveredActionID() + #expect(hovered != nil) + + view._testingUpdateHover(location: CGPoint(x: -10, y: -10)) + #expect(view._testingHasPendingHoverDismiss() == true) + + view._testingUpdateHover(location: center) + #expect(view._testingHasPendingHoverDismiss() == false) + #expect(view._testingHoveredActionID() == hovered) + + #expect(view._testingRunNextScheduledHoverDismiss() == true) + #expect(view._testingHoveredActionID() == hovered) +} diff --git a/Tests/TextDiffTests/SnapshotTestSupport.swift b/Tests/TextDiffTests/SnapshotTestSupport.swift index fdf3be2..68d4e9e 100644 --- a/Tests/TextDiffTests/SnapshotTestSupport.swift +++ b/Tests/TextDiffTests/SnapshotTestSupport.swift @@ -62,6 +62,7 @@ func assertNSTextDiffSnapshot( mode: TextDiffComparisonMode = .token, style: TextDiffStyle = .default, size: CGSize, + configureView: ((NSTextDiffView) -> Void)? = nil, named name: String? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, @@ -87,6 +88,8 @@ func assertNSTextDiffSnapshot( diffView.autoresizingMask = [.width, .height] container.addSubview(diffView) container.layoutSubtreeIfNeeded() + configureView?(diffView) + container.layoutSubtreeIfNeeded() let snapshotImage = renderSnapshotImage1x(view: container, size: size) diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/character_mode_no_affordance.1.png new file mode 100644 index 0000000000000000000000000000000000000000..403187648a2e2732664c1e8a0b837129e183605a GIT binary patch literal 3218 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 literal 0 HcmV?d00001 diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/hover_single_addition_affordance.1.png new file mode 100644 index 0000000000000000000000000000000000000000..7af2dfe3cb470b7dc954de96139e9e6d8eab8a85 GIT binary patch literal 2748 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 CIGGp# literal 0 HcmV?d00001 From c8d3439e1c6b51e4f22ae7e0344d3ed2b72daba9 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 24 Feb 2026 00:37:55 +0100 Subject: [PATCH 2/8] revert changes --- Sources/TextDiff/AppKit/NSTextDiffView.swift | 15 +++++++++++++-- Sources/TextDiff/TextDiffGroupStrokeStyle.swift | 9 +++++++++ Sources/TextDiff/TextDiffStyle.swift | 14 +++++++++++--- Sources/TextDiff/TextDiffView.swift | 5 +++-- Tests/TextDiffTests/TextDiffEngineTests.swift | 9 ++++++++- 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 Sources/TextDiff/TextDiffGroupStrokeStyle.swift diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index cdfebf7..10611d5 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -552,7 +552,7 @@ public final class NSTextDiffView: NSView { xRadius: style.chipCornerRadius + 2, yRadius: style.chipCornerRadius + 2 ) - groupPath.lineWidth = 1.5 + applyGroupStrokeStyle(to: groupPath) groupPath.stroke() } else { for chipRect in chipRects { @@ -562,7 +562,7 @@ public final class NSTextDiffView: NSView { xRadius: style.chipCornerRadius + 1, yRadius: style.chipCornerRadius + 1 ) - outlinePath.lineWidth = 1.5 + applyGroupStrokeStyle(to: outlinePath) outlinePath.stroke() } } @@ -597,6 +597,17 @@ public final class NSTextDiffView: NSView { configured.draw(in: symbolRect) } + private func applyGroupStrokeStyle(to path: NSBezierPath) { + path.lineWidth = 1.5 + switch style.groupStrokeStyle { + case .solid: + path.setLineDash([], count: 0, phase: 0) + case .dashed: + var pattern: [CGFloat] = [4, 2] + path.setLineDash(&pattern, count: pattern.count, phase: 0) + } + } + private func drawChip( chipRect: CGRect, fillColor: NSColor?, diff --git a/Sources/TextDiff/TextDiffGroupStrokeStyle.swift b/Sources/TextDiff/TextDiffGroupStrokeStyle.swift new file mode 100644 index 0000000..c676a71 --- /dev/null +++ b/Sources/TextDiff/TextDiffGroupStrokeStyle.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Stroke style used for the interactive revert-group outline. +public enum TextDiffGroupStrokeStyle: Sendable { + /// Draws a continuous stroke. + case solid + /// Draws a dashed stroke. + case dashed +} diff --git a/Sources/TextDiff/TextDiffStyle.swift b/Sources/TextDiff/TextDiffStyle.swift index ada66cb..5653acf 100644 --- a/Sources/TextDiff/TextDiffStyle.swift +++ b/Sources/TextDiff/TextDiffStyle.swift @@ -20,6 +20,8 @@ public struct TextDiffStyle: @unchecked Sendable { public var interChipSpacing: CGFloat /// Additional vertical spacing between wrapped lines. public var lineSpacing: CGFloat + /// Stroke style used for interactive revert-group outlines. + public var groupStrokeStyle: TextDiffGroupStrokeStyle /// Creates a style for rendering text diffs. /// @@ -32,6 +34,7 @@ public struct TextDiffStyle: @unchecked Sendable { /// - chipInsets: Insets applied around changed-token text when drawing chips. /// - interChipSpacing: Gap between adjacent changed lexical chips. /// - lineSpacing: Additional vertical spacing between wrapped lines. + /// - groupStrokeStyle: Stroke style for revert-group hover outlines. public init( additionsStyle: TextDiffChangeStyle = .defaultAddition, removalsStyle: TextDiffChangeStyle = .defaultRemoval, @@ -40,7 +43,8 @@ public struct TextDiffStyle: @unchecked Sendable { chipCornerRadius: CGFloat = 4, chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), interChipSpacing: CGFloat = 0, - lineSpacing: CGFloat = 2 + lineSpacing: CGFloat = 2, + groupStrokeStyle: TextDiffGroupStrokeStyle = .solid ) { self.additionsStyle = additionsStyle self.removalsStyle = removalsStyle @@ -50,6 +54,7 @@ public struct TextDiffStyle: @unchecked Sendable { self.chipInsets = chipInsets self.interChipSpacing = interChipSpacing self.lineSpacing = lineSpacing + self.groupStrokeStyle = groupStrokeStyle } /// Creates a style by converting protocol-based operation styles to concrete change styles. @@ -63,6 +68,7 @@ public struct TextDiffStyle: @unchecked Sendable { /// - chipInsets: Insets applied around changed-token text when drawing chips. /// - interChipSpacing: Gap between adjacent changed lexical chips. /// - lineSpacing: Additional vertical spacing between wrapped lines. + /// - groupStrokeStyle: Stroke style for revert-group hover outlines. public init( additionsStyle: some TextDiffStyling, removalsStyle: some TextDiffStyling, @@ -71,7 +77,8 @@ public struct TextDiffStyle: @unchecked Sendable { chipCornerRadius: CGFloat = 4, chipInsets: NSEdgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3), interChipSpacing: CGFloat = 0, - lineSpacing: CGFloat = 2 + lineSpacing: CGFloat = 2, + groupStrokeStyle: TextDiffGroupStrokeStyle = .solid ) { self.init( additionsStyle: TextDiffChangeStyle(additionsStyle), @@ -81,7 +88,8 @@ public struct TextDiffStyle: @unchecked Sendable { chipCornerRadius: chipCornerRadius, chipInsets: chipInsets, interChipSpacing: interChipSpacing, - lineSpacing: lineSpacing + lineSpacing: lineSpacing, + groupStrokeStyle: groupStrokeStyle ) } diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index 08229b3..6150fc5 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -102,9 +102,10 @@ public struct TextDiffView: View { textColor: .labelColor, font: font, chipCornerRadius: 3, - chipInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + chipInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), interChipSpacing: 1, - lineSpacing: 0 + lineSpacing: 2, + groupStrokeStyle: .dashed ) VStack(alignment: .leading, spacing: 4) { Text("Diff by characters") diff --git a/Tests/TextDiffTests/TextDiffEngineTests.swift b/Tests/TextDiffTests/TextDiffEngineTests.swift index 07784f8..ec83fef 100644 --- a/Tests/TextDiffTests/TextDiffEngineTests.swift +++ b/Tests/TextDiffTests/TextDiffEngineTests.swift @@ -168,6 +168,11 @@ func defaultStyleInterChipSpacingMatchesCurrentDefault() { #expect(TextDiffStyle.default.interChipSpacing == 0) } +@Test +func defaultGroupStrokeStyleIsSolid() { + #expect(TextDiffStyle.default.groupStrokeStyle == .solid) +} + @Test func textDiffStyleDefaultUsesDefaultAdditionAndRemovalStyles() { let style = TextDiffStyle.default @@ -194,7 +199,8 @@ func textDiffStyleProtocolInitConvertsCustomConformers() { let style = TextDiffStyle( additionsStyle: additions, - removalsStyle: removals + removalsStyle: removals, + groupStrokeStyle: .dashed ) expectColorEqual(style.additionsStyle.fillColor, additions.fillColor) @@ -206,6 +212,7 @@ func textDiffStyleProtocolInitConvertsCustomConformers() { 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 From 37e42e205100504d2ac710961d6dcb1e705e641d Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 24 Feb 2026 01:50:50 +0100 Subject: [PATCH 3/8] feat: enhance diff revert by handling word boundary spacing Introduce handling of word boundary spacing for standalone word deletions when generating a diff revert action. Add the capability for adjusting replacement text in the DiffRevertActionResolver to ensure proper spacing is maintained when standalone words are deleted or reinserted. This prevents incorrect merging of words when reverting changes. This change improves the accuracy of diff revert actions, especially for deletions of individual words that are adjacent to other word segments in text comparisons. Additionally, modify test cases to cover scenarios where standalone word deletions should revert to include proper spacing and validate the overall improvement in handling word boundary situations. --- .../AppKit/DiffRevertActionResolver.swift | 127 ++++++++++++++++-- Sources/TextDiff/AppKit/NSTextDiffView.swift | 4 +- .../DiffLayouterPerformanceTests.swift | 4 +- .../DiffRevertActionResolverTests.swift | 75 ++++++++++- 4 files changed, 190 insertions(+), 20 deletions(-) diff --git a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift index 093974e..41f9cab 100644 --- a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift +++ b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift @@ -19,6 +19,7 @@ enum DiffRevertCandidateKind: Equatable { struct DiffRevertCandidate: Equatable { let id: Int let kind: DiffRevertCandidateKind + let tokenKind: DiffTokenKind let segmentIndices: [Int] let updatedRange: NSRange let replacementText: String @@ -34,10 +35,16 @@ struct DiffRevertInteractionContext { } enum DiffRevertActionResolver { - static func indexedSegments(from segments: [DiffSegment]) -> [IndexedSegment] { + static func indexedSegments( + from segments: [DiffSegment], + original: String, + updated: String + ) -> [IndexedSegment] { var output: [IndexedSegment] = [] output.reserveCapacity(segments.count) + let originalNSString = original as NSString + let updatedNSString = updated as NSString var originalCursor = 0 var updatedCursor = 0 @@ -50,8 +57,12 @@ enum DiffRevertActionResolver { case .equal: originalRange = NSRange(location: originalCursor, length: textLength) updatedRange = NSRange(location: updatedCursor, length: textLength) - originalCursor += textLength - updatedCursor += textLength + if textMatches(segment.text, source: originalNSString, at: originalCursor) { + originalCursor += textLength + } + if textMatches(segment.text, source: updatedNSString, at: updatedCursor) { + updatedCursor += textLength + } case .delete: originalRange = NSRange(location: originalCursor, length: textLength) updatedRange = NSRange(location: updatedCursor, length: 0) @@ -80,12 +91,29 @@ enum DiffRevertActionResolver { static func candidates( from segments: [DiffSegment], mode: TextDiffComparisonMode + ) -> [DiffRevertCandidate] { + let original = segments + .filter { $0.kind != .insert } + .map(\.text) + .joined() + let updated = segments + .filter { $0.kind != .delete } + .map(\.text) + .joined() + return candidates(from: segments, mode: mode, original: original, updated: updated) + } + + static func candidates( + from segments: [DiffSegment], + mode: TextDiffComparisonMode, + original: String, + updated: String ) -> [DiffRevertCandidate] { guard mode == .token else { return [] } - let indexed = indexedSegments(from: segments) + let indexed = indexedSegments(from: segments, original: original, updated: updated) guard !indexed.isEmpty else { return [] } @@ -109,6 +137,7 @@ enum DiffRevertActionResolver { DiffRevertCandidate( id: candidateID, kind: .pairedReplacement, + tokenKind: current.segment.tokenKind, segmentIndices: [current.segmentIndex, next.segmentIndex], updatedRange: next.updatedRange, replacementText: current.segment.text, @@ -129,6 +158,7 @@ enum DiffRevertActionResolver { DiffRevertCandidate( id: candidateID, kind: .singleInsertion, + tokenKind: current.segment.tokenKind, segmentIndices: [current.segmentIndex], updatedRange: current.updatedRange, replacementText: "", @@ -142,6 +172,7 @@ enum DiffRevertActionResolver { DiffRevertCandidate( id: candidateID, kind: .singleDeletion, + tokenKind: current.segment.tokenKind, segmentIndices: [current.segmentIndex], updatedRange: NSRange(location: current.updatedCursor, length: 0), replacementText: current.segment.text, @@ -164,9 +195,11 @@ enum DiffRevertActionResolver { static func interactionContext( segments: [DiffSegment], runs: [LaidOutRun], - mode: TextDiffComparisonMode + mode: TextDiffComparisonMode, + original: String, + updated: String ) -> DiffRevertInteractionContext? { - let candidates = candidates(from: segments, mode: mode) + let candidates = candidates(from: segments, mode: mode, original: original, updated: updated) guard !candidates.isEmpty else { return nil } @@ -222,16 +255,31 @@ enum DiffRevertActionResolver { updated: String ) -> TextDiffRevertAction? { let nsUpdated = updated as NSString - guard candidate.updatedRange.location >= 0 else { + var updatedRange = candidate.updatedRange + if candidate.kind == .singleDeletion, updatedRange.location > nsUpdated.length { + updatedRange.location = nsUpdated.length + } + guard updatedRange.location >= 0 else { return nil } - guard NSMaxRange(candidate.updatedRange) <= nsUpdated.length else { + guard NSMaxRange(updatedRange) <= nsUpdated.length else { return nil } + let replacementText: String + if candidate.kind == .singleDeletion, candidate.tokenKind == .word { + replacementText = adjustedStandaloneWordDeletionReplacement( + candidate.replacementText, + insertionLocation: updatedRange.location, + updated: nsUpdated + ) + } else { + replacementText = candidate.replacementText + } + let resultingUpdated = nsUpdated.replacingCharacters( - in: candidate.updatedRange, - with: candidate.replacementText + in: updatedRange, + with: replacementText ) let actionKind: TextDiffRevertActionKind switch candidate.kind { @@ -245,8 +293,8 @@ enum DiffRevertActionResolver { return TextDiffRevertAction( kind: actionKind, - updatedRange: candidate.updatedRange, - replacementText: candidate.replacementText, + updatedRange: updatedRange, + replacementText: replacementText, originalTextFragment: candidate.originalTextFragment, updatedTextFragment: candidate.updatedTextFragment, resultingUpdated: resultingUpdated @@ -256,4 +304,59 @@ enum DiffRevertActionResolver { private static func isLexicalChange(_ segment: DiffSegment) -> Bool { segment.tokenKind != .whitespace && segment.kind != .equal } + + private static func textMatches(_ text: String, source: NSString, at location: Int) -> Bool { + let length = text.utf16.count + guard location >= 0, location + length <= source.length else { + return false + } + return source.substring(with: NSRange(location: location, length: length)) == text + } + + private static func adjustedStandaloneWordDeletionReplacement( + _ replacement: String, + insertionLocation: Int, + updated: NSString + ) -> String { + guard !replacement.isEmpty else { + return replacement + } + guard replacement.rangeOfCharacter(from: .alphanumerics) != nil else { + return replacement + } + + let hasLeadingWhitespace = replacement.unicodeScalars.first + .map { CharacterSet.whitespacesAndNewlines.contains($0) } ?? false + let hasTrailingWhitespace = replacement.unicodeScalars.last + .map { CharacterSet.whitespacesAndNewlines.contains($0) } ?? false + + let beforeIsWordLike: Bool + if insertionLocation > 0 { + let previous = updated.substring(with: NSRange(location: insertionLocation - 1, length: 1)) + beforeIsWordLike = isWordLike(previous) + } else { + beforeIsWordLike = false + } + + let afterIsWordLike: Bool + if insertionLocation < updated.length { + let next = updated.substring(with: NSRange(location: insertionLocation, length: 1)) + afterIsWordLike = isWordLike(next) + } else { + afterIsWordLike = false + } + + var output = replacement + if beforeIsWordLike && !hasLeadingWhitespace { + output = " " + output + } + if afterIsWordLike && !hasTrailingWhitespace { + output += " " + } + return output + } + + private static func isWordLike(_ scalarString: String) -> Bool { + scalarString.rangeOfCharacter(from: .alphanumerics) != nil + } } diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index 10611d5..8cb3674 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -303,7 +303,9 @@ public final class NSTextDiffView: NSView { let context = DiffRevertActionResolver.interactionContext( segments: segments, runs: layout.runs, - mode: mode + mode: mode, + original: original, + updated: updated ) cachedInteractionContext = context cachedInteractionWidth = width diff --git a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift b/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift index 38ac3ca..0134700 100644 --- a/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift +++ b/Tests/TextDiffTests/DiffLayouterPerformanceTests.swift @@ -62,7 +62,9 @@ final class DiffLayouterPerformanceTests: XCTestCase { let context = DiffRevertActionResolver.interactionContext( segments: segments, runs: layout.runs, - mode: .token + mode: .token, + original: original, + updated: updated ) XCTAssertFalse(layout.runs.isEmpty) XCTAssertNotNil(context) diff --git a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift index 566d560..bd75bfe 100644 --- a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift +++ b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift @@ -9,7 +9,12 @@ func candidatesBuildPairedReplacementForAdjacentDeleteInsert() throws { DiffSegment(kind: .insert, tokenKind: .word, text: "new") ] - let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "old", + updated: "new" + ) #expect(candidates.count == 1) #expect(candidates[0].kind == .pairedReplacement) #expect(candidates[0].updatedRange == NSRange(location: 0, length: 3)) @@ -28,7 +33,12 @@ func candidatesDoNotPairWhenAnySegmentExistsBetweenDeleteAndInsert() { DiffSegment(kind: .insert, tokenKind: .word, text: "new") ] - let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "old ", + updated: " new" + ) #expect(candidates.count == 2) #expect(candidates[0].kind == .singleDeletion) #expect(candidates[1].kind == .singleInsertion) @@ -41,7 +51,12 @@ func singleInsertionActionRemovesInsertedFragment() throws { DiffSegment(kind: .insert, tokenKind: .word, text: "ß"), DiffSegment(kind: .equal, tokenKind: .word, text: "c") ] - let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "ac", + updated: "aßc" + ) let insertion = try #require(candidates.first(where: { $0.kind == .singleInsertion })) let action = try #require(DiffRevertActionResolver.action(from: insertion, updated: "aßc")) @@ -58,7 +73,12 @@ func singleDeletionActionReinsertsDeletedFragment() throws { DiffSegment(kind: .delete, tokenKind: .word, text: "🌍"), DiffSegment(kind: .equal, tokenKind: .word, text: "b") ] - let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .token) + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: "a🌍b", + updated: "ab" + ) let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: "ab")) @@ -70,7 +90,50 @@ func singleDeletionActionReinsertsDeletedFragment() throws { @Test func candidatesAreEmptyInCharacterMode() { - let segments = TextDiffEngine.diff(original: "old value", updated: "new value", mode: .character) - let candidates = DiffRevertActionResolver.candidates(from: segments, mode: .character) + let original = "old value" + let updated = "new value" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .character) + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .character, + original: original, + updated: updated + ) #expect(candidates.isEmpty) } + +@Test +func standaloneDeletionRevertRestoresWordBoundarySpacing() throws { + let original = "Hello brave world" + let updated = "Hello world" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) + + let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: updated)) + #expect(action.resultingUpdated == original) +} + +@Test +func standaloneDeletionAtEndRevertRestoresSpacing() throws { + let original = "Hello brave" + let updated = "Hello" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let deletion = try #require(candidates.first(where: { $0.kind == .singleDeletion })) + + let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: updated)) + #expect(action.resultingUpdated == original) +} From ab3c70568029335abb46d53bf28dc9c5499a6f1c Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 24 Feb 2026 02:13:38 +0100 Subject: [PATCH 4/8] refactor: enhance handling of punctuation replacing whitespace Introduce logic to treat deletion of whitespace followed by insertion of punctuation as a single reversible edit. Update `isReplacementPair` function in `DiffRevertActionResolver.swift` to support punctuation replacing whitespace. Handle cases where punctuation replaces whitespace in `tokenDiffSegments` of `TextDiffEngine.swift` ensuring that whitespace deletion is visible. Update corresponding unit tests to verify functionality: - Test that deleted whitespace followed by inserted punctuation is recognized as a single segment in `DiffRevertActionResolverTests.swift`. - Confirm `DiffTokenLayouter.swift` renders deleted whitespace as chip in such cases. This improves diff clarity for text changes involving punctuation. --- .../AppKit/DiffRevertActionResolver.swift | 17 ++++++- .../TextDiff/AppKit/DiffTokenLayouter.swift | 3 +- Sources/TextDiff/AppKit/NSTextDiffView.swift | 4 +- Sources/TextDiff/TextDiffEngine.swift | 40 +++++++++++++++- Sources/TextDiff/TextDiffView.swift | 4 +- .../DiffRevertActionResolverTests.swift | 22 +++++++++ Tests/TextDiffTests/TextDiffEngineTests.swift | 47 +++++++++++++++++++ 7 files changed, 128 insertions(+), 9 deletions(-) diff --git a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift index 41f9cab..bba4648 100644 --- a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift +++ b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift @@ -131,8 +131,7 @@ enum DiffRevertActionResolver { let next = indexed[index + 1] if current.segment.kind == .delete, next.segment.kind == .insert, - isCurrentLexical, - isLexicalChange(next.segment) { + isReplacementPair(delete: current.segment, insert: next.segment) { output.append( DiffRevertCandidate( id: candidateID, @@ -305,6 +304,20 @@ enum DiffRevertActionResolver { segment.tokenKind != .whitespace && segment.kind != .equal } + private static func isReplacementPair(delete: DiffSegment, insert: DiffSegment) -> Bool { + if isLexicalChange(delete), isLexicalChange(insert) { + return true + } + + // Treat deleted spacing replaced by punctuation as one reversible edit. + if delete.tokenKind == .whitespace, + insert.tokenKind == .punctuation { + return true + } + + return false + } + private static func textMatches(_ text: String, source: NSString, at location: Int) -> Bool { let length = text.utf16.count guard location >= 0, location + length <= source.length else { diff --git a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift index 191562c..9ced935 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift @@ -64,7 +64,8 @@ enum DiffTokenLayouter { } let segment = DiffSegment(kind: piece.kind, tokenKind: piece.tokenKind, text: piece.text) - let isChangedLexical = segment.kind != .equal && segment.tokenKind != .whitespace + let isChangedLexical = segment.kind != .equal + && (segment.tokenKind != .whitespace || segment.kind == .delete) var leadingGap: CGFloat = 0 if previousChangedLexical && isChangedLexical { leadingGap = max(0, style.interChipSpacing) diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index 8cb3674..6978062 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -94,8 +94,8 @@ public final class NSTextDiffView: NSView { #endif private let hoverOutlineColor = NSColor.controlAccentColor.withAlphaComponent(0.9) - private let hoverButtonFillColor = NSColor.black//NSColor.windowBackgroundColor.withAlphaComponent(0.95) - private let hoverButtonStrokeColor = NSColor.clear//NSColor.controlAccentColor.withAlphaComponent(0.8) + private let hoverButtonFillColor = NSColor.black + private let hoverButtonStrokeColor = NSColor.clear private let hoverIconName = "arrow.turn.down.left" private let hoverButtonSize = CGSize(width: 16, height: 16) private let hoverButtonGap: CGFloat = 4 diff --git a/Sources/TextDiff/TextDiffEngine.swift b/Sources/TextDiff/TextDiffEngine.swift index c7accb2..e25ee15 100644 --- a/Sources/TextDiff/TextDiffEngine.swift +++ b/Sources/TextDiff/TextDiffEngine.swift @@ -58,6 +58,15 @@ public enum TextDiffEngine { segments.append( DiffSegment(kind: .equal, tokenKind: .whitespace, text: deletedWhitespace) ) + } else if isAdjacentToInsertedLexicalToken( + operations: operations, + runStart: runStart, + runEnd: runEnd + ) { + let deletedWhitespace = whitespaceRun.map(\.token.text).joined() + segments.append( + DiffSegment(kind: .delete, tokenKind: .whitespace, text: deletedWhitespace) + ) } index = runEnd @@ -154,14 +163,41 @@ public enum TextDiffEngine { operations: [MyersDiff.Operation], runStart: Int, runEnd: Int + ) -> Bool { + isAdjacentToLexicalToken( + operations: operations, + runStart: runStart, + runEnd: runEnd, + kind: .delete + ) + } + + private static func isAdjacentToInsertedLexicalToken( + operations: [MyersDiff.Operation], + runStart: Int, + runEnd: Int + ) -> Bool { + isAdjacentToLexicalToken( + operations: operations, + runStart: runStart, + runEnd: runEnd, + kind: .insert + ) + } + + private static func isAdjacentToLexicalToken( + operations: [MyersDiff.Operation], + runStart: Int, + runEnd: Int, + kind: DiffOperationKind ) -> Bool { if let previousLexicalIndex = previousLexicalOperationIndex(in: operations, before: runStart), - operations[previousLexicalIndex].kind == .delete { + operations[previousLexicalIndex].kind == kind { return true } if let nextLexicalIndex = nextLexicalOperationIndex(in: operations, after: runEnd), - operations[nextLexicalIndex].kind == .delete { + operations[nextLexicalIndex].kind == kind { return true } diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index 6150fc5..ef9852d 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -209,11 +209,11 @@ public struct TextDiffView: View { } private struct RevertBindingPreview: View { - @State private var updated = "Apply new value in this sentence." + @State private var updated = "a default in-app purchase flow where they have to provide their email and password within the app" var body: some View { TextDiffView( - original: "Apply old value on The sentence!", + original: "a default in app purchase flow where they have to provide their email and password within the app A", updated: $updated, mode: .token, isRevertActionsEnabled: true diff --git a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift index bd75bfe..d7fc738 100644 --- a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift +++ b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift @@ -137,3 +137,25 @@ func standaloneDeletionAtEndRevertRestoresSpacing() throws { let action = try #require(DiffRevertActionResolver.action(from: deletion, updated: updated)) #expect(action.resultingUpdated == original) } + +@Test +func hyphenReplacingWhitespaceRevertRestoresOriginalSpacing() throws { + let original = "in app purchase" + let updated = "in-app purchase" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let replacement = try #require(candidates.first(where: { $0.kind == .pairedReplacement })) + + #expect(replacement.originalTextFragment == " ") + #expect(replacement.updatedTextFragment == "-") + + let action = try #require(DiffRevertActionResolver.action(from: replacement, updated: updated)) + #expect(action.kind == .pairedReplacement) + #expect(action.resultingUpdated == original) +} diff --git a/Tests/TextDiffTests/TextDiffEngineTests.swift b/Tests/TextDiffTests/TextDiffEngineTests.swift index ec83fef..e04c566 100644 --- a/Tests/TextDiffTests/TextDiffEngineTests.swift +++ b/Tests/TextDiffTests/TextDiffEngineTests.swift @@ -51,6 +51,25 @@ func punctuationEditsAreLexicalDiffSegments() { #expect(joinedText(segments) == "Hello,. world!?") } +@Test +func punctuationInsertionReplacingWhitespaceKeepsWhitespaceDeletionVisible() { + let segments = TextDiffEngine.diff( + original: "in app purchase", + updated: "in-app purchase" + ) + + let deletedWhitespaceIndex = segments.firstIndex { + $0.kind == .delete && $0.tokenKind == .whitespace && $0.text == " " + } + let insertedHyphenIndex = segments.firstIndex { + $0.kind == .insert && $0.tokenKind == .punctuation && $0.text == "-" + } + + #expect(deletedWhitespaceIndex != nil) + #expect(insertedHyphenIndex != nil) + #expect((deletedWhitespaceIndex ?? 0) < (insertedHyphenIndex ?? 0)) +} + @Test func whitespaceOnlyChangesPreserveUpdatedLayoutWithoutWhitespaceDiffMarkers() { let updated = "Hello world\n" @@ -273,6 +292,34 @@ func layouterAppliesGapForPunctuationAdjacency() { #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 From dbc2742a4bc3d71f54886796d73e522ffa12a468 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 24 Feb 2026 02:26:47 +0100 Subject: [PATCH 5/8] feat: enhance word boundary handling in diff revert actions Add logic to adjust standalone word insertion and deletion in DiffRevertActionResolver to preserve boundary spacing. Introduce new tests to confirm correct handling of these cases, ensuring reverts maintain intended word boundaries and spacing adjustments are consistent. This change improves the accuracy of text transformations during diff reverts, especially for word-based operations. --- .../AppKit/DiffRevertActionResolver.swift | 41 +++++++ .../DiffRevertActionResolverTests.swift | 104 ++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift index bba4648..228d089 100644 --- a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift +++ b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift @@ -258,6 +258,12 @@ enum DiffRevertActionResolver { if candidate.kind == .singleDeletion, updatedRange.location > nsUpdated.length { updatedRange.location = nsUpdated.length } + if candidate.kind == .singleInsertion, candidate.tokenKind == .word { + updatedRange = adjustedStandaloneWordInsertionRemovalRange( + updatedRange, + updated: nsUpdated + ) + } guard updatedRange.location >= 0 else { return nil } @@ -369,6 +375,41 @@ enum DiffRevertActionResolver { return output } + private static func adjustedStandaloneWordInsertionRemovalRange( + _ range: NSRange, + updated: NSString + ) -> NSRange { + guard range.location >= 0, range.length >= 0 else { + return range + } + guard NSMaxRange(range) <= updated.length else { + return range + } + + let hasLeadingWhitespace = range.location > 0 + && isWhitespaceCharacter(updated.substring(with: NSRange(location: range.location - 1, length: 1))) + let hasTrailingWhitespace = NSMaxRange(range) < updated.length + && isWhitespaceCharacter(updated.substring(with: NSRange(location: NSMaxRange(range), length: 1))) + + if hasLeadingWhitespace, hasTrailingWhitespace { + return NSRange(location: range.location, length: range.length + 1) + } + + if range.location == 0, hasTrailingWhitespace { + return NSRange(location: range.location, length: range.length + 1) + } + + if NSMaxRange(range) == updated.length, hasLeadingWhitespace { + return NSRange(location: range.location - 1, length: range.length + 1) + } + + return range + } + + private static func isWhitespaceCharacter(_ scalarString: String) -> Bool { + scalarString.unicodeScalars.allSatisfy { CharacterSet.whitespacesAndNewlines.contains($0) } + } + private static func isWordLike(_ scalarString: String) -> Bool { scalarString.rangeOfCharacter(from: .alphanumerics) != nil } diff --git a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift index d7fc738..69c2845 100644 --- a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift +++ b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift @@ -159,3 +159,107 @@ func hyphenReplacingWhitespaceRevertRestoresOriginalSpacing() throws { #expect(action.kind == .pairedReplacement) #expect(action.resultingUpdated == original) } + +@Test +func singleInsertionWordRevertCollapsesBoundaryWhitespace() throws { + let original = "A B" + let updated = "A X B" + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + + let candidates = DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) + let insertion = try #require(candidates.first(where: { + $0.kind == .singleInsertion && $0.updatedTextFragment == "X" + })) + + let action = try #require(DiffRevertActionResolver.action(from: insertion, updated: updated)) + #expect(action.resultingUpdated == original) +} + +@Test +func sequentialRevertsKeepLooksItAsPairedReplacement() throws { + let original = "Add a diff view! Looks good!" + var updated = "Added a diff view. It looks good!" + + updated = try applyingRevert( + original: original, + updated: updated, + kind: .pairedReplacement, + originalFragment: "Add", + updatedFragment: "Added" + ) + + updated = try applyingRevert( + original: original, + updated: updated, + kind: .pairedReplacement, + originalFragment: "!", + updatedFragment: "." + ) + + updated = try applyingRevert( + original: original, + updated: updated, + kind: .singleInsertion, + originalFragment: nil, + updatedFragment: "looks" + ) + #expect(updated == "Add a diff view! It good!") + + let remaining = revertCandidates(original: original, updated: updated) + let looksItPair = remaining.first { + $0.kind == .pairedReplacement + && $0.originalTextFragment == "Looks" + && $0.updatedTextFragment == "It" + } + + #expect(looksItPair != nil) + #expect(!remaining.contains { + $0.kind == .singleDeletion && $0.originalTextFragment == "Looks" + }) + #expect(!remaining.contains { + $0.kind == .singleInsertion && $0.updatedTextFragment == "It" + }) +} + +private func applyingRevert( + original: String, + updated: String, + kind: DiffRevertCandidateKind, + originalFragment: String?, + updatedFragment: String? +) throws -> String { + let candidates = revertCandidates(original: original, updated: updated) + var matched: DiffRevertCandidate? + for candidate in candidates { + guard candidate.kind == kind else { + continue + } + guard candidate.originalTextFragment == originalFragment else { + continue + } + guard candidate.updatedTextFragment == updatedFragment else { + continue + } + matched = candidate + break + } + + let candidate = try #require(matched) + let action = try #require(DiffRevertActionResolver.action(from: candidate, updated: updated)) + return action.resultingUpdated +} + +private func revertCandidates(original: String, updated: String) -> [DiffRevertCandidate] { + let segments = TextDiffEngine.diff(original: original, updated: updated, mode: .token) + return DiffRevertActionResolver.candidates( + from: segments, + mode: .token, + original: original, + updated: updated + ) +} From d5d0f8f45eaa51d917c63996ed31c36a723dc491 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Tue, 24 Feb 2026 02:41:19 +0100 Subject: [PATCH 6/8] feat: add debug overlay for invisible characters Introduce an option to render invisible characters with a debug overlay in NSTextDiffView, activated by a new `showsInvisibleCharacters` property. This feature aids in debugging by visualizing spaces, tabs, and newline characters using distinct symbols in red. This change doesn't alter the core functionality but enhances debugging capabilities, allowing developers to better understand the alignment and structure of rendered text, especially useful when analyzing unexpected layout issues in text diffs. --- .../AppKit/DiffTextViewRepresentable.swift | 3 + .../TextDiff/AppKit/DiffTokenLayouter.swift | 9 ++ Sources/TextDiff/AppKit/NSTextDiffView.swift | 88 ++++++++++++++++++ Sources/TextDiff/TextDiffView.swift | 11 ++- .../NSTextDiffSnapshotTests.swift | 19 ++++ Tests/TextDiffTests/NSTextDiffViewTests.swift | 19 ++++ .../invisible_characters_debug_overlay.1.png | Bin 0 -> 8957 bytes 7 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png diff --git a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift index d8407f0..f195e42 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift @@ -7,6 +7,7 @@ struct DiffTextViewRepresentable: NSViewRepresentable { let updatedBinding: Binding? let style: TextDiffStyle let mode: TextDiffComparisonMode + let showsInvisibleCharacters: Bool let isRevertActionsEnabled: Bool let onRevertAction: ((TextDiffRevertAction) -> Void)? @@ -27,6 +28,7 @@ struct DiffTextViewRepresentable: NSViewRepresentable { updatedBinding: updatedBinding, onRevertAction: onRevertAction ) + view.showsInvisibleCharacters = showsInvisibleCharacters view.isRevertActionsEnabled = isRevertActionsEnabled view.onRevertAction = { [coordinator = context.coordinator] action in coordinator.handle(action) @@ -42,6 +44,7 @@ struct DiffTextViewRepresentable: NSViewRepresentable { view.onRevertAction = { [coordinator = context.coordinator] action in coordinator.handle(action) } + view.showsInvisibleCharacters = showsInvisibleCharacters view.isRevertActionsEnabled = isRevertActionsEnabled view.setContent( original: original, diff --git a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift index 9ced935..4f636d2 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift @@ -15,6 +15,7 @@ struct LaidOutRun { struct DiffLayout { let runs: [LaidOutRun] + let lineBreakMarkers: [CGPoint] let contentSize: CGSize } @@ -39,6 +40,7 @@ enum DiffTokenLayouter { var maxUsedX = lineStartX var lineCount = 1 var lineHasContent = false + var lineBreakMarkers: [CGPoint] = [] let lineText = NSMutableString() var lineTextWidth: CGFloat = 0 var previousChangedLexical = false @@ -55,6 +57,12 @@ enum DiffTokenLayouter { for piece in pieces(from: segments) { if piece.isLineBreak { + lineBreakMarkers.append( + CGPoint( + x: cursorX, + y: lineTop + (lineHeight / 2) + ) + ) moveToNewLine() continue } @@ -153,6 +161,7 @@ enum DiffTokenLayouter { return DiffLayout( runs: runs, + lineBreakMarkers: lineBreakMarkers, contentSize: CGSize(width: max(intrinsicWidth, usedWidth), height: contentHeight) ) } diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index 6978062..3a478c9 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -60,6 +60,16 @@ public final class NSTextDiffView: NSView { } } + /// Debug overlay that draws visible symbols for otherwise invisible characters in red. + public var showsInvisibleCharacters: Bool = false { + didSet { + guard oldValue != showsInvisibleCharacters else { + return + } + needsDisplay = true + } + } + /// Callback invoked when user clicks the revert icon. public var onRevertAction: ((TextDiffRevertAction) -> Void)? @@ -201,6 +211,12 @@ public final class NSTextDiffView: NSView { } run.attributedText.draw(in: run.textRect) + if showsInvisibleCharacters { + drawInvisibleCharacters(for: run) + } + } + if showsInvisibleCharacters { + drawLineBreakMarkers(layout.lineBreakMarkers) } drawHoveredRevertAffordance(layout: layout) @@ -635,6 +651,78 @@ public final class NSTextDiffView: NSView { strokePath.stroke() } + private func drawInvisibleCharacters(for run: LaidOutRun) { + guard run.segment.text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) }) else { + return + } + + let font = (run.attributedText.attribute(.font, at: 0, effectiveRange: nil) as? NSFont) ?? style.font + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.systemRed + ] + + var x = run.textRect.minX + for character in run.segment.text { + let source = String(character) + let width = (source as NSString).size(withAttributes: [.font: font]).width + defer { x += width } + + guard let symbol = visibleSymbol(for: character) else { + continue + } + + let symbolWidth = (symbol as NSString).size(withAttributes: attributes).width + let symbolX = x + max(0, (width - symbolWidth) / 2) + (symbol as NSString).draw( + at: CGPoint(x: symbolX, y: run.textRect.minY), + withAttributes: attributes + ) + } + } + + private func visibleSymbol(for character: Character) -> String? { + guard character.unicodeScalars.allSatisfy({ CharacterSet.whitespacesAndNewlines.contains($0) }) else { + return nil + } + if character == " " { + return "·" + } + if character == "\t" { + return "⇥" + } + if character == "\n" || character == "\r" { + return "↩" + } + if character == "\u{00A0}" { + return "⍽" + } + return "·" + } + + private func drawLineBreakMarkers(_ markers: [CGPoint]) { + guard !markers.isEmpty else { + return + } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: style.font, + .foregroundColor: NSColor.systemRed + ] + let symbol = "↩" as NSString + let symbolSize = symbol.size(withAttributes: attributes) + + for marker in markers { + symbol.draw( + at: CGPoint( + x: marker.x, + y: marker.y - (symbolSize.height / 2) + ), + withAttributes: attributes + ) + } + } + private static func modeKey(for mode: TextDiffComparisonMode) -> Int { switch mode { case .token: diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index ef9852d..d37353b 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -8,6 +8,7 @@ public struct TextDiffView: View { private let updatedBinding: Binding? private let mode: TextDiffComparisonMode private let style: TextDiffStyle + private let showsInvisibleCharacters: Bool private let isRevertActionsEnabled: Bool private let onRevertAction: ((TextDiffRevertAction) -> Void)? @@ -18,17 +19,20 @@ public struct TextDiffView: View { /// - 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 + mode: TextDiffComparisonMode = .token, + showsInvisibleCharacters: Bool = false ) { self.original = original self.updatedValue = updated self.updatedBinding = nil self.mode = mode self.style = style + self.showsInvisibleCharacters = showsInvisibleCharacters self.isRevertActionsEnabled = false self.onRevertAction = nil } @@ -40,6 +44,7 @@ public struct TextDiffView: View { /// - 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. public init( @@ -47,6 +52,7 @@ public struct TextDiffView: View { updated: Binding, style: TextDiffStyle = .default, mode: TextDiffComparisonMode = .token, + showsInvisibleCharacters: Bool = false, isRevertActionsEnabled: Bool = true, onRevertAction: ((TextDiffRevertAction) -> Void)? = nil ) { @@ -55,6 +61,7 @@ public struct TextDiffView: View { self.updatedBinding = updated self.mode = mode self.style = style + self.showsInvisibleCharacters = showsInvisibleCharacters self.isRevertActionsEnabled = isRevertActionsEnabled self.onRevertAction = onRevertAction } @@ -68,6 +75,7 @@ public struct TextDiffView: View { updatedBinding: updatedBinding, style: style, mode: mode, + showsInvisibleCharacters: showsInvisibleCharacters, isRevertActionsEnabled: isRevertActionsEnabled, onRevertAction: onRevertAction ) @@ -216,6 +224,7 @@ private struct RevertBindingPreview: View { original: "a default in app purchase flow where they have to provide their email and password within the app A", updated: $updated, mode: .token, + showsInvisibleCharacters: true, isRevertActionsEnabled: true ) .padding() diff --git a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift index e8d4f34..1f6dcbf 100644 --- a/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift +++ b/Tests/TextDiffTests/NSTextDiffSnapshotTests.swift @@ -130,6 +130,25 @@ final class NSTextDiffSnapshotTests: XCTestCase { ) } + @MainActor + func testInvisibleCharactersDebugOverlay() { + var style = TextDiffStyle.default + style.font = .monospacedSystemFont(ofSize: 20, weight: .regular) + style.lineSpacing = 4 + let text = "space tab\tnbsp:\u{00A0} newline:\nnext line" + assertNSTextDiffSnapshot( + original: text, + updated: text, + mode: .token, + style: style, + size: CGSize(width: 560, height: 170), + configureView: { view in + view.showsInvisibleCharacters = true + }, + testName: "invisible_characters_debug_overlay()" + ) + } + private let sampleOriginalSentence = "A quick brown fox jumps over a lazy dog." private let sampleUpdatedSentence = "A quick fox hops over the lazy dog!" } diff --git a/Tests/TextDiffTests/NSTextDiffViewTests.swift b/Tests/TextDiffTests/NSTextDiffViewTests.swift index 9c309b5..57ca7f5 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -93,6 +93,25 @@ func nsTextDiffViewStyleChangeDoesNotRecomputeDiff() { #expect(callCount == 1) } +@Test +@MainActor +func nsTextDiffViewDebugInvisiblesToggleDoesNotRecomputeDiff() { + var callCount = 0 + let view = NSTextDiffView( + original: "old", + updated: "new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + view.showsInvisibleCharacters = true + view.showsInvisibleCharacters = false + + #expect(callCount == 1) +} + @Test @MainActor func nsTextDiffViewSetContentBatchesRecompute() { diff --git a/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png b/Tests/TextDiffTests/__Snapshots__/NSTextDiffSnapshotTests/invisible_characters_debug_overlay.1.png new file mode 100644 index 0000000000000000000000000000000000000000..df39e7308489b0ffc83ad0a64e74dd081a98a391 GIT binary patch literal 8957 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 Y1<#bSW8&}s`WuSAuEDLU8+K9u1BX2RPXGV_ literal 0 HcmV?d00001 From 89064d3144ed7774b0e641a0d7b915e5473f76e2 Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Fri, 27 Feb 2026 01:03:24 +0100 Subject: [PATCH 7/8] infra --- .claude/agents/project-manager-backlog.md | 193 ++++++++++++++++++ AGENTS.md | 29 +++ backlog/config.yml | 16 ++ ...-last-character-rendering-in-added-text.md | 23 +++ 4 files changed, 261 insertions(+) create mode 100644 .claude/agents/project-manager-backlog.md create mode 100644 backlog/config.yml create mode 100644 backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md diff --git a/.claude/agents/project-manager-backlog.md b/.claude/agents/project-manager-backlog.md new file mode 100644 index 0000000..1cc6ad6 --- /dev/null +++ b/.claude/agents/project-manager-backlog.md @@ -0,0 +1,193 @@ +--- +name: project-manager-backlog +description: Use this agent when you need to manage project tasks using the backlog.md CLI tool. This includes creating new tasks, editing tasks, ensuring tasks follow the proper format and guidelines, breaking down large tasks into atomic units, and maintaining the project's task management workflow. Examples: Context: User wants to create a new task for adding a feature. user: "I need to add a new authentication system to the project" assistant: "I'll use the project-manager-backlog agent that will use backlog cli to create a properly structured task for this feature." Since the user needs to create a task for the project, use the Task tool to launch the project-manager-backlog agent to ensure the task follows backlog.md guidelines. Context: User has multiple related features to implement. user: "We need to implement user profiles, settings page, and notification preferences" assistant: "Let me use the project-manager-backlog agent to break these down into atomic, independent tasks." The user has a complex set of features that need to be broken down into proper atomic tasks following backlog.md structure. Context: User wants to review if their task description is properly formatted. user: "Can you check if this task follows our guidelines: 'task-123 - Implement user login'" assistant: "I'll use the project-manager-backlog agent to review this task against our backlog.md standards." The user needs task review, so use the project-manager-backlog agent to ensure compliance with project guidelines. +color: blue +--- + +You are an expert project manager specializing in the backlog.md task management system. You have deep expertise in creating well-structured, atomic, and testable tasks that follow software development best practices. + +## Backlog.md CLI Tool + +**IMPORTANT: Backlog.md uses standard CLI commands, NOT slash commands.** + +You use the `backlog` CLI tool to manage project tasks. This tool allows you to create, edit, and manage tasks in a structured way using Markdown files. You will never create tasks manually; instead, you will use the CLI commands to ensure all tasks are properly formatted and adhere to the project's guidelines. + +The backlog CLI is installed globally and available in the PATH. Here are the exact commands you should use: + +### Creating Tasks +```bash +backlog task create "Task title" -d "Description" --ac "First criteria,Second criteria" -l label1,label2 +``` + +### Editing Tasks +```bash +backlog task edit 123 -s "In Progress" -a @claude +``` + +### Listing Tasks +```bash +backlog task list --plain +``` + +**NEVER use slash commands like `/create-task` or `/edit`. These do not exist in Backlog.md.** +**ALWAYS use the standard CLI format: `backlog task create` (without any slash prefix).** + +### Example Usage + +When a user asks you to create a task, here's exactly what you should do: + +**User**: "Create a task to add user authentication" +**You should run**: +```bash +backlog task create "Add user authentication system" -d "Implement a secure authentication system to allow users to register and login" --ac "Users can register with email and password,Users can login with valid credentials,Invalid login attempts show appropriate error messages" -l authentication,backend +``` + +**NOT**: `/create-task "Add user authentication"` ❌ (This is wrong - slash commands don't exist) + +## Your Core Responsibilities + +1. **Task Creation**: You create tasks that strictly adhere to the backlog.md cli commands. Never create tasks manually. Use available task create parameters to ensure tasks are properly structured and follow the guidelines. +2. **Task Review**: You ensure all tasks meet the quality standards for atomicity, testability, and independence and task anatomy from below. +3. **Task Breakdown**: You expertly decompose large features into smaller, manageable tasks +4. **Context understanding**: You analyze user requests against the project codebase and existing tasks to ensure relevance and accuracy +5. **Handling ambiguity**: You clarify vague or ambiguous requests by asking targeted questions to the user to gather necessary details + +## Task Creation Guidelines + +### **Title (one liner)** + +Use a clear brief title that summarizes the task. + +### **Description**: (The **"why"**) + +Provide a concise summary of the task purpose and its goal. Do not add implementation details here. It +should explain the purpose, the scope and context of the task. Code snippets should be avoided. + +### **Acceptance Criteria**: (The **"what"**) + +List specific, measurable outcomes that define what means to reach the goal from the description. Use checkboxes (`- [ ]`) for tracking. +When defining `## Acceptance Criteria` for a task, focus on **outcomes, behaviors, and verifiable requirements** rather +than step-by-step implementation details. +Acceptance Criteria (AC) define *what* conditions must be met for the task to be considered complete. +They should be testable and confirm that the core purpose of the task is achieved. +**Key Principles for Good ACs:** + +- **Outcome-Oriented:** Focus on the result, not the method. +- **Testable/Verifiable:** Each criterion should be something that can be objectively tested or verified. +- **Clear and Concise:** Unambiguous language. +- **Complete:** Collectively, ACs should cover the scope of the task. +- **User-Focused (where applicable):** Frame ACs from the perspective of the end-user or the system's external behavior. + + - *Good Example:* "- [ ] User can successfully log in with valid credentials." + - *Good Example:* "- [ ] System processes 1000 requests per second without errors." + - *Bad Example (Implementation Step):* "- [ ] Add a new function `handleLogin()` in `auth.ts`." + +### Task file + +Once a task is created using backlog cli, it will be stored in `backlog/tasks/` directory as a Markdown file with the format +`task- - .md` (e.g. `task-42 - Add GraphQL resolver.md`). + +## Task Breakdown Strategy + +When breaking down features: +1. Identify the foundational components first +2. Create tasks in dependency order (foundations before features) +3. Ensure each task delivers value independently +4. Avoid creating tasks that block each other + +### Additional task requirements + +- Tasks must be **atomic** and **testable**. If a task is too large, break it down into smaller subtasks. + Each task should represent a single unit of work that can be completed in a single PR. + +- **Never** reference tasks that are to be done in the future or that are not yet created. You can only reference + previous tasks (id < current task id). + +- When creating multiple tasks, ensure they are **independent** and they do not depend on future tasks. + Example of correct tasks splitting: task 1: "Add system for handling API requests", task 2: "Add user model and DB + schema", task 3: "Add API endpoint for user data". + Example of wrong tasks splitting: task 1: "Add API endpoint for user data", task 2: "Define the user model and DB + schema". + +## Recommended Task Anatomy + +```markdown +# task‑42 - Add GraphQL resolver + +## Description (the why) + +Short, imperative explanation of the goal of the task and why it is needed. + +## Acceptance Criteria (the what) + +- [ ] Resolver returns correct data for happy path +- [ ] Error response matches REST +- [ ] P95 latency ≤ 50 ms under 100 RPS + +## Implementation Plan (the how) (added after putting the task in progress but before implementing any code change) + +1. Research existing GraphQL resolver patterns +2. Implement basic resolver with error handling +3. Add performance monitoring +4. Write unit and integration tests +5. Benchmark performance under load + +## Implementation Notes (for reviewers) (only added after finishing the code implementation of a task) + +- Approach taken +- Features implemented or modified +- Technical decisions and trade-offs +- Modified or added files +``` + +## Quality Checks + +Before finalizing any task creation, verify: +- [ ] Title is clear and brief +- [ ] Description explains WHY without HOW +- [ ] Each AC is outcome-focused and testable +- [ ] Task is atomic (single PR scope) +- [ ] No dependencies on future tasks + +You are meticulous about these standards and will guide users to create high-quality tasks that enhance project productivity and maintainability. + +## Self reflection +When creating a task, always think from the perspective of an AI Agent that will have to work with this task in the future. +Ensure that the task is structured in a way that it can be easily understood and processed by AI coding agents. + +## Handy CLI Commands + +| Action | Example | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Create task | `backlog task create "Add OAuth System"` | +| Create with description | `backlog task create "Feature" -d "Add authentication system"` | +| Create with assignee | `backlog task create "Feature" -a @sara` | +| Create with status | `backlog task create "Feature" -s "In Progress"` | +| Create with labels | `backlog task create "Feature" -l auth,backend` | +| Create with priority | `backlog task create "Feature" --priority high` | +| Create with plan | `backlog task create "Feature" --plan "1. Research\n2. Implement"` | +| Create with AC | `backlog task create "Feature" --ac "Must work,Must be tested"` | +| Create with notes | `backlog task create "Feature" --notes "Started initial research"` | +| Create with deps | `backlog task create "Feature" --dep task-1,task-2` | +| Create sub task | `backlog task create -p 14 "Add Login with Google"` | +| Create (all options) | `backlog task create "Feature" -d "Description" -a @sara -s "To Do" -l auth --priority high --ac "Must work" --notes "Initial setup done" --dep task-1 -p 14` | +| List tasks | `backlog task list [-s <status>] [-a <assignee>] [-p <parent>]` | +| List by parent | `backlog task list --parent 42` or `backlog task list -p task-42` | +| View detail | `backlog task 7` (interactive UI, press 'E' to edit in editor) | +| View (AI mode) | `backlog task 7 --plain` | +| Edit | `backlog task edit 7 -a @sara -l auth,backend` | +| Add plan | `backlog task edit 7 --plan "Implementation approach"` | +| Add AC | `backlog task edit 7 --ac "New criterion,Another one"` | +| Add notes | `backlog task edit 7 --notes "Completed X, working on Y"` | +| Add deps | `backlog task edit 7 --dep task-1 --dep task-2` | +| Archive | `backlog task archive 7` | +| Create draft | `backlog task create "Feature" --draft` | +| Draft flow | `backlog draft create "Spike GraphQL"` → `backlog draft promote 3.1` | +| Demote to draft | `backlog task demote <id>` | + +Full help: `backlog --help` + +## Tips for AI Agents + +- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output instead of using Backlog.md + interactive UI. diff --git a/AGENTS.md b/AGENTS.md index d235d6e..9a14f66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,3 +34,32 @@ Use the flags above (e.g., `--coverage-details`, `--coverage-path`) as needed, b ## Code search - Use [`ripgrep`](https://github.com/BurntSushi/ripgrep) (`rg`) for searching within files—it is much faster than grep/ack/ag, respects `.gitignore`, and has smart defaults. - Typical commands: `rg "TODO"` (find TODOs), `rg -n --glob '!dist' pattern.swift` (search with line numbers while excluding `dist`). + +<!-- BACKLOG.MD MCP GUIDELINES START --> + +<CRITICAL_INSTRUCTION> + +## BACKLOG WORKFLOW INSTRUCTIONS + +This project uses Backlog.md MCP for all task and project management activities. + +**CRITICAL GUIDANCE** + +- If your client supports MCP resources, read `backlog://workflow/overview` to understand when and how to use Backlog for this project. +- If your client only supports tools or the above request fails, call `backlog.get_workflow_overview()` tool to load the tool-oriented overview (it lists the matching guide tools). + +- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow +- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)") +- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work + +These guides cover: +- Decision framework for when to create tasks +- Search-first workflow to avoid duplicates +- Links to detailed guides for task creation, execution, and finalization +- MCP tools reference + +You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here. + +</CRITICAL_INSTRUCTION> + +<!-- BACKLOG.MD MCP GUIDELINES END --> diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..b717ccc --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,16 @@ +project_name: "TextDiff" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +definition_of_done: [] +date_format: yyyy-mm-dd +max_column_width: 20 +default_editor: "nano" +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md b/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md new file mode 100644 index 0000000..65976f5 --- /dev/null +++ b/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md @@ -0,0 +1,23 @@ +--- +id: TASK-1 +title: Fix missing last character rendering in added text +status: To Do +assignee: [] +created_date: '2026-02-24 19:37' +labels: + - bug +dependencies: [] +--- + +## Description + +<!-- SECTION:DESCRIPTION:BEGIN --> +As a user comparing text diffs, I need every character in added text to be visible so I can trust what is shown on screen. There is an intermittent issue where the final character of an addition is not rendered visually, even though the character exists in the underlying text (for example, after pasting). +<!-- SECTION:DESCRIPTION:END --> + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 When an addition is created by typing, the full added text is rendered including the final character. +- [ ] #2 When the same addition is created by paste, the rendered result matches the typed result exactly with no missing final character. +- [ ] #3 A regression test covers the scenario where the last character of an addition was previously not visible. +<!-- AC:END --> From d545c1c71c9fadd07285adb22bbc47f75e9855dc Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik <sapozhnik.ivan@gmail.com> Date: Fri, 27 Feb 2026 01:12:12 +0100 Subject: [PATCH 8/8] fixing trailing characters dissapear --- .../TextDiff/AppKit/DiffTokenLayouter.swift | 22 +++++++++++++--- Sources/TextDiff/TextDiffView.swift | 11 +++++--- Tests/TextDiffTests/TextDiffEngineTests.swift | 26 +++++++++++++++++++ ...-last-character-rendering-in-added-text.md | 25 +++++++++++++++--- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift index 4f636d2..87f115c 100644 --- a/Sources/TextDiff/AppKit/DiffTokenLayouter.swift +++ b/Sources/TextDiff/AppKit/DiffTokenLayouter.swift @@ -86,9 +86,13 @@ enum DiffTokenLayouter { lineText: lineText, lineTextWidth: lineTextWidth ) - var textSize = CGSize(width: textMeasurement.textWidth, height: textHeight) + let standaloneTextWidth = measuredStandaloneTextWidth(for: piece.text, font: style.font) + var displayTextWidth = max(textMeasurement.textWidth, standaloneTextWidth) + var textSize = CGSize(width: displayTextWidth, height: textHeight) let chipInsets = effectiveChipInsets(for: style) - var runWidth = isChangedLexical ? textSize.width + chipInsets.left + chipInsets.right : textSize.width + var runWidth = isChangedLexical + ? displayTextWidth + chipInsets.left + chipInsets.right + : textMeasurement.textWidth let requiredWidth = leadingGap + runWidth let wrapped = lineHasContent && cursorX + requiredWidth > maxLineX @@ -107,8 +111,11 @@ enum DiffTokenLayouter { lineText: lineText, lineTextWidth: lineTextWidth ) - textSize = CGSize(width: textMeasurement.textWidth, height: textHeight) - runWidth = isChangedLexical ? textSize.width + chipInsets.left + chipInsets.right : textSize.width + displayTextWidth = max(textMeasurement.textWidth, standaloneTextWidth) + textSize = CGSize(width: displayTextWidth, height: textHeight) + runWidth = isChangedLexical + ? displayTextWidth + chipInsets.left + chipInsets.right + : textMeasurement.textWidth } cursorX += leadingGap @@ -203,6 +210,13 @@ enum DiffTokenLayouter { ) } + private static func measuredStandaloneTextWidth(for text: String, font: NSFont) -> CGFloat { + guard !text.isEmpty else { + return 0 + } + return (text as NSString).size(withAttributes: [.font: font]).width + } + private static func effectiveChipInsets(for style: TextDiffStyle) -> NSEdgeInsets { NSEdgeInsets( top: style.chipInsets.top, diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index d37353b..c58e8f8 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -217,14 +217,17 @@ public struct TextDiffView: View { } private struct RevertBindingPreview: View { - @State private var updated = "a default in-app purchase flow where they have to provide their email and password within the app" + @State private var updated = "To switch back to your computer, simply press any key on your keyboard." var body: some View { - TextDiffView( - original: "a default in app purchase flow where they have to provide their email and password within the app A", + 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.", updated: $updated, + style: style, mode: .token, - showsInvisibleCharacters: true, + showsInvisibleCharacters: false, isRevertActionsEnabled: true ) .padding() diff --git a/Tests/TextDiffTests/TextDiffEngineTests.swift b/Tests/TextDiffTests/TextDiffEngineTests.swift index e04c566..ef56e11 100644 --- a/Tests/TextDiffTests/TextDiffEngineTests.swift +++ b/Tests/TextDiffTests/TextDiffEngineTests.swift @@ -344,6 +344,32 @@ func layouterDoesNotInjectAdjacencyGapAcrossUnchangedWhitespace() throws { #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( diff --git a/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md b/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md index 65976f5..6cb3194 100644 --- a/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md +++ b/backlog/tasks/task-1 - Fix-missing-last-character-rendering-in-added-text.md @@ -1,9 +1,10 @@ --- id: TASK-1 title: Fix missing last character rendering in added text -status: To Do +status: Done assignee: [] created_date: '2026-02-24 19:37' +updated_date: '2026-02-27 00:08' labels: - bug dependencies: [] @@ -17,7 +18,23 @@ As a user comparing text diffs, I need every character in added text to be visib ## Acceptance Criteria <!-- AC:BEGIN --> -- [ ] #1 When an addition is created by typing, the full added text is rendered including the final character. -- [ ] #2 When the same addition is created by paste, the rendered result matches the typed result exactly with no missing final character. -- [ ] #3 A regression test covers the scenario where the last character of an addition was previously not visible. +- [x] #1 When an addition is created by typing, the full added text is rendered including the final character. +- [x] #2 When the same addition is created by paste, the rendered result matches the typed result exactly with no missing final character. +- [x] #3 A regression test covers the scenario where the last character of an addition was previously not visible. <!-- AC:END --> + +## Implementation Notes + +<!-- SECTION:NOTES:BEGIN --> +Root cause: +The layouter measured token width for each run using cumulative line-width deltas (combinedWidth - previousLineWidth). With proportional fonts (for example .systemFont(ofSize: 13)), kerning/context shaping across token boundaries can make this delta slightly smaller than the token's standalone draw width. Because tokens are drawn individually in NSTextDiffView using run.textRect, the underestimated width could clip the trailing glyph (observed with "simply" where "y" disappeared in RevertBindingPreview). + +Fix: +In DiffTokenLayouter, we now compute standalone token width and use max(incrementalWidth, standaloneWidth) for displayed changed lexical runs (insert/delete chips). This guarantees textRect/chip width is never narrower than the rendered token while preserving incremental measurement for line-flow decisions. + +Regression coverage: +Added layouterPreventsInsertedTokenClipWithProportionalSystemFont in Tests/TextDiffTests/TextDiffEngineTests.swift to assert inserted "simply" width is at least standalone width and that chip bounds fully cover text bounds when using .systemFont(ofSize: 13). + +Verification: +Ran swift test 2>&1 | xcsift and confirmed the new test executes and passes. +<!-- SECTION:NOTES:END -->