Skip to content

Commit

Permalink
rework line higlight from drawing to layer
Browse files Browse the repository at this point in the history
  • Loading branch information
krzyzanowskim committed Aug 21, 2024
1 parent b114a8d commit 153665b
Show file tree
Hide file tree
Showing 11 changed files with 162 additions and 147 deletions.
27 changes: 27 additions & 0 deletions Sources/STTextViewAppKit/Overlays/STLineHighlightView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Created by Marcin Krzyzanowski
// https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md

import AppKit

final class STLineHighlightView: NSView {

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
clipsToBounds = true
}

required init?(coder: NSCoder) {
super.init(coder: coder)
wantsLayer = true
clipsToBounds = true
}

override var isFlipped: Bool {
#if os(macOS)
true
#else
false
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import AppKit

final class SelectionHighlightView: NSView {
final class STSelectionHighlightView: NSView {
override var isFlipped: Bool {
#if os(macOS)
true
Expand Down
3 changes: 2 additions & 1 deletion Sources/STTextViewAppKit/STTextFinderClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ final class STTextFinderClient: NSObject, NSTextFinderClient {
}

textLayoutManager.textSelections = [NSTextSelection(textRanges, affinity: .downstream, granularity: .character)]
textView?.updateSelectionHighlights()
textView?.updateSelectedRangeHighlight()
textView?.updateSelectedLineHighlight()
textView?.updateTypingAttributes()
}

Expand Down
3 changes: 2 additions & 1 deletion Sources/STTextViewAppKit/STTextView+Mouse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ extension STTextView {
if !handled, holdsShift && holdsControl {
textLayoutManager.appendInsertionPointSelection(interactingAt: eventPoint)
updateTypingAttributes()
updateSelectionHighlights()
updateSelectedRangeHighlight()
updateSelectedLineHighlight()
needsDisplay = true
handled = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ extension STTextView: NSTextViewportLayoutControllerDelegate {

public func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
sizeToFit()
updateSelectionHighlights()
updateSelectedRangeHighlight()
updateSelectedLineHighlight()
adjustViewportOffsetIfNeeded()

if let viewportRange = textViewportLayoutController.viewportRange {
Expand Down
6 changes: 4 additions & 2 deletions Sources/STTextViewAppKit/STTextView+Select.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ extension STTextView {
]

updateTypingAttributes()
updateSelectionHighlights()
updateSelectedRangeHighlight()
updateSelectedLineHighlight()
}

open override func selectLine(_ sender: Any?) {
Expand Down Expand Up @@ -496,7 +497,8 @@ extension STTextView {
}

updateTypingAttributes()
updateSelectionHighlights()
updateSelectedRangeHighlight()
updateSelectedLineHighlight()
needsDisplay = true
}

Expand Down
248 changes: 114 additions & 134 deletions Sources/STTextViewAppKit/STTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//
// STTextView
// |---selectionView
// |---(STLineHighlightView | SelectionHighlightView)
// |---contentView
// |---(STInsertionPointView | STTextLayoutFragmentView)
// |---decorationView
Expand Down Expand Up @@ -315,7 +316,7 @@ import AVFoundation
}

/// A Boolean that controls whether the text view highlights the currently selected line.
@Invalidating(.display)
@Invalidating(.layout)
@objc dynamic open var highlightSelectedLine: Bool = false

/// Enable to show line numbers in the gutter.
Expand Down Expand Up @@ -587,6 +588,7 @@ import AVFoundation
allowsUndo = true
_undoManager = CoalescingUndoManager()


textFinder = NSTextFinder()
textFinderClient = STTextFinderClient()

Expand Down Expand Up @@ -708,7 +710,8 @@ import AVFoundation

open override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
self.updateSelectionHighlights()
self.updateSelectedRangeHighlight()
self.updateSelectedLineHighlight()
}

open override func viewDidMoveToSuperview() {
Expand Down Expand Up @@ -828,126 +831,6 @@ import AVFoundation
}
}

open override func draw(_ dirtyRect: NSRect) {
drawBackground(in: dirtyRect)
super.draw(dirtyRect)
}

/// Draws the background of the text view.
open func drawBackground(in rect: NSRect) {
guard highlightSelectedLine,
textLayoutManager.textSelectionsRanges(.withoutInsertionPoints).isEmpty,
!textLayoutManager.insertionPointSelections.isEmpty
else {
// don't highlight when there's selection
return
}

drawHighlightedLine(in: rect)
}

private func drawHighlightedLine(in rect: NSRect) {

func drawHighlight(in fillRect: CGRect) {
guard let context = NSGraphicsContext.current?.cgContext else {
return
}

context.saveGState()
context.setFillColor(selectedLineHighlightColor.cgColor)
context.fill(fillRect)
context.restoreGState()
}

if textLayoutManager.documentRange.isEmpty {
// - empty document has no layout fragments, nothing, it's empty and has to be handled explicitly.
// - there's no layout fragment at the document endLocation (technically it's out of bounds), has to be handled explicitly.
if let selectionFrame = textLayoutManager.textSegmentFrame(at: textLayoutManager.documentRange.location, type: .standard) {
drawHighlight(
in: CGRect(
origin: CGPoint(
x: convert(contentView.bounds, from: contentView).minX,
y: selectionFrame.origin.y
),
size: CGSize(
width: max(scrollView?.contentSize.width ?? 0, contentView.bounds.width),
height: typingLineHeight
)
)
)
}
return
}

guard let viewportRange = textLayoutManager.textViewportLayoutController.viewportRange else {
return
}

// build the rectangle out of fragments rectangles
var combinedFragmentsRect: CGRect?

// TODO some beutiful day:
// Don't rely on NSTextParagraph.paragraphContentRange, but that
// makes tricky to get all the conditions right (especially for last line)
// Problem is that NSTextParagraph.rangeInElement span across two lines (eg. "abc\n" are two lines) while
// paragraphContentRange is just one ("abc")
//
// Another idea here is to use `textLayoutManager.textLayoutFragment(for: selectionTextRange.location)`
// to find the layout fragment and us its frame as highlight area. It has its issue when it comes to the
// extra line fragment area (sic).
textLayoutManager.enumerateTextLayoutFragments(in: viewportRange) { layoutFragment in
let contentRangeInElement = (layoutFragment.textElement as? NSTextParagraph)?.paragraphContentRange ?? layoutFragment.rangeInElement
for lineFragment in layoutFragment.textLineFragments {

func isLineSelected() -> Bool {
textLayoutManager.textSelections.flatMap(\.textRanges).reduce(true) { partialResult, selectionTextRange in
var result = true
if lineFragment.isExtraLineFragment {
let c1 = layoutFragment.rangeInElement.endLocation == selectionTextRange.location
result = result && c1
} else {
let c1 = contentRangeInElement.contains(selectionTextRange)
let c2 = contentRangeInElement.intersects(selectionTextRange)
let c3 = selectionTextRange.contains(contentRangeInElement)
let c4 = selectionTextRange.intersects(contentRangeInElement)
let c5 = contentRangeInElement.endLocation == selectionTextRange.location
result = result && (c1 || c2 || c3 || c4 || c5)
}
return partialResult && result
}
}

if isLineSelected() {
var lineFragmentFrame = layoutFragment.layoutFragmentFrame
lineFragmentFrame.size.height = lineFragment.typographicBounds.height


let r = CGRect(
origin: CGPoint(
x: convert(contentView.bounds, from: contentView).minX,
y: lineFragmentFrame.origin.y + lineFragment.typographicBounds.minY
),
size: CGSize(
width: max(scrollView?.contentSize.width ?? 0, contentView.bounds.width),
height: lineFragmentFrame.height
)
)

if let rect = combinedFragmentsRect {
combinedFragmentsRect = rect.union(r)
} else {
combinedFragmentsRect = r
}
}
}
return true
}

if let combinedFragmentsRect {
drawHighlight(in: combinedFragmentsRect.pixelAligned)
}
}

internal func setString(_ string: Any?) {
undoManager?.disableUndoRegistration()
defer {
Expand Down Expand Up @@ -1047,11 +930,118 @@ import AVFoundation
}
}

internal func updateSelectionHighlights() {
// Update selected line highlight layer
internal func updateSelectedLineHighlight() {
guard highlightSelectedLine,
textLayoutManager.textSelectionsRanges(.withoutInsertionPoints).isEmpty,
!textLayoutManager.insertionPointSelections.isEmpty
else {
// don't highlight when there's selection
return
}

func layoutHighlightView(in frameRect: CGRect) {
let highlightView = STLineHighlightView(frame: frameRect)
highlightView.layer?.backgroundColor = selectedLineHighlightColor.cgColor
selectionView.addSubview(highlightView)
}

if textLayoutManager.documentRange.isEmpty {
// - empty document has no layout fragments, nothing, it's empty and has to be handled explicitly.
// - there's no layout fragment at the document endLocation (technically it's out of bounds), has to be handled explicitly.
if let selectionFrame = textLayoutManager.textSegmentFrame(at: textLayoutManager.documentRange.location, type: .standard) {
layoutHighlightView(
in: CGRect(
origin: CGPoint(
x: selectionView.bounds.minX,
y: selectionFrame.origin.y
),
size: CGSize(
width: selectionView.bounds.width,
height: typingLineHeight
)
)
)
}
return
}

guard let viewportRange = textLayoutManager.textViewportLayoutController.viewportRange else {
return
}

// build the rectangle out of fragments rectangles
var combinedFragmentsRect: CGRect?

// TODO some beutiful day:
// Don't rely on NSTextParagraph.paragraphContentRange, but that
// makes tricky to get all the conditions right (especially for last line)
// Problem is that NSTextParagraph.rangeInElement span across two lines (eg. "abc\n" are two lines) while
// paragraphContentRange is just one ("abc")
//
// Another idea here is to use `textLayoutManager.textLayoutFragment(for: selectionTextRange.location)`
// to find the layout fragment and us its frame as highlight area. It has its issue when it comes to the
// extra line fragment area (sic).
textLayoutManager.enumerateTextLayoutFragments(in: viewportRange) { layoutFragment in
let contentRangeInElement = (layoutFragment.textElement as? NSTextParagraph)?.paragraphContentRange ?? layoutFragment.rangeInElement
for lineFragment in layoutFragment.textLineFragments {

func isLineSelected() -> Bool {
textLayoutManager.textSelections.flatMap(\.textRanges).reduce(true) { partialResult, selectionTextRange in
var result = true
if lineFragment.isExtraLineFragment {
let c1 = layoutFragment.rangeInElement.endLocation == selectionTextRange.location
result = result && c1
} else {
let c1 = contentRangeInElement.contains(selectionTextRange)
let c2 = contentRangeInElement.intersects(selectionTextRange)
let c3 = selectionTextRange.contains(contentRangeInElement)
let c4 = selectionTextRange.intersects(contentRangeInElement)
let c5 = contentRangeInElement.endLocation == selectionTextRange.location
result = result && (c1 || c2 || c3 || c4 || c5)
}
return partialResult && result
}
}

if isLineSelected() {
var lineFragmentFrame = layoutFragment.layoutFragmentFrame
lineFragmentFrame.size.height = lineFragment.typographicBounds.height


let r = CGRect(
origin: CGPoint(
x: selectionView.bounds.minX,
y: lineFragmentFrame.origin.y + lineFragment.typographicBounds.minY
),
size: CGSize(
width: selectionView.bounds.width,
height: lineFragmentFrame.height
)
)

if let rect = combinedFragmentsRect {
combinedFragmentsRect = rect.union(r)
} else {
combinedFragmentsRect = r
}
}
}
return true
}

if let combinedFragmentsRect {
layoutHighlightView(in: combinedFragmentsRect.pixelAligned)
}
}

// Update selection range highlight (on selectionView)
internal func updateSelectedRangeHighlight() {
guard !textLayoutManager.textSelections.isEmpty,
let viewportRange = textLayoutManager.textViewportLayoutController.viewportRange
else {
selectionView.subviews.removeAll()
// don't highlight when there's selection
return
}

Expand All @@ -1070,7 +1060,7 @@ import AVFoundation
}

if !selectionFrame.size.width.isZero {
let selectionHighlightView = SelectionHighlightView(frame: selectionFrame)
let selectionHighlightView = STSelectionHighlightView(frame: selectionFrame)
selectionView.addSubview(selectionHighlightView)

// Remove insertion point when selection
Expand Down Expand Up @@ -1106,16 +1096,6 @@ import AVFoundation
}
}

open override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
_configureTextContainerSize()
}

open override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
_configureTextContainerSize()
}

@objc internal func enclosingClipViewBoundsDidChange(_ notification: Notification) {
layoutGutter()
}
Expand Down
Loading

0 comments on commit 153665b

Please sign in to comment.