A Swift library for multi-cursor support
Features:
- Text system-agnostic
- Includes support for
NSTextViewandUITextView - Lazy/deferred cursor operation evaluation
Warning
Still early days. Lazy evaluation in particular is a work in progress.
dependencies: [
.package(url: "https://github.com/ChimeHQ/IBeam", branch: "main")
]Supported Traits:
ViewSupport: Enabled theIBeamTextViewSystemtype, used for directNS/UITextViewintegration.
The MultiCursorState type accepts two kinds of events to manage cursor states: InputOperation and CursorOperation.
The InputOperation type models the use actions that affect selection and text state. This closely mirrors selectors within NSResponder. The CursorOperation type models actions that affect the number active cursors. The client of a MultiCursorState instance feeds in these two types of operations, and the state manages querying and relaying mutations to its TextSystemInterface instance to execute those operations.
To support large numbers of cursors, MultiCursorState plays tricks. In particular, it may delay, combine, or otherwise reorder operations if can do so in a way that does not impact visible user state. These can be essential for performance, but you can always force a fully up-to-date system with the ensureOperationsProcessed methods.
IBeam needs to be provided with an interface to the underlying text system. The functionality required to do this is non-trivial, especially when the concepts of "range" and "text location" are fully generic.
The IBeamTextViewSystem type is available for direct integration with NS/UITextView, compatible with both TextKit 1 and 2. It is gated behind the ViewSupport package trait. However, this is unlikely to meet the needs of sophisticated users. If you need or want to implement a custom system, take a look at the TextSystemInterface protocol. It offers a lot of flexibility, particularly around how your system applies text mutations.
If you need or want to implement a custom system, take a look at the TextSystemInterface protocol. It offers a lot of flexibility, particularly around how your system applies text mutations.
If you are on macOS 14.0 or greater, you can use the TextViewIndicatorState type to manage cursor views.
NSPasteboard supports mutiple text selection. However, as far as I can tell the actual format used is undocumented. There are two extension methods you can use to encoded and decode data this way within your view system. These include the ability to control line endings.
extension NSPasteboard {
func multipleTextSelectionStrings(with seperator: String = "\n") -> [String]?
func setMultipleTextSelectionStrings(_ strings: [String], with seperator: String = "\n")
}Here's an example of using a TextSystemCursorCoordinator and IBeamTextViewSystem that ties everything together for an NSTextView. Unfortunately, a subclass is required, but it's fairly minimal.
This also makes use of the KeyCodes library to make modifier key checks easier.
import AppKit
import KeyCodes
import IBeam
extension KeyModifierFlags {
var addingCursor: Bool {
subtracting(.numericPad) == [.control, .shift]
}
}
open class MultiCursorTextView: NSTextView {
private lazy var coordinator = TextSystemCursorCoordinator(
textView: self,
system: IBeamTextViewSystem(textView: self)
)
public var operationProcessor: (InputOperation) -> Void = { _ in }
public var cursorOperationHandler: (CursorOperation<NSRange>) -> Void = { _ in }
override public init(frame frameRect: NSRect, textContainer: NSTextContainer?) {
super.init(frame: frameRect, textContainer: textContainer)
self.operationProcessor = coordinator.processOperation
self.cursorOperationHandler = coordinator.mutateCursors
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension MultiCursorTextView {
open override func insertText(_ input: Any, replacementRange: NSRange) {
// also should handle replacementRange values
let attrString: AttributedString
switch input {
case let string as String:
let container = AttributeContainer(typingAttributes)
attrString = AttributedString(string, attributes: container)
case let string as NSAttributedString:
attrString = AttributedString(string)
default:
fatalError("This API should be called with NSString or NSAttributedString only")
}
operationProcessor(.insertText(attrString))
}
open override func doCommand(by selector: Selector) {
if let op = InputOperation(selector: selector) {
operationProcessor(op)
return
}
super.doCommand(by: selector)
}
// this enable correct routing for the mouse down
open override func menu(for event: NSEvent) -> NSMenu? {
if event.keyModifierFlags?.addingCursor == true {
return nil
}
return super.menu(for: event)
}
open override func mouseDown(with event: NSEvent) {
guard event.keyModifierFlags?.addingCursor == true else {
super.mouseDown(with: event)
return
}
let point = convert(event.locationInWindow, from: nil)
let index = characterIndexForInsertion(at: point)
let range = NSRange(index..<index)
cursorOperationHandler(.add(range))
}
open override func keyDown(with event: NSEvent) {
let flags = event.keyModifierFlags?.subtracting(.numericPad) ?? []
let key = event.keyboardHIDUsage
switch (flags, key) {
case ([.control, .shift], .keyboardUpArrow):
cursorOperationHandler(.addAbove)
case ([.control, .shift], .keyboardDownArrow):
cursorOperationHandler(.addBelow)
default:
super.keyDown(with: event)
}
}
}Of course, you can also just customize everything. This is required if you want to use a custom TextSystemInterface implementation or just exert more control over how the view interacts with its cursors.
I would love to hear from you! Issues or pull requests work great. Both a Matrix space and Discord are available for live help, but I have a strong bias towards answering in the form of documentation. You can also find me on mastodon.
I prefer collaboration, and would love to find ways to work together if you have a similar project.
I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace.
By participating in this project you agree to abide by the Contributor Code of Conduct.