Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(#13): Separate Cursor Updates, Fix Focus Issues #14

Merged
merged 3 commits into from
Jan 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions Sources/CodeEditTextView/Cursors/CursorTimer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// 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<CursorView> = .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
}
}
54 changes: 54 additions & 0 deletions Sources/CodeEditTextView/Cursors/CursorView.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
121 changes: 0 additions & 121 deletions Sources/CodeEditTextView/TextSelectionManager/CursorView.swift

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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]) {
Expand All @@ -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) {
Expand All @@ -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

Expand All @@ -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 {
Expand All @@ -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()
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/CodeEditTextView/TextView/TextView+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodeEditTextView/TextView/TextView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ extension TextView {
TextSelectionManager(
layoutManager: layoutManager,
textStorage: textStorage,
layoutView: self,
textView: self,
delegate: self
)
}
Expand Down
Loading