diff --git a/README.md b/README.md
index 2caa83c..c6fc6af 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ A high-performance markdown rendering library for iOS, macOS, and visionOS.
- Inline image rendering with async loading and caching
- Comprehensive theming with fonts, colors, and spacing
- Text selection with long-press, double-tap, and triple-tap gestures
+- Line selection in code and diff views with tap (single line) or long-press-drag (multi-line) and callback
- VoiceOver accessibility for text, code blocks, tables, and math content
- UIKit and AppKit support via a single API
@@ -213,6 +214,8 @@ theme.colors.selectionTint = .systemBlue
// Optional override if you want a custom translucent fill instead of
// selectionTint.withAlphaComponent(0.2)
theme.colors.selectionBackground = .systemBlue.withAlphaComponent(0.16)
+// Optional override for line selection highlight in code/diff views
+theme.colors.lineSelectionBackground = .systemBlue.withAlphaComponent(0.15)
// Diff-specific styling
theme.diff.backgroundColor = .black
@@ -237,6 +240,36 @@ markdownView.theme = theme
Selection tint is theme-driven. By default, `selectionBackground` is derived from `selectionTint` with a 20% alpha. Set `selectionBackground` explicitly when you want a different selection fill without changing the tint.
+### Line Selection
+
+Code blocks and diff views support tapping to select a line, or long-press-and-drag to select a range of lines. The selected lines are highlighted and a callback provides the 1-based line range, the text contents, and the language.
+
+
+Show line selection example
+
+```swift
+markdownView.lineSelectionHandler = { info in
+ guard let info else {
+ print("Selection cleared")
+ return
+ }
+ print("Selected lines \(info.lineRange) in \(info.language ?? "unknown"):")
+ for line in info.contents {
+ print(" \(line)")
+ }
+}
+
+// Customize the selection highlight color
+var theme = MarkdownTheme()
+theme.colors.lineSelectionBackground = .systemBlue.withAlphaComponent(0.2)
+markdownView.theme = theme
+```
+
+
+
+Selection is exclusive: selecting lines in one code or diff block automatically clears any selection in other blocks.
+
+
### Unified Diffs
@@ -311,6 +344,3 @@ The library is split into two modules:
MIT
-## Inspiration
-
-Inspired by [Lakr233/MarkdownView](https://github.com/Lakr233/MarkdownView)
diff --git a/Sources/MarkdownView/Components/CodeView/CodeView.swift b/Sources/MarkdownView/Components/CodeView/CodeView.swift
index bd6255b..457741f 100644
--- a/Sources/MarkdownView/Components/CodeView/CodeView.swift
+++ b/Sources/MarkdownView/Components/CodeView/CodeView.swift
@@ -42,6 +42,7 @@ import Litext
textView.attributedText = highlightMap.apply(to: content, with: theme)
lineNumberView.updateForContent(content)
updateLineNumberView()
+ clearLineSelection()
}
}
@@ -55,6 +56,96 @@ import Litext
}
}
+ // MARK: - LINE SELECTION
+
+ var lineSelectionHandler: LineSelectionHandler?
+ private(set) var selectedLineRange: ClosedRange?
+ private lazy var selectionOverlay: LineSelectionOverlayView = .init()
+ private var dragAnchorLine: Int?
+
+ func clearLineSelection() {
+ guard selectedLineRange != nil else { return }
+ selectedLineRange = nil
+ selectionOverlay.clearSelection()
+ }
+
+ private func lineIndex(at point: CGPoint) -> Int? {
+ let localPoint = scrollView.convert(point, from: self)
+ let contentPoint = CGPoint(
+ x: localPoint.x + scrollView.contentOffset.x,
+ y: localPoint.y + scrollView.contentOffset.y
+ )
+ 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
+ guard line >= 1, line <= cachedLineCount else { return nil }
+ return line
+ }
+
+ private func updateLineSelection(_ range: ClosedRange?) {
+ selectedLineRange = range
+ selectionOverlay.selectedRange = range
+ if let range = range {
+ let lines = content.components(separatedBy: .newlines)
+ let contents = (range.lowerBound...range.upperBound).compactMap { idx -> String? in
+ let arrayIdx = idx - 1
+ guard arrayIdx >= 0, arrayIdx < lines.count else { return nil }
+ return lines[arrayIdx]
+ }
+ let info = LineSelectionInfo(
+ lineRange: range,
+ contents: contents,
+ language: language.isEmpty ? nil : language
+ )
+ lineSelectionHandler?(info)
+ } else {
+ lineSelectionHandler?(nil)
+ }
+ }
+
+ @objc private func handleLineTap(_ gesture: UITapGestureRecognizer) {
+ let point = gesture.location(in: self)
+ guard let line = lineIndex(at: point) else { return }
+ if selectedLineRange == line...line {
+ updateLineSelection(nil)
+ } else {
+ updateLineSelection(line...line)
+ }
+ #if !os(visionOS)
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ #endif
+ }
+
+ @objc private func handleLineLongPress(_ gesture: UILongPressGestureRecognizer) {
+ let point = gesture.location(in: self)
+ switch gesture.state {
+ case .began:
+ guard let line = lineIndex(at: point) else { return }
+ dragAnchorLine = line
+ updateLineSelection(line...line)
+ #if !os(visionOS)
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ #endif
+ case .changed:
+ guard let anchor = dragAnchorLine,
+ let line = lineIndex(at: point) else { return }
+ let newRange = min(anchor, line)...max(anchor, line)
+ if newRange != selectedLineRange {
+ updateLineSelection(newRange)
+ }
+ case .ended, .cancelled, .failed:
+ dragAnchorLine = nil
+ default:
+ break
+ }
+ }
+
+ // MARK: LINE SELECTION -
+
private let callerIdentifier = UUID()
private var currentTaskIdentifier: UUID?
@@ -213,6 +304,7 @@ import Litext
textView.attributedText = highlightMap.apply(to: content, with: theme)
lineNumberView.updateForContent(content)
updateLineNumberView()
+ clearLineSelection()
}
}
@@ -224,6 +316,84 @@ import Litext
}
}
+ // MARK: - LINE SELECTION
+
+ var lineSelectionHandler: LineSelectionHandler?
+ private(set) var selectedLineRange: ClosedRange?
+ private lazy var selectionOverlay: LineSelectionOverlayView = .init()
+ private var dragAnchorLine: Int?
+
+ func clearLineSelection() {
+ guard selectedLineRange != nil else { return }
+ selectedLineRange = nil
+ selectionOverlay.clearSelection()
+ }
+
+ private func lineIndex(at point: CGPoint) -> Int? {
+ let localPoint = convert(point, from: nil)
+ 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
+ guard adjustedY >= CodeViewConfiguration.codePadding else { return nil }
+ let line = Int((adjustedY - CodeViewConfiguration.codePadding) / rowAdvance) + 1
+ guard line >= 1, line <= cachedLineCount else { return nil }
+ return line
+ }
+
+ private func updateLineSelection(_ range: ClosedRange?) {
+ selectedLineRange = range
+ selectionOverlay.selectedRange = range
+ if let range = range {
+ let lines = content.components(separatedBy: .newlines)
+ let contents = (range.lowerBound...range.upperBound).compactMap { idx -> String? in
+ let arrayIdx = idx - 1
+ guard arrayIdx >= 0, arrayIdx < lines.count else { return nil }
+ return lines[arrayIdx]
+ }
+ let info = LineSelectionInfo(
+ lineRange: range,
+ contents: contents,
+ language: language.isEmpty ? nil : language
+ )
+ lineSelectionHandler?(info)
+ } else {
+ lineSelectionHandler?(nil)
+ }
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ let point = convert(event.locationInWindow, from: nil)
+ guard let line = lineIndex(at: point) else {
+ super.mouseDown(with: event)
+ return
+ }
+ dragAnchorLine = line
+ if selectedLineRange == line...line {
+ updateLineSelection(nil)
+ } else {
+ updateLineSelection(line...line)
+ }
+ }
+
+ override func mouseDragged(with event: NSEvent) {
+ let point = convert(event.locationInWindow, from: nil)
+ guard let anchor = dragAnchorLine,
+ let line = lineIndex(at: point) else { return }
+ let newRange = min(anchor, line)...max(anchor, line)
+ if newRange != selectedLineRange {
+ updateLineSelection(newRange)
+ }
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ dragAnchorLine = nil
+ super.mouseUp(with: event)
+ }
+
+ // MARK: LINE SELECTION -
+
private let callerIdentifier = UUID()
private var currentTaskIdentifier: UUID?
diff --git a/Sources/MarkdownView/Components/CodeView/CodeViewConfiguration.swift b/Sources/MarkdownView/Components/CodeView/CodeViewConfiguration.swift
index 4dd9461..2615872 100644
--- a/Sources/MarkdownView/Components/CodeView/CodeViewConfiguration.swift
+++ b/Sources/MarkdownView/Components/CodeView/CodeViewConfiguration.swift
@@ -76,6 +76,7 @@ enum CodeViewConfiguration {
setupScrollView()
setupTextView()
setupLineNumberView()
+ setupLineSelectionGestures()
updateHeaderVisibility()
}
@@ -142,6 +143,21 @@ enum CodeViewConfiguration {
updateLineNumberView()
}
+ private func setupLineSelectionGestures() {
+ selectionOverlay.isUserInteractionEnabled = false
+ let selectionColor = theme.colors.lineSelectionBackground
+ ?? theme.colors.selectionTint.withAlphaComponent(0.15)
+ selectionOverlay.selectionColor = selectionColor
+ scrollView.addSubview(selectionOverlay)
+
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleLineTap(_:)))
+ addGestureRecognizer(tap)
+
+ let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLineLongPress(_:)))
+ longPress.minimumPressDuration = 0.15
+ addGestureRecognizer(longPress)
+ }
+
func performLayout() {
let labelSize = languageLabel.intrinsicContentSize
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
@@ -236,12 +252,24 @@ enum CodeViewConfiguration {
textView.setNeedsLayout()
textView.layoutIfNeeded()
- lineNumberView.updateLineRects(
- offsetCodeLineRects(
- textView.lineRects(),
- by: textView.frame.origin
+ let resolvedLineRects = offsetCodeLineRects(
+ textView.lineRects(),
+ by: textView.frame.origin
+ )
+ lineNumberView.updateLineRects(resolvedLineRects)
+
+ selectionOverlay.frame = CGRect(
+ origin: .zero,
+ size: CGSize(
+ width: scrollView.contentSize.width,
+ height: max(textView.frame.maxY + CodeViewConfiguration.codePadding, scrollView.bounds.height)
)
)
+ selectionOverlay.updateLineRects(resolvedLineRects)
+
+ let selectionColor = theme.colors.lineSelectionBackground
+ ?? theme.colors.selectionTint.withAlphaComponent(0.15)
+ selectionOverlay.selectionColor = selectionColor
}
}
@@ -254,6 +282,7 @@ enum CodeViewConfiguration {
setupScrollView()
setupTextView()
setupLineNumberView()
+ setupLineSelectionOverlay()
updateHeaderVisibility()
}
@@ -330,6 +359,13 @@ enum CodeViewConfiguration {
updateLineNumberView()
}
+ private func setupLineSelectionOverlay() {
+ let selectionColor = theme.colors.lineSelectionBackground
+ ?? theme.colors.selectionTint.withAlphaComponent(0.15)
+ selectionOverlay.selectionColor = selectionColor
+ scrollView.documentView?.addSubview(selectionOverlay, positioned: .below, relativeTo: textView)
+ }
+
func performLayout() {
let labelSize = languageLabel.intrinsicContentSize
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
@@ -419,12 +455,24 @@ enum CodeViewConfiguration {
textView.needsLayout = true
textView.layoutSubtreeIfNeeded()
- lineNumberView.updateLineRects(
- offsetCodeLineRects(
- textView.lineRects(),
- by: textView.frame.origin
+ let resolvedLineRects = offsetCodeLineRects(
+ textView.lineRects(),
+ by: textView.frame.origin
+ )
+ lineNumberView.updateLineRects(resolvedLineRects)
+
+ selectionOverlay.frame = CGRect(
+ origin: .zero,
+ size: CGSize(
+ width: max(scrollView.bounds.width - CodeViewConfiguration.codePadding * 2, textView.frame.width),
+ height: max(textView.frame.maxY + CodeViewConfiguration.codePadding, scrollView.bounds.height)
)
)
+ selectionOverlay.updateLineRects(resolvedLineRects)
+
+ let selectionColor = theme.colors.lineSelectionBackground
+ ?? theme.colors.selectionTint.withAlphaComponent(0.15)
+ selectionOverlay.selectionColor = selectionColor
}
}
#endif
diff --git a/Sources/MarkdownView/Components/DiffView/DiffView.swift b/Sources/MarkdownView/Components/DiffView/DiffView.swift
index bdba05b..a5506ca 100644
--- a/Sources/MarkdownView/Components/DiffView/DiffView.swift
+++ b/Sources/MarkdownView/Components/DiffView/DiffView.swift
@@ -796,6 +796,7 @@ private func makeSideBySideAttributedText(
var renderBlock: DiffRenderBlock = .init(language: nil, rows: []) {
didSet {
+ clearLineSelection()
applyRenderBlock()
}
}
@@ -804,6 +805,101 @@ private func makeSideBySideAttributedText(
private var displayRows: DiffDisplayRows = .unified([])
private var sideBySideTextMetrics: DiffSideBySideTextMetrics?
+ // MARK: - LINE SELECTION
+
+ var lineSelectionHandler: LineSelectionHandler?
+ private(set) var selectedLineRange: ClosedRange?
+ private lazy var selectionOverlay: LineSelectionOverlayView = .init()
+ private var dragAnchorLine: Int?
+
+ func clearLineSelection() {
+ guard selectedLineRange != nil else { return }
+ selectedLineRange = nil
+ selectionOverlay.clearSelection()
+ }
+
+ private func diffRowCount() -> Int {
+ displayRows.effectiveCount
+ }
+
+ private func rowIndex(at point: CGPoint) -> Int? {
+ let localPoint = scrollView.convert(point, from: self)
+ let contentPoint = CGPoint(
+ x: localPoint.x + scrollView.contentOffset.x,
+ y: localPoint.y + scrollView.contentOffset.y
+ )
+ let font = theme.fonts.code
+ let lineHeight = font.lineHeight
+ let rowAdvance = lineHeight + CodeViewConfiguration.codeLineSpacing
+ let adjustedY = contentPoint.y - DiffViewConfiguration.verticalPadding
+ guard adjustedY >= 0 else { return nil }
+ let row = Int(adjustedY / rowAdvance) + 1
+ guard row >= 1, row <= diffRowCount() else { return nil }
+ return row
+ }
+
+ private func rowContents(for range: ClosedRange) -> [String] {
+ range.compactMap { idx -> String? in
+ let arrayIdx = idx - 1
+ guard arrayIdx >= 0, arrayIdx < renderBlock.rows.count else { return nil }
+ return renderBlock.rows[arrayIdx].text
+ }
+ }
+
+ private func updateDiffLineSelection(_ range: ClosedRange?) {
+ selectedLineRange = range
+ selectionOverlay.selectedRange = range
+ if let range = range {
+ let info = LineSelectionInfo(
+ lineRange: range,
+ contents: rowContents(for: range),
+ language: renderBlock.language
+ )
+ lineSelectionHandler?(info)
+ } else {
+ lineSelectionHandler?(nil)
+ }
+ }
+
+ @objc private func handleDiffLineTap(_ gesture: UITapGestureRecognizer) {
+ let point = gesture.location(in: self)
+ guard let row = rowIndex(at: point) else { return }
+ if selectedLineRange == row...row {
+ updateDiffLineSelection(nil)
+ } else {
+ updateDiffLineSelection(row...row)
+ }
+ #if !os(visionOS)
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ #endif
+ }
+
+ @objc private func handleDiffLineLongPress(_ gesture: UILongPressGestureRecognizer) {
+ let point = gesture.location(in: self)
+ switch gesture.state {
+ case .began:
+ guard let row = rowIndex(at: point) else { return }
+ dragAnchorLine = row
+ updateDiffLineSelection(row...row)
+ #if !os(visionOS)
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ #endif
+ case .changed:
+ guard let anchor = dragAnchorLine,
+ let row = rowIndex(at: point) else { return }
+ let newRange = min(anchor, row)...max(anchor, row)
+ if newRange != selectedLineRange {
+ updateDiffLineSelection(newRange)
+ }
+ case .ended, .cancelled, .failed:
+ dragAnchorLine = nil
+ default:
+ break
+ }
+ }
+
+ // MARK: LINE SELECTION -
+
lazy var scrollView: UIScrollView = .init()
lazy var barView: UIView = .init()
lazy var titleLabel: UILabel = .init()
@@ -988,6 +1084,12 @@ private func makeSideBySideAttributedText(
)
gutterView.updateLineRects(lineRects)
backgroundView.updateLineRects(lineRects)
+ selectionOverlay.frame = contentContainerView.bounds
+ selectionOverlay.updateLineRects(lineRects)
+
+ let selectionColor = theme.colors.lineSelectionBackground
+ ?? theme.colors.selectionTint.withAlphaComponent(0.15)
+ selectionOverlay.selectionColor = selectionColor
}
private func performLayout() {
@@ -1507,6 +1609,7 @@ private func makeSideBySideAttributedText(
var renderBlock: DiffRenderBlock = .init(language: nil, rows: []) {
didSet {
+ clearLineSelection()
applyRenderBlock()
}
}
@@ -1515,6 +1618,90 @@ private func makeSideBySideAttributedText(
private var displayRows: DiffDisplayRows = .unified([])
private var sideBySideTextMetrics: DiffSideBySideTextMetrics?
+ // MARK: - LINE SELECTION
+
+ var lineSelectionHandler: LineSelectionHandler?
+ private(set) var selectedLineRange: ClosedRange?
+ private lazy var selectionOverlay: LineSelectionOverlayView = .init()
+ private var dragAnchorLine: Int?
+
+ func clearLineSelection() {
+ guard selectedLineRange != nil else { return }
+ selectedLineRange = nil
+ selectionOverlay.clearSelection()
+ }
+
+ private func diffRowCount() -> Int {
+ displayRows.effectiveCount
+ }
+
+ private func rowIndex(at point: CGPoint) -> Int? {
+ let localPoint = convert(point, from: nil)
+ 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.verticalPadding
+ guard adjustedY >= 0 else { return nil }
+ let row = Int(adjustedY / rowAdvance) + 1
+ guard row >= 1, row <= diffRowCount() else { return nil }
+ return row
+ }
+
+ private func rowContents(for range: ClosedRange) -> [String] {
+ range.compactMap { idx -> String? in
+ let arrayIdx = idx - 1
+ guard arrayIdx >= 0, arrayIdx < renderBlock.rows.count else { return nil }
+ return renderBlock.rows[arrayIdx].text
+ }
+ }
+
+ private func updateDiffLineSelection(_ range: ClosedRange?) {
+ selectedLineRange = range
+ selectionOverlay.selectedRange = range
+ if let range = range {
+ let info = LineSelectionInfo(
+ lineRange: range,
+ contents: rowContents(for: range),
+ language: renderBlock.language
+ )
+ lineSelectionHandler?(info)
+ } else {
+ lineSelectionHandler?(nil)
+ }
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ let point = convert(event.locationInWindow, from: nil)
+ guard let row = rowIndex(at: point) else {
+ super.mouseDown(with: event)
+ return
+ }
+ dragAnchorLine = row
+ if selectedLineRange == row...row {
+ updateDiffLineSelection(nil)
+ } else {
+ updateDiffLineSelection(row...row)
+ }
+ }
+
+ override func mouseDragged(with event: NSEvent) {
+ let point = convert(event.locationInWindow, from: nil)
+ guard let anchor = dragAnchorLine,
+ let row = rowIndex(at: point) else { return }
+ let newRange = min(anchor, row)...max(anchor, row)
+ if newRange != selectedLineRange {
+ updateDiffLineSelection(newRange)
+ }
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ dragAnchorLine = nil
+ super.mouseUp(with: event)
+ }
+
+ // MARK: LINE SELECTION -
+
lazy var scrollView: NSScrollView = {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = false
@@ -1621,6 +1808,12 @@ private func makeSideBySideAttributedText(
textView.isSelectable = true
textView.selectionBackgroundColor = theme.colors.selectionBackground
contentContainerView.addSubview(textView)
+
+ let selectionColor = theme.colors.lineSelectionBackground
+ ?? theme.colors.selectionTint.withAlphaComponent(0.15)
+ selectionOverlay.selectionColor = selectionColor
+ contentContainerView.addSubview(selectionOverlay, positioned: .above, relativeTo: backgroundView)
+
updateHeaderVisibility()
}
@@ -1711,6 +1904,12 @@ private func makeSideBySideAttributedText(
)
gutterView.updateLineRects(lineRects)
backgroundView.updateLineRects(lineRects)
+ selectionOverlay.frame = contentContainerView.bounds
+ selectionOverlay.updateLineRects(lineRects)
+
+ let selectionColor = theme.colors.lineSelectionBackground
+ ?? theme.colors.selectionTint.withAlphaComponent(0.15)
+ selectionOverlay.selectionColor = selectionColor
}
private func performLayout() {
diff --git a/Sources/MarkdownView/Components/LineSelection/LineSelectionOverlayView.swift b/Sources/MarkdownView/Components/LineSelection/LineSelectionOverlayView.swift
new file mode 100644
index 0000000..d3c02cc
--- /dev/null
+++ b/Sources/MarkdownView/Components/LineSelection/LineSelectionOverlayView.swift
@@ -0,0 +1,119 @@
+#if canImport(UIKit)
+ import UIKit
+
+ final class LineSelectionOverlayView: UIView {
+ var selectionColor: UIColor = .systemBlue.withAlphaComponent(0.15) {
+ didSet { setNeedsDisplay() }
+ }
+
+ var selectedRange: ClosedRange? {
+ didSet {
+ guard oldValue != selectedRange else { return }
+ setNeedsDisplay()
+ }
+ }
+
+ private var lineRects: [CGRect] = []
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ isOpaque = false
+ backgroundColor = .clear
+ }
+
+ @available(*, unavailable)
+ required init?(coder _: NSCoder) { fatalError() }
+
+ func updateLineRects(_ rects: [CGRect]) {
+ lineRects = rects
+ setNeedsDisplay()
+ }
+
+ func clearSelection() {
+ selectedRange = nil
+ }
+
+ override func draw(_ rect: CGRect) {
+ guard let range = selectedRange,
+ let ctx = UIGraphicsGetCurrentContext()
+ else { return }
+
+ ctx.setFillColor(selectionColor.cgColor)
+
+ for lineIndex in range {
+ let arrayIndex = lineIndex - 1
+ guard arrayIndex >= 0, arrayIndex < lineRects.count else { continue }
+ let lineRect = lineRects[arrayIndex]
+ // Extend selection across full width
+ let highlightRect = CGRect(
+ x: 0,
+ y: lineRect.origin.y,
+ width: bounds.width,
+ height: lineRect.height
+ )
+ guard highlightRect.intersects(rect) else { continue }
+ ctx.fill(highlightRect)
+ }
+ }
+ }
+
+#elseif canImport(AppKit)
+ import AppKit
+
+ final class LineSelectionOverlayView: NSView {
+ var selectionColor: NSColor = .systemBlue.withAlphaComponent(0.15) {
+ didSet { needsDisplay = true }
+ }
+
+ var selectedRange: ClosedRange? {
+ didSet {
+ guard oldValue != selectedRange else { return }
+ needsDisplay = true
+ }
+ }
+
+ private var lineRects: [CGRect] = []
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ wantsLayer = true
+ layer?.backgroundColor = NSColor.clear.cgColor
+ }
+
+ @available(*, unavailable)
+ required init?(coder _: NSCoder) { fatalError() }
+
+ override var isFlipped: Bool { true }
+
+ func updateLineRects(_ rects: [CGRect]) {
+ lineRects = rects
+ needsDisplay = true
+ }
+
+ func clearSelection() {
+ selectedRange = nil
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let range = selectedRange,
+ let ctx = NSGraphicsContext.current?.cgContext
+ else { return }
+
+ ctx.setFillColor(selectionColor.cgColor)
+
+ for lineIndex in range {
+ let arrayIndex = lineIndex - 1
+ guard arrayIndex >= 0, arrayIndex < lineRects.count else { continue }
+ let lineRect = lineRects[arrayIndex]
+ let highlightRect = CGRect(
+ x: 0,
+ y: lineRect.origin.y,
+ width: bounds.width,
+ height: lineRect.height
+ )
+ guard highlightRect.intersects(dirtyRect) else { continue }
+ ctx.fill(highlightRect)
+ }
+ }
+ }
+#endif
diff --git a/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Do.swift b/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Do.swift
index 813f19c..5f849be 100644
--- a/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Do.swift
+++ b/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Do.swift
@@ -179,6 +179,10 @@ extension TextBuilder {
size: .init(width: view.bounds.width - leftIndent, height: intrinsicContentSize.height)
)
codeView.previewAction = view.codePreviewHandler
+ codeView.lineSelectionHandler = { [weak view, weak codeView] info in
+ guard let view, let codeView else { return }
+ view.handleLineSelection(from: codeView, info: info)
+ }
}
.withDiffDrawing { [weak view] _, line, lineOrigin in
guard let view else { return }
@@ -201,6 +205,10 @@ extension TextBuilder {
origin: .init(x: lineOrigin.x + leftIndent, y: view.bounds.height - lineBoundingBox.maxY),
size: .init(width: view.bounds.width - leftIndent, height: intrinsicContentSize.height)
)
+ diffView.lineSelectionHandler = { [weak view, weak diffView] info in
+ guard let view, let diffView else { return }
+ view.handleLineSelection(from: diffView, info: info)
+ }
}
.withTableDrawing { [weak view] _, line, lineOrigin in
guard let view else { return }
diff --git a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift
index 85d7d35..5e0f00d 100644
--- a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift
+++ b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift
@@ -21,6 +21,7 @@ enum ContentPipelineMode {
public var linkHandler: ((LinkPayload, NSRange, CGPoint) -> Void)?
public var imageTapHandler: ((String, CGPoint) -> Void)?
public var codePreviewHandler: ((String?, NSAttributedString) -> Void)?
+ public var lineSelectionHandler: LineSelectionHandler?
public internal(set) var document: PreprocessedContent = .init()
public let textView: LTXLabel = .init()
@@ -69,6 +70,21 @@ enum ContentPipelineMode {
observeImageLoading()
}
+ /// Handles line selection from a code or diff view, clearing other blocks (exclusive mode)
+ /// and forwarding the selection info to the public handler.
+ func handleLineSelection(from sourceView: UIView, info: LineSelectionInfo?) {
+ // Exclusive mode: clear selection in all other code/diff views
+ for view in contextViews {
+ guard view !== sourceView else { continue }
+ if let codeView = view as? CodeView {
+ codeView.clearLineSelection()
+ } else if let diffView = view as? DiffView {
+ diffView.clearLineSelection()
+ }
+ }
+ lineSelectionHandler?(info)
+ }
+
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
@@ -143,6 +159,7 @@ enum ContentPipelineMode {
public var linkHandler: ((LinkPayload, NSRange, CGPoint) -> Void)?
public var imageTapHandler: ((String, CGPoint) -> Void)?
public var codePreviewHandler: ((String?, NSAttributedString) -> Void)?
+ public var lineSelectionHandler: LineSelectionHandler?
public internal(set) var document: PreprocessedContent = .init()
public let textView: LTXLabel = .init()
@@ -191,6 +208,20 @@ enum ContentPipelineMode {
observeImageLoading()
}
+ /// Handles line selection from a code or diff view, clearing other blocks (exclusive mode)
+ /// and forwarding the selection info to the public handler.
+ func handleLineSelection(from sourceView: NSView, info: LineSelectionInfo?) {
+ for view in contextViews {
+ guard view !== sourceView else { continue }
+ if let codeView = view as? CodeView {
+ codeView.clearLineSelection()
+ } else if let diffView = view as? DiffView {
+ diffView.clearLineSelection()
+ }
+ }
+ lineSelectionHandler?(info)
+ }
+
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
diff --git a/Sources/MarkdownView/MarkdownTheme/MarkdownTheme.swift b/Sources/MarkdownView/MarkdownTheme/MarkdownTheme.swift
index c830f41..1e222eb 100644
--- a/Sources/MarkdownView/MarkdownTheme/MarkdownTheme.swift
+++ b/Sources/MarkdownView/MarkdownTheme/MarkdownTheme.swift
@@ -76,6 +76,10 @@ public struct MarkdownTheme: Equatable {
get { selectionBackgroundOverride ?? selectionTint.withAlphaComponent(0.2) }
set { selectionBackgroundOverride = newValue }
}
+
+ /// Background color for line selection highlights in code and diff views.
+ /// Defaults to `selectionTint` at 15% opacity when `nil`.
+ public var lineSelectionBackground: UIColor?
#elseif canImport(AppKit)
private static var defaultAccentColor: NSColor {
NSColor(named: "AccentColor")
@@ -95,6 +99,10 @@ public struct MarkdownTheme: Equatable {
get { selectionBackgroundOverride ?? selectionTint.withAlphaComponent(0.2) }
set { selectionBackgroundOverride = newValue }
}
+
+ /// Background color for line selection highlights in code and diff views.
+ /// Defaults to `selectionTint` at 15% opacity when `nil`.
+ public var lineSelectionBackground: NSColor?
#endif
}