Skip to content

Commit 6653c21

Browse files
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 <!--- Add things that are not yet implemented above --> - [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
1 parent 7d2412c commit 6653c21

File tree

4 files changed

+131
-12
lines changed

4 files changed

+131
-12
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// GC+ApproximateEqual.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 2/16/24.
6+
//
7+
8+
import Foundation
9+
10+
extension CGFloat {
11+
func approxEqual(_ other: CGFloat, tolerance: CGFloat = 0.5) -> Bool {
12+
abs(self - other) <= tolerance
13+
}
14+
}
15+
16+
extension CGPoint {
17+
func approxEqual(_ other: CGPoint, tolerance: CGFloat = 0.5) -> Bool {
18+
return self.x.approxEqual(other.x, tolerance: tolerance)
19+
&& self.y.approxEqual(other.y, tolerance: tolerance)
20+
}
21+
}
22+
23+
extension CGRect {
24+
func approxEqual(_ other: CGRect, tolerance: CGFloat = 0.5) -> Bool {
25+
return self.origin.approxEqual(other.origin, tolerance: tolerance)
26+
&& self.width.approxEqual(other.width, tolerance: tolerance)
27+
&& self.height.approxEqual(other.height, tolerance: tolerance)
28+
}
29+
}

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public class TextSelectionManager: NSObject {
2323

2424
public class TextSelection: Hashable, Equatable {
2525
public var range: NSRange
26-
weak var view: CursorView?
26+
weak var view: NSView?
2727
var boundingRect: CGRect = .zero
2828
var suggestedXPos: CGFloat?
2929
/// The position this selection should 'rotate' around when modifying selections.
@@ -71,12 +71,17 @@ public class TextSelectionManager: NSObject {
7171

7272
public var insertionPointColor: NSColor = NSColor.labelColor {
7373
didSet {
74-
textSelections.forEach { $0.view?.color = insertionPointColor }
74+
textSelections.compactMap({ $0.view as? CursorView }).forEach { $0.color = insertionPointColor }
7575
}
7676
}
7777
public var highlightSelectedLine: Bool = true
7878
public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled)
7979
public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor
80+
public var useSystemCursor: Bool = false {
81+
didSet {
82+
updateSelectionViews()
83+
}
84+
}
8085

8186
internal(set) public var textSelections: [TextSelection] = []
8287
weak var layoutManager: TextLayoutManager?
@@ -89,7 +94,8 @@ public class TextSelectionManager: NSObject {
8994
layoutManager: TextLayoutManager,
9095
textStorage: NSTextStorage,
9196
textView: TextView?,
92-
delegate: TextSelectionManagerDelegate?
97+
delegate: TextSelectionManagerDelegate?,
98+
useSystemCursor: Bool = false
9399
) {
94100
self.layoutManager = layoutManager
95101
self.textStorage = textStorage
@@ -168,21 +174,47 @@ public class TextSelectionManager: NSObject {
168174
for textSelection in textSelections {
169175
if textSelection.range.isEmpty {
170176
let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin
171-
if textSelection.view == nil
172-
|| textSelection.boundingRect.origin != cursorOrigin
173-
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 {
174-
textSelection.view?.removeFromSuperview()
175-
textSelection.view = nil
176177

177-
let cursorView = CursorView(color: insertionPointColor)
178+
var doesViewNeedReposition: Bool
179+
180+
// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
181+
// approximate equals in that case to avoid extra updates.
182+
if useSystemCursor, #available(macOS 14.0, *) {
183+
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorOrigin)
184+
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
185+
} else {
186+
doesViewNeedReposition = textSelection.boundingRect.origin != cursorOrigin
187+
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
188+
}
189+
190+
if textSelection.view == nil || doesViewNeedReposition {
191+
let cursorView: NSView
192+
193+
if let existingCursorView = textSelection.view {
194+
cursorView = existingCursorView
195+
} else {
196+
textSelection.view?.removeFromSuperview()
197+
textSelection.view = nil
198+
199+
if useSystemCursor, #available(macOS 14.0, *) {
200+
let systemCursorView = NSTextInsertionIndicator(frame: .zero)
201+
cursorView = systemCursorView
202+
systemCursorView.displayMode = .automatic
203+
} else {
204+
let internalCursorView = CursorView(color: insertionPointColor)
205+
cursorView = internalCursorView
206+
cursorTimer.register(internalCursorView)
207+
}
208+
209+
textView?.addSubview(cursorView)
210+
}
211+
178212
cursorView.frame.origin = cursorOrigin
179213
cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0
180-
textView?.addSubview(cursorView)
214+
181215
textSelection.view = cursorView
182216
textSelection.boundingRect = cursorView.frame
183217

184-
cursorTimer.register(cursorView)
185-
186218
didUpdate = true
187219
}
188220
} else if !textSelection.range.isEmpty && textSelection.view != nil {

Sources/CodeEditTextView/TextView/TextView+Setup.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,35 @@ extension TextView {
2626
delegate: self
2727
)
2828
}
29+
30+
func setUpScrollListeners(scrollView: NSScrollView) {
31+
NotificationCenter.default.removeObserver(self, name: NSScrollView.willStartLiveScrollNotification, object: nil)
32+
NotificationCenter.default.removeObserver(self, name: NSScrollView.didEndLiveScrollNotification, object: nil)
33+
34+
NotificationCenter.default.addObserver(
35+
self,
36+
selector: #selector(scrollViewWillStartScroll),
37+
name: NSScrollView.willStartLiveScrollNotification,
38+
object: scrollView
39+
)
40+
41+
NotificationCenter.default.addObserver(
42+
self,
43+
selector: #selector(scrollViewDidEndScroll),
44+
name: NSScrollView.didEndLiveScrollNotification,
45+
object: scrollView
46+
)
47+
}
48+
49+
@objc func scrollViewWillStartScroll() {
50+
if #available(macOS 14.0, *) {
51+
inputContext?.textInputClientWillStartScrollingOrZooming()
52+
}
53+
}
54+
55+
@objc func scrollViewDidEndScroll() {
56+
if #available(macOS 14.0, *) {
57+
inputContext?.textInputClientDidEndScrollingOrZooming()
58+
}
59+
}
2960
}

Sources/CodeEditTextView/TextView/TextView.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,22 @@ public class TextView: NSView, NSTextContent {
178178
}
179179
}
180180

