diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index cdffd74b..08a5cc5f 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -236,7 +236,7 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { height: snappedMaxY - snappedY ) - let selectionRects = selectionRectsInDrawCoordinates(drawPoint: point, snappedY: snappedY, snappedMaxY: snappedMaxY) + let selectionRects = selectionRectsInDrawCoordinates(drawPoint: point, scale: scale) color.setFill() if selectionRects.isEmpty { NSBezierPath(rect: bgRect).fill() @@ -253,11 +253,10 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { /// Returns active text-selection rectangles intersecting this fragment, in /// the same draw-relative coordinate system used by `drawCodeBlockBackground`. - private func selectionRectsInDrawCoordinates(drawPoint: CGPoint, snappedY: CGFloat, snappedMaxY: CGFloat) -> [CGRect] { + private func selectionRectsInDrawCoordinates(drawPoint: CGPoint, scale: CGFloat) -> [CGRect] { guard let tlm = textLayoutManager else { return [] } var rects: [CGRect] = [] - let dx = drawPoint.x - layoutFragmentFrame.origin.x let myRange = self.rangeInElement for selection in tlm.textSelections { @@ -270,15 +269,12 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let intersection = NSTextRange(location: interStart, end: interEnd) else { continue } tlm.enumerateTextSegments(in: intersection, type: .selection, options: []) { _, segFrame, _, _ in - // Expand vertically to match the bgRect's snapped span so the - // even-odd cut-out is geometrically congruent with the fill. - let drawRect = CGRect( - x: segFrame.origin.x + dx, - y: snappedY, - width: segFrame.width, - height: snappedMaxY - snappedY - ) - rects.append(drawRect) + rects.append(Self.selectionDrawRect( + segmentFrame: segFrame, + drawPoint: drawPoint, + fragmentFrame: layoutFragmentFrame, + scale: scale + )) return true } } @@ -286,6 +282,24 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { return rects } + static func selectionDrawRect( + segmentFrame: CGRect, + drawPoint: CGPoint, + fragmentFrame: CGRect, + scale: CGFloat + ) -> CGRect { + let dx = drawPoint.x - fragmentFrame.minX + let dy = drawPoint.y - fragmentFrame.minY + let minY = floor((segmentFrame.minY + dy) * scale) / scale + let maxY = ceil((segmentFrame.maxY + dy) * scale) / scale + return CGRect( + x: segmentFrame.minX + dx, + y: minY, + width: segmentFrame.width, + height: maxY - minY + ) + } + private func isCodeBlockBackgroundColor(_ color: NSColor) -> Bool { let highlighter = (textLayoutManager?.textContainer?.textView as? NativeTextView)? .configuration.services.syntaxHighlighter diff --git a/Tests/MarkdownEngineTests/SelectionGeometryTests.swift b/Tests/MarkdownEngineTests/SelectionGeometryTests.swift new file mode 100644 index 00000000..a5a17224 --- /dev/null +++ b/Tests/MarkdownEngineTests/SelectionGeometryTests.swift @@ -0,0 +1,31 @@ +import AppKit +import Testing +@testable import MarkdownEngine + +@Suite("Selection geometry") +struct SelectionGeometryTests { + @Test("wrapped-line selection rectangles stay line-local") + func wrappedLineSelectionStaysLineLocal() { + let fragmentFrame = CGRect(x: 20, y: 100, width: 300, height: 60) + let drawPoint = CGPoint(x: 5, y: 10) + let firstSegment = CGRect(x: 40, y: 105, width: 120, height: 18) + let secondSegment = CGRect(x: 60, y: 125, width: 90, height: 18) + + let first = MarkdownTextLayoutFragment.selectionDrawRect( + segmentFrame: firstSegment, + drawPoint: drawPoint, + fragmentFrame: fragmentFrame, + scale: 2 + ) + let second = MarkdownTextLayoutFragment.selectionDrawRect( + segmentFrame: secondSegment, + drawPoint: drawPoint, + fragmentFrame: fragmentFrame, + scale: 2 + ) + + #expect(first.maxY <= second.minY) + #expect(first.height == 18) + #expect(second.height == 18) + } +}