From ba63c7882498dfbae6348b8f30c5a4499e06eac5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 16 Jan 2024 12:33:38 -0600 Subject: [PATCH 1/3] Separate Cursor Updates, Fix Focus Issues --- .../Cursors/CursorTimer.swift | 78 +++++++++++ .../CodeEditTextView/Cursors/CursorView.swift | 54 ++++++++ .../TextSelectionManager/CursorView.swift | 121 ------------------ ...lectionManager+SelectionManipulation.swift | 6 +- .../TextSelectionManager.swift | 41 ++++-- .../TextView/TextView+Menu.swift | 5 +- .../TextView/TextView+Setup.swift | 2 +- .../CodeEditTextView/TextView/TextView.swift | 1 + 8 files changed, 170 insertions(+), 138 deletions(-) create mode 100644 Sources/CodeEditTextView/Cursors/CursorTimer.swift create mode 100644 Sources/CodeEditTextView/Cursors/CursorView.swift delete mode 100644 Sources/CodeEditTextView/TextSelectionManager/CursorView.swift diff --git a/Sources/CodeEditTextView/Cursors/CursorTimer.swift b/Sources/CodeEditTextView/Cursors/CursorTimer.swift new file mode 100644 index 00000000..859d6c08 --- /dev/null +++ b/Sources/CodeEditTextView/Cursors/CursorTimer.swift @@ -0,0 +1,78 @@ +// +// CursorTimer.swift +// CodeEditTextView +// +// Created by Khan Winter on 1/16/24. +// + +import Foundation +import AppKit + +class CursorTimer { + /// # Properties + + /// The timer that publishes the cursor toggle timer. + private var timer: Timer? + /// Maps to all cursor views, uses weak memory to not cause a strong reference cycle. + private var cursors: NSHashTable = .init(options: .weakMemory) + /// Tracks whether cursors are hidden or not. + var shouldHide: Bool = false + + // MARK: - Methods + + /// Resets the cursor blink timer. + /// - Parameter newBlinkDuration: The duration to blink, leave as nil to never blink. + func resetTimer(newBlinkDuration: TimeInterval? = 0.5) { + timer?.invalidate() + + guard let newBlinkDuration else { + notifyCursors(shouldHide: true) + return + } + + shouldHide = false + notifyCursors(shouldHide: shouldHide) + + timer = Timer.scheduledTimer(withTimeInterval: newBlinkDuration, repeats: true) { [weak self] _ in + self?.assertMain() + self?.shouldHide.toggle() + guard let shouldHide = self?.shouldHide else { return } + self?.notifyCursors(shouldHide: shouldHide) + } + } + + func stopTimer() { + shouldHide = true + notifyCursors(shouldHide: true) + cursors.removeAllObjects() + timer?.invalidate() + timer = nil + } + + /// Notify all cursors of a new blink state. + /// - Parameter shouldHide: Whether or not the cursors should be hidden or not. + private func notifyCursors(shouldHide: Bool) { + for cursor in cursors.allObjects { + cursor.blinkTimer(shouldHide) + } + } + + /// Register a new cursor view with the timer. + /// - Parameter newCursor: The cursor to blink. + func register(_ newCursor: CursorView) { + cursors.add(newCursor) + } + + + deinit { + timer?.invalidate() + timer = nil + cursors.removeAllObjects() + } + + private func assertMain() { +#if DEBUG + assert(Thread.isMainThread, "CursorTimer used from non-main thread") +#endif + } +} diff --git a/Sources/CodeEditTextView/Cursors/CursorView.swift b/Sources/CodeEditTextView/Cursors/CursorView.swift new file mode 100644 index 00000000..105209a7 --- /dev/null +++ b/Sources/CodeEditTextView/Cursors/CursorView.swift @@ -0,0 +1,54 @@ +// +// CursorView.swift +// CodeEditTextView +// +// Created by Khan Winter on 8/15/23. +// + +import AppKit + +/// Animates a cursor. Will sync animation with any other cursor views. +open class CursorView: NSView { + /// The color of the cursor. + public var color: NSColor { + didSet { + layer?.backgroundColor = color.cgColor + } + } + + /// The width of the cursor. + private let width: CGFloat + /// The timer observer. + private var observer: NSObjectProtocol? + + open override var isFlipped: Bool { + true + } + + /// Create a cursor view. + /// - Parameters: + /// - blinkDuration: The duration to blink, leave as nil to never blink. + /// - color: The color of the cursor. + /// - width: How wide the cursor should be. + init( + color: NSColor = NSColor.labelColor, + width: CGFloat = 1.0 + ) { + self.color = color + self.width = width + + super.init(frame: .zero) + + frame.size.width = width + wantsLayer = true + layer?.backgroundColor = color.cgColor + } + + func blinkTimer(_ shouldHideCursor: Bool) { + self.isHidden = shouldHideCursor + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/CodeEditTextView/TextSelectionManager/CursorView.swift b/Sources/CodeEditTextView/TextSelectionManager/CursorView.swift deleted file mode 100644 index 3c54e122..00000000 --- a/Sources/CodeEditTextView/TextSelectionManager/CursorView.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// CursorView.swift -// CodeEditTextView -// -// Created by Khan Winter on 8/15/23. -// - -import AppKit - -/// Animates a cursor. Will sync animation with any other cursor views. -open class CursorView: NSView { - /// Used to sync the cursor view animations when there's multiple cursors. - /// - Note: Do not use any methods in this class from a non-main thread. - private class CursorTimerService { - static let notification: NSNotification.Name = .init("com.CodeEdit.CursorTimerService.notification") - var timer: Timer? - var isHidden: Bool = false - var listeners: Int = 0 - - func setUpTimer(blinkDuration: TimeInterval?) { - assertMain() - timer?.invalidate() - timer = nil - isHidden = false - NotificationCenter.default.post(name: Self.notification, object: nil) - if let blinkDuration { - timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in - self?.timerReceived() - }) - } - listeners += 1 - } - - func timerReceived() { - assertMain() - isHidden.toggle() - NotificationCenter.default.post(name: Self.notification, object: nil) - } - - func destroySharedTimer() { - assertMain() - listeners -= 1 - if listeners == 0 { - timer?.invalidate() - timer = nil - isHidden = false - } - } - - private func assertMain() { -#if DEBUG - // swiftlint:disable:next line_length - assert(Thread.isMainThread, "CursorTimerService used from non-main thread. This may cause a race condition.") -#endif - } - } - - /// The shared timer service - private static let timerService: CursorTimerService = CursorTimerService() - - /// The color of the cursor. - public var color: NSColor { - didSet { - layer?.backgroundColor = color.cgColor - } - } - - /// How often the cursor toggles it's visibility. Leave `nil` to never blink. - private let blinkDuration: TimeInterval? - /// The width of the cursor. - private let width: CGFloat - /// The timer observer. - private var observer: NSObjectProtocol? - - open override var isFlipped: Bool { - true - } - - /// Create a cursor view. - /// - Parameters: - /// - blinkDuration: The duration to blink, leave as nil to never blink. - /// - color: The color of the cursor. - /// - width: How wide the cursor should be. - init( - blinkDuration: TimeInterval? = 0.5, - color: NSColor = NSColor.labelColor, - width: CGFloat = 1.0 - ) { - self.blinkDuration = blinkDuration - self.color = color - self.width = width - - super.init(frame: .zero) - - frame.size.width = width - wantsLayer = true - layer?.backgroundColor = color.cgColor - - CursorView.timerService.setUpTimer(blinkDuration: blinkDuration) - - observer = NotificationCenter.default.addObserver( - forName: CursorTimerService.notification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.isHidden = CursorView.timerService.isHidden - } - } - - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - self.observer = nil - CursorView.timerService.destroySharedTimer() - } -} diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 4046144f..3391b7e1 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -384,10 +384,10 @@ public extension TextSelectionManager { /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. /// - Returns: The range of the extended selection. private func extendSelectionContainer(from offset: Int, delta: Int) -> NSRange { - guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint( + guard let textView, let endOffset = layoutManager?.textOffsetAtPoint( CGPoint( - x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX, - y: delta > 0 ? layoutView.frame.maxY : layoutView.frame.minY + x: delta > 0 ? textView.frame.maxX : textView.frame.minX, + y: delta > 0 ? textView.frame.maxY : textView.frame.minY ) ) else { return NSRange(location: offset, length: 0) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 3f70ee53..67664ae7 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -81,19 +81,21 @@ public class TextSelectionManager: NSObject { internal(set) public var textSelections: [TextSelection] = [] weak var layoutManager: TextLayoutManager? weak var textStorage: NSTextStorage? - weak var layoutView: NSView? + weak var textView: TextView? weak var delegate: TextSelectionManagerDelegate? + var cursorTimer: CursorTimer init( layoutManager: TextLayoutManager, textStorage: NSTextStorage, - layoutView: NSView?, + textView: TextView?, delegate: TextSelectionManagerDelegate? ) { self.layoutManager = layoutManager self.textStorage = textStorage - self.layoutView = layoutView + self.textView = textView self.delegate = delegate + self.cursorTimer = CursorTimer() super.init() textSelections = [] updateSelectionViews() @@ -106,8 +108,10 @@ public class TextSelectionManager: NSObject { let selection = TextSelection(range: range) selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX textSelections = [selection] - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + if textView?.isFirstResponder ?? false { + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + } } public func setSelectedRanges(_ ranges: [NSRange]) { @@ -123,8 +127,10 @@ public class TextSelectionManager: NSObject { selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX return selection } - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + if textView?.isFirstResponder ?? false { + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + } } public func addSelectedRange(_ range: NSRange) { @@ -146,12 +152,16 @@ public class TextSelectionManager: NSObject { textSelections.append(newTextSelection) } - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + if textView?.isFirstResponder ?? false { + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + } } // MARK: - Selection Views - + + /// Update all selection cursors. Placing them in the correct position for each text selection and reseting the + /// blink timer. func updateSelectionViews() { var didUpdate: Bool = false @@ -163,12 +173,16 @@ public class TextSelectionManager: NSObject { || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 { textSelection.view?.removeFromSuperview() textSelection.view = nil + let cursorView = CursorView(color: insertionPointColor) cursorView.frame.origin = cursorOrigin cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0 - layoutView?.addSubview(cursorView) + textView?.addSubview(cursorView) textSelection.view = cursorView textSelection.boundingRect = cursorView.frame + + cursorTimer.register(cursorView) + didUpdate = true } } else if !textSelection.range.isEmpty && textSelection.view != nil { @@ -180,10 +194,13 @@ public class TextSelectionManager: NSObject { if didUpdate { delegate?.setNeedsDisplay() + cursorTimer.resetTimer() } } - + + /// Removes all cursor views and stops the cursor blink timer. func removeCursors() { + cursorTimer.stopTimer() for textSelection in textSelections { textSelection.view?.removeFromSuperview() } diff --git a/Sources/CodeEditTextView/TextView/TextView+Menu.swift b/Sources/CodeEditTextView/TextView/TextView+Menu.swift index 64ac309f..e7f6f7c8 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -8,10 +8,13 @@ import AppKit extension TextView { - open override class var defaultMenu: NSMenu? { + override public func menu(for event: NSEvent) -> NSMenu? { + guard event.type == .rightMouseDown else { return nil } + let menu = NSMenu() menu.items = [ + NSMenuItem(title: "Cut", action: #selector(cut(_:)), keyEquivalent: "x"), NSMenuItem(title: "Copy", action: #selector(undo(_:)), keyEquivalent: "c"), NSMenuItem(title: "Paste", action: #selector(undo(_:)), keyEquivalent: "v") ] diff --git a/Sources/CodeEditTextView/TextView/TextView+Setup.swift b/Sources/CodeEditTextView/TextView/TextView+Setup.swift index 1efdf98c..e315a480 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Setup.swift @@ -22,7 +22,7 @@ extension TextView { TextSelectionManager( layoutManager: layoutManager, textStorage: textStorage, - layoutView: self, + textView: self, delegate: self ) } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 7094a1c9..c55ac58c 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -313,6 +313,7 @@ public class TextView: NSView, NSTextContent { open override func becomeFirstResponder() -> Bool { isFirstResponder = true + selectionManager.cursorTimer.resetTimer() return super.becomeFirstResponder() } From d79b88e513bab00db46842e312b9661f08250c01 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 16 Jan 2024 12:41:34 -0600 Subject: [PATCH 2/3] Linter --- Sources/CodeEditTextView/Cursors/CursorTimer.swift | 5 ++--- .../TextSelectionManager/TextSelectionManager.swift | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditTextView/Cursors/CursorTimer.swift b/Sources/CodeEditTextView/Cursors/CursorTimer.swift index 859d6c08..912ce9b6 100644 --- a/Sources/CodeEditTextView/Cursors/CursorTimer.swift +++ b/Sources/CodeEditTextView/Cursors/CursorTimer.swift @@ -19,7 +19,7 @@ class CursorTimer { var shouldHide: Bool = false // MARK: - Methods - + /// Resets the cursor blink timer. /// - Parameter newBlinkDuration: The duration to blink, leave as nil to never blink. func resetTimer(newBlinkDuration: TimeInterval? = 0.5) { @@ -56,13 +56,12 @@ class CursorTimer { cursor.blinkTimer(shouldHide) } } - + /// Register a new cursor view with the timer. /// - Parameter newCursor: The cursor to blink. func register(_ newCursor: CursorView) { cursors.add(newCursor) } - deinit { timer?.invalidate() diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 67664ae7..fb7cb4b0 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -159,7 +159,7 @@ public class TextSelectionManager: NSObject { } // MARK: - Selection Views - + /// Update all selection cursors. Placing them in the correct position for each text selection and reseting the /// blink timer. func updateSelectionViews() { @@ -197,7 +197,7 @@ public class TextSelectionManager: NSObject { cursorTimer.resetTimer() } } - + /// Removes all cursor views and stops the cursor blink timer. func removeCursors() { cursorTimer.stopTimer() From f4060a0f9e47dbb5c2ff61d19b35bccab2e9da80 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 16 Jan 2024 12:54:49 -0600 Subject: [PATCH 3/3] Update TextSelectionManagerTests.swift --- Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift b/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift index c700b8bb..38ea3983 100644 --- a/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift +++ b/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift @@ -17,7 +17,7 @@ final class TextSelectionManagerTests: XCTestCase { return TextSelectionManager( layoutManager: layoutManager, textStorage: textStorage, - layoutView: nil, + textView: nil, delegate: nil ) }