From 6653c21a603babf365a12d4d331fadc8f8b52d99 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 21 Feb 2024 12:28:17 -0600 Subject: [PATCH] Optionally Use System Cursor (#21) ### Description Adds the ability to use the system cursor if available and enabled using a `useSystemCursor` variable on either `TextView` or `TextSelectionManager`. ### Related Issues * closes #5 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Example usage in CodeEditSourceEditor: https://github.com/CodeEditApp/CodeEditTextView/assets/35942988/5b5adeee-dd80-4f92-92d0-121be18ab6ae --- .../Extensions/GC+ApproximateEqual.swift | 29 ++++++++++ .../TextSelectionManager.swift | 56 +++++++++++++++---- .../TextView/TextView+Setup.swift | 31 ++++++++++ .../CodeEditTextView/TextView/TextView.swift | 27 +++++++++ 4 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 Sources/CodeEditTextView/Extensions/GC+ApproximateEqual.swift diff --git a/Sources/CodeEditTextView/Extensions/GC+ApproximateEqual.swift b/Sources/CodeEditTextView/Extensions/GC+ApproximateEqual.swift new file mode 100644 index 00000000..ef0a258b --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/GC+ApproximateEqual.swift @@ -0,0 +1,29 @@ +// +// GC+ApproximateEqual.swift +// CodeEditTextView +// +// Created by Khan Winter on 2/16/24. +// + +import Foundation + +extension CGFloat { + func approxEqual(_ other: CGFloat, tolerance: CGFloat = 0.5) -> Bool { + abs(self - other) <= tolerance + } +} + +extension CGPoint { + func approxEqual(_ other: CGPoint, tolerance: CGFloat = 0.5) -> Bool { + return self.x.approxEqual(other.x, tolerance: tolerance) + && self.y.approxEqual(other.y, tolerance: tolerance) + } +} + +extension CGRect { + func approxEqual(_ other: CGRect, tolerance: CGFloat = 0.5) -> Bool { + return self.origin.approxEqual(other.origin, tolerance: tolerance) + && self.width.approxEqual(other.width, tolerance: tolerance) + && self.height.approxEqual(other.height, tolerance: tolerance) + } +} diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index c26b0e20..effcae62 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -23,7 +23,7 @@ public class TextSelectionManager: NSObject { public class TextSelection: Hashable, Equatable { public var range: NSRange - weak var view: CursorView? + weak var view: NSView? var boundingRect: CGRect = .zero var suggestedXPos: CGFloat? /// The position this selection should 'rotate' around when modifying selections. @@ -71,12 +71,17 @@ public class TextSelectionManager: NSObject { public var insertionPointColor: NSColor = NSColor.labelColor { didSet { - textSelections.forEach { $0.view?.color = insertionPointColor } + textSelections.compactMap({ $0.view as? CursorView }).forEach { $0.color = insertionPointColor } } } public var highlightSelectedLine: Bool = true public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor + public var useSystemCursor: Bool = false { + didSet { + updateSelectionViews() + } + } internal(set) public var textSelections: [TextSelection] = [] weak var layoutManager: TextLayoutManager? @@ -89,7 +94,8 @@ public class TextSelectionManager: NSObject { layoutManager: TextLayoutManager, textStorage: NSTextStorage, textView: TextView?, - delegate: TextSelectionManagerDelegate? + delegate: TextSelectionManagerDelegate?, + useSystemCursor: Bool = false ) { self.layoutManager = layoutManager self.textStorage = textStorage @@ -168,21 +174,47 @@ public class TextSelectionManager: NSObject { for textSelection in textSelections { if textSelection.range.isEmpty { let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin - if textSelection.view == nil - || textSelection.boundingRect.origin != cursorOrigin - || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 { - textSelection.view?.removeFromSuperview() - textSelection.view = nil - let cursorView = CursorView(color: insertionPointColor) + var doesViewNeedReposition: Bool + + // If using the system cursor, macOS will change the origin and height by about 0.5, so we do an + // approximate equals in that case to avoid extra updates. + if useSystemCursor, #available(macOS 14.0, *) { + doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorOrigin) + || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) + } else { + doesViewNeedReposition = textSelection.boundingRect.origin != cursorOrigin + || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 + } + + if textSelection.view == nil || doesViewNeedReposition { + let cursorView: NSView + + if let existingCursorView = textSelection.view { + cursorView = existingCursorView + } else { + textSelection.view?.removeFromSuperview() + textSelection.view = nil + + if useSystemCursor, #available(macOS 14.0, *) { + let systemCursorView = NSTextInsertionIndicator(frame: .zero) + cursorView = systemCursorView + systemCursorView.displayMode = .automatic + } else { + let internalCursorView = CursorView(color: insertionPointColor) + cursorView = internalCursorView + cursorTimer.register(internalCursorView) + } + + textView?.addSubview(cursorView) + } + cursorView.frame.origin = cursorOrigin cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0 - textView?.addSubview(cursorView) + textSelection.view = cursorView textSelection.boundingRect = cursorView.frame - cursorTimer.register(cursorView) - didUpdate = true } } else if !textSelection.range.isEmpty && textSelection.view != nil { diff --git a/Sources/CodeEditTextView/TextView/TextView+Setup.swift b/Sources/CodeEditTextView/TextView/TextView+Setup.swift index e315a480..c894cf04 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Setup.swift @@ -26,4 +26,35 @@ extension TextView { delegate: self ) } + + func setUpScrollListeners(scrollView: NSScrollView) { + NotificationCenter.default.removeObserver(self, name: NSScrollView.willStartLiveScrollNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: NSScrollView.didEndLiveScrollNotification, object: nil) + + NotificationCenter.default.addObserver( + self, + selector: #selector(scrollViewWillStartScroll), + name: NSScrollView.willStartLiveScrollNotification, + object: scrollView + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(scrollViewDidEndScroll), + name: NSScrollView.didEndLiveScrollNotification, + object: scrollView + ) + } + + @objc func scrollViewWillStartScroll() { + if #available(macOS 14.0, *) { + inputContext?.textInputClientWillStartScrollingOrZooming() + } + } + + @objc func scrollViewDidEndScroll() { + if #available(macOS 14.0, *) { + inputContext?.textInputClientDidEndScrollingOrZooming() + } + } } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index e7a4a6a7..13b3a5b7 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -178,6 +178,22 @@ public class TextView: NSView, NSTextContent { } } + /// Determines if the text view uses the macOS system cursor or a ``CursorView`` for cursors. + /// + /// - Important: Only available after macOS 14. + public var useSystemCursor: Bool { + get { + selectionManager?.useSystemCursor ?? false + } + set { + guard #available(macOS 14, *) else { + logger.warning("useSystemCursor only available after macOS 14.") + return + } + selectionManager?.useSystemCursor = newValue + } + } + open var contentType: NSTextContentType? /// The text view's delegate. @@ -225,6 +241,7 @@ public class TextView: NSView, NSTextContent { /// - isEditable: Determines if the view is editable. /// - isSelectable: Determines if the view is selectable. /// - letterSpacing: Sets the letter spacing on the view. + /// - useSystemCursor: Set to true to use the system cursor. Only available in macOS >= 14. /// - delegate: The text view's delegate. public init( string: String, @@ -235,6 +252,7 @@ public class TextView: NSView, NSTextContent { isEditable: Bool, isSelectable: Bool, letterSpacing: Double, + useSystemCursor: Bool = false, delegate: TextViewDelegate ) { self.textStorage = NSTextStorage(string: string) @@ -264,6 +282,7 @@ public class TextView: NSView, NSTextContent { layoutManager = setUpLayoutManager(lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines) storageDelegate.addDelegate(layoutManager) selectionManager = setUpSelectionManager() + selectionManager.useSystemCursor = useSystemCursor _undoManager = CEUndoManager(textView: self) @@ -370,6 +389,14 @@ public class TextView: NSView, NSTextContent { layoutManager.layoutLines() } + override public func viewWillMove(toSuperview newSuperview: NSView?) { + guard let scrollView = enclosingScrollView else { + return + } + + setUpScrollListeners(scrollView: scrollView) + } + override public func viewDidEndLiveResize() { super.viewDidEndLiveResize() updateFrameIfNeeded()