Skip to content

Commit

Permalink
Functional implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
mattmassicotte committed Dec 16, 2024
1 parent 876364c commit 18d6ef8
Show file tree
Hide file tree
Showing 20 changed files with 1,474 additions and 295 deletions.
91 changes: 91 additions & 0 deletions IBeamTextViewSystem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import AppKit

import IBeam
import Glyph
import Ligature

extension IBeam.TextGranularity {
var ligatureGranulaity: Ligature.TextGranularity {
switch self {
case .character: .character
case .word: .word
case .line: .line
}
}
}

@MainActor
public struct IBeamTextViewSystem {
let textView: NSTextView
let tokenizer: UTF16CodePointTextViewTextTokenizer

public init(textView: NSTextView) {
self.textView = textView
self.tokenizer = UTF16CodePointTextViewTextTokenizer(textView: textView)
}

private var partialSystem: MutableStringPartialSystem {
MutableStringPartialSystem(textView.textStorage ?? NSTextStorage())
}
}

extension IBeamTextViewSystem : @preconcurrency IBeam.TextSystem {
public typealias TextRange = NSRange
public typealias TextPosition = Int

public func location(for position: TextPosition) -> CGFloat? {
tokenizer.location(for: position)
}

// movement calculation
public func position(from position: TextPosition, moving direction: IBeam.TextDirection, by granularity: IBeam.TextGranularity) -> TextPosition? {
let ligGranularity = granularity.ligatureGranulaity

switch direction {
case .forward:
return tokenizer.position(from: position, toBoundary: ligGranularity, inDirection: .storage(.forward))
case .backward:
return tokenizer.position(from: position, toBoundary: ligGranularity, inDirection: .storage(.backward))
case .left:
return tokenizer.position(from: position, toBoundary: ligGranularity, inDirection: .layout(.left))
case .right:
return tokenizer.position(from: position, toBoundary: ligGranularity, inDirection: .layout(.right))
case let .down(alignment):
return tokenizer.position(from: position, toBoundary: ligGranularity, inDirection: .layout(.down), alignment: alignment)
case let .up(alignment):
return tokenizer.position(from: position, toBoundary: ligGranularity, inDirection: .layout(.up), alignment: alignment)
}
}

public func position(from start: TextPosition, offset: Int) -> TextPosition? {
partialSystem.position(from: start, offset: offset)
}

public func layoutDirection(at position: TextPosition) -> IBeam.TextLayoutDirection? {
partialSystem.layoutDirection(at: position)
}

// range calculation
public var beginningOfDocument: TextPosition { partialSystem.beginningOfDocument }
public var endOfDocument: TextPosition { partialSystem.endOfDocument }

public func compare(_ position: TextPosition, to other: TextPosition) -> ComparisonResult {
partialSystem.compare(position, to: other)
}

public func positions(composing range: TextRange) -> (TextPosition, TextPosition) {
partialSystem.positions(composing: range)
}

public func textRange(from start: TextPosition, to end: TextPosition) -> TextRange? {
partialSystem.textRange(from: start, to: end)
}

// content mutation
public func beginEditing() { partialSystem.beginEditing() }
public func endEditing() { partialSystem.endEditing() }

public func applyMutation(_ range: TextRange, string: AttributedString) -> MutationOutput<TextRange>? {
partialSystem.applyMutation(range, string: string)
}
}
23 changes: 0 additions & 23 deletions Package.resolved

This file was deleted.

10 changes: 3 additions & 7 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.10
// swift-tools-version: 6.0

import PackageDescription

Expand All @@ -13,12 +13,8 @@ let package = Package(
products: [
.library(name: "IBeam", targets: ["IBeam"]),
],
dependencies: [
.package(url: "https://github.com/ChimeHQ/Ligature", revision: "2da2638e59eef2aa6ce0e2078d4e075267bacd4b"),
.package(url: "https://github.com/ChimeHQ/KeyCodes", from: "1.0.3"),
],
targets: [
.target(name: "IBeam", dependencies: ["KeyCodes", "Ligature"]),
.testTarget(name: "IBeamTests", dependencies: ["IBeam", "Ligature"]),
.target(name: "IBeam"),
.testTarget(name: "IBeamTests", dependencies: ["IBeam"]),
]
)
144 changes: 141 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ A Swift library for multi-cursor support

Features:

