From 5cd0787b43f038051d42b39db3319cbf78c42861 Mon Sep 17 00:00:00 2001 From: Matt <85322+mattmassicotte@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:17:30 -0500 Subject: [PATCH] Remove adapter concept and MainOffender dependency --- Package.resolved | 33 ++--- Package.swift | 3 +- .../TextStory/LazyTextStoringMonitor.swift | 1 - .../TextStory/TextMutationEventRouter.swift | 39 ++---- Sources/TextStory/TextStorageAdapter.swift | 121 ------------------ .../TextStory/TextStorageEventRouter.swift | 73 ----------- Sources/TextStory/TextStoringMonitor.swift | 5 - Sources/TextStory/TextView+TextStoring.swift | 98 ++++++++++++++ Tests/TextStoryTests/TextViewTests.swift | 3 +- 9 files changed, 125 insertions(+), 251 deletions(-) delete mode 100644 Sources/TextStory/TextStorageAdapter.swift delete mode 100644 Sources/TextStory/TextStorageEventRouter.swift create mode 100644 Sources/TextStory/TextView+TextStoring.swift diff --git a/Package.resolved b/Package.resolved index 430a04d..4525fda 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,25 +1,14 @@ { - "object": { - "pins": [ - { - "package": "MainOffender", - "repositoryURL": "https://github.com/mattmassicotte/MainOffender", - "state": { - "branch": null, - "revision": "343cc3797618c29b48b037b4e2beea0664e75315", - "version": "0.1.0" - } - }, - { - "package": "Rearrange", - "repositoryURL": "https://github.com/ChimeHQ/Rearrange", - "state": { - "branch": null, - "revision": "9faf1d7f80bb49b58bea2943e711d38fa8504060", - "version": "1.5.0" - } + "pins" : [ + { + "identity" : "rearrange", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/Rearrange", + "state" : { + "revision" : "9faf1d7f80bb49b58bea2943e711d38fa8504060", + "version" : "1.5.0" } - ] - }, - "version": 1 + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index faeee8c..a778112 100644 --- a/Package.swift +++ b/Package.swift @@ -11,11 +11,10 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ChimeHQ/Rearrange", from: "1.5.0"), - .package(url: "https://github.com/mattmassicotte/MainOffender", from: "0.1.0"), ], targets: [ .target(name: "Internal", publicHeadersPath: "."), - .target(name: "TextStory", dependencies: ["Internal", "MainOffender", "Rearrange"]), + .target(name: "TextStory", dependencies: ["Internal", "Rearrange"]), .target(name: "TextStoryTesting", dependencies: ["TextStory"]), .testTarget(name: "TextStoryTests", dependencies: ["TextStory", "TextStoryTesting"]), ] diff --git a/Sources/TextStory/LazyTextStoringMonitor.swift b/Sources/TextStory/LazyTextStoringMonitor.swift index 02734c5..063715c 100644 --- a/Sources/TextStory/LazyTextStoringMonitor.swift +++ b/Sources/TextStory/LazyTextStoringMonitor.swift @@ -1,6 +1,5 @@ import Foundation -@MainActor public final class LazyTextStoringMonitor { public let storingMonitor: TextStoringMonitor public private(set) var maximumProcessedLocation: Int diff --git a/Sources/TextStory/TextMutationEventRouter.swift b/Sources/TextStory/TextMutationEventRouter.swift index 0b236ed..324cc0b 100644 --- a/Sources/TextStory/TextMutationEventRouter.swift +++ b/Sources/TextStory/TextMutationEventRouter.swift @@ -1,14 +1,11 @@ import Foundation -import MainOffender - /// Routes `TSYTextStorageDelegate` calls to multiple `TextStoringMonitor` instances /// /// This class can except all the `TSYTextStorageDelegate` calls and forward them /// to multiple `TextStoringMonitor` instances. While it can be directly assigned /// as the `storageDelegate` of `TSYTextStorageDelegate`, that could potentially be /// inconvenient for your usage. In that case, just forward along the calls. -@MainActor public final class TextMutationEventRouter: NSObject { private var internalMonitor = CompositeTextStoringMonitor(monitors: []) public var storingMonitorsCompletionBlock: ((TextStoring) -> Void)? @@ -28,19 +25,17 @@ public final class TextMutationEventRouter: NSObject { } extension TextMutationEventRouter: TSYTextStorageDelegate { - public nonisolated func textStorage(_ textStorage: TSYTextStorage, willReplaceCharactersIn range: NSRange, with string: String) { - MainActor.runUnsafely { - precondition(processingTextChange == false, "Must not be processing a text change when another is begun") + public func textStorage(_ textStorage: TSYTextStorage, willReplaceCharactersIn range: NSRange, with string: String) { + precondition(processingTextChange == false, "Must not be processing a text change when another is begun") - let mutation = TextMutation(string: string, range: range, limit: textStorage.length) + let mutation = TextMutation(string: string, range: range, limit: textStorage.length) - self.pendingMutation = mutation + self.pendingMutation = mutation - internalMonitor.willApplyMutation(mutation, to: textStorage) - } + internalMonitor.willApplyMutation(mutation, to: textStorage) } - public nonisolated func textStorage(_ textStorage: TSYTextStorage, didReplaceCharactersIn range: NSRange, with string: String) { + public func textStorage(_ textStorage: TSYTextStorage, didReplaceCharactersIn range: NSRange, with string: String) { // it's necessary to recreate the limit, because at this point the storage has changed, // and the length has now been modified let delta = string.utf16.count - range.length @@ -48,24 +43,18 @@ extension TextMutationEventRouter: TSYTextStorageDelegate { let mutation = TextMutation(string: string, range: range, limit: preeditLimit) - MainActor.runUnsafely { - precondition(mutation == pendingMutation, "Pre and post mutations must be the same") + precondition(mutation == pendingMutation, "Pre and post mutations must be the same") - internalMonitor.didApplyMutation(mutation, to: textStorage) - } + internalMonitor.didApplyMutation(mutation, to: textStorage) } - public nonisolated func textStorageWillCompleteProcessingEdit(_ textStorage: TSYTextStorage) { - MainActor.runUnsafely { - internalMonitor.willCompleteChangeProcessing(of: pendingMutation, in: textStorage) - } + public func textStorageWillCompleteProcessingEdit(_ textStorage: TSYTextStorage) { + internalMonitor.willCompleteChangeProcessing(of: pendingMutation, in: textStorage) } - public nonisolated func textStorageDidCompleteProcessingEdit(_ textStorage: TSYTextStorage) { - MainActor.runUnsafely { - internalMonitor.didCompleteChangeProcessing(of: pendingMutation, in: textStorage) - - pendingMutation = nil - } + public func textStorageDidCompleteProcessingEdit(_ textStorage: TSYTextStorage) { + internalMonitor.didCompleteChangeProcessing(of: pendingMutation, in: textStorage) + + pendingMutation = nil } } diff --git a/Sources/TextStory/TextStorageAdapter.swift b/Sources/TextStory/TextStorageAdapter.swift deleted file mode 100644 index 833f9fc..0000000 --- a/Sources/TextStory/TextStorageAdapter.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Foundation - -/// A concrete `TextStoring` implementation that uses functions to apply the `TextStoring` protocol. -/// -/// This class exists to help connect a `MainActor`-isolated system to a `TextStoring`-compatible type. -public final class TextStorageAdapter { - public typealias LengthProvider = () -> Int - public typealias SubstringProvider = (NSRange) -> String? - public typealias MutationApplier = (TextMutation) -> Void - - public let lengthProvider: LengthProvider - public let substringProvider: SubstringProvider - public let mutationApplier: MutationApplier - - public init( - lengthProvider: @escaping LengthProvider, - substringProvider: @escaping SubstringProvider, - mutationApplier: @escaping MutationApplier - ) { - self.lengthProvider = lengthProvider - self.substringProvider = substringProvider - self.mutationApplier = mutationApplier - } -} - -extension TextStorageAdapter: TextStoring { - public var length: Int { - lengthProvider() - } - - public func substring(from range: NSRange) -> String? { - substringProvider(range) - } - - public func applyMutation(_ mutation: TextMutation) { - mutationApplier(mutation) - } -} - -extension TextStoring { - func registerMutation(_ mutation: TextMutation, with undoManager: UndoManager?) { - guard let manager = undoManager else { return } - let inverse = inverseMutation(for: mutation) - - manager.registerUndo(withTarget: self, handler: { $0.applyMutation(inverse) }) - } -} - -#if canImport(AppKit) -import AppKit - -extension NSTextView { - func applyMutation(_ mutation: TextMutation) { - guard let storage = textStorage else { return } - - if let manager = undoManager { - let inverse = storage.inverseMutation(for: mutation) - - manager.registerUndo(withTarget: self, handler: { $0.applyMutation(inverse) }) - } - - replaceCharacters(in: mutation.range, with: mutation.string) - - didChangeText() - } -} - -extension TextStorageAdapter { - @MainActor - public convenience init(textView: NSTextView) { - self.init { - textView.textStorage?.length ?? 0 - } substringProvider: { range in - textView.textStorage?.substring(from: range) - } mutationApplier: { mutation in - textView.applyMutation(mutation) - } - } -} -#elseif canImport(UIKit) -import UIKit - -extension UITextView { - func applyMutation(_ mutation: TextMutation) { - if let manager = undoManager { - let inverse = textStorage.inverseMutation(for: mutation) - - manager.registerUndo(withTarget: self, handler: { (storable) in - storable.applyMutation(inverse) - }) - } - - guard let start = position(from: self.beginningOfDocument, offset: mutation.range.location) else { - preconditionFailure("Unable to determine range start location") - } - - guard let end = position(from: start, offset: mutation.range.length) else { - preconditionFailure("Unable to determine range end location") - } - - guard let range = textRange(from: start, to: end) else { - preconditionFailure("Unable to build range from start and end") - } - - replace(range, withText: mutation.string) - } -} - -extension TextStorageAdapter { - @MainActor - public convenience init(textView: UITextView) { - self.init { - textView.textStorage.length - } substringProvider: { range in - textView.textStorage.substring(from: range) - } mutationApplier: { mutation in - textView.applyMutation(mutation) - } - } -} -#endif diff --git a/Sources/TextStory/TextStorageEventRouter.swift b/Sources/TextStory/TextStorageEventRouter.swift deleted file mode 100644 index cfd24c6..0000000 --- a/Sources/TextStory/TextStorageEventRouter.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation - -public protocol TextStorageMonitor { - func textStorage(_ textStorage: TSYTextStorage, willReplaceCharactersIn range: NSRange, with string: String) - func textStorage(_ textStorage: TSYTextStorage, didReplaceCharactersIn range: NSRange, with string: String) - func textStorageWillCompleteProcessingEdit(_ textStorage: TSYTextStorage) - func textStorageDidCompleteProcessingEdit(_ textStorage: TSYTextStorage) -} - -public extension TextStorageMonitor { - func textStorage(_ textStorage: TSYTextStorage, willReplaceCharactersIn range: NSRange, with string: String) {} - func textStorageWillCompleteProcessingEdit(_ textStorage: TSYTextStorage) {} - func textStorageDidCompleteProcessingEdit(_ textStorage: TSYTextStorage) {} -} - -/// Routes `TSYTextStorageDelegate` calls to multiple `TextStorageMonitor` instances. -/// -/// This class can except all the `TSYTextStorageDelegate` calls and forward them to multiple `TextStorageMonitor` instances. It can be directly assigned as the `storageDelegate` of `TSYTextStorageDelegate`. -public final class TextStorageEventRouter: NSObject { - public typealias DoubleClickWordRangeProvider = (Int) -> NSRange - public typealias WordBoundaryProvider = (Int, Bool) -> Int - - public var monitors = [TextStorageMonitor]() - public var doubleClickWordRangeProvider: DoubleClickWordRangeProvider? - public var workBoundaryProvider: WordBoundaryProvider? - - public override init() { - } -} - -extension TextStorageEventRouter: TSYTextStorageDelegate { - public func textStorage(_ textStorage: TSYTextStorage, willReplaceCharactersIn range: NSRange, with string: String) { - for monitor in monitors { - monitor.textStorage(textStorage, willReplaceCharactersIn: range, with: string) - } - } - - public func textStorage(_ textStorage: TSYTextStorage, didReplaceCharactersIn range: NSRange, with string: String) { - for monitor in monitors { - monitor.textStorage(textStorage, didReplaceCharactersIn: range, with: string) - } - } - - public func textStorageWillCompleteProcessingEdit(_ textStorage: TSYTextStorage) { - for monitor in monitors { - monitor.textStorageWillCompleteProcessingEdit(textStorage) - } - } - - public func textStorageDidCompleteProcessingEdit(_ textStorage: TSYTextStorage) { - for monitor in monitors { - monitor.textStorageDidCompleteProcessingEdit(textStorage) - } - } - -#if os(macOS) - public func textStorage(_ textStorage: TSYTextStorage, doubleClickRangeForLocation location: UInt) -> NSRange { - if let provider = doubleClickWordRangeProvider { - return provider(Int(location)) - } - - return textStorage.internalStorage.doubleClick(at: Int(location)) - } - - public func textStorage(_ textStorage: TSYTextStorage, nextWordIndexFromLocation location: UInt, direction forward: Bool) -> UInt { - if let provider = workBoundaryProvider { - return UInt(provider(Int(location), forward)) - } - - return UInt(textStorage.internalStorage.nextWord(from: Int(location), forward: forward)) - } -#endif -} diff --git a/Sources/TextStory/TextStoringMonitor.swift b/Sources/TextStory/TextStoringMonitor.swift index d401601..2feb995 100644 --- a/Sources/TextStory/TextStoringMonitor.swift +++ b/Sources/TextStory/TextStoringMonitor.swift @@ -1,17 +1,12 @@ import Foundation public protocol TextStoringMonitor { - @MainActor func willApplyMutation(_ mutation: TextMutation, to storage: TextStoring) - @MainActor func didApplyMutation(_ mutation: TextMutation, to storage: TextStoring) - @MainActor func willCompleteChangeProcessing(of mutation: TextMutation?, in storage: TextStoring) - @MainActor func didCompleteChangeProcessing(of mutation: TextMutation?, in storage: TextStoring) } -@MainActor public extension TextStoringMonitor { /// Invoke all the monitoring methods in order func processMutation(_ mutation: TextMutation, in storage: TextStoring) { diff --git a/Sources/TextStory/TextView+TextStoring.swift b/Sources/TextStory/TextView+TextStoring.swift new file mode 100644 index 0000000..4ec6f5b --- /dev/null +++ b/Sources/TextStory/TextView+TextStoring.swift @@ -0,0 +1,98 @@ +import Foundation + +extension MainActor { + /// Execute the given body closure on the main actor without enforcing MainActor isolation. + /// + /// It will crash if run on any non-main thread. + /// + /// This was copied from the MainOffender library + @_unavailableFromAsync + static func runUnsafely(_ body: @MainActor () throws -> T) rethrows -> T { +#if swift(>=5.9) + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + return try MainActor.assumeIsolated(body) + } +#endif + + dispatchPrecondition(condition: .onQueue(.main)) + return try withoutActuallyEscaping(body) { fn in + try unsafeBitCast(fn, to: (() throws -> T).self)() + } + } +} + +#if os(macOS) +import class AppKit.NSTextView + +extension NSTextView: TextStoring { + public nonisolated var length: Int { + MainActor.runUnsafely { + textStorage?.length ?? 0 + } + } + + public nonisolated func substring(from range: NSRange) -> String? { + MainActor.runUnsafely { + textStorage?.substring(from: range) + } + } + + public nonisolated func applyMutation(_ mutation: TextMutation) { + MainActor.runUnsafely { + if let manager = undoManager { + let inverse = inverseMutation(for: mutation) + + manager.registerUndo(withTarget: self, handler: { (storable) in + storable.applyMutation(inverse) + }) + } + + replaceCharacters(in: mutation.range, with: mutation.string) + + didChangeText() + } + } +} +#else +import UIKit + +extension UITextView: TextStoring { + public nonisolated var length: Int { + MainActor.runUnsafely { + textStorage.length + } + } + + public nonisolated func substring(from range: NSRange) -> String? { + MainActor.runUnsafely { + textStorage.substring(from: range) + } + } + + public nonisolated func applyMutation(_ mutation: TextMutation) { + MainActor.runUnsafely { + if let manager = undoManager { + let inverse = inverseMutation(for: mutation) + + manager.registerUndo(withTarget: self, handler: { (storable) in + storable.applyMutation(inverse) + }) + } + + guard let start = position(from: self.beginningOfDocument, offset: mutation.range.location) else { + preconditionFailure("Unable to determine range start location") + } + + guard let end = position(from: start, offset: mutation.range.length) else { + preconditionFailure("Unable to determine range end location") + } + + guard let range = textRange(from: start, to: end) else { + preconditionFailure("Unable to build range from start and end") + } + + replace(range, withText: mutation.string) + } + } +} +#endif diff --git a/Tests/TextStoryTests/TextViewTests.swift b/Tests/TextStoryTests/TextViewTests.swift index 0537f48..0d8ca38 100644 --- a/Tests/TextStoryTests/TextViewTests.swift +++ b/Tests/TextStoryTests/TextViewTests.swift @@ -13,11 +13,10 @@ final class TextViewTests: XCTestCase { func testProgrammaticModificationSupportsUndo() throws { let textView = UndoSettingTextView() textView.settableUndoManager = UndoManager() - let storage = TextStorageAdapter(textView: textView) let mutation = TextMutation(string: "hello", range: NSRange.zero, limit: 0) - storage.applyMutation(mutation) + textView.applyMutation(mutation) XCTAssertEqual(textView.text, "hello") XCTAssertEqual(textView.selectedRange, NSRange(5..<5))