181+
/// Determines if the text view uses the macOS system cursor or a ``CursorView`` for cursors.
182+
///
183+
/// - Important: Only available after macOS 14.
184+
public var useSystemCursor: Bool {
185+
get {
186+
selectionManager?.useSystemCursor ?? false
187+
}
188+
set {
189+
guard #available(macOS 14, *) else {
190+
logger.warning("useSystemCursor only available after macOS 14.")
191+
return
192+
}
193+
selectionManager?.useSystemCursor = newValue
194+
}
195+
}
196+
181197
open var contentType: NSTextContentType?
182198

183199
/// The text view's delegate.
@@ -225,6 +241,7 @@ public class TextView: NSView, NSTextContent {
225241
/// - isEditable: Determines if the view is editable.
226242
/// - isSelectable: Determines if the view is selectable.
227243
/// - letterSpacing: Sets the letter spacing on the view.
244+
/// - useSystemCursor: Set to true to use the system cursor. Only available in macOS >= 14.
228245
/// - delegate: The text view's delegate.
229246
public init(
230247
string: String,
@@ -235,6 +252,7 @@ public class TextView: NSView, NSTextContent {
235252
isEditable: Bool,
236253
isSelectable: Bool,
237254
letterSpacing: Double,
255+
useSystemCursor: Bool = false,
238256
delegate: TextViewDelegate
239257
) {
240258
self.textStorage = NSTextStorage(string: string)
@@ -264,6 +282,7 @@ public class TextView: NSView, NSTextContent {
264282
layoutManager = setUpLayoutManager(lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines)
265283
storageDelegate.addDelegate(layoutManager)
266284
selectionManager = setUpSelectionManager()
285+
selectionManager.useSystemCursor = useSystemCursor
267286

268287
_undoManager = CEUndoManager(textView: self)
269288

@@ -370,6 +389,14 @@ public class TextView: NSView, NSTextContent {
370389
layoutManager.layoutLines()
371390
}
372391

392+
override public func viewWillMove(toSuperview newSuperview: NSView?) {
393+
guard let scrollView = enclosingScrollView else {
394+
return
395+
}
396+
397+
setUpScrollListeners(scrollView: scrollView)
398+
}
399+
373400
override public func viewDidEndLiveResize() {
374401
super.viewDidEndLiveResize()
375402
updateFrameIfNeeded()

0 commit comments

Comments
 (0)