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 }