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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions Sources/MarkdownView/Components/CodeView/CodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,23 @@ import Litext
}

private func lineIndex(at point: CGPoint) -> Int? {
// Hit-test in the selection overlay's space against the resolved
// CoreText line rects it draws, so the picked line always matches
// the rendered highlight. Estimating from a uniform row advance
// (font.lineHeight + spacing) drifts from the real layout as
// blocks grow: the actual per-line advance differs by fractions
// of a point that accumulate over hundreds of lines, and lines
// with fallback-font glyphs are taller still.
if selectionOverlay.hasLineRects {
let overlayPoint = selectionOverlay.convert(point, from: self)
guard let line = selectionOverlay.lineIndex(
atY: overlayPoint.y,
trailingGap: CodeViewConfiguration.codeLineSpacing
), line <= cachedLineCount else { return nil }
return line
}

// Fallback before the first layout pass resolves line rects.
let localPoint = scrollView.convert(point, from: self)
let contentPoint = CGPoint(
x: localPoint.x + scrollView.contentOffset.x,
Expand All @@ -120,7 +137,6 @@ import Litext
let font = theme.fonts.code
let lineHeight = font.lineHeight
let rowAdvance = lineHeight + CodeViewConfiguration.codeLineSpacing
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
let adjustedY = contentPoint.y
guard adjustedY >= CodeViewConfiguration.codePadding else { return nil }
let line = Int((adjustedY - CodeViewConfiguration.codePadding) / rowAdvance) + 1
Expand Down Expand Up @@ -405,13 +421,29 @@ import Litext
lineSelectionEndedHandler?(currentLineSelectionInfo())
}

/// `point` is in this view's coordinate space (the mouse handlers
/// convert from the window before calling).
private func lineIndex(at point: CGPoint) -> Int? {
let localPoint = convert(point, from: nil)
// Hit-test in the selection overlay's space against the resolved
// CoreText line rects it draws, so the picked line always matches
// the rendered highlight (the arithmetic estimate below drifts
// from the real layout as blocks grow). The overlay is flipped,
// so the converted point is top-down like the rects.
if selectionOverlay.hasLineRects {
let overlayPoint = selectionOverlay.convert(point, from: self)
guard let line = selectionOverlay.lineIndex(
atY: overlayPoint.y,
trailingGap: CodeViewConfiguration.codeLineSpacing
), line <= cachedLineCount else { return nil }
return line
}

// Fallback before the first layout pass resolves line rects.
let font = theme.fonts.code
let lineHeight = font.ascender + abs(font.descender) + font.leading
let rowAdvance = lineHeight + CodeViewConfiguration.codeLineSpacing
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
let adjustedY = localPoint.y - barHeight
let adjustedY = point.y - barHeight
guard adjustedY >= CodeViewConfiguration.codePadding else { return nil }
let line = Int((adjustedY - CodeViewConfiguration.codePadding) / rowAdvance) + 1
guard line >= 1, line <= cachedLineCount else { return nil }
Expand Down
37 changes: 35 additions & 2 deletions Sources/MarkdownView/Components/DiffView/DiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,23 @@ private func makeSideBySideAttributedText(
}

private func rowIndex(at point: CGPoint) -> Int? {
// Hit-test in the selection overlay's space against the resolved
// CoreText line rects it draws, so the picked row always matches
// the rendered highlight. Estimating from a uniform row advance
// (font.lineHeight + spacing) drifts from the real layout as
// blocks grow: the actual per-line advance differs by fractions
// of a point that accumulate over hundreds of rows, and lines
// with fallback-font glyphs are taller still.
if selectionOverlay.hasLineRects {
let overlayPoint = selectionOverlay.convert(point, from: self)
guard let row = selectionOverlay.lineIndex(
atY: overlayPoint.y,
trailingGap: CodeViewConfiguration.codeLineSpacing
), row <= diffRowCount() else { return nil }
return row
}

// Fallback before the first layout pass resolves line rects.
let localPoint = scrollView.convert(point, from: self)
let contentPoint = CGPoint(
x: localPoint.x + scrollView.contentOffset.x,
Expand Down Expand Up @@ -1750,13 +1767,29 @@ private func makeSideBySideAttributedText(
displayRows.effectiveCount
}

/// `point` is in this view's coordinate space (the mouse handlers
/// convert from the window before calling).
private func rowIndex(at point: CGPoint) -> Int? {
let localPoint = convert(point, from: nil)
// Hit-test in the selection overlay's space against the resolved
// CoreText line rects it draws, so the picked row always matches
// the rendered highlight (the arithmetic estimate below drifts
// from the real layout as blocks grow). The overlay is flipped,
// so the converted point is top-down like the rects.
if selectionOverlay.hasLineRects {
let overlayPoint = selectionOverlay.convert(point, from: self)
guard let row = selectionOverlay.lineIndex(
atY: overlayPoint.y,
trailingGap: CodeViewConfiguration.codeLineSpacing
), row <= diffRowCount() else { return nil }
return row
}

// Fallback before the first layout pass resolves line rects.
let barHeight = DiffViewConfiguration.barHeight(theme: theme)
let font = theme.fonts.code
let lineHeight = font.ascender + abs(font.descender) + font.leading
let rowAdvance = lineHeight + CodeViewConfiguration.codeLineSpacing
let adjustedY = localPoint.y - barHeight - DiffViewConfiguration.contentVerticalPadding(theme: theme)
let adjustedY = point.y - barHeight - DiffViewConfiguration.contentVerticalPadding(theme: theme)
guard adjustedY >= 0 else { return nil }
let row = Int(adjustedY / rowAdvance) + 1
guard row >= 1, row <= diffRowCount() else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

private var lineRects: [CGRect] = []

var hasLineRects: Bool { !lineRects.isEmpty }

override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false
Expand All @@ -29,6 +31,29 @@
setNeedsDisplay()
}

/// Maps a y in this overlay's coordinate space to a 1-based line index
/// using the same rects the highlight draws, so a hit-tested line always
/// matches the rendered selection. Each line owns the spacing gap below
/// it; nil above the first line. Indices past the last rect are
/// extrapolated at the last line's advance — CoreText emits no line for
/// a trailing blank row (text ending in a newline), so logical rows can
/// outnumber resolved rects. Callers cap the result to their logical
/// line count, which turns points beyond the real content into nil.
func lineIndex(atY y: CGFloat, trailingGap: CGFloat) -> Int? {
guard let first = lineRects.first, let last = lineRects.last else { return nil }
guard y >= first.minY else { return nil }
for index in 0 ..< (lineRects.count - 1) where y < lineRects[index + 1].minY {
return index + 1
}
if y < last.maxY + trailingGap {
return lineRects.count
}
let advance = last.height + trailingGap
guard advance > 0 else { return nil }
let extra = Int((y - (last.maxY + trailingGap)) / advance) + 1
return lineRects.count + extra
}
Comment on lines +34 to +55

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in b60efc9: LineSelectionOverlayViewTests covers inside-line hits, gap ownership, above-first bounds, trailing gap, single line, and uneven line heights (the motivating case). Extended in 409c033 for the beyond-last-rect extrapolation.


func clearSelection() {
selectedRange = nil
}
Expand Down Expand Up @@ -74,6 +99,8 @@

private var lineRects: [CGRect] = []

var hasLineRects: Bool { !lineRects.isEmpty }

override init(frame: CGRect) {
super.init(frame: frame)
wantsLayer = true
Expand All @@ -90,6 +117,29 @@
needsDisplay = true
}

/// Maps a y in this overlay's coordinate space to a 1-based line index
/// using the same rects the highlight draws, so a hit-tested line always
/// matches the rendered selection. Each line owns the spacing gap below
/// it; nil above the first line. Indices past the last rect are
/// extrapolated at the last line's advance — CoreText emits no line for
/// a trailing blank row (text ending in a newline), so logical rows can
/// outnumber resolved rects. Callers cap the result to their logical
/// line count, which turns points beyond the real content into nil.
func lineIndex(atY y: CGFloat, trailingGap: CGFloat) -> Int? {
guard let first = lineRects.first, let last = lineRects.last else { return nil }
guard y >= first.minY else { return nil }
for index in 0 ..< (lineRects.count - 1) where y < lineRects[index + 1].minY {
return index + 1
}
if y < last.maxY + trailingGap {
return lineRects.count
}
let advance = last.height + trailingGap
guard advance > 0 else { return nil }
let extra = Int((y - (last.maxY + trailingGap)) / advance) + 1
return lineRects.count + extra
}

func clearSelection() {
selectedRange = nil
}
Expand Down
110 changes: 110 additions & 0 deletions Tests/MarkdownViewTests/LineSelectionOverlayViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import XCTest
@testable import MarkdownView
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif

@MainActor
final class LineSelectionOverlayViewTests: XCTestCase {
private let lineHeight: CGFloat = 20
private let gap: CGFloat = 4
private let firstLineY: CGFloat = 10

/// Synthetic resolved line rects: three 20pt lines separated by a 4pt gap,
/// starting at y=10 — line 1 spans 10..<30, line 2 spans 34..<54, line 3
/// spans 58..<78.
private func makeOverlay(lineCount: Int = 3) -> LineSelectionOverlayView {
let overlay = LineSelectionOverlayView(frame: .zero)
let rects = (0 ..< lineCount).map { index in
CGRect(
x: 0,
y: firstLineY + CGFloat(index) * (lineHeight + gap),
width: 100,
height: lineHeight
)
}
overlay.updateLineRects(rects)
return overlay
}

func testHasLineRects() {
let empty = LineSelectionOverlayView(frame: .zero)
XCTAssertFalse(empty.hasLineRects)
XCTAssertTrue(makeOverlay().hasLineRects)
}

func testEmptyRectsReturnsNil() {
let overlay = LineSelectionOverlayView(frame: .zero)
XCTAssertNil(overlay.lineIndex(atY: 10, trailingGap: gap))
}

func testPointInsideLine() {
let overlay = makeOverlay()
XCTAssertEqual(overlay.lineIndex(atY: 10, trailingGap: gap), 1)
XCTAssertEqual(overlay.lineIndex(atY: 29, trailingGap: gap), 1)
XCTAssertEqual(overlay.lineIndex(atY: 44, trailingGap: gap), 2)
XCTAssertEqual(overlay.lineIndex(atY: 70, trailingGap: gap), 3)
}

func testPointInGapBelongsToLineAbove() {
let overlay = makeOverlay()
// Gap between line 1 (ends 30) and line 2 (starts 34).
XCTAssertEqual(overlay.lineIndex(atY: 30, trailingGap: gap), 1)
XCTAssertEqual(overlay.lineIndex(atY: 33.5, trailingGap: gap), 1)
// Gap between line 2 (ends 54) and line 3 (starts 58).
XCTAssertEqual(overlay.lineIndex(atY: 56, trailingGap: gap), 2)
}

func testPointAboveFirstLineReturnsNil() {
let overlay = makeOverlay()
XCTAssertNil(overlay.lineIndex(atY: 9.5, trailingGap: gap))
XCTAssertNil(overlay.lineIndex(atY: -5, trailingGap: gap))
}

func testTrailingGapBelongsToLastLine() {
let overlay = makeOverlay()
// Line 3 ends at 78; it owns one trailing gap (< 82).
XCTAssertEqual(overlay.lineIndex(atY: 78, trailingGap: gap), 3)
XCTAssertEqual(overlay.lineIndex(atY: 81.5, trailingGap: gap), 3)
}

/// CoreText emits no line for a trailing blank row (text ending in a
/// newline), so logical rows can outnumber resolved rects. Points past
/// the last rect extrapolate at the last line's advance; callers cap to
/// their logical line count.
func testExtrapolatesBeyondLastRect() {
let overlay = makeOverlay()
// Line 3's owned region ends at 82; each extrapolated row advances 24.
XCTAssertEqual(overlay.lineIndex(atY: 82, trailingGap: gap), 4)
XCTAssertEqual(overlay.lineIndex(atY: 105.5, trailingGap: gap), 4)
XCTAssertEqual(overlay.lineIndex(atY: 106, trailingGap: gap), 5)
}

func testSingleLine() {
let overlay = makeOverlay(lineCount: 1)
XCTAssertNil(overlay.lineIndex(atY: 9, trailingGap: gap))
XCTAssertEqual(overlay.lineIndex(atY: 10, trailingGap: gap), 1)
XCTAssertEqual(overlay.lineIndex(atY: 33, trailingGap: gap), 1)
// Beyond the owned region: extrapolated to the (possibly virtual)
// next row, for the caller's count guard to accept or reject.
XCTAssertEqual(overlay.lineIndex(atY: 34, trailingGap: gap), 2)
}

/// The motivating case for rect-based hit-testing: lines whose real
/// heights vary (fallback-font glyphs, accumulated layout drift) must
/// still resolve to the rect that contains the point.
func testUnevenLineHeights() {
let overlay = LineSelectionOverlayView(frame: .zero)
overlay.updateLineRects([
CGRect(x: 0, y: 0, width: 100, height: 20),
CGRect(x: 0, y: 24, width: 100, height: 32), // taller (emoji/CJK)
CGRect(x: 0, y: 60, width: 100, height: 20),
])
XCTAssertEqual(overlay.lineIndex(atY: 12, trailingGap: gap), 1)
XCTAssertEqual(overlay.lineIndex(atY: 40, trailingGap: gap), 2)
XCTAssertEqual(overlay.lineIndex(atY: 59, trailingGap: gap), 2)
XCTAssertEqual(overlay.lineIndex(atY: 61, trailingGap: gap), 3)
}
}
Loading