Skip to content

Commit fad3087

Browse files
fix(#13): Separate Cursor Updates, Fix Focus Issues (#14)
* Separate Cursor Updates, Fix Focus Issues * Linter * Update TextSelectionManagerTests.swift
1 parent 6abce20 commit fad3087

File tree

9 files changed

+168
-137
lines changed

9 files changed

+168
-137
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//
2+
// CursorTimer.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 1/16/24.
6+
//
7+
8+
import Foundation
9+
import AppKit
10+
11+
class CursorTimer {
12+
/// # Properties
13+
14+
/// The timer that publishes the cursor toggle timer.
15+
private var timer: Timer?
16+
/// Maps to all cursor views, uses weak memory to not cause a strong reference cycle.
17+
private var cursors: NSHashTable<CursorView> = .init(options: .weakMemory)
18+
/// Tracks whether cursors are hidden or not.
19+
var shouldHide: Bool = false
20+
21+
// MARK: - Methods
22+
23+
/// Resets the cursor blink timer.
24+
/// - Parameter newBlinkDuration: The duration to blink, leave as nil to never blink.
25+
func resetTimer(newBlinkDuration: TimeInterval? = 0.5) {
26+
timer?.invalidate()
27+
28+
guard let newBlinkDuration else {
29+
notifyCursors(shouldHide: true)
30+
return
31+
}
32+
33+
shouldHide = false
34+
notifyCursors(shouldHide: shouldHide)
35+
36+
timer = Timer.scheduledTimer(withTimeInterval: newBlinkDuration, repeats: true) { [weak self] _ in
37+
self?.assertMain()
38+
self?.shouldHide.toggle()
39+
guard let shouldHide = self?.shouldHide else { return }
40+
self?.notifyCursors(shouldHide: shouldHide)
41+
}
42+
}
43+
44+
func stopTimer() {
45+
shouldHide = true
46+
notifyCursors(shouldHide: true)
47+
cursors.removeAllObjects()
48+
timer?.invalidate()
49+
timer = nil
50+
}
51+
52+
/// Notify all cursors of a new blink state.
53+
/// - Parameter shouldHide: Whether or not the cursors should be hidden or not.
54+
private func notifyCursors(shouldHide: Bool) {
55+
for cursor in cursors.allObjects {
56+
cursor.blinkTimer(shouldHide)
57+
}
58+
}
59+
60+
/// Register a new cursor view with the timer.
61+
/// - Parameter newCursor: The cursor to blink.
62+
func register(_ newCursor: CursorView) {
63+
cursors.add(newCursor)
64+
}
65+
66+
deinit {
67+
timer?.invalidate()
68+
timer = nil
69+
cursors.removeAllObjects()
70+
}
71+
72+
private func assertMain() {
73+
#if DEBUG
74+
assert(Thread.isMainThread, "CursorTimer used from non-main thread")
75+
#endif
76+
}
77+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// CursorView.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 8/15/23.
6+
//
7+
8+
import AppKit
9+
10+
/// Animates a cursor. Will sync animation with any other cursor views.
11+
open class CursorView: NSView {
12+
/// The color of the cursor.
13+
public var color: NSColor {
14+
didSet {
15+
layer?.backgroundColor = color.cgColor
16+
}
17+
}
18+
19+
/// The width of the cursor.
20+
private let width: CGFloat
21+
/// The timer observer.
22+
private var observer: NSObjectProtocol?
23+
24+
open override var isFlipped: Bool {
25+
true
26+
}
27+
28+
/// Create a cursor view.
29+
/// - Parameters:
30+
/// - blinkDuration: The duration to blink, leave as nil to never blink.
31+
/// - color: The color of the cursor.
32+
/// - width: How wide the cursor should be.
33+
init(
34+
color: NSColor = NSColor.labelColor,
35+
width: CGFloat = 1.0
36+
) {
37+
self.color = color
38+
self.width = width
39+
40+
super.init(frame: .zero)
41+
42+
frame.size.width = width
43+
wantsLayer = true
44+
layer?.backgroundColor = color.cgColor
45+
}
46+
47+
func blinkTimer(_ shouldHideCursor: Bool) {
48+
self.isHidden = shouldHideCursor
49+
}
50+
51+
public required init?(coder: NSCoder) {
52+
fatalError("init(coder:) has not been implemented")
53+
}
54+
}

Sources/CodeEditTextView/TextSelectionManager/CursorView.swift

Lines changed: 0 additions & 121 deletions
This file was deleted.

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,10 @@ public extension TextSelectionManager {
384384
/// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
385385
/// - Returns: The range of the extended selection.
386386
private func extendSelectionContainer(from offset: Int, delta: Int) -> NSRange {
387-
guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint(
387+
guard let textView, let endOffset = layoutManager?.textOffsetAtPoint(
388388
CGPoint(
389-
x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX,
390-
y: delta > 0 ? layoutView.frame.maxY : layoutView.frame.minY
389+
x: delta > 0 ? textView.frame.maxX : textView.frame.minX,
390+
y: delta > 0 ? textView.frame.maxY : textView.frame.minY
391391
)
392392
) else {
393393
return NSRange(location: offset, length: 0)

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,21 @@ public class TextSelectionManager: NSObject {
8181
internal(set) public var textSelections: [TextSelection] = []
8282
weak var layoutManager: TextLayoutManager?
8383
weak var textStorage: NSTextStorage?
84-
weak var layoutView: NSView?
84+
weak var textView: TextView?
8585
weak var delegate: TextSelectionManagerDelegate?
86+
var cursorTimer: CursorTimer
8687

8788
init(
8889
layoutManager: TextLayoutManager,
8990
textStorage: NSTextStorage,
90-
layoutView: NSView?,
91+
textView: TextView?,
9192
delegate: TextSelectionManagerDelegate?
9293
) {
9394
self.layoutManager = layoutManager
9495
self.textStorage = textStorage
95-
self.layoutView = layoutView
96+
self.textView = textView
9697
self.delegate = delegate
98+
self.cursorTimer = CursorTimer()
9799
super.init()
98100
textSelections = []
99101
updateSelectionViews()
@@ -106,8 +108,10 @@ public class TextSelectionManager: NSObject {
106108
let selection = TextSelection(range: range)
107109
selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX
108110
textSelections = [selection]
109-
updateSelectionViews()
110-
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
111+
if textView?.isFirstResponder ?? false {
112+
updateSelectionViews()
113+
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
114+
}
111115
}
112116

113117
public func setSelectedRanges(_ ranges: [NSRange]) {
@@ -123,8 +127,10 @@ public class TextSelectionManager: NSObject {
123127
selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX
124128
return selection
125129
}
126-
updateSelectionViews()
127-
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
130+
if textView?.isFirstResponder ?? false {
131+
updateSelectionViews()
132+
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
133+
}
128134
}
129135

130136
public func addSelectedRange(_ range: NSRange) {
@@ -146,12 +152,16 @@ public class TextSelectionManager: NSObject {
146152
textSelections.append(newTextSelection)
147153
}
148154

149-
updateSelectionViews()
150-
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
155+
if textView?.isFirstResponder ?? false {
156+
updateSelectionViews()
157+
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
158+
}
151159
}
152160

153161
// MARK: - Selection Views
154162

163+
/// Update all selection cursors. Placing them in the correct position for each text selection and reseting the
164+
/// blink timer.
155165
func updateSelectionViews() {
156166
var didUpdate: Bool = false
157167

@@ -163,12 +173,16 @@ public class TextSelectionManager: NSObject {
163173
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 {
164174
textSelection.view?.removeFromSuperview()
165175
textSelection.view = nil
176+
166177
let cursorView = CursorView(color: insertionPointColor)
167178
cursorView.frame.origin = cursorOrigin
168179
cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0
169-
layoutView?.addSubview(cursorView)
180+
textView?.addSubview(cursorView)
170181
textSelection.view = cursorView
171182
textSelection.boundingRect = cursorView.frame
183+
184+
cursorTimer.register(cursorView)
185+
172186
didUpdate = true
173187
}
174188
} else if !textSelection.range.isEmpty && textSelection.view != nil {
@@ -180,10 +194,13 @@ public class TextSelectionManager: NSObject {
180194

181195
if didUpdate {
182196
delegate?.setNeedsDisplay()
197+
cursorTimer.resetTimer()
183198
}
184199
}
185200

201+
/// Removes all cursor views and stops the cursor blink timer.
186202
func removeCursors() {
203+
cursorTimer.stopTimer()
187204
for textSelection in textSelections {
188205
textSelection.view?.removeFromSuperview()
189206
}

Sources/CodeEditTextView/TextView/TextView+Menu.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
import AppKit
99

1010
extension TextView {
11-
open override class var defaultMenu: NSMenu? {
11+
override public func menu(for event: NSEvent) -> NSMenu? {
12+
guard event.type == .rightMouseDown else { return nil }
13+
1214
let menu = NSMenu()
1315

1416
menu.items = [
17+
NSMenuItem(title: "Cut", action: #selector(cut(_:)), keyEquivalent: "x"),
1518
NSMenuItem(title: "Copy", action: #selector(undo(_:)), keyEquivalent: "c"),
1619
NSMenuItem(title: "Paste", action: #selector(undo(_:)), keyEquivalent: "v")
1720
]

Sources/CodeEditTextView/TextView/TextView+Setup.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ extension TextView {
2222
TextSelectionManager(
2323
layoutManager: layoutManager,
2424
textStorage: textStorage,
25-
layoutView: self,
25+
textView: self,
2626
delegate: self
2727
)
2828
}

0 commit comments

Comments
 (0)