- Text system agnostic
- Includes support for `NSTextView`
- Text system-agnostic
- Includes support for `NSTextView` and `UITextView`
- Lazy cursor operation evaluation to support large numbers of cursors

> [!WARNING]
> Still very much a WIP.
> Still early days. Lazy evaluation in particular is a work in progress.
## Integration

Expand All @@ -26,6 +27,143 @@ dependencies: [
]
```

## Concepts

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 `TextSystem` 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.

## Implementing a Text System

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.

If you are interested in just connecting this up to AppKit/UIKit, you can do this with [IBeamTextViewSystem](IBeamTextViewSystem.swift). It makes use of [Ligature][] to efficiently implement the needed facilities. And, because that library is implemented with [Glyph][] internally, it is compatible with both TextKit 1 and 2.

[Ligature]: https://github.com/ChimeHQ/Ligature
[Glyph]: https://github.com/ChimeHQ/Glyph

This is a fair bit of work, but it is not included in this library for three reasons:

- Ligature and Glyph may only make sense if you are using a pure NS/UITextView implementation
- A custom view subclass likely means you'll need to do customization on your own anyways

If you need or want to implement a custom system, take a look at the `TextSystem` 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.

## Usage

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.

[KeyCodes]: https://github.com/chimeHQ/KeyCodes

```swift
import AppKit

import KeyCodes
import IBeam

extension KeyModifierFlags {
var addingCursor: Bool {
subtracting(.numericPad) == [.control, .shift]
}
}

open class MultiCursorTextView: NSTextView {
// this is a retain cycle, but it's convenient
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 TextSystem implementation or just exert more control over how the view interacts with its cursors.

## Contributing and Collaboration

I would love to hear from you! Issues or pull requests work great. Both a [Matrix space][matrix] and [Discord][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](https://mastodon.social/@mattiem).
Expand Down
26 changes: 20 additions & 6 deletions Sources/IBeam/Cursor.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import Foundation

public struct Cursor<TextRange>: Identifiable {
public struct Cursor<TextRange> {
public let id: UUID
public var textRanges: [TextRange]
public var textRange: TextRange
public var alignment: CGFloat?

public init(textRanges: [TextRange]) {
self.textRanges = textRanges
public init(_ textRange: TextRange, alignment: CGFloat?) {
self.textRange = textRange
self.alignment = alignment
self.id = UUID()
}

public init(textRange: TextRange) {
self.init(textRanges: [textRange])
init(id: UUID, textRange: TextRange, alignment: CGFloat?) {
self.id = id
self.textRange = textRange
self.alignment = alignment
}
}

Expand All @@ -19,3 +23,13 @@ extension Cursor: Hashable where TextRange: Hashable {}
extension Cursor: Sendable where TextRange: Sendable {}
extension Cursor: Decodable where TextRange: Decodable {}
extension Cursor: Encodable where TextRange: Encodable {}

extension Cursor: Identifiable {}

extension Cursor: CustomStringConvertible {
public var description: String {
let str = alignment.map { $0.description } ?? "-"

return "<Cursor \(id) \(textRange) \(str)>"
}
}
46 changes: 46 additions & 0 deletions Sources/IBeam/InputOperation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import AppKit

public enum InputOperation {
case deleteBackwards(TextGranularity)
case moveLeft(TextGranularity)
case moveRight(TextGranularity)
case moveUp
case moveDown
case insertText(AttributedString)
case moveToLeftEndOfLine
case moveToRightEndOfLine

public static func insertText(_ value: String) -> InputOperation {
Self.insertText(AttributedString(value))
}

public init?(selector: Selector) {
switch selector {
case #selector(NSResponder.moveLeft(_:)):
self = .moveLeft(.character)
case #selector(NSResponder.moveRight(_:)):
self = .moveRight(.character)
case #selector(NSResponder.moveDown(_:)):
self = .moveDown
case #selector(NSResponder.moveUp(_:)):
self = .moveUp
case #selector(NSResponder.deleteBackward(_:)):
self = .deleteBackwards(.character)
case #selector(NSResponder.moveToLeftEndOfLine(_:)):
self = .moveToLeftEndOfLine
case #selector(NSResponder.moveToRightEndOfLine(_:)):
self = .moveToRightEndOfLine
default:
return nil
}
}

public var affectsAlignment: Bool {
switch self {
case .moveUp, .moveDown:
return false
default:
return true
}
}
}
Loading

0 comments on commit 18d6ef8

Please sign in to comment.