From 593cae881ffd62d0f2e59bc403106f4951f64b3e Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:42:57 +0800 Subject: [PATCH] Support control bindings --- Package.swift | 8 +- Package@swift-6.0.swift | 4 + .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../AppKitNavigation/Bindings/NSAlert.swift | 48 +++ .../Bindings/NSColorPanel.swift | 41 +++ .../Bindings/NSColorWell.swift | 27 ++ .../AppKitNavigation/Bindings/NSControl.swift | 43 +++ .../Bindings/NSDatePicker.swift | 27 ++ .../Bindings/NSFontManager.swift | 77 ++++ .../Bindings/NSMenuItem.swift | 17 + .../Bindings/NSPathControl.swift | 29 ++ .../Bindings/NSSaveOpenPanel.swift | 62 ++++ .../Bindings/NSSegmentedControl.swift | 79 ++++ .../AppKitNavigation/Bindings/NSSlider.swift | 26 ++ .../AppKitNavigation/Bindings/NSStepper.swift | 26 ++ .../AppKitNavigation/Bindings/NSSwitch.swift | 34 ++ .../Bindings/NSTargetAction.swift | 143 ++++++++ .../Bindings/NSTargetActionProxy.swift | 88 +++++ .../Bindings/NSTextField.swift | 342 ++++++++++++++++++ .../Bindings/NSToolbarItem.swift | 17 + Sources/AppKitNavigationShim/include/shim.h | 17 + Sources/AppKitNavigationShim/shim.m | 66 ++++ .../xcshareddata/swiftpm/Package.resolved | 34 +- 23 files changed, 1238 insertions(+), 19 deletions(-) create mode 100755 Sources/AppKitNavigation/Bindings/NSAlert.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSColorPanel.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSColorWell.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSControl.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSDatePicker.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSFontManager.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSMenuItem.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSPathControl.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSlider.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSStepper.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSwitch.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSTargetAction.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSTextField.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSToolbarItem.swift create mode 100755 Sources/AppKitNavigationShim/include/shim.h create mode 100755 Sources/AppKitNavigationShim/shim.m diff --git a/Package.swift b/Package.swift index 2194c6fcf..2fb60be22 100644 --- a/Package.swift +++ b/Package.swift @@ -36,6 +36,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.1.0"), ], targets: [ .target( @@ -82,9 +83,14 @@ let package = Package( .target( name: "AppKitNavigation", dependencies: [ - "SwiftNavigation" + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "IdentifiedCollections", package: "swift-identified-collections") ] ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 490fe55fc..169fd9540 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -83,8 +83,12 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", + "AppKitNavigationShim", ] ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 096761ab3..7d469824e 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -52,8 +52,8 @@ return try result!._rethrowGet() case let .swiftUI(animation): - var result: Swift.Result? #if swift(>=6) + var result: Swift.Result? if #available(macOS 15, *) { NSAnimationContext.animate(animation) { result = Swift.Result(catching: body) diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift new file mode 100755 index 000000000..812f89481 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -0,0 +1,48 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSAlert { + /// Creates and returns a alert for displaying an alert using a data description. + /// + /// - Parameters: + /// - state: A data description of the alert. + /// - handler: A closure that is invoked with an action held in `state`. + public convenience init( + state: AlertState, + handler: @escaping (_ action: Action?) -> Void + ) { + self.init() + self.messageText = String(state: state.title) + state.message.map { self.informativeText = String(state: $0) } + + for button in state.buttons { + addButton(button, action: handler) + } + } +} + +extension NSAlert { + public func addButton( + _ buttonState: ButtonState, + action handler: @escaping (_ action: Action?) -> Void = { (_: Never?) in } + ) { + let button = addButton(withTitle: String(state: buttonState.label)) + + button.createActionProxyIfNeeded().addBindingAction { _ in + buttonState.withAction(handler) + } + + if buttonState.role == .destructive, #available(macOS 11.0, *) { + button.hasDestructiveAction = true + } + + if buttonState.role == .cancel { + button.keyEquivalent = "\u{1b}" + } + + if #available(macOS 12, *) { + button.setAccessibilityLabel(buttonState.label.accessibilityLabel.map { String(state: $0) }) + } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift new file mode 100755 index 000000000..d2287caf9 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSColorPanel: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { setTarget(newValue) } + get { value(forKeyPath: "target") as? AnyObject } + } + + public var appkitNavigationAction: Selector? { + set { setAction(newValue) } + get { value(forKeyPath: "action") as? Selector } + } +} + +extension NSColorPanel { + /// Creates a new color panel and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - color: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(color: UIBinding) { + self.init() + bind(color: color) + } + + /// Establishes a two-way connection between a binding and the color panel's selected color. + /// + /// - Parameter color: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(color: UIBinding) -> ObserveToken { + bind(color, to: \.color) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorWell.swift b/Sources/AppKitNavigation/Bindings/NSColorWell.swift new file mode 100755 index 000000000..71eab7986 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSColorWell.swift @@ -0,0 +1,27 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSColorWell { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - color: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(frame: CGRect = .zero, color: UIBinding) { + self.init(frame: frame) + bind(color: color) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter color: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(color: UIBinding) -> ObserveToken { + bind(color, to: \.color) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift new file mode 100755 index 000000000..7edc6aaef --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -0,0 +1,43 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSControl: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +extension NSControl { + public convenience init(action: @escaping (Self) -> Void) { + self.init(frame: .zero) + createActionProxyIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } + + @discardableResult + public func addAction(_ action: @escaping (NSControl) -> Void) -> UUID { + createActionProxyIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } + + public func removeAction(for id: UUID) { + createActionProxyIfNeeded().removeAction(for: id) + } + + public func removeAllActions() { + createActionProxyIfNeeded().removeAllActions() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSDatePicker.swift b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift new file mode 100755 index 000000000..17e3d26ec --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift @@ -0,0 +1,27 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSDatePicker { + /// Creates a new date picker with the specified frame and registers the binding against the + /// selected date. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected date, and write to when the selected + /// date changes. + public convenience init(frame: CGRect = .zero, date: UIBinding) { + self.init(frame: frame) + bind(date: date) + } + + /// Establishes a two-way connection between a binding and the date picker's selected date. + /// + /// - Parameter date: The binding to read from for the selected date, and write to when the + /// selected date changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(date: UIBinding) -> ObserveToken { + bind(date, to: \.dateValue) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift new file mode 100755 index 000000000..9797d82ae --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -0,0 +1,77 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import SwiftNavigation + +extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { + public var appkitNavigationTarget: AnyObject? { + set { appkitNavigationDelegate.target = newValue } + get { appkitNavigationDelegate.target } + } + + public var appkitNavigationAction: Selector? { + set { appkitNavigationDelegate.action = newValue } + get { appkitNavigationDelegate.action } + } + + private static let appkitNavigationDelegateKey = malloc(1)! + + private var appkitNavigationDelegate: Delegate { + set { + objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let delegate = objc_getAssociatedObject(self, Self.appkitNavigationDelegateKey) as? Delegate { + return delegate + } else { + let delegate = Delegate() + target = delegate + self.appkitNavigationDelegate = delegate + return delegate + } + } + } + + private class Delegate: NSObject, NSFontChanging { + var target: AnyObject? + var action: Selector? + + func changeFont(_ sender: NSFontManager?) { + if let action { + NSApplication.shared.sendAction(action, to: target, from: sender) + } + } + } +} + +@MainActor +extension NSFontManager { + /// Creates a new date picker with the specified frame and registers the binding against the + /// selected date. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected date, and write to when the selected + /// date changes. + public convenience init(font: UIBinding) { + self.init() + bind(font: font) + } + + /// Establishes a two-way connection between a binding and the date picker's selected date. + /// + /// - Parameter date: The binding to read from for the selected date, and write to when the + /// selected date changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(font: UIBinding) -> ObserveToken { + bind(font, to: \._selectedFont) + } + + @objc private var _selectedFont: NSFont { + set { setSelectedFont(newValue, isMultiple: false) } + get { convert(.systemFont(ofSize: 0)) } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift new file mode 100755 index 000000000..25d7dd5c0 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSMenuItem: NSTargetActionProtocol, @unchecked Sendable { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSPathControl.swift b/Sources/AppKitNavigation/Bindings/NSPathControl.swift new file mode 100755 index 000000000..fda06940b --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSPathControl.swift @@ -0,0 +1,29 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSPathControl { + /// Creates a new path control with the specified frame and registers the binding against the + /// selected url. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected url, and write to when the selected + /// url changes. + public convenience init(frame: CGRect = .zero, date: UIBinding) { + self.init(frame: frame) + bind(url: date) + } + + /// Establishes a two-way connection between a binding and the path control's selected url. + /// + /// - Parameter url: The binding to read from for the selected url, and write to when the + /// selected url changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(url: UIBinding) -> ObserveToken { + bind(url, to: \.url) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift new file mode 100755 index 000000000..8220b8936 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift @@ -0,0 +1,62 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigationShim + +extension NSSavePanel { + public convenience init(url: UIBinding) { + self.init() + bind(url: url) + } + + @discardableResult + public func bind(url binding: UIBinding) -> ObserveToken { + appKitNavigation_onFinalURL = { url in + binding.wrappedValue = url + } + + let observationToken = ObserveToken { [weak self] in + guard let self else { return } + MainActor._assumeIsolated { + self.appKitNavigation_onFinalURL = nil + } + } + observationTokens[\NSSavePanel.url] = observationToken + return observationToken + } + + public func unbindURL() { + observationTokens[\NSSavePanel.url]?.cancel() + observationTokens[\NSSavePanel.url] = nil + } +} + +extension NSOpenPanel { + public convenience init(urls: UIBinding<[URL]>) { + self.init() + bind(urls: urls) + } + + @discardableResult + public func bind(urls binding: UIBinding<[URL]>) -> ObserveToken { + appKitNavigation_onFinalURLs = { urls in + binding.wrappedValue = urls + } + + let observationToken = ObserveToken { [weak self] in + guard let self else { return } + MainActor._assumeIsolated { + self.appKitNavigation_onFinalURLs = nil + } + } + observationTokens[\NSOpenPanel.urls] = observationToken + return observationToken + } + + public func unbindURLs() { + observationTokens[\NSOpenPanel.urls]?.cancel() + observationTokens[\NSOpenPanel.urls] = nil + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift new file mode 100755 index 000000000..25b1bdd44 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift @@ -0,0 +1,79 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import IssueReporting +import AppKit + +extension NSSegmentedControl { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - selectedSegment: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init( + frame: CGRect = .zero, selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.init(frame: frame) + bind( + selectedSegment: selectedSegment, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter selectedSegment: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind( + selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> ObserveToken { + let fileID = HashableStaticString(rawValue: fileID) + let filePath = HashableStaticString(rawValue: filePath) + return bind( + selectedSegment[fileID: fileID, filePath: filePath, line: line, column: column], + to: \.selectedSegment + ) + } +} + +extension RawRepresentable { + fileprivate subscript( + fileID fileID: HashableStaticString, + filePath filePath: HashableStaticString, + line line: UInt, + column column: UInt + ) -> Int { + get { rawValue } + set { + guard let rawRepresentable = Self(rawValue: newValue) + else { + reportIssue( + """ + Raw-representable 'UIBinding<\(Self.self)>' attempted to write an invalid raw value \ + ('\(newValue)'). + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) + return + } + self = rawRepresentable + } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSlider.swift b/Sources/AppKitNavigation/Bindings/NSSlider.swift new file mode 100755 index 000000000..7931a6d92 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSlider.swift @@ -0,0 +1,26 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSSlider { + /// Creates a new slider with the specified frame and registers the binding against the value. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - value: The binding to read from for the current value, and write to when the value + /// changes. + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + bind(value: value) + } + + /// Establishes a two-way connection between a binding and the slider's current value. + /// + /// - Parameter value: The binding to read from for the current value, and write to when the + /// value changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(value: UIBinding) -> ObserveToken { + bind(value, to: \.floatValue) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSStepper.swift b/Sources/AppKitNavigation/Bindings/NSStepper.swift new file mode 100755 index 000000000..59a679d71 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSStepper.swift @@ -0,0 +1,26 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSStepper { + /// Creates a new stepper with the specified frame and registers the binding against the value. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - value: The binding to read from for the current value, and write to when the value + /// changes. + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + bind(value: value) + } + + /// Establishes a two-way connection between a binding and the stepper's current value. + /// + /// - Parameter value: The binding to read from for the current value, and write to when the + /// value changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(value: UIBinding) -> ObserveToken { + bind(value, to: \.doubleValue) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSwitch.swift b/Sources/AppKitNavigation/Bindings/NSSwitch.swift new file mode 100755 index 000000000..60a92b14f --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSwitch.swift @@ -0,0 +1,34 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSSwitch { + /// Creates a new switch with the specified frame and registers the binding against whether or + /// not the switch is on. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - isOn: The binding to read from for the current state, and write to when the state + /// changes. + public convenience init(frame: CGRect = .zero, isOn: UIBinding) { + self.init(frame: frame) + bind(isOn: isOn) + } + + /// Establishes a two-way connection between a binding and the switch's current state. + /// + /// - Parameter isOn: The binding to read from for the current state, and write to when the + /// state changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(isOn: UIBinding) -> ObserveToken { + bind(isOn, to: \.boolValue) { control, isOn, transaction in + control.boolValue = isOn + } + } + + @objc var boolValue: Bool { + set { state = newValue ? .on : .off } + get { state == .on } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift new file mode 100755 index 000000000..1d72abb4e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -0,0 +1,143 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import ConcurrencyExtras +@_spi(Internals) import SwiftNavigation +import AppKit + +/// A protocol used to extend `NSControl, NSMenuItem...`. +@MainActor +public protocol NSTargetActionProtocol: NSObject, Sendable { + var appkitNavigationTarget: AnyObject? { set get } + var appkitNavigationAction: Selector? { set get } +} + +extension NSTargetActionProtocol { + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath + ) -> ObserveToken { + bind(binding, to: keyPath) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + var actionProxy: NSTargetActionProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionProxyKey) as? NSTargetActionProxy + } + } + + func createActionProxyIfNeeded() -> NSTargetActionProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = NSTargetActionProxy(owner: self) + self.actionProxy = actionProxy + return actionProxy + } + } + + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - set: A closure that is called when the binding's value changes with a weakly-captured + /// control, a new value that can be used to configure the control, and a transaction, which + /// can be used to determine how and if the change should be animated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: KeyPath, + set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObservationToken { + unbind(keyPath) + let actionProxy = createActionProxyIfNeeded() + let actionID = actionProxy.addBindingAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + + let isSetting = LockIsolated(false) + let token = observe { [weak self] transaction in + guard let self else { return } + isSetting.withValue { $0 = true } + defer { isSetting.withValue { $0 = false } } + set( + self, + binding.wrappedValue, + transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations + ? binding.transaction + : transaction + ) + } + // NB: This key path must only be accessed on the main actor + @UncheckedSendable var uncheckedKeyPath = keyPath + let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in + guard isSetting.withValue({ !$0 }) else { return } + MainActor._assumeIsolated { + binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + } + } + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.actionProxy?.removeAction(for: actionID) + } + token.cancel() + observation.invalidate() + } + observationTokens[keyPath] = observationToken + return observationToken + } + + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } + +// var observationTokens: [AnyKeyPath: ObservationToken] { +// get { +// objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] +// ?? [:] +// } +// set { +// objc_setAssociatedObject( +// self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC +// ) +// } +// } +} + +@MainActor +extension NSObject { + var observationTokens: [AnyKeyPath: ObservationToken] { + get { + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] + ?? [:] + } + set { + objc_setAssociatedObject( + self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} + +@MainActor +private let observationTokensKey = malloc(1)! +@MainActor +private let actionProxyKey = malloc(1)! + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift new file mode 100755 index 000000000..2d2054d0e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift @@ -0,0 +1,88 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import IdentifiedCollections + +@MainActor +class NSTargetActionProxy: NSObject { + typealias ActionClosure = (Any?) -> Void + + typealias ActionIdentifier = UUID + + private struct Action: Identifiable { + let id = UUID() + + var closure: ActionClosure + + func invoke(_ sender: Any?) { + closure(sender) + } + } + + private var bindingActions: IdentifiedArrayOf = [] + + private var actions: IdentifiedArrayOf = [] + + private var originTarget: AnyObject? + + private var originAction: Selector? + + weak var owner: NSTargetActionProtocol? + + required init(owner: NSTargetActionProtocol) { + self.owner = owner + super.init() + self.originTarget = owner.appkitNavigationTarget + self.originAction = owner.appkitNavigationAction + owner.appkitNavigationTarget = self + owner.appkitNavigationAction = #selector(invokeAction(_:)) + if let textField = owner as? NSTextField { + NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) + } + } + + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0.invoke(obj.object) } + actions.forEach { $0.invoke(obj.object) } + } + + @objc func invokeAction(_ sender: Any?) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) + } + bindingActions.forEach { $0.invoke(sender) } + actions.forEach { $0.invoke(sender) } + } + + @discardableResult + func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { + let action = Action(closure: actionClosure) + actions.append(action) + return action.id + } + + func removeAction(for id: ActionIdentifier) { + actions.remove(id: id) + } + + func removeAllActions() { + actions.removeAll() + } + + @discardableResult + func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { + let bindingAction = Action(closure: bindingActionClosure) + bindingActions.append(bindingAction) + return bindingAction.id + } + + func removeBindingAction(for id: ActionIdentifier) { + bindingActions.remove(id: id) + } + + func removeAllBindingActions() { + bindingActions.removeAll() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift new file mode 100755 index 000000000..897efc841 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -0,0 +1,342 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import Combine +import SwiftNavigation + +@MainActor +extension NSTextField: NSTextViewDelegate { + /// Creates a new text field with the specified frame and registers the binding against its + /// text. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - text: The binding to read from for the current text, and write to when the text + /// changes. + public convenience init(frame: CGRect = .zero, text: UIBinding) { + self.init(frame: frame) + bind(text: text) + } + + /// Creates a new text field with the specified frame and registers the binding against its + /// text. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - attributedText: The binding to read from for the current text, and write to when the + /// attributed text changes. + public convenience init(frame: CGRect = .zero, attributedText: UIBinding) { + self.init(frame: frame) + bind(attributedText: attributedText) + } + + /// Establishes a two-way connection between a binding and the text field's current text. + /// + /// - Parameter text: The binding to read from for the current text, and write to when the text + /// changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(text: UIBinding) -> ObserveToken { + bind(text, to: \.stringValue) + } + + /// Establishes a two-way connection between a binding and the text field's current text. + /// + /// - Parameter attributedText: The binding to read from for the current text, and write to when + /// the attributed text changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(attributedText: UIBinding) -> ObserveToken { + bind(attributedText, to: \.attributedStringValue) + } + + /// Establishes a two-way connection between a binding and the text field's current selection. + /// + /// - Parameter selection: The binding to read from for the current selection, and write to when + /// the selected text range changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(selection: UIBinding) -> ObserveToken { + let editingChangedAction = NotificationCenter.default.publisher(for: NSTextField.textDidChangeNotification, object: self) + .sink { [weak self] _ in + guard let self else { return } + selection.wrappedValue = self.textSelection + } + let editingDidEndAction = NotificationCenter.default.publisher(for: NSTextField.textDidEndEditingNotification, object: self).sink { _ in selection.wrappedValue = nil } + let token = observe { [weak self] in + guard let self else { return } + textSelection = selection.wrappedValue + } + textSelectionObserver = TextSelectionObserver { control in + MainActor._assumeIsolated { + selection.wrappedValue = control.textSelection + } + } + + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + editingChangedAction.cancel() + editingDidEndAction.cancel() + token.cancel() + self?.textSelectionObserver = nil + } + } + observationTokens[\NSTextField.selectedRange] = observationToken + return observationToken + } + + fileprivate var selectedRange: NSRange? { + set { + currentEditor()?.selectedRange = newValue ?? .init(location: 0, length: 0) + } + + get { + currentEditor()?.selectedRange + } + } + + fileprivate class TextSelectionObserver: NSObject { + let observer: (NSTextField) -> Void + + init(observer: @escaping (NSTextField) -> Void) { + self.observer = observer + } + } + + private static let textSelectionObserverKey = malloc(1)! + private var textSelectionObserver: TextSelectionObserver? { + set { + objc_setAssociatedObject(self, Self.textSelectionObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, Self.textSelectionObserverKey) as? TextSelectionObserver + } + } + + public func textViewDidChangeSelection(_ notification: Notification) { + textSelectionObserver?.observer(self) + } + + fileprivate var textSelection: AppKitTextSelection? { + get { + guard + let textRange = selectedRange + else { + return nil + } + let text = stringValue + let lowerBound = + text.index( + text.startIndex, + offsetBy: textRange.location, + limitedBy: text.endIndex + ) ?? text.endIndex + let upperBound = + text.index( + text.startIndex, + offsetBy: NSMaxRange(textRange), + limitedBy: text.endIndex + ) ?? text.endIndex + return AppKitTextSelection(range: lowerBound ..< upperBound) + } + set { + guard let selection = newValue?.range else { + selectedRange = nil + return + } + let text = stringValue + let from = text.distance( + from: text.startIndex, to: min(selection.lowerBound, text.endIndex) + ) + let to = text.distance( + from: text.startIndex, to: min(selection.upperBound, text.endIndex) + ) + selectedRange = .init(location: from, length: to - from) + } + } + + /// Modifies this text field by binding its focus state to the given state value. + /// + /// Use this modifier to cause the text field to receive focus whenever the the `binding` equals + /// the `value`. Typically, you create an enumeration of fields that may receive focus, bind an + /// instance of this enumeration, and assign its cases to focusable text fields. + /// + /// The following example uses the cases of a `LoginForm` enumeration to bind the focus state of + /// two `UITextField` views. A sign-in button validates the fields and sets the bound + /// `focusedField` value to any field that requires the user to correct a problem. + /// + /// ```swift + /// final class LoginViewController: UIViewController { + /// enum Field { + /// case usernameField + /// case passwordField + /// } + /// + /// @UIBinding private var username = "" + /// @UIBinding private var password = "" + /// @UIBinding private var focusedField: Field? + /// + /// // ... + /// + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let usernameTextField = UITextField(text: $username) + /// usernameTextField.focus($focusedField, equals: .usernameField) + /// + /// let passwordTextField = UITextField(text: $password) + /// passwordTextField.focus($focusedField, equals: .passwordField) + /// passwordTextField.isSecureTextEntry = true + /// + /// let signInButton = UIButton( + /// style: .system, + /// primaryAction: UIAction { [weak self] _ in + /// guard let self else { return } + /// if username.isEmpty { + /// focusedField = .usernameField + /// } else if password.isEmpty { + /// focusedField = .passwordField + /// } else { + /// handleLogin(username, password) + /// } + /// } + /// ) + /// signInButton.setTitle("Sign In", for: .normal) + /// + /// // ... + /// } + /// } + /// ``` + /// + /// To control focus using a Boolean, use the ``UIKit/UITextField/bind(focus:)`` method instead. + /// + /// - Parameters: + /// - focus: The state binding to register. When focus moves to the text field, the binding + /// sets the bound value to the corresponding match value. If a caller sets the state value + /// programmatically to the matching value, then focus moves to the text field. When focus + /// leaves the text field, the binding sets the bound value to `nil`. If a caller sets the + /// value to `nil`, UIKit automatically dismisses focus. + /// - value: The value to match against when determining whether the binding should change. + /// - Returns: A cancel token. + @discardableResult + public func bind( + focus: UIBinding, equals value: Value + ) -> ObserveToken { + focusToken?.cancel() + let editingDidBeginAction = NotificationCenter.default.publisher(for: NSTextField.textDidBeginEditingNotification, object: self).sink { _ in + focus.wrappedValue = value + } + + let editingDidEndAction = NotificationCenter.default.publisher(for: NSTextField.textDidEndEditingNotification, object: self).sink { _ in + guard focus.wrappedValue == value else { return } + focus.wrappedValue = nil + } + + let innerToken = observe { [weak self] in + guard let self else { return } + switch (focus.wrappedValue, currentEditor() != nil) { + case (value, false): + becomeFirstResponder() + case (nil, true): + window?.makeFirstResponder(nil) + default: + break + } + } + let outerToken = ObservationToken { + editingDidBeginAction.cancel() + editingDidEndAction.cancel() + innerToken.cancel() + } + focusToken = outerToken + return outerToken + } + + /// Binds this text field's focus state to the given Boolean state value. + /// + /// Use this method to cause the text field to receive focus whenever the the `condition` value + /// is `true`. You can use this method to observe the focus state of a text field, or + /// programmatically set and remove focus from the text field. + /// + /// In the following example, a single `UITextField` accepts a user's desired `username`. The + /// text field binds its focus state to the Boolean value `usernameFieldIsFocused`. A "Submit" + /// button's action verifies whether the name is available. If the name is unavailable, the + /// button sets `usernameFieldIsFocused` to `true`, which causes focus to return to the text + /// field, so the user can enter a different name. + /// + /// ```swift + /// final class LoginViewController: UIViewController { + /// @UIBindable private var username = "" + /// @UIBindable private var usernameFieldIsFocused = false + /// + /// // ... + /// + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let textField = UITextField(text: $username) + /// textField.focus($usernameFieldIsFocused) + /// + /// let submitButton = UIButton( + /// style: .system, + /// primaryAction: UIAction { [weak self] _ in + /// guard let self else { return } + /// if !isUserNameAvailable(username: username) { + /// usernameFieldIsFocused = true + /// } + /// } + /// ) + /// submitButton.setTitle("Sign In", for: .normal) + /// + /// // ... + /// } + /// } + /// ``` + /// + /// To control focus by matching a value, use the ``UIKit/UITextField/bind(focus:equals:)`` + /// method instead. + /// + /// - Parameter condition: The focus state to bind. When focus moves to the text field, the + /// binding sets the bound value to `true`. If a caller sets the value to `true` + /// programmatically, then focus moves to the text field. When focus leaves the text field, + /// the binding sets the value to `false`. If a caller sets the value to `false`, UIKit + /// automatically dismisses focus. + /// - Returns: A cancel token. + @discardableResult + public func bind(focus condition: UIBinding) -> ObserveToken { + bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) + } + + private var focusToken: ObserveToken? { + get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObserveToken } + set { + objc_setAssociatedObject( + self, Self.focusTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static let focusTokenKey = malloc(1)! +} + +/// Represents a selection of text. +/// +/// Like SwiftUI's `TextSelection`, but for UIKit. +public struct AppKitTextSelection: Hashable, Sendable { + public var range: Range + + public init(range: Range) { + self.range = range + } + + public init(insertionPoint: String.Index) { + self.range = insertionPoint ..< insertionPoint + } + + public var isInsertion: Bool { + range.isEmpty + } +} + +extension AnyCancellable: @unchecked Sendable {} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift new file mode 100755 index 000000000..478253a51 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSToolbarItem: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100755 index 000000000..9bd711190 --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,17 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface NSSavePanel (AppKitNavigation) +@property (nullable) void (^ AppKitNavigation_onFinalURL)(NSURL *_Nullable); +@property (nullable) void (^ AppKitNavigation_onFinalURLs)(NSArray *); +@end + + +NS_ASSUME_NONNULL_END +#endif +#endif /* if __has_include() */ diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m new file mode 100755 index 000000000..8101d5535 --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,66 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import ObjectiveC; +@import AppKit; +#import "shim.h" + +@interface AppKitNavigationShim : NSObject + +@end + +@implementation AppKitNavigationShim + +// NB: We must use Objective-C here to eagerly swizzle view controller code that is responsible +// for state-driven presentation and dismissal of child features. + ++ (void)load { + method_exchangeImplementations( + class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURL:")), + class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURL:)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURLs:")), + class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURLs:)) + ); +} + +@end + +@implementation NSSavePanel (AppKitNavigation) + +- (void)setAppKitNavigation_onFinalURLs:(void (^)(NSArray * _Nonnull))AppKitNavigation_onFinalURLs { + objc_setAssociatedObject(self, @selector(AppKitNavigation_onFinalURLs), AppKitNavigation_onFinalURLs, OBJC_ASSOCIATION_COPY); +} + +- (void (^)(NSArray * _Nonnull))AppKitNavigation_onFinalURLs { + return objc_getAssociatedObject(self, @selector(AppKitNavigation_onFinalURLs)); +} + +- (void)setAppKitNavigation_onFinalURL:(void (^)(NSURL * _Nullable))AppKitNavigation_onFinalURL { + objc_setAssociatedObject(self, @selector(AppKitNavigation_onFinalURL), AppKitNavigation_onFinalURL, OBJC_ASSOCIATION_COPY); +} + +- (void (^)(NSURL * _Nullable))AppKitNavigation_onFinalURL { + return objc_getAssociatedObject(self, @selector(AppKitNavigation_onFinalURL)); +} + +- (void)AppKitNavigation_setFinalURL:(nullable NSURL *)url { + [self AppKitNavigation_setFinalURL:url]; + if (self.AppKitNavigation_onFinalURL) { + self.AppKitNavigation_onFinalURL(url); + } +} + +- (void)AppKitNavigation_setFinalURLs:(NSArray *)urls { + [self AppKitNavigation_setFinalURLs:urls]; + if (self.AppKitNavigation_onFinalURLs) { + self.AppKitNavigation_onFinalURLs(urls); + } +} + +@end + +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ diff --git a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index 23b807902..e5c147398 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", - "version" : "1.5.4" + "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", + "version" : "1.5.6" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "3581e280bf0d90c3fb9236fb23e75a5d8c46b533", - "version" : "1.0.4" + "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", + "version" : "1.0.5" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "d7472be6b3c89251ce4c0db07d32405b43426781", - "version" : "1.3.7" + "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", + "version" : "1.4.1" } }, { @@ -68,14 +68,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", - "version" : "1.3.0" + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" } }, { "identity" : "swift-docc-symbolkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-symbolkit", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", "state" : { "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "1552c8f722ac256cc0b8daaf1a7073217d4fcdfb", - "version" : "1.3.4" + "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", + "version" : "1.3.5" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "06b5cdc432e93b60e3bdf53aff2857c6b312991a", - "version" : "600.0.0-prerelease-2024-07-30" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", - "version" : "1.2.2" + "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", + "version" : "1.4.2" } } ],