diff --git a/Package.resolved b/Package.resolved index 2afd825..ef2bf29 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "a37ab791b028e91ba927aed2f01979a802c12b4164919a272b77af2d9e021dbb", + "originHash" : "00743848cbb91a47eb19084a5d4a354ab430ceab782cdee3f3c57556b13f019f", "pins" : [ + { + "identity" : "keycodes", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/KeyCodes", + "state" : { + "revision" : "93ba759d2bfd7b5233294188bc685fd09dce2729", + "version" : "1.0.3" + } + }, { "identity" : "ligature", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 88c35e2..7bdd7ff 100644 --- a/Package.swift +++ b/Package.swift @@ -14,11 +14,11 @@ let package = Package( .library(name: "IBeam", targets: ["IBeam"]), ], dependencies: [ - .package(url: "https://github.com/ChimeHQ/Ligature", branch: "main"), + .package(url: "https://github.com/ChimeHQ/Ligature", revision: "b184b82dd4c68327ed37257d1b60fc9f41a50042"), .package(url: "https://github.com/ChimeHQ/KeyCodes", from: "1.0.3"), ], targets: [ .target(name: "IBeam", dependencies: ["KeyCodes", "Ligature"]), - .testTarget(name: "IBeamTests", dependencies: ["IBeam"]), + .testTarget(name: "IBeamTests", dependencies: ["IBeam", "Ligature"]), ] ) diff --git a/Sources/IBeam/Cursor.swift b/Sources/IBeam/Cursor.swift index bd6085a..e9b8dd5 100644 --- a/Sources/IBeam/Cursor.swift +++ b/Sources/IBeam/Cursor.swift @@ -10,7 +10,7 @@ public struct Cursor: Identifiable { public typealias TextRange = Range public let id: UUID - public let range: TextRange + public var range: TextRange public init(range: TextRange) { self.range = range diff --git a/Sources/IBeam/MultiCursorState.swift b/Sources/IBeam/MultiCursorState.swift index a6b494c..9296c2c 100644 --- a/Sources/IBeam/MultiCursorState.swift +++ b/Sources/IBeam/MultiCursorState.swift @@ -5,24 +5,26 @@ import UIKit #endif import KeyCodes +import Ligature public enum SelectionAffinity: Hashable, Sendable, Codable { case upstream case downstream } -public struct MultiCursorState { - public typealias TextLocation = CoordinateProvider.TextLocation - public typealias Cursor = IBeam.Cursor +@MainActor +public final class MultiCursorState where Tokenizer.Position: Comparable { + public typealias Position = Tokenizer.Position + public typealias Cursor = IBeam.Cursor public var affinity: SelectionAffinity public var cursors: [Cursor] - public let coordinateProvider: CoordinateProvider + public let tokenizer: Tokenizer - public init(cursors: [Cursor] = [], affinity: SelectionAffinity = .upstream, coordinateProvider: CoordinateProvider) { + public init(cursors: [Cursor] = [], affinity: SelectionAffinity = .upstream, tokenizer: Tokenizer) { self.cursors = cursors self.affinity = affinity - self.coordinateProvider = coordinateProvider + self.tokenizer = tokenizer } public var hasMultipleCursors: Bool { @@ -35,15 +37,15 @@ extension MultiCursorState { } - public mutating func addCursorBelow() { + public func addCursorBelow() { guard let range = cursors.last?.range else { assertionFailure("at least one cursor should be present") return } guard - let start = coordinateProvider.closestMatchingVerticalLocation(to: range.lowerBound, above: false), - let end = coordinateProvider.closestMatchingVerticalLocation(to: range.upperBound, above: false) + let start = tokenizer.position(from: range.lowerBound, toBoundary: .character, inDirection: .layout(.down)), + let end = tokenizer.position(from: range.upperBound, toBoundary: .character, inDirection: .layout(.down)) else { return } @@ -54,8 +56,8 @@ extension MultiCursorState { self.cursors.sort() } - public mutating func addCursor(at location: TextLocation) { - let newCursor = Cursor(range: location.. Bool { + public func handleKeyDown(with event: NSEvent) -> Bool { let flags = event.keyModifierFlags?.subtracting(.numericPad) ?? [] let key = event.keyboardHIDUsage @@ -77,14 +80,36 @@ extension MultiCursorState { case ([.control, .shift], .keyboardDownArrow): addCursorBelow() return true + case ([], .keyboardLeftArrow): + moveLeft(self) + return true default: break } - guard hasMultipleCursors else { - return false - } - return false } +#endif +} + +extension MultiCursorState { + public func moveLeft(_ sender: Any?) { + self.cursors = cursors.compactMap { cursor in + let start = cursor.range.lowerBound + guard let newStart = tokenizer.position(from: start, toBoundary: .character, inDirection: .layout(.left)) else { + return nil + } + + var cursor = cursor + + switch affinity { + case .upstream: + cursor.range = newStart.. TextLocation? -} diff --git a/Tests/IBeamTests/MultiCursorStateTests.swift b/Tests/IBeamTests/MultiCursorStateTests.swift index e4bc17e..87214fa 100644 --- a/Tests/IBeamTests/MultiCursorStateTests.swift +++ b/Tests/IBeamTests/MultiCursorStateTests.swift @@ -1,23 +1,32 @@ import Testing import IBeam -class MockCoordinateProvider: TextCoordinateProvider { - func closestMatchingVerticalLocation(to location: Int, above: Bool) -> Int? { - let offset = above ? -5 : 5 - - return location + offset - } -} +import Ligature final class MultiCursorStateTests { - @Test - func addCursorBelow() async throws { + @Test @MainActor + func addCursorBelow() throws { let cursor = Cursor(range: 0..<10) - let provider = MockCoordinateProvider() - var state = MultiCursorState(cursors: [cursor], coordinateProvider: provider) + let tokenizer = MockTextTokenizer() + let state = MultiCursorState(cursors: [cursor], tokenizer: tokenizer) + tokenizer.responses = [.position(5), .position(15)] state.addCursorBelow() + #expect(tokenizer.requests == [.position(0, .character, .layout(.down)), .position(10, .character, .layout(.down))]) #expect(state.cursors.map(\.range) == [0..<10, 5..<15]) } + + @Test @MainActor + func handleLeftArrowWithSingleInsertion() throws { + let cursor = Cursor(range: 1..<1) + let tokenizer = MockTextTokenizer() + let state = MultiCursorState(cursors: [cursor], affinity: .upstream, tokenizer: tokenizer) + + tokenizer.responses = [.position(0)] + state.moveLeft(nil) + + #expect(tokenizer.requests == [.position(1, .character, .layout(.left))]) + #expect(state.cursors.map(\.range) == [0..<0]) + } }