diff --git a/Sources/STTextViewAppKit/Overlays/STLineHighlightView.swift b/Sources/STTextViewAppKit/Overlays/STLineHighlightView.swift new file mode 100644 index 0000000..5f530f8 --- /dev/null +++ b/Sources/STTextViewAppKit/Overlays/STLineHighlightView.swift @@ -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 + } +} diff --git a/Sources/STTextViewAppKit/Overlays/SelectionHighlightView.swift b/Sources/STTextViewAppKit/Overlays/STSelectionHighlightView.swift similarity index 94% rename from Sources/STTextViewAppKit/Overlays/SelectionHighlightView.swift rename to Sources/STTextViewAppKit/Overlays/STSelectionHighlightView.swift index bd956bb..b1311b0 100644 --- a/Sources/STTextViewAppKit/Overlays/SelectionHighlightView.swift +++ b/Sources/STTextViewAppKit/Overlays/STSelectionHighlightView.swift @@ -3,7 +3,7 @@ import AppKit -final class SelectionHighlightView: NSView { +final class STSelectionHighlightView: NSView { override var isFlipped: Bool { #if os(macOS) true diff --git a/Sources/STTextViewAppKit/STTextFinderClient.swift b/Sources/STTextViewAppKit/STTextFinderClient.swift index 1c282bd..bf1f40b 100644 --- a/Sources/STTextViewAppKit/STTextFinderClient.swift +++ b/Sources/STTextViewAppKit/STTextFinderClient.swift @@ -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() } diff --git a/Sources/STTextViewAppKit/STTextView+Mouse.swift b/Sources/STTextViewAppKit/STTextView+Mouse.swift index 7a675f3..90a6007 100644 --- a/Sources/STTextViewAppKit/STTextView+Mouse.swift +++ b/Sources/STTextViewAppKit/STTextView+Mouse.swift @@ -28,7 +28,8 @@ extension STTextView { if !handled, holdsShift && holdsControl { textLayoutManager.appendInsertionPointSelection(interactingAt: eventPoint) updateTypingAttributes() - updateSelectionHighlights() + updateSelectedRangeHighlight() + updateSelectedLineHighlight() needsDisplay = true handled = true } diff --git a/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift b/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift index 3c10fbd..d5c4540 100644 --- a/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift +++ b/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift @@ -70,7 +70,8 @@ extension STTextView: NSTextViewportLayoutControllerDelegate { public func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) { sizeToFit() - updateSelectionHighlights() + updateSelectedRangeHighlight() + updateSelectedLineHighlight() adjustViewportOffsetIfNeeded() if let viewportRange = textViewportLayoutController.viewportRange { diff --git a/Sources/STTextViewAppKit/STTextView+Select.swift b/Sources/STTextViewAppKit/STTextView+Select.swift index 14fdb8c..8f69a07 100644 --- a/Sources/STTextViewAppKit/STTextView+Select.swift +++ b/Sources/STTextViewAppKit/STTextView+Select.swift @@ -44,7 +44,8 @@ extension STTextView { ] updateTypingAttributes() - updateSelectionHighlights() + updateSelectedRangeHighlight() + updateSelectedLineHighlight() } open override func selectLine(_ sender: Any?) { @@ -496,7 +497,8 @@ extension STTextView { } updateTypingAttributes() - updateSelectionHighlights() + updateSelectedRangeHighlight() + updateSelectedLineHighlight() needsDisplay = true } diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index f53f05d..6c28dc1 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -4,6 +4,7 @@ // // STTextView // |---selectionView +// |---(STLineHighlightView | SelectionHighlightView) // |---contentView // |---(STInsertionPointView | STTextLayoutFragmentView) // |---decorationView @@ -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. @@ -587,6 +588,7 @@ import AVFoundation allowsUndo = true _undoManager = CoalescingUndoManager() + textFinder = NSTextFinder() textFinderClient = STTextFinderClient() @@ -708,7 +710,8 @@ import AVFoundation open override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() - self.updateSelectionHighlights() + self.updateSelectedRangeHighlight() + self.updateSelectedLineHighlight() } open override func viewDidMoveToSuperview() { @@ -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 { @@ -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 } @@ -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 @@ -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() } diff --git a/Sources/STTextViewUIKit/STTextView+NSTextViewportLayoutControllerDelegate.swift b/Sources/STTextViewUIKit/STTextView+NSTextViewportLayoutControllerDelegate.swift index d69e390..6ffd47d 100644 --- a/Sources/STTextViewUIKit/STTextView+NSTextViewportLayoutControllerDelegate.swift +++ b/Sources/STTextViewUIKit/STTextView+NSTextViewportLayoutControllerDelegate.swift @@ -37,7 +37,7 @@ extension STTextView: NSTextViewportLayoutControllerDelegate { public func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) { sizeToFit() - updateSelectionHighlights() + updateSelectedLineHighlight() // adjustViewportOffsetIfNeeded() if let viewportRange = textViewportLayoutController.viewportRange { diff --git a/Sources/STTextViewUIKit/STTextView+UIResponderStandardEditActions.swift b/Sources/STTextViewUIKit/STTextView+UIResponderStandardEditActions.swift index 3ea052c..951f765 100644 --- a/Sources/STTextViewUIKit/STTextView+UIResponderStandardEditActions.swift +++ b/Sources/STTextViewUIKit/STTextView+UIResponderStandardEditActions.swift @@ -114,7 +114,7 @@ extension STTextView { ] updateTypingAttributes() - updateSelectionHighlights() + updateSelectedLineHighlight() setNeedsLayout() inputDelegate?.selectionDidChange(self) diff --git a/Sources/STTextViewUIKit/STTextView+UITextInput.swift b/Sources/STTextViewUIKit/STTextView+UITextInput.swift index 14bd77a..76cbef4 100644 --- a/Sources/STTextViewUIKit/STTextView+UITextInput.swift +++ b/Sources/STTextViewUIKit/STTextView+UITextInput.swift @@ -29,7 +29,7 @@ extension STTextView: UITextInput { textLayoutManager.textSelections = [] } inputDelegate?.selectionDidChange(self) - updateSelectionHighlights() + updateSelectedLineHighlight() layoutGutter() if let newValue, var rect = self.selectionRects(for: newValue).last?.rect { diff --git a/Sources/STTextViewUIKit/STTextView.swift b/Sources/STTextViewUIKit/STTextView.swift index d3756b6..93608a0 100644 --- a/Sources/STTextViewUIKit/STTextView.swift +++ b/Sources/STTextViewUIKit/STTextView.swift @@ -180,6 +180,8 @@ import STTextViewCommon /// Content view. Layout fragments content. internal let contentView: ContentView + + /// Line highlight view. internal let lineHighlightView: STLineHighlightView internal var fragmentViewMap: NSMapTable @@ -411,12 +413,12 @@ import STTextViewCommon contentView = ContentView() - allowsUndo = true - _undoManager = CoalescingUndoManager() - lineHighlightView = STLineHighlightView() lineHighlightView.isHidden = true + allowsUndo = true + _undoManager = CoalescingUndoManager() + _defaultTypingAttributes = [ .paragraphStyle: NSParagraphStyle.default, .font: UIFont.preferredFont(forTextStyle: .body), @@ -845,7 +847,8 @@ import STTextViewCommon textLayoutManager.textViewportLayoutController.layoutViewport() } - internal func updateSelectionHighlights() { + // Update selected line highlight layer + internal func updateSelectedLineHighlight() { guard highlightSelectedLine, textLayoutManager.textSelectionsRanges(.withoutInsertionPoints).isEmpty, !textLayoutManager.insertionPointSelections.isEmpty