From 37e7adbef1bde09de869686b18a9eeee26fc81d3 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:09:48 +0800 Subject: [PATCH 01/42] Initial commit --- Package.swift | 24 + .../Bindings/UIColorWell.swift | 30 ++ .../AppKitNavigation/Bindings/UIControl.swift | 107 ++++ .../Bindings/UIDatePicker.swift | 30 ++ .../Bindings/UIPageControl.swift | 28 ++ .../Bindings/UISegmentedControl.swift | 81 +++ .../AppKitNavigation/Bindings/UISlider.swift | 29 ++ .../AppKitNavigation/Bindings/UIStepper.swift | 29 ++ .../AppKitNavigation/Bindings/UISwitch.swift | 32 ++ .../Bindings/UITabBarController.swift | 65 +++ .../Bindings/UITextField.swift | 311 ++++++++++++ .../Extensions/UIColorWell.md | 8 + .../Extensions/UIControlProtocol.md | 8 + .../Extensions/UIDatePicker.md | 8 + .../Extensions/UIKitAnimation.md | 35 ++ .../Extensions/UIPageControl.md | 8 + .../Documentation.docc/Extensions/UISlider.md | 8 + .../Extensions/UIStepper.md | 8 + .../Documentation.docc/Extensions/UISwitch.md | 8 + .../Extensions/UITextField.md | 22 + .../Extensions/UIViewController.md | 26 + .../Documentation.docc/Extensions/observe.md | 7 + .../Documentation.docc/UIKitNavigation.md | 165 +++++++ .../Internal/AssumeIsolated.swift | 30 ++ .../Internal/ErrorMechanism.swift | 20 + .../AppKitNavigation/Internal/Exports.swift | 3 + .../Internal/ToOptionalUnit.swift | 12 + .../Navigation/Presentation.swift | 463 ++++++++++++++++++ .../Navigation/UIAlertController.swift | 87 ++++ Sources/AppKitNavigation/Observe.swift | 188 +++++++ .../SwiftUI/Representable.swift | 24 + Sources/AppKitNavigation/UIBinding.swift | 15 + Sources/AppKitNavigation/UIKitAnimation.swift | 144 ++++++ Sources/AppKitNavigation/UITransaction.swift | 53 ++ Sources/AppKitNavigationShim/include/shim.h | 19 + Sources/AppKitNavigationShim/shim.m | 106 ++++ 36 files changed, 2241 insertions(+) create mode 100644 Sources/AppKitNavigation/Bindings/UIColorWell.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIDatePicker.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIPageControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/UISegmentedControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/UISlider.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIStepper.swift create mode 100644 Sources/AppKitNavigation/Bindings/UISwitch.swift create mode 100644 Sources/AppKitNavigation/Bindings/UITabBarController.swift create mode 100644 Sources/AppKitNavigation/Bindings/UITextField.swift create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIColorWell.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIControlProtocol.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIDatePicker.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIKitAnimation.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIPageControl.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UISlider.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIStepper.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UISwitch.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UITextField.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIViewController.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/observe.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/UIKitNavigation.md create mode 100644 Sources/AppKitNavigation/Internal/AssumeIsolated.swift create mode 100644 Sources/AppKitNavigation/Internal/ErrorMechanism.swift create mode 100644 Sources/AppKitNavigation/Internal/Exports.swift create mode 100644 Sources/AppKitNavigation/Internal/ToOptionalUnit.swift create mode 100644 Sources/AppKitNavigation/Navigation/Presentation.swift create mode 100644 Sources/AppKitNavigation/Navigation/UIAlertController.swift create mode 100644 Sources/AppKitNavigation/Observe.swift create mode 100644 Sources/AppKitNavigation/SwiftUI/Representable.swift create mode 100644 Sources/AppKitNavigation/UIBinding.swift create mode 100644 Sources/AppKitNavigation/UIKitAnimation.swift create mode 100644 Sources/AppKitNavigation/UITransaction.swift create mode 100644 Sources/AppKitNavigationShim/include/shim.h create mode 100644 Sources/AppKitNavigationShim/shim.m diff --git a/Package.swift b/Package.swift index 46c1d7edbe..3bc91c28ee 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,10 @@ let package = Package( name: "UIKitNavigation", targets: ["UIKitNavigation"] ), + .library( + name: "AppKitNavigation", + targets: ["AppKitNavigation"] + ), ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -62,10 +66,18 @@ let package = Package( "SwiftUINavigation" ] ), + .target( + name: "CocoaNavigation", + dependencies: [ + "SwiftNavigation", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ] + ), .target( name: "UIKitNavigation", dependencies: [ "SwiftNavigation", + "CocoaNavigation", "UIKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] @@ -73,6 +85,18 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "CocoaNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ] + ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/Bindings/UIColorWell.swift b/Sources/AppKitNavigation/Bindings/UIColorWell.swift new file mode 100644 index 0000000000..96c0a12f16 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIColorWell.swift @@ -0,0 +1,30 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UIColorWell { + /// 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. + /// - selectedColor: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(frame: CGRect = .zero, selectedColor: UIBinding) { + self.init(frame: frame) + bind(selectedColor: selectedColor) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter selectedColor: 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(selectedColor: UIBinding) -> ObservationToken { + bind(selectedColor, to: \.selectedColor, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIControl.swift b/Sources/AppKitNavigation/Bindings/UIControl.swift new file mode 100644 index 0000000000..2854c166d0 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIControl.swift @@ -0,0 +1,107 @@ +#if canImport(UIKit) && !os(watchOS) + import ConcurrencyExtras + @_spi(Internals) import SwiftNavigation + import UIKit + + /// A protocol used to extend `UIControl`. + @MainActor + public protocol UIControlProtocol: UIControl {} + + extension UIControl: UIControlProtocol {} + + @available(iOS 14, tvOS 14, *) + extension UIControlProtocol { + /// 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, + for event: UIControl.Event + ) -> ObservationToken { + bind(binding, to: keyPath, for: event) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + /// 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, + for event: UIControl.Event, + set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObservationToken { + unbind(keyPath) + let action = UIAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + addAction(action, for: event) + 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.uiKit.animation == nil && !transaction.uiKit.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?.removeAction(action, for: .allEvents) + } + 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 + private let observationTokensKey = malloc(1)! +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIDatePicker.swift b/Sources/AppKitNavigation/Bindings/UIDatePicker.swift new file mode 100644 index 0000000000..b4c02a007f --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIDatePicker.swift @@ -0,0 +1,30 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UIDatePicker { + /// 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) -> ObservationToken { + bind(date, to: \.date, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIPageControl.swift b/Sources/AppKitNavigation/Bindings/UIPageControl.swift new file mode 100644 index 0000000000..626798fe36 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIPageControl.swift @@ -0,0 +1,28 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKit + + @available(iOS 14, tvOS 14, *) + extension UIPageControl { + /// Creates a new page control with the specified frame and registers the binding against the + /// current page. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - currentPage: The binding to read from for the current page, and write to when the + /// current page changes. + public convenience init(frame: CGRect = .zero, currentPage: UIBinding) { + self.init(frame: frame) + bind(currentPage: currentPage) + } + + /// Establishes a two-way connection between a binding and the page control's current page. + /// + /// - Parameter currentPage: The binding to read from for the current page, and write to when + /// the current page changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(currentPage: UIBinding) -> ObservationToken { + bind(currentPage, to: \.currentPage, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift b/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift new file mode 100644 index 0000000000..5c2c9a86a6 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift @@ -0,0 +1,81 @@ +#if canImport(UIKit) && !os(watchOS) + import IssueReporting + import UIKit + + @available(iOS 14, tvOS 14, *) + extension UISegmentedControl { + /// 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 + ) -> ObservationToken { + let fileID = HashableStaticString(rawValue: fileID) + let filePath = HashableStaticString(rawValue: filePath) + return bind( + selectedSegment[fileID: fileID, filePath: filePath, line: line, column: column], + to: \.selectedSegmentIndex, + for: .valueChanged + ) + } + } + + 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/UISlider.swift b/Sources/AppKitNavigation/Bindings/UISlider.swift new file mode 100644 index 0000000000..f2fd9a2f61 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UISlider.swift @@ -0,0 +1,29 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UISlider { + /// 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) -> ObservationToken { + bind(value, to: \.value, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIStepper.swift b/Sources/AppKitNavigation/Bindings/UIStepper.swift new file mode 100644 index 0000000000..d3e664e9d2 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIStepper.swift @@ -0,0 +1,29 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UIStepper { + /// 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) -> ObservationToken { + bind(value, to: \.value, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UISwitch.swift b/Sources/AppKitNavigation/Bindings/UISwitch.swift new file mode 100644 index 0000000000..c8e4ab0ff3 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UISwitch.swift @@ -0,0 +1,32 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UISwitch { + /// 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) -> ObservationToken { + bind(isOn, to: \.isOn, for: .valueChanged) { control, isOn, transaction in + control.setOn(isOn, animated: !transaction.uiKit.disablesAnimations) + } + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UITabBarController.swift b/Sources/AppKitNavigation/Bindings/UITabBarController.swift new file mode 100644 index 0000000000..7b9e6351a1 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UITabBarController.swift @@ -0,0 +1,65 @@ +#if swift(>=6) && canImport(UIKit) && !os(tvOS) && !os(watchOS) + import IssueReporting + import UIKit + + @available(iOS 18, tvOS 18, visionOS 2, *) + extension UITabBarController { + @discardableResult + public func bind( + selectedTab: UIBinding, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> ObservationToken { + let token = observe { [weak self] in + guard let self else { return } + guard let identifier = selectedTab.wrappedValue else { + self.selectedTab = nil + return + } + guard let tab = tabs.first(where: { $0.identifier == identifier }) + else { + reportIssue( + """ + Tab bar controller binding attempted to write an invalid identifier ('\(identifier)'). + + Valid identifiers: \(tabs.map(\.identifier)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + self.selectedTab = nil + return + } + self.selectedTab = tab + } + let observation = observe(\.selectedTab) { controller, _ in + MainActor.assumeIsolated { + selectedTab.wrappedValue = controller.selectedTab?.identifier + } + } + let observationToken = ObservationToken { + token.cancel() + observation.invalidate() + } + self.observationToken = observationToken + return observationToken + } + + private var observationToken: ObservationToken? { + get { + objc_getAssociatedObject(self, Self.observationTokenKey) as? ObservationToken + } + set { + objc_setAssociatedObject( + self, Self.observationTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static let observationTokenKey = malloc(1)! + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UITextField.swift b/Sources/AppKitNavigation/Bindings/UITextField.swift new file mode 100644 index 0000000000..6214680fe9 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UITextField.swift @@ -0,0 +1,311 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKit + + @available(iOS 14, tvOS 14, *) + extension UITextField { + /// 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) -> ObservationToken { + bind(UIBinding(text), to: \.text, for: .editingChanged) + } + + /// 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) -> ObservationToken { + bind(UIBinding(attributedText), to: \.attributedText, for: .editingChanged) + } + + /// 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) -> ObservationToken { + let editingChangedAction = UIAction { [weak self] _ in + guard let self else { return } + selection.wrappedValue = self.textSelection + } + addAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) + let editingDidEndAction = UIAction { _ in selection.wrappedValue = nil } + addAction(editingDidEndAction, for: .editingDidEnd) + let token = observe { [weak self] in + guard let self else { return } + textSelection = selection.wrappedValue + } + let observation = observe(\.selectedTextRange) { control, _ in + MainActor._assumeIsolated { + selection.wrappedValue = control.textSelection + } + } + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.removeAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) + self?.removeAction(editingDidEndAction, for: .editingDidEnd) + } + token.cancel() + observation.invalidate() + } + observationTokens[\UITextField.selectedTextRange] = observationToken + return observationToken + } + + fileprivate var textSelection: UITextSelection? { + get { + guard + let textRange = selectedTextRange, + let text + else { + return nil + } + let lowerBound = + text.index( + text.startIndex, + offsetBy: offset(from: beginningOfDocument, to: textRange.start), + limitedBy: text.endIndex + ) ?? text.endIndex + let upperBound = + text.index( + text.startIndex, + offsetBy: offset(from: beginningOfDocument, to: textRange.end), + limitedBy: text.endIndex + ) ?? text.endIndex + return UITextSelection(range: lowerBound..( + focus: UIBinding, equals value: Value + ) -> ObservationToken { + self.focusToken?.cancel() + let editingDidBeginAction = UIAction { _ in focus.wrappedValue = value } + let editingDidEndAction = UIAction { _ in + guard focus.wrappedValue == value else { return } + focus.wrappedValue = nil + } + addAction(editingDidBeginAction, for: .editingDidBegin) + addAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) + let innerToken = observe { [weak self] in + guard let self else { return } + switch (focus.wrappedValue, isFirstResponder) { + case (value, false): + becomeFirstResponder() + case (nil, true): + resignFirstResponder() + default: + break + } + } + let outerToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.removeAction(editingDidBeginAction, for: .editingDidBegin) + self?.removeAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) + } + innerToken.cancel() + } + self.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) -> ObservationToken { + bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) + } + + private var focusToken: ObservationToken? { + get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObservationToken } + 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 UITextSelection: Hashable, Sendable { + public var range: Range + + public init(range: Range) { + self.range = range + } + public init(insertionPoint: String.Index) { + self.range = insertionPoint..( + _ operation: @MainActor () throws -> T, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows -> T { + #if swift(<5.10) + typealias YesActor = @MainActor () throws -> T + typealias NoActor = () throws -> T + + guard Thread.isMainThread else { + fatalError( + "Incorrect actor executor assumption; Expected same executor as \(self).", + file: file, + line: line + ) + } + + return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in + let rawFn = unsafeBitCast(fn, to: NoActor.self) + return try rawFn() + } + #else + return try assumeIsolated(operation, file: file, line: line) + #endif + } +} diff --git a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift new file mode 100644 index 0000000000..d527e73ca7 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift @@ -0,0 +1,20 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + @rethrows + protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output + } + + extension _ErrorMechanism { + func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + + func _rethrowGet() rethrows -> Output { + return try get() + } + } + + extension Result: _ErrorMechanism {} +#endif diff --git a/Sources/AppKitNavigation/Internal/Exports.swift b/Sources/AppKitNavigation/Internal/Exports.swift new file mode 100644 index 0000000000..6f94b2acad --- /dev/null +++ b/Sources/AppKitNavigation/Internal/Exports.swift @@ -0,0 +1,3 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + @_exported import SwiftNavigation +#endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift new file mode 100644 index 0000000000..bbe60acbac --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift @@ -0,0 +1,12 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + extension Bool { + struct Unit: Hashable, Identifiable { + var id: Unit { self } + } + + var toOptionalUnit: Unit? { + get { self ? Unit() : nil } + set { self = newValue != nil } + } + } +#endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift new file mode 100644 index 0000000000..6952a998b6 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -0,0 +1,463 @@ +#if canImport(UIKit) && !os(watchOS) + import IssueReporting + @_spi(Internals) import SwiftNavigation + import UIKit + import UIKitNavigationShim + + extension UIViewController { + /// Presents a view controller modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the view + /// controller. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func present( + isPresented: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> UIViewController + ) -> ObservationToken { + present(item: isPresented.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func present( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + present(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + present(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + present(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + destination(item: item, id: id) { $item in + content($item) + } present: { [weak self] child, transaction in + guard let self else { return } + if presentedViewController != nil { + self.dismiss(animated: !transaction.uiKit.disablesAnimations) { + onDismiss?() + self.present(child, animated: !transaction.uiKit.disablesAnimations) + } + } else { + self.present(child, animated: !transaction.uiKit.disablesAnimations) + } + } dismiss: { [weak self] _, transaction in + self?.dismiss(animated: !transaction.uiKit.disablesAnimations) { + onDismiss?() + } + } + } + + /// Pushes a view controller onto the receiver's stack when a binding to a Boolean value you + /// provide is true. + /// + /// Like SwiftUI's `navigationDestination(isPresented:)` view modifier, but for UIKit. + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to push the view + /// controller. + /// - content: A closure that returns the view controller to display onto the receiver's + /// stack. + @discardableResult + public func navigationDestination( + isPresented: UIBinding, + content: @escaping () -> UIViewController + ) -> ObservationToken { + navigationDestination(item: isPresented.toOptionalUnit) { _ in content() } + } + + /// Pushes a view controller onto the receiver's stack using the given item as a data source for + /// its content. + /// + /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. + /// - content: A closure that returns the view controller to display onto the receiver's + /// stack. + @discardableResult + public func navigationDestination( + item: UIBinding, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + navigationDestination(item: item) { + content($0.wrappedValue) + } + } + + /// Pushes a view controller onto the receiver's stack using the given item as a data source for + /// its content. + /// + /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. + /// - content: A closure that returns the view controller to display onto the receiver's + /// stack. + @_disfavoredOverload + @discardableResult + public func navigationDestination( + item: UIBinding, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + destination(item: item) { $item in + content($item) + } present: { [weak self] child, transaction in + guard + let navigationController = self?.navigationController ?? self as? UINavigationController + else { + reportIssue( + """ + Can't present navigation item: "navigationController" is "nil". + """ + ) + return + } + navigationController.pushViewController( + child, animated: !transaction.uiKit.disablesAnimations + ) + } dismiss: { [weak self] child, transaction in + guard + let navigationController = self?.navigationController ?? self as? UINavigationController + else { + reportIssue( + """ + Can't dismiss navigation item: "navigationController" is "nil". + """ + ) + return + } + navigationController.popFromViewController( + child, animated: !transaction.uiKit.disablesAnimations + ) + } + } + + /// Presents a view controller when a binding to a Boolean value you provide is true. + /// + /// This helper powers ``present(isPresented:onDismiss:content:)`` and + /// ``UIKit/UINavigationController/pushViewController(isPresented:content:)`` and can be used to + /// define custom transitions. + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the view + /// controller. + /// - content: A closure that returns the view controller to display. + /// - present: The closure to execute when presenting the view controller. + /// - dismiss: The closure to execute when dismissing the view controller. + @discardableResult + public func destination( + isPresented: UIBinding, + content: @escaping () -> UIViewController, + present: @escaping (UIViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) + } + + /// Presents a view controller using the given item as a data source for its content. + /// + /// This helper powers ``navigationDestination(item:content:)-367r6`` and can be used to define + /// custom transitions. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. + /// - content: A closure that returns the view controller to display. + /// - present: The closure to execute when presenting the view controller. + /// - dismiss: The closure to execute when dismissing the view controller. + @discardableResult + public func destination( + item: UIBinding, + content: @escaping (UIBinding) -> UIViewController, + present: @escaping (UIViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + destination( + item: item, + id: { _ in nil }, + content: content, + present: present, + dismiss: dismiss + ) + } + + /// Presents a view controller using the given item as a data source for its content. + /// + /// This helper powers ``present(item:onDismiss:content:)-34iup`` and can be used to define + /// custom transitions. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - content: A closure that returns the view controller to display. + /// - present: The closure to execute when presenting the view controller. + /// - dismiss: The closure to execute when dismissing the view controller. + @discardableResult + public func destination( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> UIViewController, + present: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + destination( + item: item, + id: { $0[keyPath: id] }, + content: content, + present: present, + dismiss: dismiss + ) + } + + private func destination( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> UIViewController, + present: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = presentedByID[key] { + guard let presentationID = presented.presentationID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let childController = content(unwrappedItem) + let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) + { + item.wrappedValue = nil + } + } + childController.onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + present(childController, transaction) + } + } + if hasViewAppeared { + work() + } else { + onViewAppear.append(work) + } + } else if let presented = presentedByID[key] { + if let controller = presented.controller { + dismiss(controller, transaction) + } + self.presentedByID[key] = nil + } + } + } + + fileprivate var presentedByID: [UIBindingIdentifier: Presented] { + get { + (objc_getAssociatedObject(self, Self.presentedKey) + as? [UIBindingIdentifier: Presented]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static let presentedKey = malloc(1)! + } + + extension UINavigationController { + @available( + *, deprecated, + message: """ + Use 'self.navigationDestination(isPresented:)' instead of 'self.navigationController?.pushViewController(isPresented:)'. + """ + ) + @discardableResult + public func pushViewController( + isPresented: UIBinding, + content: @escaping () -> UIViewController + ) -> ObservationToken { + navigationDestination(isPresented: isPresented, content: content) + } + + @available( + *, deprecated, + message: """ + Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. + """ + ) + @discardableResult + public func pushViewController( + item: UIBinding, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + navigationDestination(item: item, content: content) + } + + @available( + *, deprecated, + message: """ + Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. + """ + ) + @_disfavoredOverload + @discardableResult + public func pushViewController( + item: UIBinding, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + navigationDestination(item: item, content: content) + } + } + + @MainActor + private class Presented { + weak var controller: UIViewController? + let presentationID: AnyHashable? + deinit { + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + self.controller?.dismiss(animated: false) + } + } + init(_ controller: UIViewController, id presentationID: AnyHashable? = nil) { + self.controller = controller + self.presentationID = presentationID + } + } +#endif diff --git a/Sources/AppKitNavigation/Navigation/UIAlertController.swift b/Sources/AppKitNavigation/Navigation/UIAlertController.swift new file mode 100644 index 0000000000..0151edcdf4 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/UIAlertController.swift @@ -0,0 +1,87 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKit + + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS, unavailable) + extension UIAlertController { + /// Creates and returns a view controller 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( + title: String(state: state.title), + message: state.message.map { String(state: $0) }, + preferredStyle: .alert + ) + for button in state.buttons { + addAction(UIAlertAction(button, action: handler)) + } + } + + /// Creates and returns a view controller for displaying an action sheet 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: ConfirmationDialogState, + handler: @escaping (_ action: Action?) -> Void + ) { + self.init( + title: state.titleVisibility == .visible ? String(state: state.title) : nil, + message: state.message.map { String(state: $0) }, + preferredStyle: .actionSheet + ) + for button in state.buttons { + addAction(UIAlertAction(button, action: handler)) + } + } + } + + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS, unavailable) + extension UIAlertAction.Style { + public init(_ role: ButtonStateRole) { + switch role { + case .cancel: + self = .cancel + case .destructive: + self = .destructive + } + } + } + + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS, unavailable) + extension UIAlertAction { + public convenience init( + _ button: ButtonState, + action handler: @escaping (_ action: Action?) -> Void + ) { + self.init( + title: String(state: button.label), + style: button.role.map(UIAlertAction.Style.init) ?? .default + ) { _ in + button.withAction(handler) + } + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } + } + } + } +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift new file mode 100644 index 0000000000..d7a6d934a7 --- /dev/null +++ b/Sources/AppKitNavigation/Observe.swift @@ -0,0 +1,188 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + @_spi(Internals) import SwiftNavigation + import AppKit + + @MainActor + extension NSObject { + /// Observe access to properties of an observable (or perceptible) object. + /// + /// This tool allows you to set up an observation loop so that you can access fields from an + /// observable model in order to populate your view, and also automatically track changes to + /// any accessed fields so that the view is always up-to-date. + /// + /// It is most useful when dealing with non-SwiftUI views, such as UIKit views and controller. + /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all + /// the view elements: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let countLabel = UILabel() + /// let incrementButton = UIButton(primaryAction: UIAction { [weak self] _ in + /// self?.model.incrementButtonTapped() + /// }) + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.text = "\(model.count)" + /// } + /// } + /// ``` + /// + /// This closure is immediately called, allowing you to set the initial state of your UI + /// components from the feature's state. And if the `count` property in the feature's state is + /// ever mutated, this trailing closure will be called again, allowing us to update the view + /// again. + /// + /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your + /// view, such as `viewDidLoad` for `UIViewController`. This works even if you have many UI + /// components to update: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.isHidden = model.isObservingCount + /// if !countLabel.isHidden { + /// countLabel.text = "\(model.count)" + /// } + /// factLabel.text = model.fact + /// } + /// } + /// ``` + /// + /// This does mean that you may execute the line `factLabel.text = model.fact` even when + /// something unrelated changes, such as `store.model`, but that is typically OK for simple + /// properties of UI components. It is not a performance problem to repeatedly set the `text` of + /// a label or the `isHidden` of a button. + /// + /// However, if there is heavy work you need to perform when state changes, then it is best to + /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or + /// collection view when a collection changes: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// dataSource = model.items + /// tableView.reloadData() + /// } + /// } + /// ``` + /// + /// ## Cancellation + /// + /// The method returns an ``ObservationToken`` that can be used to cancel observation. For + /// example, if you only want to observe while a view controller is visible, you can start + /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: + /// + /// ```swift + /// var observation: ObservationToken? + /// + /// func viewWillAppear() { + /// super.viewWillAppear() + /// observation = observe { [weak self] in + /// // ... + /// } + /// } + /// func viewWillDisappear() { + /// super.viewWillDisappear() + /// observation?.cancel() + /// } + /// ``` + /// + /// - Parameter apply: A closure that contains properties to track and is invoked when the value + /// of a property changes. + /// - Returns: A cancellation token. + @discardableResult + public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { + observe { _ in apply() } + } + + /// Observe access to properties of an observable (or perceptible) object. + /// + /// A version of ``observe(_:)`` that is passed the current transaction. + /// + /// - Parameter apply: A closure that contains properties to track and is invoked when the value + /// of a property changes. + /// - Returns: A cancellation token. + @discardableResult + public func observe( + _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void + ) -> ObservationToken { + let token = SwiftNavigation.observe { transaction in + MainActor._assumeIsolated { + withUITransaction(transaction) { + #if os(watchOS) + apply(transaction) + #else + if transaction.appKit.disablesAnimations { + NSView.performWithoutAnimation { apply(transaction) } + for completion in transaction.appKit.animationCompletions { + completion(true) + } + } else if let animation = transaction.appKit.animation { + return animation.perform( + { apply(transaction) }, + completion: transaction.appKit.animationCompletions.isEmpty + ? nil + : { + for completion in transaction.appKit.animationCompletions { + completion($0) + } + } + ) + } else { + apply(transaction) + for completion in transaction.appKit.animationCompletions { + completion(true) + } + } + #endif + } + } + } task: { transaction, work in + DispatchQueue.main.async { + withUITransaction(transaction, work) + } + } + tokens.append(token) + return token + } + + fileprivate var tokens: [Any] { + get { + objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] + } + set { + objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private static let tokensKey = malloc(1)! + } + +extension NSView { + static func performWithoutAnimation(_ block: () -> Void) { + if NSAnimationContext.current.allowsImplicitAnimation { + NSAnimationContext.current.allowsImplicitAnimation = false + block() + NSAnimationContext.current.allowsImplicitAnimation = true + } else { + block() + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/SwiftUI/Representable.swift b/Sources/AppKitNavigation/SwiftUI/Representable.swift new file mode 100644 index 0000000000..29ac8b2301 --- /dev/null +++ b/Sources/AppKitNavigation/SwiftUI/Representable.swift @@ -0,0 +1,24 @@ +#if canImport(SwiftUI) && canImport(AppKit) && !targetEnvironment(macCatalyst) + import SwiftUI + import AppKit + + public struct NSViewControllerRepresenting< + NSViewControllerType: NSViewController + >: NSViewControllerRepresentable { + private let base: NSViewControllerType + public init(_ base: () -> NSViewControllerType) { + self.base = base() + } + public func makeNSViewController(context _: Context) -> NSViewControllerType { base } + public func updateNSViewController(_: NSViewControllerType, context _: Context) {} + } + + public struct NSViewRepresenting: NSViewRepresentable { + private let base: NSViewType + public init(_ base: () -> NSViewType) { + self.base = base() + } + public func makeNSView(context _: Context) -> NSViewType { base } + public func updateNSView(_: NSViewType, context _: Context) {} + } +#endif diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift new file mode 100644 index 0000000000..4a66220b17 --- /dev/null +++ b/Sources/AppKitNavigation/UIBinding.swift @@ -0,0 +1,15 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import SwiftNavigation + + extension UIBinding { + /// Specifies an animation to perform when the binding value changes. + /// + /// - Parameter animation: An animation sequence performed when the binding value changes. + /// - Returns: A new binding. + public func animation(_ animation: AppKitAnimation? = .default) -> Self { + var binding = self + binding.transaction.appKit.animation = animation + return binding + } + } +#endif diff --git a/Sources/AppKitNavigation/UIKitAnimation.swift b/Sources/AppKitNavigation/UIKitAnimation.swift new file mode 100644 index 0000000000..7e5106edc5 --- /dev/null +++ b/Sources/AppKitNavigation/UIKitAnimation.swift @@ -0,0 +1,144 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import AppKit + + #if canImport(SwiftUI) + import SwiftUI + #endif + import SwiftNavigation + /// Executes a closure with the specified animation and returns the result. + /// + /// - Parameters: + /// - animation: An animation, set in the ``UITransaction/appKit`` property of the thread's + /// current transaction. + /// - body: A closure to execute. + /// - completion: A completion to run when the animation is complete. + /// - Returns: The result of executing the closure with the specified animation. + @MainActor + public func withAppKitAnimation( + _ animation: AppKitAnimation? = .default, + _ body: () throws -> Result, + completion: (@Sendable (Bool?) -> Void)? = nil + ) rethrows -> Result { + var transaction = UITransaction() + transaction.appKit.animation = animation + if let completion { + transaction.appKit.addAnimationCompletion(completion) + } + return try withUITransaction(transaction, body) + } + + /// The way a view changes over time to create a smooth visual transition from one state to + /// another. + public struct AppKitAnimation: Hashable, Sendable { + fileprivate let framework: Framework + + @MainActor + func perform( + _ body: () throws -> Result, + completion: ((Bool?) -> Void)? = nil + ) rethrows -> Result { + switch framework { + case let .swiftUI(animation): + #if swift(>=6) + if #available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) { + var result: Swift.Result? + UIView.animate( + with: animation, + changes: { result = Swift.Result(catching: body) }, + completion: completion.map { completion in { completion(true) } } + ) + return try result!._rethrowGet() + } + #endif + _ = animation + fatalError() + + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.duration = animation.duration + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) + } + + return try result!._rethrowGet() + + + } + } + + fileprivate enum Framework: Hashable, Sendable { + case appKit(AppKit) + case swiftUI(Animation) + + fileprivate struct AppKit: Hashable, Sendable { + fileprivate var duration: TimeInterval + + func hash(into hasher: inout Hasher) { + hasher.combine(duration) + } + + } + } + } + + extension AppKitAnimation { + + /// Performs am animation using a timing curve corresponding to the motion of a physical spring. + /// + /// A value description of + /// `UIView.animate(withDuration:delay:dampingRatio:velocity:options:animations:completion:)` + /// that can be used with ``withAppKitAnimation(_:_:completion:)``. + /// + /// - Parameters: + /// - duration: The total duration of the animations, measured in seconds. If you specify a + /// negative value or `0`, the changes are made without animating them. + /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. + /// Specify a value of `0` to begin the animations immediately. + /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent + /// state. + /// + /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a + /// damping ratio closer to zero to increase oscillation. + /// - velocity: The initial spring velocity. For smooth start to the animation, match this + /// value to the view's velocity as it was prior to attachment. + /// + /// A value of `1` corresponds to the total animation distance traversed in one second. For + /// example, if the total animation distance is 200 points and you want the start of the + /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. + /// - options: A mask of options indicating how you want to perform the animations. For a list + /// of valid constants, see `UIView.AnimationOptions`. + /// - Returns: An animation using a timing curve corresponding to the motion of a physical + /// spring. + public static func animate( + withDuration duration: TimeInterval = 0.25 + ) -> Self { + Self( + framework: .appKit( + Framework.AppKit( + duration: duration + ) + ) + ) + } + + + /// Animates changes using the specified SwiftUI animation. + /// + /// - Parameter animation: The animation to use for the changes. + @available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) + public init(_ animation: Animation) { + self.init(framework: .swiftUI(animation)) + } + + /// A default animation instance. + public static var `default`: Self { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return .animate() + } else { + return .animate(withDuration: 0.35) + } + } + } +#endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift new file mode 100644 index 0000000000..88ba674b30 --- /dev/null +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -0,0 +1,53 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import SwiftNavigation + + extension UITransaction { + /// Creates a transaction and assigns its animation property. + /// + /// - Parameter animation: The animation to perform when the current state changes. + public init(animation: AppKitAnimation? = nil) { + self.init() + self.appKit.animation = animation + } + + /// AppKit-specific data associated with the current state change. + public var appKit: AppKit { + get { self[AppKitKey.self] } + set { self[AppKitKey.self] = newValue } + } + + private enum AppKitKey: UITransactionKey { + static let defaultValue = AppKit() + } + + /// AppKit-specific data associated with a ``UITransaction``. + public struct AppKit: Sendable { + /// The animation, if any, associated with the current state change. + public var animation: AppKitAnimation? + + /// A Boolean value that indicates whether views should disable animations. + public var disablesAnimations = false + + var animationCompletions: [@Sendable (Bool?) -> Void] = [] + + /// Adds a completion to run when the animations created with this transaction are all + /// complete. + /// + /// The completion callback will always be fired exactly one time. + public mutating func addAnimationCompletion( + _ completion: @escaping @Sendable (Bool?) -> Void + ) { + animationCompletions.append(completion) + } + } + } + + private enum AnimationCompletionsKey: UITransactionKey { + static let defaultValue: [@Sendable (Bool?) -> Void] = [] + } + + private enum DisablesAnimationsKey: UITransactionKey { + static let defaultValue = false + } +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100644 index 0000000000..1d2054f3ca --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,19 @@ +#if __has_include() + #include + + #if __has_include() && !TARGET_OS_MACCATALYST + @import AppKit; + + NS_ASSUME_NONNULL_BEGIN + + @interface NSViewController (AppKitNavigation) + + @property BOOL hasViewAppeared; + @property (nullable) void (^onDismiss)(); + @property NSArray *onViewAppear; + + @end + + NS_ASSUME_NONNULL_END + #endif +#endif diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m new file mode 100644 index 0000000000..a2fb9479a5 --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,106 @@ +#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(NSViewController.class, @selector(viewDidAppear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) + ); + } + + @end + + static void *hasViewAppearedKey = &hasViewAppearedKey; + static void *onDismissKey = &onDismissKey; + static void *onViewAppearKey = &onViewAppearKey; + + @implementation NSViewController (AppKitNavigation) + + - (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; + + if (self.hasViewAppeared) { + return; + } + self.hasViewAppeared = YES; + for (void (^work)() in self.onViewAppear) { + work(); + } + self.onViewAppear = @[]; + } + + - (void)setBeingDismissed:(BOOL)beingDismissed { + objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); + } + + - (BOOL)isBeingDismissed { + return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; + } + + - (void)AppKitNavigation_viewDidDisappear { + [self AppKitNavigation_viewDidDisappear]; + + if ((self.isBeingDismissed) && self.onDismiss != NULL) { + self.onDismiss(); + self.onDismiss = nil; + [self setBeingDismissed:NO]; + } + } + + - (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; + } + + - (BOOL)hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; + } + + - (void)setHasViewAppeared:(BOOL)hasViewAppeared { + objc_setAssociatedObject( + self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + ); + } + + - (void (^)())onDismiss { + return objc_getAssociatedObject(self, onDismissKey); + } + + - (void)setOnDismiss:(void (^)())onDismiss { + objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); + } + + - (NSMutableArray *)onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); + return onViewAppear == nil ? @[] : onViewAppear; + } + + - (void)setOnViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); + } + + @end + #endif +#endif From 4678f0adba6576a56467f0136a34b9d14cda985a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 14 Aug 2024 01:33:33 +0800 Subject: [PATCH 02/42] Updates --- Package.swift | 9 - .../AppKitNavigation/AppKitAnimation.swift | 121 +++++ .../Bindings/NSColorPanel.swift | 41 ++ .../Bindings/NSColorWell.swift | 27 + .../AppKitNavigation/Bindings/NSControl.swift | 17 + ...{UIDatePicker.swift => NSDatePicker.swift} | 17 +- .../Bindings/NSFontManager.swift | 74 +++ .../Bindings/NSMenuItem.swift | 17 + .../Bindings/NSOpenPanel.swift | 5 + .../Bindings/NSPathControl.swift | 29 ++ .../Bindings/NSSavePanel.swift | 5 + .../Bindings/NSSegmentedControl.swift | 79 +++ .../{UISlider.swift => NSSlider.swift} | 17 +- .../{UIStepper.swift => NSStepper.swift} | 17 +- .../{UISwitch.swift => NSSwitch.swift} | 26 +- .../Bindings/NSTargetAction.swift | 127 +++++ .../{UITextField.swift => NSTextField.swift} | 260 +++++----- .../Bindings/NSToolbarItem.swift | 17 + .../Bindings/UIColorWell.swift | 30 -- .../AppKitNavigation/Bindings/UIControl.swift | 107 ---- .../Bindings/UIPageControl.swift | 28 -- .../Bindings/UISegmentedControl.swift | 81 --- .../Bindings/UITabBarController.swift | 65 --- .../Navigation/Presentation.swift | 475 +++++++----------- .../Navigation/UIAlertController.swift | 168 +++---- Sources/AppKitNavigation/UIKitAnimation.swift | 144 ------ 26 files changed, 999 insertions(+), 1004 deletions(-) create mode 100644 Sources/AppKitNavigation/AppKitAnimation.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSColorPanel.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSColorWell.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSControl.swift rename Sources/AppKitNavigation/Bindings/{UIDatePicker.swift => NSDatePicker.swift} (73%) create mode 100644 Sources/AppKitNavigation/Bindings/NSFontManager.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSMenuItem.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSOpenPanel.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSPathControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSSavePanel.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift rename Sources/AppKitNavigation/Bindings/{UISlider.swift => NSSlider.swift} (72%) rename Sources/AppKitNavigation/Bindings/{UIStepper.swift => NSStepper.swift} (72%) rename Sources/AppKitNavigation/Bindings/{UISwitch.swift => NSSwitch.swift} (66%) create mode 100644 Sources/AppKitNavigation/Bindings/NSTargetAction.swift rename Sources/AppKitNavigation/Bindings/{UITextField.swift => NSTextField.swift} (58%) create mode 100644 Sources/AppKitNavigation/Bindings/NSToolbarItem.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UIColorWell.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UIControl.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UIPageControl.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UISegmentedControl.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UITabBarController.swift delete mode 100644 Sources/AppKitNavigation/UIKitAnimation.swift diff --git a/Package.swift b/Package.swift index 3bc91c28ee..480e3861b8 100644 --- a/Package.swift +++ b/Package.swift @@ -66,18 +66,10 @@ let package = Package( "SwiftUINavigation" ] ), - .target( - name: "CocoaNavigation", - dependencies: [ - "SwiftNavigation", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - ] - ), .target( name: "UIKitNavigation", dependencies: [ "SwiftNavigation", - "CocoaNavigation", "UIKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] @@ -89,7 +81,6 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - "CocoaNavigation", "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift new file mode 100644 index 0000000000..75e40b8f86 --- /dev/null +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -0,0 +1,121 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +#if canImport(SwiftUI) +import SwiftUI +#endif + +import SwiftNavigation + +/// Executes a closure with the specified animation and returns the result. +/// +/// - Parameters: +/// - animation: An animation, set in the ``UITransaction/appKit`` property of the thread's +/// current transaction. +/// - body: A closure to execute. +/// - completion: A completion to run when the animation is complete. +/// - Returns: The result of executing the closure with the specified animation. +@MainActor +public func withAppKitAnimation( + _ animation: AppKitAnimation? = .default, + _ body: () throws -> Result, + completion: (@Sendable (Bool?) -> Void)? = nil +) rethrows -> Result { + var transaction = UITransaction() + transaction.appKit.animation = animation + if let completion { + transaction.appKit.addAnimationCompletion(completion) + } + return try withUITransaction(transaction, body) +} + +/// The way a view changes over time to create a smooth visual transition from one state to +/// another. +public struct AppKitAnimation: Hashable, Sendable { + fileprivate let framework: Framework + + @MainActor + func perform( + _ body: () throws -> Result, + completion: ((Bool?) -> Void)? = nil + ) rethrows -> Result { + switch framework { + case let .swiftUI(animation): + _ = animation + fatalError() + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.duration = animation.duration + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) + } + + return try result!._rethrowGet() + } + } + + fileprivate enum Framework: Hashable, Sendable { + case appKit(AppKit) + case swiftUI(Animation) + + fileprivate struct AppKit: Hashable, Sendable { + fileprivate var duration: TimeInterval + + func hash(into hasher: inout Hasher) { + hasher.combine(duration) + } + } + } +} + +extension AppKitAnimation { + /// Performs am animation using a timing curve corresponding to the motion of a physical spring. + /// + /// A value description of + /// `UIView.animate(withDuration:delay:dampingRatio:velocity:options:animations:completion:)` + /// that can be used with ``withAppKitAnimation(_:_:completion:)``. + /// + /// - Parameters: + /// - duration: The total duration of the animations, measured in seconds. If you specify a + /// negative value or `0`, the changes are made without animating them. + /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. + /// Specify a value of `0` to begin the animations immediately. + /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent + /// state. + /// + /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a + /// damping ratio closer to zero to increase oscillation. + /// - velocity: The initial spring velocity. For smooth start to the animation, match this + /// value to the view's velocity as it was prior to attachment. + /// + /// A value of `1` corresponds to the total animation distance traversed in one second. For + /// example, if the total animation distance is 200 points and you want the start of the + /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. + /// - options: A mask of options indicating how you want to perform the animations. For a list + /// of valid constants, see `UIView.AnimationOptions`. + /// - Returns: An animation using a timing curve corresponding to the motion of a physical + /// spring. + public static func animate( + withDuration duration: TimeInterval = 0.25 + ) -> Self { + Self( + framework: .appKit( + Framework.AppKit( + duration: duration + ) + ) + ) + } + + /// A default animation instance. + public static var `default`: Self { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return .animate() + } else { + return .animate(withDuration: 0.35) + } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift new file mode 100644 index 0000000000..e266d8bc8a --- /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) -> ObservationToken { + bind(color, to: \.color) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorWell.swift b/Sources/AppKitNavigation/Bindings/NSColorWell.swift new file mode 100644 index 0000000000..8dda1c8a1c --- /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) -> ObservationToken { + bind(color, to: \.color) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift new file mode 100644 index 0000000000..de03e4bd48 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -0,0 +1,17 @@ +#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 } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIDatePicker.swift b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift similarity index 73% rename from Sources/AppKitNavigation/Bindings/UIDatePicker.swift rename to Sources/AppKitNavigation/Bindings/NSDatePicker.swift index b4c02a007f..793dcc4d8f 100644 --- a/Sources/AppKitNavigation/Bindings/UIDatePicker.swift +++ b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UIDatePicker { +extension NSDatePicker { /// Creates a new date picker with the specified frame and registers the binding against the /// selected date. /// @@ -13,8 +10,8 @@ /// - 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) + self.init(frame: frame) + bind(date: date) } /// Establishes a two-way connection between a binding and the date picker's selected date. @@ -24,7 +21,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(date: UIBinding) -> ObservationToken { - bind(date, to: \.date, for: .valueChanged) + bind(date, to: \.dateValue) } - } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift new file mode 100644 index 0000000000..373b40f34d --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -0,0 +1,74 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +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 var appkitNavigationDelegate: Delegate { + set { + objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let delegate = objc_getAssociatedObject(self, #function) as? Delegate { + return delegate + } else { + let delegate = Delegate() + self.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) -> ObservationToken { + bind(font, to: \._selectedFont) + } + + 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 100644 index 0000000000..25d7dd5c0a --- /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/NSOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift new file mode 100644 index 0000000000..893f31d20e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift @@ -0,0 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSPathControl.swift b/Sources/AppKitNavigation/Bindings/NSPathControl.swift new file mode 100644 index 0000000000..78a0bf0f0c --- /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) -> ObservationToken { + bind(url, to: \.url) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSavePanel.swift b/Sources/AppKitNavigation/Bindings/NSSavePanel.swift new file mode 100644 index 0000000000..893f31d20e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSavePanel.swift @@ -0,0 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift new file mode 100644 index 0000000000..fa32c08dd5 --- /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 + ) -> ObservationToken { + 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/UISlider.swift b/Sources/AppKitNavigation/Bindings/NSSlider.swift similarity index 72% rename from Sources/AppKitNavigation/Bindings/UISlider.swift rename to Sources/AppKitNavigation/Bindings/NSSlider.swift index f2fd9a2f61..50778248a1 100644 --- a/Sources/AppKitNavigation/Bindings/UISlider.swift +++ b/Sources/AppKitNavigation/Bindings/NSSlider.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UISlider { +extension NSSlider { /// Creates a new slider with the specified frame and registers the binding against the value. /// /// - Parameters: @@ -12,8 +9,8 @@ /// - 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) + self.init(frame: frame) + bind(value: value) } /// Establishes a two-way connection between a binding and the slider's current value. @@ -23,7 +20,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(value: UIBinding) -> ObservationToken { - bind(value, to: \.value, for: .valueChanged) + bind(value, to: \.floatValue) } - } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/UIStepper.swift b/Sources/AppKitNavigation/Bindings/NSStepper.swift similarity index 72% rename from Sources/AppKitNavigation/Bindings/UIStepper.swift rename to Sources/AppKitNavigation/Bindings/NSStepper.swift index d3e664e9d2..a2fb9bbf43 100644 --- a/Sources/AppKitNavigation/Bindings/UIStepper.swift +++ b/Sources/AppKitNavigation/Bindings/NSStepper.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UIStepper { +extension NSStepper { /// Creates a new stepper with the specified frame and registers the binding against the value. /// /// - Parameters: @@ -12,8 +9,8 @@ /// - 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) + self.init(frame: frame) + bind(value: value) } /// Establishes a two-way connection between a binding and the stepper's current value. @@ -23,7 +20,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(value: UIBinding) -> ObservationToken { - bind(value, to: \.value, for: .valueChanged) + bind(value, to: \.doubleValue) } - } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/UISwitch.swift b/Sources/AppKitNavigation/Bindings/NSSwitch.swift similarity index 66% rename from Sources/AppKitNavigation/Bindings/UISwitch.swift rename to Sources/AppKitNavigation/Bindings/NSSwitch.swift index c8e4ab0ff3..21b752be18 100644 --- a/Sources/AppKitNavigation/Bindings/UISwitch.swift +++ b/Sources/AppKitNavigation/Bindings/NSSwitch.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UISwitch { +extension NSSwitch { /// Creates a new switch with the specified frame and registers the binding against whether or /// not the switch is on. /// @@ -13,8 +10,8 @@ /// - 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) + self.init(frame: frame) + bind(isOn: isOn) } /// Establishes a two-way connection between a binding and the switch's current state. @@ -24,9 +21,14 @@ /// - Returns: A cancel token. @discardableResult public func bind(isOn: UIBinding) -> ObservationToken { - bind(isOn, to: \.isOn, for: .valueChanged) { control, isOn, transaction in - control.setOn(isOn, animated: !transaction.uiKit.disablesAnimations) - } + bind(isOn, to: \.boolValue) { control, isOn, transaction in + control.boolValue = isOn + } } - } + + 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 100644 index 0000000000..305ef278ed --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -0,0 +1,127 @@ +#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 } +} + +private class NSControlActionHandler: NSObject { + let action: (NSControl) -> Void + + init(action: @escaping (NSControl) -> Void) { + self.action = action + } + + @objc func invokeAction(_ sender: NSControl) { + action(sender) + } +} + +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 + ) -> ObservationToken { + bind(binding, to: keyPath) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + private var actionHandler: NSControlActionHandler? { + set { objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + get { objc_getAssociatedObject(self, #function) as? NSControlActionHandler } + } + + /// 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 actionHandler = NSControlActionHandler { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + self.actionHandler = actionHandler + appkitNavigationTarget = actionHandler + appkitNavigationAction = #selector(NSControlActionHandler.invokeAction(_:)) + + 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?.appkitNavigationTarget = nil + self?.appkitNavigationAction = nil + self?.actionHandler = nil + } + 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 +private let observationTokensKey = malloc(1)! +#endif diff --git a/Sources/AppKitNavigation/Bindings/UITextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift similarity index 58% rename from Sources/AppKitNavigation/Bindings/UITextField.swift rename to Sources/AppKitNavigation/Bindings/NSTextField.swift index 6214680fe9..e5c4370993 100644 --- a/Sources/AppKitNavigation/Bindings/UITextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -1,8 +1,8 @@ -#if canImport(UIKit) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import SwiftNavigation - @available(iOS 14, tvOS 14, *) - extension UITextField { +extension NSTextField: NSTextViewDelegate { /// Creates a new text field with the specified frame and registers the binding against its /// text. /// @@ -11,8 +11,8 @@ /// - 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) + self.init(frame: frame) + bind(text: text) } /// Creates a new text field with the specified frame and registers the binding against its @@ -23,8 +23,8 @@ /// - 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) + self.init(frame: frame) + bind(attributedText: attributedText) } /// Establishes a two-way connection between a binding and the text field's current text. @@ -34,7 +34,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(text: UIBinding) -> ObservationToken { - bind(UIBinding(text), to: \.text, for: .editingChanged) + bind(text, to: \.stringValue) } /// Establishes a two-way connection between a binding and the text field's current text. @@ -44,7 +44,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(attributedText: UIBinding) -> ObservationToken { - bind(UIBinding(attributedText), to: \.attributedText, for: .editingChanged) + bind(attributedText, to: \.attributedStringValue) } /// Establishes a two-way connection between a binding and the text field's current selection. @@ -53,79 +53,101 @@ /// the selected text range changes. /// - Returns: A cancel token. @discardableResult - public func bind(selection: UIBinding) -> ObservationToken { - let editingChangedAction = UIAction { [weak self] _ in - guard let self else { return } - selection.wrappedValue = self.textSelection - } - addAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) - let editingDidEndAction = UIAction { _ in selection.wrappedValue = nil } - addAction(editingDidEndAction, for: .editingDidEnd) - let token = observe { [weak self] in - guard let self else { return } - textSelection = selection.wrappedValue - } - let observation = observe(\.selectedTextRange) { control, _ in - MainActor._assumeIsolated { - selection.wrappedValue = control.textSelection + public func bind(selection: UIBinding) -> ObservationToken { + 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 } - } - let observationToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - self?.removeAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) - self?.removeAction(editingDidEndAction, for: .editingDidEnd) + 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 } - token.cancel() - observation.invalidate() - } - observationTokens[\UITextField.selectedTextRange] = observationToken - return observationToken } - fileprivate var textSelection: UITextSelection? { - get { - guard - let textRange = selectedTextRange, - let text - else { - return nil + fileprivate class TextSelectionObserver: NSObject { + let observer: (NSTextField) -> Void + + init(observer: @escaping (NSTextField) -> Void) { + self.observer = observer + } + } + + fileprivate var textSelectionObserver: TextSelectionObserver? { + set { + objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, #function) as? TextSelectionObserver } - let lowerBound = - text.index( - text.startIndex, - offsetBy: offset(from: beginningOfDocument, to: textRange.start), - limitedBy: text.endIndex - ) ?? text.endIndex - let upperBound = - text.index( - text.startIndex, - offsetBy: offset(from: beginningOfDocument, to: textRange.end), - limitedBy: text.endIndex - ) ?? text.endIndex - return UITextSelection(range: lowerBound..( - focus: UIBinding, equals value: Value + focus: UIBinding, equals value: Value ) -> ObservationToken { - self.focusToken?.cancel() - let editingDidBeginAction = UIAction { _ in focus.wrappedValue = value } - let editingDidEndAction = UIAction { _ in - guard focus.wrappedValue == value else { return } - focus.wrappedValue = nil - } - addAction(editingDidBeginAction, for: .editingDidBegin) - addAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) - let innerToken = observe { [weak self] in - guard let self else { return } - switch (focus.wrappedValue, isFirstResponder) { - case (value, false): - becomeFirstResponder() - case (nil, true): - resignFirstResponder() - default: - break + 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 outerToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - self?.removeAction(editingDidBeginAction, for: .editingDidBegin) - self?.removeAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) + + 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() } - innerToken.cancel() - } - self.focusToken = outerToken - return outerToken + focusToken = outerToken + return outerToken } /// Binds this text field's focus state to the given Boolean state value. @@ -277,35 +299,37 @@ /// - Returns: A cancel token. @discardableResult public func bind(focus condition: UIBinding) -> ObservationToken { - bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) + bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) } private var focusToken: ObservationToken? { - get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObservationToken } - set { - objc_setAssociatedObject( - self, Self.focusTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } + get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObservationToken } + 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 UITextSelection: Hashable, Sendable { +/// 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 + self.range = range } + public init(insertionPoint: String.Index) { - self.range = insertionPoint..) { - self.init(frame: frame) - bind(selectedColor: selectedColor) - } - - /// Establishes a two-way connection between a binding and the color well's selected color. - /// - /// - Parameter selectedColor: 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(selectedColor: UIBinding) -> ObservationToken { - bind(selectedColor, to: \.selectedColor, for: .valueChanged) - } - } -#endif diff --git a/Sources/AppKitNavigation/Bindings/UIControl.swift b/Sources/AppKitNavigation/Bindings/UIControl.swift deleted file mode 100644 index 2854c166d0..0000000000 --- a/Sources/AppKitNavigation/Bindings/UIControl.swift +++ /dev/null @@ -1,107 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) - import ConcurrencyExtras - @_spi(Internals) import SwiftNavigation - import UIKit - - /// A protocol used to extend `UIControl`. - @MainActor - public protocol UIControlProtocol: UIControl {} - - extension UIControl: UIControlProtocol {} - - @available(iOS 14, tvOS 14, *) - extension UIControlProtocol { - /// 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, - for event: UIControl.Event - ) -> ObservationToken { - bind(binding, to: keyPath, for: event) { control, newValue, _ in - control[keyPath: keyPath] = newValue - } - } - - /// 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, - for event: UIControl.Event, - set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObservationToken { - unbind(keyPath) - let action = UIAction { [weak self] _ in - guard let self else { return } - binding.wrappedValue = self[keyPath: keyPath] - } - addAction(action, for: event) - 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.uiKit.animation == nil && !transaction.uiKit.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?.removeAction(action, for: .allEvents) - } - 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 - private let observationTokensKey = malloc(1)! -#endif diff --git a/Sources/AppKitNavigation/Bindings/UIPageControl.swift b/Sources/AppKitNavigation/Bindings/UIPageControl.swift deleted file mode 100644 index 626798fe36..0000000000 --- a/Sources/AppKitNavigation/Bindings/UIPageControl.swift +++ /dev/null @@ -1,28 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) - import UIKit - - @available(iOS 14, tvOS 14, *) - extension UIPageControl { - /// Creates a new page control with the specified frame and registers the binding against the - /// current page. - /// - /// - Parameters: - /// - frame: The frame rectangle for the view, measured in points. - /// - currentPage: The binding to read from for the current page, and write to when the - /// current page changes. - public convenience init(frame: CGRect = .zero, currentPage: UIBinding) { - self.init(frame: frame) - bind(currentPage: currentPage) - } - - /// Establishes a two-way connection between a binding and the page control's current page. - /// - /// - Parameter currentPage: The binding to read from for the current page, and write to when - /// the current page changes. - /// - Returns: A cancel token. - @discardableResult - public func bind(currentPage: UIBinding) -> ObservationToken { - bind(currentPage, to: \.currentPage, for: .valueChanged) - } - } -#endif diff --git a/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift b/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift deleted file mode 100644 index 5c2c9a86a6..0000000000 --- a/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift +++ /dev/null @@ -1,81 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) - import IssueReporting - import UIKit - - @available(iOS 14, tvOS 14, *) - extension UISegmentedControl { - /// 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 - ) -> ObservationToken { - let fileID = HashableStaticString(rawValue: fileID) - let filePath = HashableStaticString(rawValue: filePath) - return bind( - selectedSegment[fileID: fileID, filePath: filePath, line: line, column: column], - to: \.selectedSegmentIndex, - for: .valueChanged - ) - } - } - - 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/UITabBarController.swift b/Sources/AppKitNavigation/Bindings/UITabBarController.swift deleted file mode 100644 index 7b9e6351a1..0000000000 --- a/Sources/AppKitNavigation/Bindings/UITabBarController.swift +++ /dev/null @@ -1,65 +0,0 @@ -#if swift(>=6) && canImport(UIKit) && !os(tvOS) && !os(watchOS) - import IssueReporting - import UIKit - - @available(iOS 18, tvOS 18, visionOS 2, *) - extension UITabBarController { - @discardableResult - public func bind( - selectedTab: UIBinding, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> ObservationToken { - let token = observe { [weak self] in - guard let self else { return } - guard let identifier = selectedTab.wrappedValue else { - self.selectedTab = nil - return - } - guard let tab = tabs.first(where: { $0.identifier == identifier }) - else { - reportIssue( - """ - Tab bar controller binding attempted to write an invalid identifier ('\(identifier)'). - - Valid identifiers: \(tabs.map(\.identifier)) - """, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - self.selectedTab = nil - return - } - self.selectedTab = tab - } - let observation = observe(\.selectedTab) { controller, _ in - MainActor.assumeIsolated { - selectedTab.wrappedValue = controller.selectedTab?.identifier - } - } - let observationToken = ObservationToken { - token.cancel() - observation.invalidate() - } - self.observationToken = observationToken - return observationToken - } - - private var observationToken: ObservationToken? { - get { - objc_getAssociatedObject(self, Self.observationTokenKey) as? ObservationToken - } - set { - objc_setAssociatedObject( - self, Self.observationTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - private static let observationTokenKey = malloc(1)! - } -#endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 6952a998b6..bc9c377ea6 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -1,10 +1,10 @@ -#if canImport(UIKit) && !os(watchOS) - import IssueReporting - @_spi(Internals) import SwiftNavigation - import UIKit - import UIKitNavigationShim +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import IssueReporting +@_spi(Internals) import SwiftNavigation +import AppKit +import AppKitNavigationShim - extension UIViewController { +extension NSViewController { /// Presents a view controller modally when a binding to a Boolean value you provide is true. /// /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. @@ -17,11 +17,12 @@ /// controller's content. @discardableResult public func present( - isPresented: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> UIViewController + isPresented: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController ) -> ObservationToken { - present(item: isPresented.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } /// Presents a view controller modally using the given item as a data source for its content. @@ -39,11 +40,12 @@ /// controller's content. @discardableResult public func present( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> UIViewController + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController ) -> ObservationToken { - present(item: item, id: \.id, onDismiss: onDismiss, content: content) + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } /// Presents a view controller modally using the given item as a data source for its content. @@ -62,11 +64,12 @@ @_disfavoredOverload @discardableResult public func present( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> UIViewController + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController ) -> ObservationToken { - present(item: item, id: \.id, onDismiss: onDismiss, content: content) + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } /// Presents a view controller modally using the given item as a data source for its content. @@ -85,14 +88,15 @@ /// controller's content. @discardableResult public func present( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> UIViewController + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController ) -> ObservationToken { - present(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } + present(item: item, id: id, style: style, onDismiss: onDismiss) { + content($0.wrappedValue) + } } /// Presents a view controller modally using the given item as a data source for its content. @@ -112,117 +116,49 @@ @_disfavoredOverload @discardableResult public func present( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> UIViewController + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController ) -> ObservationToken { - destination(item: item, id: id) { $item in - content($item) - } present: { [weak self] child, transaction in - guard let self else { return } - if presentedViewController != nil { - self.dismiss(animated: !transaction.uiKit.disablesAnimations) { + destination(item: item, id: id) { $item in + content($item) + } present: { [weak self] child, transaction in + guard let self else { return } + if presentedViewControllers != nil { + self.dismiss(nil) + onDismiss?() + self.present(child, for: style) + + } else { + self.present(child, for: style) + } + } dismiss: { [weak self] _, transaction in + self?.dismiss(nil) onDismiss?() - self.present(child, animated: !transaction.uiKit.disablesAnimations) - } - } else { - self.present(child, animated: !transaction.uiKit.disablesAnimations) } - } dismiss: { [weak self] _, transaction in - self?.dismiss(animated: !transaction.uiKit.disablesAnimations) { - onDismiss?() - } - } - } - - /// Pushes a view controller onto the receiver's stack when a binding to a Boolean value you - /// provide is true. - /// - /// Like SwiftUI's `navigationDestination(isPresented:)` view modifier, but for UIKit. - /// - /// - Parameters: - /// - isPresented: A binding to a Boolean value that determines whether to push the view - /// controller. - /// - content: A closure that returns the view controller to display onto the receiver's - /// stack. - @discardableResult - public func navigationDestination( - isPresented: UIBinding, - content: @escaping () -> UIViewController - ) -> ObservationToken { - navigationDestination(item: isPresented.toOptionalUnit) { _ in content() } } - /// Pushes a view controller onto the receiver's stack using the given item as a data source for - /// its content. - /// - /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the view controller. When `item` is - /// non-`nil`, the item's content is passed to the `content` closure. You display this - /// content in a view controller that you create that is displayed to the user. - /// - content: A closure that returns the view controller to display onto the receiver's - /// stack. - @discardableResult - public func navigationDestination( - item: UIBinding, - content: @escaping (Item) -> UIViewController - ) -> ObservationToken { - navigationDestination(item: item) { - content($0.wrappedValue) - } + + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) } - /// Pushes a view controller onto the receiver's stack using the given item as a data source for - /// its content. - /// - /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the view controller. When `item` is - /// non-`nil`, the item's content is passed to the `content` closure. You display this - /// content in a view controller that you create that is displayed to the user. - /// - content: A closure that returns the view controller to display onto the receiver's - /// stack. - @_disfavoredOverload - @discardableResult - public func navigationDestination( - item: UIBinding, - content: @escaping (UIBinding) -> UIViewController - ) -> ObservationToken { - destination(item: item) { $item in - content($item) - } present: { [weak self] child, transaction in - guard - let navigationController = self?.navigationController ?? self as? UINavigationController - else { - reportIssue( - """ - Can't present navigation item: "navigationController" is "nil". - """ - ) - return + fileprivate func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case .popover(let rect, let view, let preferredEdge, let behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case .custom(let animator): + present(viewControllerToPresent, animator: animator) } - navigationController.pushViewController( - child, animated: !transaction.uiKit.disablesAnimations - ) - } dismiss: { [weak self] child, transaction in - guard - let navigationController = self?.navigationController ?? self as? UINavigationController - else { - reportIssue( - """ - Can't dismiss navigation item: "navigationController" is "nil". - """ - ) - return - } - navigationController.popFromViewController( - child, animated: !transaction.uiKit.disablesAnimations - ) - } } /// Presents a view controller when a binding to a Boolean value you provide is true. @@ -239,20 +175,20 @@ /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult public func destination( - isPresented: UIBinding, - content: @escaping () -> UIViewController, - present: @escaping (UIViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + isPresented: UIBinding, + content: @escaping () -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - destination( - item: isPresented.toOptionalUnit, - content: { _ in content() }, - present: present, - dismiss: dismiss - ) + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) } /// Presents a view controller using the given item as a data source for its content. @@ -269,21 +205,21 @@ /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult public func destination( - item: UIBinding, - content: @escaping (UIBinding) -> UIViewController, - present: @escaping (UIViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + item: UIBinding, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - destination( - item: item, - id: { _ in nil }, - content: content, - present: present, - dismiss: dismiss - ) + destination( + item: item, + id: { _ in nil }, + content: content, + present: present, + dismiss: dismiss + ) } /// Presents a view controller using the given item as a data source for its content. @@ -303,161 +239,112 @@ /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult public func destination( - item: UIBinding, - id: KeyPath, - content: @escaping (UIBinding) -> UIViewController, - present: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - destination( - item: item, - id: { $0[keyPath: id] }, - content: content, - present: present, - dismiss: dismiss - ) + destination( + item: item, + id: { $0[keyPath: id] }, + content: content, + present: present, + dismiss: dismiss + ) } private func destination( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> UIViewController, - present: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = presentedByID[key] { - guard let presentationID = presented.presentationID, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let childController = content(unwrappedItem) - let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) - { - item.wrappedValue = nil + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = presentedByID[key] { + guard let presentationID = presented.presentationID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let childController = content(unwrappedItem) + let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + childController.onDismiss = onDismiss + + self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + present(childController, transaction) + } + } + if hasViewAppeared { + work() + } else { + onViewAppear.append(work) + } + } else if let presented = presentedByID[key] { + if let controller = presented.controller { + dismiss(controller, transaction) + } + self.presentedByID[key] = nil } - } - childController.onDismiss = onDismiss - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - childController.traitOverrides.dismiss = UIDismissAction { _ in - onDismiss() - } - } - self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - present(childController, transaction) - } - } - if hasViewAppeared { - work() - } else { - onViewAppear.append(work) - } - } else if let presented = presentedByID[key] { - if let controller = presented.controller { - dismiss(controller, transaction) - } - self.presentedByID[key] = nil } - } } fileprivate var presentedByID: [UIBindingIdentifier: Presented] { - get { - (objc_getAssociatedObject(self, Self.presentedKey) - as? [UIBindingIdentifier: Presented]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } + get { + (objc_getAssociatedObject(self, Self.presentedKey) + as? [UIBindingIdentifier: Presented]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } } private static let presentedKey = malloc(1)! - } +} - extension UINavigationController { - @available( - *, deprecated, - message: """ - Use 'self.navigationDestination(isPresented:)' instead of 'self.navigationController?.pushViewController(isPresented:)'. - """ - ) - @discardableResult - public func pushViewController( - isPresented: UIBinding, - content: @escaping () -> UIViewController - ) -> ObservationToken { - navigationDestination(isPresented: isPresented, content: content) - } - - @available( - *, deprecated, - message: """ - Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. - """ - ) - @discardableResult - public func pushViewController( - item: UIBinding, - content: @escaping (Item) -> UIViewController - ) -> ObservationToken { - navigationDestination(item: item, content: content) - } - - @available( - *, deprecated, - message: """ - Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. - """ - ) - @_disfavoredOverload - @discardableResult - public func pushViewController( - item: UIBinding, - content: @escaping (UIBinding) -> UIViewController - ) -> ObservationToken { - navigationDestination(item: item, content: content) - } - } - - @MainActor - private class Presented { - weak var controller: UIViewController? +@MainActor +private class Presented { + weak var controller: NSViewController? let presentationID: AnyHashable? deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - self.controller?.dismiss(animated: false) - } + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + self.controller?.dismiss(nil) + } } - init(_ controller: UIViewController, id presentationID: AnyHashable? = nil) { - self.controller = controller - self.presentationID = presentationID + + init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { + self.controller = controller + self.presentationID = presentationID } - } +} #endif diff --git a/Sources/AppKitNavigation/Navigation/UIAlertController.swift b/Sources/AppKitNavigation/Navigation/UIAlertController.swift index 0151edcdf4..ba24c57eed 100644 --- a/Sources/AppKitNavigation/Navigation/UIAlertController.swift +++ b/Sources/AppKitNavigation/Navigation/UIAlertController.swift @@ -1,87 +1,83 @@ -#if canImport(UIKit) && !os(watchOS) - import UIKit - - @available(iOS 13, *) - @available(macCatalyst 13, *) - @available(macOS, unavailable) - @available(tvOS 13, *) - @available(watchOS, unavailable) - extension UIAlertController { - /// Creates and returns a view controller 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( - title: String(state: state.title), - message: state.message.map { String(state: $0) }, - preferredStyle: .alert - ) - for button in state.buttons { - addAction(UIAlertAction(button, action: handler)) - } - } - - /// Creates and returns a view controller for displaying an action sheet 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: ConfirmationDialogState, - handler: @escaping (_ action: Action?) -> Void - ) { - self.init( - title: state.titleVisibility == .visible ? String(state: state.title) : nil, - message: state.message.map { String(state: $0) }, - preferredStyle: .actionSheet - ) - for button in state.buttons { - addAction(UIAlertAction(button, action: handler)) - } - } - } - - @available(iOS 13, *) - @available(macCatalyst 13, *) - @available(macOS, unavailable) - @available(tvOS 13, *) - @available(watchOS, unavailable) - extension UIAlertAction.Style { - public init(_ role: ButtonStateRole) { - switch role { - case .cancel: - self = .cancel - case .destructive: - self = .destructive - } - } - } - - @available(iOS 13, *) - @available(macCatalyst 13, *) - @available(macOS, unavailable) - @available(tvOS 13, *) - @available(watchOS, unavailable) - extension UIAlertAction { - public convenience init( - _ button: ButtonState, - action handler: @escaping (_ action: Action?) -> Void - ) { - self.init( - title: String(state: button.label), - style: button.role.map(UIAlertAction.Style.init) ?? .default - ) { _ in - button.withAction(handler) - } - if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { - self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } - } - } - } +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +// import AppKit +// +// +// extension NSAlert { +// /// Creates and returns a view controller 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( +// title: String(state: state.title), +// message: state.message.map { String(state: $0) }, +// preferredStyle: .alert +// ) +// for button in state.buttons { +// addAction(UIAlertAction(button, action: handler)) +// } +// } +// +// /// Creates and returns a view controller for displaying an action sheet 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: ConfirmationDialogState, +// handler: @escaping (_ action: Action?) -> Void +// ) { +// self.init( +// title: state.titleVisibility == .visible ? String(state: state.title) : nil, +// message: state.message.map { String(state: $0) }, +// preferredStyle: .actionSheet +// ) +// for button in state.buttons { +// addAction(UIAlertAction(button, action: handler)) +// } +// } +// } +// +// @available(iOS 13, *) +// @available(macCatalyst 13, *) +// @available(macOS, unavailable) +// @available(tvOS 13, *) +// @available(watchOS, unavailable) +// extension UIAlertAction.Style { +// public init(_ role: ButtonStateRole) { +// switch role { +// case .cancel: +// self = .cancel +// case .destructive: +// self = .destructive +// } +// } +// } +// +// @available(iOS 13, *) +// @available(macCatalyst 13, *) +// @available(macOS, unavailable) +// @available(tvOS 13, *) +// @available(watchOS, unavailable) +// extension UIAlertAction { +// public convenience init( +// _ button: ButtonState, +// action handler: @escaping (_ action: Action?) -> Void +// ) { +// self.init( +// title: String(state: button.label), +// style: button.role.map(UIAlertAction.Style.init) ?? .default +// ) { _ in +// button.withAction(handler) +// } +// if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { +// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } +// } +// } +// } #endif diff --git a/Sources/AppKitNavigation/UIKitAnimation.swift b/Sources/AppKitNavigation/UIKitAnimation.swift deleted file mode 100644 index 7e5106edc5..0000000000 --- a/Sources/AppKitNavigation/UIKitAnimation.swift +++ /dev/null @@ -1,144 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - import AppKit - - #if canImport(SwiftUI) - import SwiftUI - #endif - import SwiftNavigation - /// Executes a closure with the specified animation and returns the result. - /// - /// - Parameters: - /// - animation: An animation, set in the ``UITransaction/appKit`` property of the thread's - /// current transaction. - /// - body: A closure to execute. - /// - completion: A completion to run when the animation is complete. - /// - Returns: The result of executing the closure with the specified animation. - @MainActor - public func withAppKitAnimation( - _ animation: AppKitAnimation? = .default, - _ body: () throws -> Result, - completion: (@Sendable (Bool?) -> Void)? = nil - ) rethrows -> Result { - var transaction = UITransaction() - transaction.appKit.animation = animation - if let completion { - transaction.appKit.addAnimationCompletion(completion) - } - return try withUITransaction(transaction, body) - } - - /// The way a view changes over time to create a smooth visual transition from one state to - /// another. - public struct AppKitAnimation: Hashable, Sendable { - fileprivate let framework: Framework - - @MainActor - func perform( - _ body: () throws -> Result, - completion: ((Bool?) -> Void)? = nil - ) rethrows -> Result { - switch framework { - case let .swiftUI(animation): - #if swift(>=6) - if #available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) { - var result: Swift.Result? - UIView.animate( - with: animation, - changes: { result = Swift.Result(catching: body) }, - completion: completion.map { completion in { completion(true) } } - ) - return try result!._rethrowGet() - } - #endif - _ = animation - fatalError() - - case let .appKit(animation): - var result: Swift.Result? - NSAnimationContext.runAnimationGroup { context in - context.duration = animation.duration - result = Swift.Result(catching: body) - } completionHandler: { - completion?(true) - } - - return try result!._rethrowGet() - - - } - } - - fileprivate enum Framework: Hashable, Sendable { - case appKit(AppKit) - case swiftUI(Animation) - - fileprivate struct AppKit: Hashable, Sendable { - fileprivate var duration: TimeInterval - - func hash(into hasher: inout Hasher) { - hasher.combine(duration) - } - - } - } - } - - extension AppKitAnimation { - - /// Performs am animation using a timing curve corresponding to the motion of a physical spring. - /// - /// A value description of - /// `UIView.animate(withDuration:delay:dampingRatio:velocity:options:animations:completion:)` - /// that can be used with ``withAppKitAnimation(_:_:completion:)``. - /// - /// - Parameters: - /// - duration: The total duration of the animations, measured in seconds. If you specify a - /// negative value or `0`, the changes are made without animating them. - /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. - /// Specify a value of `0` to begin the animations immediately. - /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent - /// state. - /// - /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a - /// damping ratio closer to zero to increase oscillation. - /// - velocity: The initial spring velocity. For smooth start to the animation, match this - /// value to the view's velocity as it was prior to attachment. - /// - /// A value of `1` corresponds to the total animation distance traversed in one second. For - /// example, if the total animation distance is 200 points and you want the start of the - /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. - /// - options: A mask of options indicating how you want to perform the animations. For a list - /// of valid constants, see `UIView.AnimationOptions`. - /// - Returns: An animation using a timing curve corresponding to the motion of a physical - /// spring. - public static func animate( - withDuration duration: TimeInterval = 0.25 - ) -> Self { - Self( - framework: .appKit( - Framework.AppKit( - duration: duration - ) - ) - ) - } - - - /// Animates changes using the specified SwiftUI animation. - /// - /// - Parameter animation: The animation to use for the changes. - @available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) - public init(_ animation: Animation) { - self.init(framework: .swiftUI(animation)) - } - - /// A default animation instance. - public static var `default`: Self { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - return .animate() - } else { - return .animate(withDuration: 0.35) - } - } - } -#endif From 4611a2a737d6570a3db3c34fa34021baaf1a7cb0 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:14:31 +0800 Subject: [PATCH 03/42] Updates --- ...Kit+MinimalObservationViewController.swift | 78 +++++++++++ .../NSControlBindingsViewController.swift | 126 ++++++++++++++++++ Examples/CaseStudies/Internal/CaseStudy.swift | 24 +++- .../CaseStudies/Internal/DetentsHelper.swift | 2 + Examples/CaseStudies/RootView.swift | 2 + .../CaseStudies/SwiftUI/EnumNavigation.swift | 4 +- .../SwiftUI/OptionalNavigation.swift | 4 +- .../UIKit/AnimationsViewController.swift | 2 + .../BasicsNavigationViewController.swift | 2 + .../ConciseEnumNavigationViewController.swift | 2 + .../UIKit/EnumControlsViewController.swift | 2 + .../ErasedNavigationStackController.swift | 2 + .../UIKit/FocusViewController.swift | 2 + .../MinimalObservationViewController.swift | 2 + .../StaticNavigationStackController.swift | 2 + .../UIControlBindingsViewController.swift | 2 + .../CaseStudies/UIKit/UIKitCaseStudies.swift | 2 + .../WiFiFeature/ConnectToNetworkFeature.swift | 2 + .../WiFiFeature/NetworkDetailFeature.swift | 2 + .../WiFiFeature/WiFiSettingsFeature.swift | 2 + .../CaseStudiesTests/Internal/SetUp.swift | 2 + .../NavigationPathTests.swift | 2 + .../NavigationStackTests.swift | 2 + .../CaseStudiesTests/PresentationTests.swift | 2 + .../RuntimeWarningTests.swift | 2 + Examples/Examples.xcodeproj/project.pbxproj | 33 +++++ .../Bindings/NSFontManager.swift | 6 +- .../AppKitNavigation/Bindings/NSSwitch.swift | 2 +- .../Bindings/NSTargetAction.swift | 27 +++- .../Bindings/NSTextField.swift | 16 ++- .../Internal/AssumeIsolated.swift | 48 +++---- .../Internal/ErrorMechanism.swift | 18 +-- .../AppKitNavigation/Internal/Exports.swift | 2 +- .../Internal/ToOptionalUnit.swift | 10 +- .../AppKitNavigation/Navigation/NSAlert.swift | 48 +++++++ .../Navigation/UIAlertController.swift | 83 ------------ Sources/AppKitNavigation/Observe.swift | 7 +- 37 files changed, 423 insertions(+), 153 deletions(-) create mode 100644 Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift create mode 100644 Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift create mode 100644 Sources/AppKitNavigation/Navigation/NSAlert.swift delete mode 100644 Sources/AppKitNavigation/Navigation/UIAlertController.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift new file mode 100644 index 0000000000..9fcc665639 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift @@ -0,0 +1,78 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import AppKitNavigation +import ConcurrencyExtras + +class MinimalObservationViewController: NSViewController, AppKitCaseStudy { + let caseStudyTitle = "Minimal observation" + let readMe = """ + This case study demonstrates how to use the 'observe' tool from the library in order to \ + minimally observe changes to an @Observable model. + + To see this, tap the "Increment" button to see that the view re-renders each time you count \ + up. Then, hide the counter and increment again to see that the view does not re-render, even \ + though the count is changing. This shows that only the state accessed inside the trailing \ + closure of 'observe' causes re-renders. + """ + @UIBindable var model = Model() + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + + let countLabel = NSTextField(labelWithString: "") + let incrementButton = NSButton(title: "Increment", target: self, action: #selector(incrementButtonAction(_:))) + let isCountHiddenSwitch = NSSwitch(isOn: $model.isCountHidden) + let isCountHiddenLabel = NSTextField(labelWithString: "Is count hidden?") + let viewRenderLabel = NSTextField(labelWithString: "") + let stack = NSStackView(views: [ + countLabel, + incrementButton, + isCountHiddenLabel, + isCountHiddenSwitch, + viewRenderLabel, + ]) + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + let viewRenderCount = LockIsolated(0) + observe { [weak self] in + guard let self else { return } + viewRenderCount.withValue { $0 += 1 } + + if !model.isCountHidden { + // NB: We do not access 'model.count' when the count is hidden, and therefore its mutations + // will not cause a re-render of the view. + countLabel.stringValue = model.count.description + } + countLabel.isHidden = model.isCountHidden + viewRenderLabel.stringValue = "# of view renders: \(viewRenderCount.value)" + } + } + + @objc func incrementButtonAction(_ sender: NSButton) { + model.count += 1 + } + + @Observable + class Model { + var count = 0 + var isCountHidden = false + } +} + +#Preview { + MinimalObservationViewController() +} + +#endif diff --git a/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift b/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift new file mode 100644 index 0000000000..fbecc6031d --- /dev/null +++ b/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift @@ -0,0 +1,126 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import AppKitNavigation + +class XiblessViewController: NSViewController { + lazy var contentView = View() + + override func loadView() { + view = contentView + } +} + +class NSControlBindingsViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "NSControl bindings" + let readMe = """ + This demonstrates how to use the library's `@UIBinding` and `@UIBindable` property wrappers \ + to bind an observable model to various `NSControl`s. For the most part it works exactly \ + as it does in SwiftUI. Just use `$model` to construct an object that can derive bindings, \ + and then use simple dot-syntax to decide which field you want to derive a binding for. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + contentView.boxType = .custom + contentView.borderWidth = 0 + let colorWell = NSColorWell(color: $model.color) + let datePicker = NSDatePicker(date: $model.date) + let segmentControl = NSSegmentedControl(labels: Model.Segment.allCases.map { "\($0)" }, trackingMode: .selectOne, target: nil, action: nil) + segmentControl.bind(selectedSegment: $model.segment) + let slider = NSSlider(value: $model.sliderValue) + let stepper = NSStepper(value: $model.stepperValue) + let `switch` = NSSwitch(isOn: $model.isOn) + let textField = NSTextField(text: $model.text) + textField.bind(focus: $model.focus, equals: .text) + textField.bind(selection: $model.textSelection) + textField.bezelStyle = .roundedBezel + let label = NSTextField(labelWithString: "") + label.maximumNumberOfLines = 0 + let stack = NSStackView(views: [ + colorWell, + datePicker, + segmentControl, + slider, + stepper, + `switch`, + textField, + label, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.contentView?.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + colorWell.heightAnchor.constraint(equalToConstant: 50), + ]) + + observe { [weak self] in + guard let self else { return } + + contentView.fillColor = model.color + + let textSelection = model.textSelection.map { + self.model.text.range(for: $0.range) + } + + label.stringValue = """ + MyModel( + color: \(model.color.description), + date: \(model.date), + focus: \(model.focus.map(String.init(describing:)) ?? "nil"), + isOn: \(model.isOn), + segment: \(model.segment), + sliderValue: \(model.sliderValue), + stepperValue: \(model.stepperValue), + text: \(model.text.debugDescription), + textSelection: \(textSelection.map(String.init(describing:)) ?? "nil") + ) + """ + } + } + + @MainActor + @Observable + final class Model: HashableObject { + var color: NSColor = .windowBackgroundColor + var date = Date() + var focus: Focus? + var isOn = false + var segment = Segment.columnA + var sliderValue: Float = 0.5 + var stepperValue: Double = 5 + var text = "Blob" + var textSelection: AppKitTextSelection? + + enum Focus: Hashable { + case attributedText + case text + } + + enum Segment: Int, CaseIterable { + case columnA + case columnB + } + } +} + +extension String { + fileprivate func range(for range: Range) -> Range { + distance( + from: startIndex, to: range.lowerBound + ) ..< distance(from: startIndex, to: range.upperBound) + } +} + + +#Preview(traits: .fixedLayout(width: 500, height: 800)) { + NSControlBindingsViewController() +} + + +#endif diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index 2f2a60cffe..9f7a8d1167 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -9,7 +9,18 @@ protocol CaseStudy { var isPresentedInSheet: Bool { get } } protocol SwiftUICaseStudy: CaseStudy, View {} +#if canImport(UIKit) && !os(watchOS) protocol UIKitCaseStudy: CaseStudy, UIViewController {} +extension UIKitCaseStudy { + var usesOwnLayout: Bool { true } +} +#endif +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +protocol AppKitCaseStudy: CaseStudy, NSViewController {} +extension AppKitCaseStudy { + var usesOwnLayout: Bool { true } +} +#endif extension CaseStudy { var caseStudyNavigationTitle: String { caseStudyTitle } @@ -18,9 +29,7 @@ extension CaseStudy { extension SwiftUICaseStudy { var usesOwnLayout: Bool { false } } -extension UIKitCaseStudy { - var usesOwnLayout: Bool { true } -} + @resultBuilder @MainActor @@ -31,10 +40,12 @@ enum CaseStudyViewBuilder { static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { SwiftUICaseStudyButton(caseStudy: caseStudy) } +#if canImport(UIKit) && !os(watchOS) @ViewBuilder static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { UIKitCaseStudyButton(caseStudy: caseStudy) } + #endif static func buildPartialBlock(first: some View) -> some View { first } @@ -69,7 +80,7 @@ struct SwiftUICaseStudyButton: View { } } } - +#if canImport(UIKit) && !os(watchOS) struct UIKitCaseStudyButton: View { let caseStudy: C @State var isPresented = false @@ -116,7 +127,7 @@ extension UINavigationController { return self } } - +#endif struct CaseStudyModifier: ViewModifier { let caseStudy: C @State var isAboutPresented = false @@ -209,7 +220,7 @@ private struct DemoCaseStudy: SwiftUICaseStudy { Text("Hello!") } } - +#if canImport(UIKit) && !os(watchOS) private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { let caseStudyTitle = "Demo Case Study" let readMe = """ @@ -218,3 +229,4 @@ private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { Enjoy! """ } +#endif diff --git a/Examples/CaseStudies/Internal/DetentsHelper.swift b/Examples/CaseStudies/Internal/DetentsHelper.swift index 6f9ed41449..a782d5c297 100644 --- a/Examples/CaseStudies/Internal/DetentsHelper.swift +++ b/Examples/CaseStudies/Internal/DetentsHelper.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit extension UIViewController { @@ -10,3 +11,4 @@ extension UIViewController { } } } +#endif diff --git a/Examples/CaseStudies/RootView.swift b/Examples/CaseStudies/RootView.swift index 81287338db..d552ddf930 100644 --- a/Examples/CaseStudies/RootView.swift +++ b/Examples/CaseStudies/RootView.swift @@ -8,9 +8,11 @@ struct RootView: View { NavigationLink("SwiftUI") { SwiftUICaseStudiesView() } +#if canImport(UIKit) && !os(watchOS) NavigationLink("UIKit") { UIKitCaseStudiesView() } +#endif } .navigationTitle("Case studies") } diff --git a/Examples/CaseStudies/SwiftUI/EnumNavigation.swift b/Examples/CaseStudies/SwiftUI/EnumNavigation.swift index c3769345df..9e5750bee4 100644 --- a/Examples/CaseStudies/SwiftUI/EnumNavigation.swift +++ b/Examples/CaseStudies/SwiftUI/EnumNavigation.swift @@ -70,7 +70,7 @@ struct EnumNavigation: SwiftUICaseStudy { .navigationTitle("Sheet with payload") .presentationDetents([.medium]) } - +#if canImport(UIKit) && !os(watchOS) Button("Cover is presented: \(destination.is(\.fullScreenCover) ? "✅" : "❌")") { destination = .fullScreenCover(.random(in: 1...1_000)) } @@ -92,7 +92,7 @@ struct EnumNavigation: SwiftUICaseStudy { } } } - + #endif Button("Popover is presented: \(destination.is(\.popover) ? "✅" : "❌")") { destination = .popover(.random(in: 1...1_000)) } diff --git a/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift b/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift index 1d6b0160ac..2378d76283 100644 --- a/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift +++ b/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift @@ -57,7 +57,7 @@ struct OptionalNavigation: SwiftUICaseStudy { .navigationTitle("Sheet") .presentationDetents([.medium]) } - +#if canImport(UIKit) && !os(watchOS) Button("Cover is presented: \(fullScreenCover != nil ? "✅" : "❌")") { fullScreenCover = .random(in: 1...1_000) } @@ -79,7 +79,7 @@ struct OptionalNavigation: SwiftUICaseStudy { } } } - + #endif Button("Popover is presented: \(popover != nil ? "✅" : "❌")") { popover = .random(in: 1...1_000) } diff --git a/Examples/CaseStudies/UIKit/AnimationsViewController.swift b/Examples/CaseStudies/UIKit/AnimationsViewController.swift index d06f6c9116..664a35855c 100644 --- a/Examples/CaseStudies/UIKit/AnimationsViewController.swift +++ b/Examples/CaseStudies/UIKit/AnimationsViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -89,3 +90,4 @@ class AnimationsViewController: UIViewController, UIKitCaseStudy { #Preview { AnimationsViewController() } +#endif diff --git a/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift b/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift index 6df4ef23ad..85433562c2 100644 --- a/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -119,3 +120,4 @@ class BasicsNavigationViewController: UIViewController, UIKitCaseStudy { rootViewController: BasicsNavigationViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift index 6b022f528d..c66bdb9d31 100644 --- a/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift +++ b/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -134,3 +135,4 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { rootViewController: BasicsNavigationViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/EnumControlsViewController.swift b/Examples/CaseStudies/UIKit/EnumControlsViewController.swift index 0348a3e8c4..26919741aa 100644 --- a/Examples/CaseStudies/UIKit/EnumControlsViewController.swift +++ b/Examples/CaseStudies/UIKit/EnumControlsViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit import UIKitNavigation @@ -111,3 +112,4 @@ extension Int { set { self = Int(newValue) } } } +#endif diff --git a/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift b/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift index c77aae3e3d..d7bb43b6d8 100644 --- a/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift +++ b/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -258,3 +259,4 @@ private class BoolFeatureViewController: UIViewController { #Preview { ErasedNavigationStackController(model: ErasedNavigationStackController.Model()) } +#endif diff --git a/Examples/CaseStudies/UIKit/FocusViewController.swift b/Examples/CaseStudies/UIKit/FocusViewController.swift index 8e6d4cb59d..20243756d6 100644 --- a/Examples/CaseStudies/UIKit/FocusViewController.swift +++ b/Examples/CaseStudies/UIKit/FocusViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -130,3 +131,4 @@ class FocusViewController: UIViewController, UIKitCaseStudy { #Preview { FocusViewController() } +#endif diff --git a/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift b/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift index 887eebf62e..99655a4a7e 100644 --- a/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift +++ b/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import ConcurrencyExtras import UIKit import UIKitNavigation @@ -76,3 +77,4 @@ class MinimalObservationViewController: UIViewController, UIKitCaseStudy { rootViewController: MinimalObservationViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift b/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift index 54128d9a25..ed5d72b896 100644 --- a/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift +++ b/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -172,3 +173,4 @@ private class FeatureViewController: UIViewController { #Preview { StaticNavigationStackController() } +#endif diff --git a/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift b/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift index f9a2fe907c..9995dd4c0b 100644 --- a/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift +++ b/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit import UIKitNavigation @@ -127,3 +128,4 @@ extension String { rootViewController: UIControlBindingsViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift b/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift index f4b47e72a1..6751d3439e 100644 --- a/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift +++ b/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import SwiftUINavigation @@ -38,3 +39,4 @@ struct UIKitCaseStudiesView: View { UIKitCaseStudiesView() } } +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift index fee0ef6472..fe305a2748 100644 --- a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift +++ b/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKitNavigation import XCTestDynamicOverlay @@ -109,3 +110,4 @@ final class ConnectToNetworkViewController: UIViewController { ) } } +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift index 3255e01d04..124b64124c 100644 --- a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift +++ b/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKitNavigation import XCTestDynamicOverlay @@ -97,3 +98,4 @@ final class NetworkDetailViewController: UIViewController { ) } } +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift index 3fb9c068c7..a025d7855e 100644 --- a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift +++ b/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKitNavigation @@ -309,3 +310,4 @@ extension NSDiffableDataSourceSnapshot< } } } +#endif diff --git a/Examples/CaseStudiesTests/Internal/SetUp.swift b/Examples/CaseStudiesTests/Internal/SetUp.swift index 8eb7a5a9fc..432aacbfb1 100644 --- a/Examples/CaseStudiesTests/Internal/SetUp.swift +++ b/Examples/CaseStudiesTests/Internal/SetUp.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit import XCTest @@ -16,3 +17,4 @@ extension XCTestCase { try await Task.sleep(for: .milliseconds(100)) } } +#endif diff --git a/Examples/CaseStudiesTests/NavigationPathTests.swift b/Examples/CaseStudiesTests/NavigationPathTests.swift index f9604841a4..6014c6ce09 100644 --- a/Examples/CaseStudiesTests/NavigationPathTests.swift +++ b/Examples/CaseStudiesTests/NavigationPathTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) @_spi(Internals) import SwiftNavigation import UIKitNavigation import XCTest @@ -644,3 +645,4 @@ private struct User: Hashable, Codable { static let mangledTypeName = "21UIKitCaseStudiesTests014NavigationPathD0C10$10685e7e0yXZ10$10685e7ecyXZ4UserV" } +#endif diff --git a/Examples/CaseStudiesTests/NavigationStackTests.swift b/Examples/CaseStudiesTests/NavigationStackTests.swift index e504e94c33..d4a9c05884 100644 --- a/Examples/CaseStudiesTests/NavigationStackTests.swift +++ b/Examples/CaseStudiesTests/NavigationStackTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import XCTest @@ -316,3 +317,4 @@ private final class ChildViewController: UIViewController { } } } +#endif diff --git a/Examples/CaseStudiesTests/PresentationTests.swift b/Examples/CaseStudiesTests/PresentationTests.swift index d318c35552..ff453ba76a 100644 --- a/Examples/CaseStudiesTests/PresentationTests.swift +++ b/Examples/CaseStudiesTests/PresentationTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import XCTest @@ -496,3 +497,4 @@ private class BasicViewController: UIViewController { } } } +#endif diff --git a/Examples/CaseStudiesTests/RuntimeWarningTests.swift b/Examples/CaseStudiesTests/RuntimeWarningTests.swift index 0730982b3c..7d80bb2bad 100644 --- a/Examples/CaseStudiesTests/RuntimeWarningTests.swift +++ b/Examples/CaseStudiesTests/RuntimeWarningTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import XCTest @@ -142,3 +143,4 @@ final class RuntimeWarningTests: XCTestCase { try await setUp(controller: vc) } } +#endif diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 46455e484b..9a7069ae89 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -52,6 +52,9 @@ CADCA3662C1CE8BE00DE645F /* CaseStudy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADCA3652C1CE8BE00DE645F /* CaseStudy.swift */; }; DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */; }; DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC86E8702C208D8A003C0EC9 /* Text+Template.swift */; }; + E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */; }; + E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; + E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -110,6 +113,8 @@ DC5EB9D32C0525FA0034B757 /* NavigationStackTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationStackTests.swift; sourceTree = ""; }; DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedBindings.swift; sourceTree = ""; }; DC86E8702C208D8A003C0EC9 /* Text+Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Template.swift"; sourceTree = ""; }; + E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+MinimalObservationViewController.swift"; sourceTree = ""; }; + E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSControlBindingsViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -130,6 +135,7 @@ CA48F2FA2C49645100BE2C3C /* SwiftUINavigation in Frameworks */, CA48F3052C49650F00BE2C3C /* Tagged in Frameworks */, CA48F2FC2C49645100BE2C3C /* UIKitNavigation in Frameworks */, + E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -194,6 +200,7 @@ CA9D70492C1F642A003B672A /* Internal */, CADCA3642C1CE80B00DE645F /* SwiftUI */, CA9D704A2C1F6431003B672A /* UIKit */, + E9836D902C6C9E3000FC6EFD /* AppKit */, ); path = CaseStudies; sourceTree = ""; @@ -274,6 +281,15 @@ path = Internal; sourceTree = ""; }; + E9836D902C6C9E3000FC6EFD /* AppKit */ = { + isa = PBXGroup; + children = ( + E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */, + E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */, + ); + path = AppKit; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -316,6 +332,7 @@ CA48F3012C49650100BE2C3C /* IdentifiedCollections */, CA48F3042C49650F00BE2C3C /* Tagged */, CA48F3072C49651700BE2C3C /* ConcurrencyExtras */, + E9836D932C6CA07300FC6EFD /* AppKitNavigation */, ); productName = CaseStudies; productReference = CA473804272F0D330012CAC3 /* CaseStudies.app */; @@ -432,6 +449,7 @@ buildActionMask = 2147483647; files = ( CABE9FC1272F2C0000AFC150 /* EnumNavigation.swift in Sources */, + E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */, DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */, CA9D70502C1FB431003B672A /* StaticNavigationStackController.swift in Sources */, CA473837272F0D860012CAC3 /* FactClient.swift in Sources */, @@ -441,6 +459,7 @@ CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */, CA473834272F0D860012CAC3 /* RootView.swift in Sources */, CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */, + E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */, CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */, CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */, CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */, @@ -683,6 +702,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -713,6 +734,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -725,6 +748,7 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -734,6 +758,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -748,6 +774,7 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -757,6 +784,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -868,6 +897,10 @@ package = CA48F3062C49651700BE2C3C /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */; productName = ConcurrencyExtras; }; + E9836D932C6CA07300FC6EFD /* AppKitNavigation */ = { + isa = XCSwiftPackageProductDependency; + productName = AppKitNavigation; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CA47378C272F08EF0012CAC3 /* Project object */; diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 373b40f34d..85a5cd29d2 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -13,12 +13,14 @@ extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { get { appkitNavigationDelegate.action } } + private static let appkitNavigationDelegateKey = malloc(1)! + private var appkitNavigationDelegate: Delegate { set { - objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - if let delegate = objc_getAssociatedObject(self, #function) as? Delegate { + if let delegate = objc_getAssociatedObject(self, Self.appkitNavigationDelegateKey) as? Delegate { return delegate } else { let delegate = Delegate() diff --git a/Sources/AppKitNavigation/Bindings/NSSwitch.swift b/Sources/AppKitNavigation/Bindings/NSSwitch.swift index 21b752be18..47f4eead06 100644 --- a/Sources/AppKitNavigation/Bindings/NSSwitch.swift +++ b/Sources/AppKitNavigation/Bindings/NSSwitch.swift @@ -26,7 +26,7 @@ extension NSSwitch { } } - var boolValue: Bool { + @objc var boolValue: Bool { set { state = newValue ? .on : .off } get { state == .on } } diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 305ef278ed..295ff6b670 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -10,18 +10,28 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { var appkitNavigationAction: Selector? { set get } } -private class NSControlActionHandler: NSObject { +@MainActor +internal class NSTargetActionHandler: NSObject { let action: (NSControl) -> Void + var originTarget: AnyObject? + + var originAction: Selector? + init(action: @escaping (NSControl) -> Void) { self.action = action } @objc func invokeAction(_ sender: NSControl) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) + } action(sender) + } } + extension NSTargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. /// @@ -40,9 +50,11 @@ extension NSTargetActionProtocol { } } - private var actionHandler: NSControlActionHandler? { - set { objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - get { objc_getAssociatedObject(self, #function) as? NSControlActionHandler } + + + internal var actionHandler: NSTargetActionHandler? { + set { objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + get { objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler } } /// Establishes a two-way connection between a source of truth and a property of this control. @@ -62,13 +74,13 @@ extension NSTargetActionProtocol { set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void ) -> ObservationToken { unbind(keyPath) - let actionHandler = NSControlActionHandler { [weak self] _ in + let actionHandler = NSTargetActionHandler { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } self.actionHandler = actionHandler appkitNavigationTarget = actionHandler - appkitNavigationAction = #selector(NSControlActionHandler.invokeAction(_:)) + appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) let isSetting = LockIsolated(false) let token = observe { [weak self] transaction in @@ -124,4 +136,7 @@ extension NSTargetActionProtocol { @MainActor private let observationTokensKey = malloc(1)! +@MainActor +private let actionHandlerKey = malloc(1)! + #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index e5c4370993..64d1c0de7a 100644 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -69,11 +69,12 @@ extension NSTextField: NSTextViewDelegate { selection.wrappedValue = control.textSelection } } + let observationToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - editingChangedAction.cancel() - editingDidEndAction.cancel() - } +// MainActor._assumeIsolated { + editingChangedAction.cancel() + editingDidEndAction.cancel() +// } token.cancel() self?.textSelectionObserver = nil } @@ -99,12 +100,13 @@ extension NSTextField: NSTextViewDelegate { } } - fileprivate var textSelectionObserver: TextSelectionObserver? { + private static let textSelectionObserverKey = malloc(1)! + private var textSelectionObserver: TextSelectionObserver? { set { - objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(self, Self.textSelectionObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - objc_getAssociatedObject(self, #function) as? TextSelectionObserver + objc_getAssociatedObject(self, Self.textSelectionObserverKey) as? TextSelectionObserver } } diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift index fea107e3ae..9a7465a99f 100644 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift @@ -1,30 +1,30 @@ import Foundation extension MainActor { - // NB: This functionality was not back-deployed in Swift 5.9 - static func _assumeIsolated( - _ operation: @MainActor () throws -> T, - file: StaticString = #fileID, - line: UInt = #line - ) rethrows -> T { - #if swift(<5.10) - typealias YesActor = @MainActor () throws -> T - typealias NoActor = () throws -> T + // NB: This functionality was not back-deployed in Swift 5.9 + static func _assumeIsolated( + _ operation: @MainActor () throws -> T, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows -> T { + #if swift(<5.10) + typealias YesActor = @MainActor () throws -> T + typealias NoActor = () throws -> T - guard Thread.isMainThread else { - fatalError( - "Incorrect actor executor assumption; Expected same executor as \(self).", - file: file, - line: line - ) - } + guard Thread.isMainThread else { + fatalError( + "Incorrect actor executor assumption; Expected same executor as \(self).", + file: file, + line: line + ) + } - return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in - let rawFn = unsafeBitCast(fn, to: NoActor.self) - return try rawFn() - } - #else - return try assumeIsolated(operation, file: file, line: line) - #endif - } + return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in + let rawFn = unsafeBitCast(fn, to: NoActor.self) + return try rawFn() + } + #else + return try assumeIsolated(operation, file: file, line: line) + #endif + } } diff --git a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift index d527e73ca7..1ec4c47b0a 100644 --- a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift +++ b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift @@ -1,20 +1,20 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @rethrows - protocol _ErrorMechanism { +@rethrows +protocol _ErrorMechanism { associatedtype Output func get() throws -> Output - } +} - extension _ErrorMechanism { +extension _ErrorMechanism { func _rethrowError() rethrows -> Never { - _ = try _rethrowGet() - fatalError() + _ = try _rethrowGet() + fatalError() } func _rethrowGet() rethrows -> Output { - return try get() + return try get() } - } +} - extension Result: _ErrorMechanism {} +extension Result: _ErrorMechanism {} #endif diff --git a/Sources/AppKitNavigation/Internal/Exports.swift b/Sources/AppKitNavigation/Internal/Exports.swift index 6f94b2acad..554225adc1 100644 --- a/Sources/AppKitNavigation/Internal/Exports.swift +++ b/Sources/AppKitNavigation/Internal/Exports.swift @@ -1,3 +1,3 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @_exported import SwiftNavigation +@_exported import SwiftNavigation #endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift index bbe60acbac..a11cfaaf1d 100644 --- a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift +++ b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift @@ -1,12 +1,12 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - extension Bool { +extension Bool { struct Unit: Hashable, Identifiable { - var id: Unit { self } + var id: Unit { self } } var toOptionalUnit: Unit? { - get { self ? Unit() : nil } - set { self = newValue != nil } + get { self ? Unit() : nil } + set { self = newValue != nil } } - } +} #endif diff --git a/Sources/AppKitNavigation/Navigation/NSAlert.swift b/Sources/AppKitNavigation/Navigation/NSAlert.swift new file mode 100644 index 0000000000..09229b6e57 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NSAlert.swift @@ -0,0 +1,48 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSAlert { + /// Creates and returns a view controller 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 + ) { + let button = addButton(withTitle: String(state: buttonState.label)) + + let actionHandler = NSTargetActionHandler { _ in + buttonState.withAction(handler) + } + actionHandler.originTarget = button.target + actionHandler.originAction = button.action + button.target = actionHandler + button.action = #selector(NSTargetActionHandler.invokeAction(_:)) + button.actionHandler = actionHandler + if buttonState.role == .destructive, #available(macOS 11.0, *) { + button.hasDestructiveAction = true + } + if buttonState.role == .cancel { + button.keyEquivalent = "\u{1b}" + } +// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } + } +} +#endif diff --git a/Sources/AppKitNavigation/Navigation/UIAlertController.swift b/Sources/AppKitNavigation/Navigation/UIAlertController.swift deleted file mode 100644 index ba24c57eed..0000000000 --- a/Sources/AppKitNavigation/Navigation/UIAlertController.swift +++ /dev/null @@ -1,83 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -// import AppKit -// -// -// extension NSAlert { -// /// Creates and returns a view controller 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( -// title: String(state: state.title), -// message: state.message.map { String(state: $0) }, -// preferredStyle: .alert -// ) -// for button in state.buttons { -// addAction(UIAlertAction(button, action: handler)) -// } -// } -// -// /// Creates and returns a view controller for displaying an action sheet 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: ConfirmationDialogState, -// handler: @escaping (_ action: Action?) -> Void -// ) { -// self.init( -// title: state.titleVisibility == .visible ? String(state: state.title) : nil, -// message: state.message.map { String(state: $0) }, -// preferredStyle: .actionSheet -// ) -// for button in state.buttons { -// addAction(UIAlertAction(button, action: handler)) -// } -// } -// } -// -// @available(iOS 13, *) -// @available(macCatalyst 13, *) -// @available(macOS, unavailable) -// @available(tvOS 13, *) -// @available(watchOS, unavailable) -// extension UIAlertAction.Style { -// public init(_ role: ButtonStateRole) { -// switch role { -// case .cancel: -// self = .cancel -// case .destructive: -// self = .destructive -// } -// } -// } -// -// @available(iOS 13, *) -// @available(macCatalyst 13, *) -// @available(macOS, unavailable) -// @available(tvOS 13, *) -// @available(watchOS, unavailable) -// extension UIAlertAction { -// public convenience init( -// _ button: ButtonState, -// action handler: @escaping (_ action: Action?) -> Void -// ) { -// self.init( -// title: String(state: button.label), -// style: button.role.map(UIAlertAction.Style.init) ?? .default -// ) { _ in -// button.withAction(handler) -// } -// if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { -// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } -// } -// } -// } -#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index d7a6d934a7..59e3fcbb23 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -175,11 +175,8 @@ extension NSView { static func performWithoutAnimation(_ block: () -> Void) { - if NSAnimationContext.current.allowsImplicitAnimation { - NSAnimationContext.current.allowsImplicitAnimation = false - block() - NSAnimationContext.current.allowsImplicitAnimation = true - } else { + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = false block() } } From 06f09afa8c225407f41d6919cddb7c5ad9c48c8b Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Thu, 15 Aug 2024 01:10:56 +0800 Subject: [PATCH 04/42] Updates --- ...ppKit+BasicsNavigationViewController.swift | 93 +++++ ...+ConciseEnumNavigationViewController.swift | 138 +++++++ .../AppKit+EnumControlsViewController.swift | 117 ++++++ .../AppKit/AppKit+FocusViewController.swift | 131 +++++++ ...Kit+MinimalObservationViewController.swift | 6 +- ...Kit+NSControlBindingsViewController.swift} | 0 .../AppKit/AppKitCaseStudies.swift | 42 +++ Examples/CaseStudies/Internal/CaseStudy.swift | 336 ++++++++++-------- Examples/CaseStudies/RootView.swift | 37 +- Examples/Examples.xcodeproj/project.pbxproj | 28 +- .../AppKitNavigation/Bindings/NSControl.swift | 10 + .../Bindings/NSFontManager.swift | 2 +- .../Bindings/NSTargetAction.swift | 51 ++- .../AppKitNavigation/Navigation/NSAlert.swift | 8 +- .../Navigation/Presentation.swift | 12 +- .../AppKitNavigation/Navigation/Sheet.swift | 228 ++++++++++++ Sources/AppKitNavigation/Observe.swift | 96 ++--- 17 files changed, 1084 insertions(+), 251 deletions(-) create mode 100644 Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift create mode 100644 Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift create mode 100644 Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift create mode 100644 Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift rename Examples/CaseStudies/AppKit/{NSControlBindingsViewController.swift => AppKit+NSControlBindingsViewController.swift} (100%) create mode 100644 Examples/CaseStudies/AppKit/AppKitCaseStudies.swift create mode 100644 Sources/AppKitNavigation/Navigation/Sheet.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift new file mode 100644 index 0000000000..eeede2e017 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -0,0 +1,93 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftUI +import AppKit +import AppKitNavigation + +class BasicsNavigationViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "Basics" + let readMe = """ + This case study demonstrates how to perform every major form of navigation in UIKit (alerts, \ + sheets, drill-downs) by driving navigation off of optional and boolean state. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let showAlertButton = NSButton { [weak self] _ in + self?.model.alert = "Hello!" + } + + let showSheetButton = NSButton { [weak self] _ in + self?.model.sheet = .random(in: 1 ... 1_000) + } + + let showSheetFromBooleanButton = NSButton { [weak self] _ in + self?.model.isSheetPresented = true + } + + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" + + } + +// present(item: $model.alert, id: \.self) { message in +// let alert = UIAlertController( +// title: "This is an alert", +// message: message, +// preferredStyle: .alert +// ) +// alert.addAction(UIAlertAction(title: "OK", style: .default)) +// return alert +// } + sheet(item: $model.sheet, id: \.self) { count in + NSAlert(error: CocoaError.error(.coderInvalidValue)) + } +// present(item: $model.sheet, id: \.self, style: .sheet) { count in +//// let vc = NSHostingController( +//// rootView: Form { Text(count.description) } +//// ) +// let vc = XiblessViewController() +// vc.preferredContentSize = .init(width: 300, height: 200) +// return vc +// } + present(isPresented: $model.isSheetPresented, style: .sheet) { + let vc = NSHostingController( + rootView: Form { Text("Hello!") } + ) + return vc + } + } + + @Observable + class Model { + var alert: String? + var isSheetPresented = false + var sheet: Int? + } +} + +#Preview { + BasicsNavigationViewController() +} +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift new file mode 100644 index 0000000000..af08f37dfe --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift @@ -0,0 +1,138 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +//import SwiftUI +//import UIKit +//import UIKitNavigation +// +//class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { +// let caseStudyNavigationTitle = "Enum navigation" +// let caseStudyTitle = "Concise enum navigation" +// let readMe = """ +// This case study demonstrates how to navigate to multiple destinations from a single optional \ +// enum. +// +// This allows you to be very concise with your domain modeling by having a single enum \ +// describe all the possible destinations you can navigate to. In the case of this demo, we have \ +// four cases in the enum, which means there are exactly 5 possible states, including the case \ +// where none are active. +// +// If you were to instead model this domain with 4 optionals (or booleans), then you would have \ +// 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ +// because you can never be sure of exactly what is presented at a given time. +// """ +// @UIBindable var model = Model() +// +// override func viewDidLoad() { +// super.viewDidLoad() +// view.backgroundColor = .systemBackground +// +// let showAlertButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .alert("Hello!") +// }) +// let showSheetButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .sheet(.random(in: 1...1_000)) +// }) +// let showSheetFromBooleanButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .sheetWithoutPayload +// }) +// let drillDownButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .drillDown(.random(in: 1...1_000)) +// }) +// +// let stack = UIStackView(arrangedSubviews: [ +// showAlertButton, +// showSheetButton, +// drillDownButton, +// showSheetFromBooleanButton, +// ]) +// stack.axis = .vertical +// stack.spacing = 12 +// stack.translatesAutoresizingMaskIntoConstraints = false +// +// view.addSubview(stack) +// NSLayoutConstraint.activate([ +// stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), +// stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// ]) +// +// observe { [weak self] in +// guard let self else { return } +// +// showAlertButton.setTitle( +// "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")", +// for: .normal +// ) +// showSheetButton.setTitle( +// "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")", +// for: .normal +// ) +// showSheetFromBooleanButton.setTitle( +// "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")", +// for: .normal +// ) +// drillDownButton.setTitle( +// "Drill-down is presented: \(model.destination?.drillDown != nil ? "✅" : "❌")", +// for: .normal +// ) +// } +// +// present(item: $model.destination.alert, id: \.self) { message in +// let alert = UIAlertController( +// title: "This is an alert", +// message: message, +// preferredStyle: .alert +// ) +// alert.addAction(UIAlertAction(title: "OK", style: .default)) +// return alert +// } +// present(item: $model.destination.sheet, id: \.self) { count in +// let vc = UIHostingController( +// rootView: Form { Text(count.description) } +// ) +// vc.mediumDetents() +// return vc +// } +// present(isPresented: UIBinding($model.destination.sheetWithoutPayload)) { +// let vc = UIHostingController( +// rootView: Form { Text("Hello!") } +// ) +// vc.mediumDetents() +// return vc +// } +// navigationDestination(item: $model.destination.drillDown) { count in +// UIHostingController( +// rootView: Form { +// Text(count.description) +// } +// ) +// } +// } +// +// @Observable +// class Model { +// var destination: Destination? +// @CasePathable +// @dynamicMemberLookup +// enum Destination { +// case alert(String) +// case drillDown(Int) +// case sheet(Int) +// case sheetWithoutPayload +// } +// } +//} +// +//#Preview { +// UINavigationController( +// rootViewController: BasicsNavigationViewController() +// ) +//} +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift new file mode 100644 index 0000000000..f6aada2f04 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift @@ -0,0 +1,117 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation + +class EnumControlsViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyNavigationTitle = "Enum controls" + let caseStudyTitle = "Concise enum controls" + let readMe = """ + This case study demonstrates how to drive form controls from bindings to enum state. In this \ + example, a single `Status` enum holds two cases: + + • An integer quantity for when an item is in stock, which can drive a stepper. + • A Boolean for whether an item is on back order when it is _not_ in stock, which can drive a \ + switch. + + This library provides tools to chain deeper into a binding's case by applying the \ + `@CasePathable` macro. + """ + + @CasePathable + enum Status { + case inStock(quantity: Int) + case outOfStock(isOnBackOrder: Bool) + } + + @UIBinding var status: Status = .inStock(quantity: 100) + + override func viewDidLoad() { + super.viewDidLoad() + + let quantityLabel = NSTextField(labelWithString: "") + let quantityStepper = NSStepper() + quantityStepper.maxValue = .infinity + let quantityStack = NSStackView(views: [ + quantityLabel, + quantityStepper, + ]) + let outOfStockButton = NSButton { [weak self] _ in + self?.status = .outOfStock(isOnBackOrder: false) + } + outOfStockButton.title = "Out of stock" + let inStockStack = NSStackView(views: [ + quantityStack, + outOfStockButton, + ]) + inStockStack.orientation = .vertical + + let isOnBackOrderLabel = NSTextField(labelWithString: "Is on back order?") + let isOnBackOrderSwitch = NSSwitch() + let isOnBackOrderStack = NSStackView(views: [ + isOnBackOrderLabel, + isOnBackOrderSwitch, + ]) + let backInStockButton = NSButton { [weak self] _ in + self?.status = .inStock(quantity: 100) + } + + backInStockButton.title = "Back in stock!" + let outOfStockStack = NSStackView(views: [ + isOnBackOrderStack, + backInStockButton, + ]) + outOfStockStack.orientation = .vertical + + let stack = NSStackView(views: [ + inStockStack, + outOfStockStack, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + inStockStack.isHidden = !status.is(\.inStock) + outOfStockStack.isHidden = !status.is(\.outOfStock) + + switch status { + case .inStock: + if let quantity = $status.inStock { + quantityLabel.stringValue = "Quantity: \(quantity.wrappedValue)" + quantityStepper.bind(value: quantity.asDouble) + } + + case .outOfStock: + if let isOnBackOrder = $status.outOfStock { + isOnBackOrderSwitch.bind(isOn: isOnBackOrder) + } + } + } + } +} + +@available(macOS 14.0, *) +#Preview { + EnumControlsViewController() +} + + +extension Int { + fileprivate var asDouble: Double { + get { Double(self) } + set { self = Int(newValue) } + } +} + +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift new file mode 100644 index 0000000000..9008fdf714 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift @@ -0,0 +1,131 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation + +class FocusViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "Focus" + let readMe = """ + This case study demonstrates how to handle `UITextField` focus in a state-driven manner. The \ + focus in the UI is kept in sync with the focus value held in an observable model so that \ + changes in one are immediately reflected in the other. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let bioTextField = NSTextField(text: $model.bio) + bioTextField.bind(focus: $model.focus, equals: .bio) + bioTextField.bezelStyle = .roundedBezel + bioTextField.placeholderString = "Bio" + let emailTextField = NSTextField(text: $model.email) + emailTextField.bind(focus: $model.focus, equals: .email) + emailTextField.bezelStyle = .roundedBezel + emailTextField.placeholderString = "Email" + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bind(focus: $model.focus, equals: .password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.placeholderString = "Password" + let usernameTextField = NSTextField(text: $model.username) + usernameTextField.bind(focus: $model.focus, equals: .username) + usernameTextField.bezelStyle = .roundedBezel + usernameTextField.placeholderString = "Username" + + let currentFocusLabel = NSTextField(labelWithString: "") + + let focusBioButton = NSButton { [weak self] _ in + self?.model.focus = .bio + } + + focusBioButton.title = "Focus bio" + let focusEmailButton = NSButton { [weak self] _ in + self?.model.focus = .email + } + + focusEmailButton.title = "Focus email" + let focusPasswordButton = NSButton { [weak self] _ in + self?.model.focus = .password + } + + focusPasswordButton.title = "Focus password" + let focusUsernameButton = NSButton { [weak self] _ in + self?.model.focus = .username + } + + focusUsernameButton.title = "Focus username" + let resignFirstResponder = NSButton { [weak self] _ in + self?.view.window?.makeFirstResponder(nil) + } + + resignFirstResponder.title = "Resign first responder" + + let stack = NSStackView(views: [ + usernameTextField, + emailTextField, + passwordTextField, + bioTextField, + currentFocusLabel, + focusUsernameButton, + focusEmailButton, + focusPasswordButton, + focusBioButton, + resignFirstResponder, + ]) + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.leadingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), + stack.trailingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + ]) + + observe { [weak self] in + guard let self else { return } + + currentFocusLabel.stringValue = "Current focus: \(model.focus?.rawValue ?? "none")" + bioTextField.backgroundColor = nil + emailTextField.backgroundColor = nil + passwordTextField.backgroundColor = nil + usernameTextField.backgroundColor = nil + switch model.focus { + case .bio: + bioTextField.backgroundColor = .lightGray + case .email: + emailTextField.backgroundColor = .lightGray + case .password: + passwordTextField.backgroundColor = .lightGray + case .username: + usernameTextField.backgroundColor = .lightGray + case .none: + break + } + } + } + + @Observable + class Model { + var bio = "" + var email = "" + var focus: Focus? + var password = "" + var username = "" + enum Focus: String { + case bio + case email + case password + case username + } + } +} + +#Preview { + FocusViewController() +} + +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift index 9fcc665639..f9b51ffca1 100644 --- a/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift @@ -3,7 +3,7 @@ import AppKit import AppKitNavigation import ConcurrencyExtras -class MinimalObservationViewController: NSViewController, AppKitCaseStudy { +class MinimalObservationViewController: XiblessViewController, AppKitCaseStudy { let caseStudyTitle = "Minimal observation" let readMe = """ This case study demonstrates how to use the 'observe' tool from the library in order to \ @@ -15,10 +15,6 @@ class MinimalObservationViewController: NSViewController, AppKitCaseStudy { closure of 'observe' causes re-renders. """ @UIBindable var model = Model() - - override func loadView() { - view = NSView() - } override func viewDidLoad() { super.viewDidLoad() diff --git a/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift similarity index 100% rename from Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift rename to Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift diff --git a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift new file mode 100644 index 0000000000..acdf6cb0e2 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift @@ -0,0 +1,42 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftUI +import SwiftUINavigation + +struct AppKitCaseStudiesView: View { + var body: some View { + List { + CaseStudyGroupView("Observation") { + MinimalObservationViewController() +// AnimationsViewController() + } + CaseStudyGroupView("Bindings") { + NSControlBindingsViewController() + EnumControlsViewController() + FocusViewController() + } + CaseStudyGroupView("Optional navigation") { + BasicsNavigationViewController() + // TODO: Alert/dialog state +// ConciseEnumNavigationViewController() + } +// CaseStudyGroupView("Stack navigation") { +// StaticNavigationStackController() +// ErasedNavigationStackController(model: ErasedNavigationStackController.Model()) +// // TODO: state restoration +// } +// CaseStudyGroupView("Advanced") { +// // TODO: Deep link +// // TODO: Dismissal (show off from VCs and views) +// WiFiSettingsViewController(model: WiFiSettingsModel(foundNetworks: .mocks)) +// } + } + .navigationTitle("AppKit") + } +} + +#Preview { + NavigationStack { + AppKitCaseStudiesView() + } +} +#endif diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index 9f7a8d1167..88730a9b21 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -1,18 +1,20 @@ import SwiftUI +import AppKitNavigation import UIKitNavigation protocol CaseStudy { - var readMe: String { get } - var caseStudyTitle: String { get } - var caseStudyNavigationTitle: String { get } - var usesOwnLayout: Bool { get } - var isPresentedInSheet: Bool { get } + var readMe: String { get } + var caseStudyTitle: String { get } + var caseStudyNavigationTitle: String { get } + var usesOwnLayout: Bool { get } + var isPresentedInSheet: Bool { get } } + protocol SwiftUICaseStudy: CaseStudy, View {} #if canImport(UIKit) && !os(watchOS) protocol UIKitCaseStudy: CaseStudy, UIViewController {} extension UIKitCaseStudy { - var usesOwnLayout: Bool { true } + var usesOwnLayout: Bool { true } } #endif #if canImport(AppKit) && !targetEnvironment(macCatalyst) @@ -23,207 +25,249 @@ extension AppKitCaseStudy { #endif extension CaseStudy { - var caseStudyNavigationTitle: String { caseStudyTitle } - var isPresentedInSheet: Bool { false } + var caseStudyNavigationTitle: String { caseStudyTitle } + var isPresentedInSheet: Bool { false } } + extension SwiftUICaseStudy { - var usesOwnLayout: Bool { false } + var usesOwnLayout: Bool { false } } - @resultBuilder @MainActor enum CaseStudyViewBuilder { - @ViewBuilder - static func buildBlock() -> some View {} - @ViewBuilder - static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { - SwiftUICaseStudyButton(caseStudy: caseStudy) - } -#if canImport(UIKit) && !os(watchOS) - @ViewBuilder - static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { - UIKitCaseStudyButton(caseStudy: caseStudy) - } + @ViewBuilder + static func buildBlock() -> some View {} + @ViewBuilder + static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { + SwiftUICaseStudyButton(caseStudy: caseStudy) + } + + #if canImport(UIKit) && !os(watchOS) + @ViewBuilder + static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { + UIKitCaseStudyButton(caseStudy: caseStudy) + } + #endif + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + @ViewBuilder + static func buildExpression(_ caseStudy: some AppKitCaseStudy) -> some View { + AppKitCaseStudyButton(caseStudy: caseStudy) + } #endif - static func buildPartialBlock(first: some View) -> some View { - first - } - @ViewBuilder - static func buildPartialBlock(accumulated: some View, next: some View) -> some View { - accumulated - next - } + static func buildPartialBlock(first: some View) -> some View { + first + } + + @ViewBuilder + static func buildPartialBlock(accumulated: some View, next: some View) -> some View { + accumulated + next + } } struct SwiftUICaseStudyButton: View { - let caseStudy: C - @State var isPresented = false - var body: some View { - if caseStudy.isPresentedInSheet { - Button(caseStudy.caseStudyTitle) { - isPresented = true - } - .sheet(isPresented: $isPresented) { - CaseStudyView { - caseStudy + let caseStudy: C + @State var isPresented = false + var body: some View { + if caseStudy.isPresentedInSheet { + Button(caseStudy.caseStudyTitle) { + isPresented = true + } + .sheet(isPresented: $isPresented) { + CaseStudyView { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } else { + NavigationLink(caseStudy.caseStudyTitle) { + CaseStudyView { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } - } else { - NavigationLink(caseStudy.caseStudyTitle) { - CaseStudyView { - caseStudy + } +} + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +struct AppKitCaseStudyButton: View { + let caseStudy: C + @State var isPresented = false + var body: some View { +// if caseStudy.isPresentedInSheet { +// Button(caseStudy.caseStudyTitle) { +// isPresented = true +// } +// .sheet(isPresented: $isPresented) { +// NSViewControllerRepresenting { +// ((caseStudy as? UINavigationController) +// ?? UINavigationController(rootViewController: caseStudy)) +// .setUp(caseStudy: caseStudy) +// } +// .modifier(CaseStudyModifier(caseStudy: caseStudy)) +// } +// } else { + NavigationLink(caseStudy.caseStudyTitle) { + NSViewControllerRepresenting { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } +// } } - } } + +#endif + #if canImport(UIKit) && !os(watchOS) struct UIKitCaseStudyButton: View { - let caseStudy: C - @State var isPresented = false - var body: some View { - if caseStudy.isPresentedInSheet { - Button(caseStudy.caseStudyTitle) { - isPresented = true - } - .sheet(isPresented: $isPresented) { - UIViewControllerRepresenting { - ((caseStudy as? UINavigationController) - ?? UINavigationController(rootViewController: caseStudy)) - .setUp(caseStudy: caseStudy) - } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } - } else { - NavigationLink(caseStudy.caseStudyTitle) { - UIViewControllerRepresenting { - caseStudy + let caseStudy: C + @State var isPresented = false + var body: some View { + if caseStudy.isPresentedInSheet { + Button(caseStudy.caseStudyTitle) { + isPresented = true + } + .sheet(isPresented: $isPresented) { + UIViewControllerRepresenting { + ((caseStudy as? UINavigationController) + ?? UINavigationController(rootViewController: caseStudy)) + .setUp(caseStudy: caseStudy) + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } else { + NavigationLink(caseStudy.caseStudyTitle) { + UIViewControllerRepresenting { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } } - } } extension UINavigationController { - func setUp(caseStudy: some CaseStudy) -> Self { - self.viewControllers[0].title = caseStudy.caseStudyNavigationTitle - self.viewControllers[0].navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "About", - primaryAction: UIAction { [weak self] _ in - self?.present( - UIHostingController( - rootView: Form { - Text(template: caseStudy.readMe) + func setUp(caseStudy: some CaseStudy) -> Self { + viewControllers[0].title = caseStudy.caseStudyNavigationTitle + viewControllers[0].navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "About", + primaryAction: UIAction { [weak self] _ in + self?.present( + UIHostingController( + rootView: Form { + Text(template: caseStudy.readMe) + } + .presentationDetents([.medium]) + ), + animated: true + ) } - .presentationDetents([.medium]) - ), - animated: true ) - }) - return self - } + return self + } } #endif struct CaseStudyModifier: ViewModifier { - let caseStudy: C - @State var isAboutPresented = false - func body(content: Content) -> some View { - content - .navigationTitle(caseStudy.caseStudyNavigationTitle) - .toolbar { - ToolbarItem { - Button("About") { isAboutPresented = true } - } - } - .sheet(isPresented: $isAboutPresented) { - Form { - Text(template: caseStudy.readMe) - } - .presentationDetents([.medium]) - } - } + let caseStudy: C + @State var isAboutPresented = false + func body(content: Content) -> some View { + content + .navigationTitle(caseStudy.caseStudyNavigationTitle) + .toolbar { + ToolbarItem { + Button("About") { isAboutPresented = true } + } + } + .sheet(isPresented: $isAboutPresented) { + Form { + Text(template: caseStudy.readMe) + } + .presentationDetents([.medium]) + } + } } struct CaseStudyView: View { - @ViewBuilder let caseStudy: C - @State var isAboutPresented = false - var body: some View { - if caseStudy.usesOwnLayout { - VStack { - caseStudy - } - } else { - Form { - caseStudy - } + @ViewBuilder let caseStudy: C + @State var isAboutPresented = false + var body: some View { + if caseStudy.usesOwnLayout { + VStack { + caseStudy + } + } else { + Form { + caseStudy + } + } } - } } struct CaseStudyGroupView: View { - @CaseStudyViewBuilder let content: Content - @ViewBuilder let title: Title + @CaseStudyViewBuilder let content: Content + @ViewBuilder let title: Title - var body: some View { - Section { - content - } header: { - title + var body: some View { + Section { + content + } header: { + title + } } - } } extension CaseStudyGroupView where Title == Text { - init(_ title: String, @CaseStudyViewBuilder content: () -> Content) { - self.init(content: content) { Text(title) } - } + init(_ title: String, @CaseStudyViewBuilder content: () -> Content) { + self.init(content: content) { Text(title) } + } } extension SwiftUICaseStudy { - fileprivate func navigationLink() -> some View { - NavigationLink(caseStudyTitle) { - self + fileprivate func navigationLink() -> some View { + NavigationLink(caseStudyTitle) { + self + } } - } } #Preview("SwiftUI case study") { - NavigationStack { - CaseStudyView { - DemoCaseStudy() + NavigationStack { + CaseStudyView { + DemoCaseStudy() + } } - } } #Preview("SwiftUI case study group") { - NavigationStack { - Form { - CaseStudyGroupView("Group") { - DemoCaseStudy() - } + NavigationStack { + Form { + CaseStudyGroupView("Group") { + DemoCaseStudy() + } + } } - } } private struct DemoCaseStudy: SwiftUICaseStudy { - let caseStudyTitle = "Demo Case Study" - let readMe = """ + let caseStudyTitle = "Demo Case Study" + let readMe = """ Hello! This is a demo case study. Enjoy! """ - var body: some View { - Text("Hello!") - } + var body: some View { + Text("Hello!") + } } + #if canImport(UIKit) && !os(watchOS) private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { - let caseStudyTitle = "Demo Case Study" - let readMe = """ + let caseStudyTitle = "Demo Case Study" + let readMe = """ Hello! This is a demo case study. Enjoy! diff --git a/Examples/CaseStudies/RootView.swift b/Examples/CaseStudies/RootView.swift index d552ddf930..cb466a2879 100644 --- a/Examples/CaseStudies/RootView.swift +++ b/Examples/CaseStudies/RootView.swift @@ -2,25 +2,30 @@ import SwiftUI import SwiftUINavigation struct RootView: View { - var body: some View { - NavigationStack { - List { - NavigationLink("SwiftUI") { - SwiftUICaseStudiesView() + var body: some View { + NavigationStack { + List { + NavigationLink("SwiftUI") { + SwiftUICaseStudiesView() + } + #if canImport(UIKit) && !os(watchOS) + NavigationLink("UIKit") { + UIKitCaseStudiesView() + } + #endif + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + NavigationLink("AppKit") { + AppKitCaseStudiesView() + } + #endif + } + .navigationTitle("Case studies") } -#if canImport(UIKit) && !os(watchOS) - NavigationLink("UIKit") { - UIKitCaseStudiesView() - } -#endif - } - .navigationTitle("Case studies") } - } } struct RootView_Previews: PreviewProvider { - static var previews: some View { - RootView() - } + static var previews: some View { + RootView() + } } diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 9a7069ae89..f3552b0cfa 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -54,7 +54,12 @@ DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC86E8702C208D8A003C0EC9 /* Text+Template.swift */; }; E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */; }; E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; - E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */; }; + E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */; }; + E9CD5B762C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */; }; + E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */; }; + E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */; }; + E9CD5B7C2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B7A2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift */; }; + E9CD5B7E2C6D14DC00CE7947 /* AppKitCaseStudies.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B7D2C6D14DC00CE7947 /* AppKitCaseStudies.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -114,7 +119,12 @@ DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedBindings.swift; sourceTree = ""; }; DC86E8702C208D8A003C0EC9 /* Text+Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Template.swift"; sourceTree = ""; }; E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+MinimalObservationViewController.swift"; sourceTree = ""; }; - E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSControlBindingsViewController.swift; sourceTree = ""; }; + E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+NSControlBindingsViewController.swift"; sourceTree = ""; }; + E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+EnumControlsViewController.swift"; sourceTree = ""; }; + E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+FocusViewController.swift"; sourceTree = ""; }; + E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppKit+BasicsNavigationViewController.swift"; sourceTree = ""; }; + E9CD5B7A2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppKit+ConciseEnumNavigationViewController.swift"; sourceTree = ""; }; + E9CD5B7D2C6D14DC00CE7947 /* AppKitCaseStudies.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppKitCaseStudies.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -284,8 +294,13 @@ E9836D902C6C9E3000FC6EFD /* AppKit */ = { isa = PBXGroup; children = ( + E9CD5B7D2C6D14DC00CE7947 /* AppKitCaseStudies.swift */, E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */, - E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */, + E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */, + E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */, + E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */, + E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */, + E9CD5B7A2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift */, ); path = AppKit; sourceTree = ""; @@ -449,17 +464,20 @@ buildActionMask = 2147483647; files = ( CABE9FC1272F2C0000AFC150 /* EnumNavigation.swift in Sources */, + E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */, E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */, + E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */, DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */, CA9D70502C1FB431003B672A /* StaticNavigationStackController.swift in Sources */, CA473837272F0D860012CAC3 /* FactClient.swift in Sources */, + E9CD5B7C2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift in Sources */, CA9D704E2C1FAFAD003B672A /* ConciseEnumNavigationViewController.swift in Sources */, CA9D70582C2088B7003B672A /* MinimalObservationViewController.swift in Sources */, CA9D70562C2087DC003B672A /* EnumControls.swift in Sources */, CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */, CA473834272F0D860012CAC3 /* RootView.swift in Sources */, CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */, - E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */, + E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */, CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */, CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */, CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */, @@ -473,8 +491,10 @@ CA601BAE2C234613006B0C5F /* AnimationsViewController.swift in Sources */, CADCA3662C1CE8BE00DE645F /* CaseStudy.swift in Sources */, CA49D9542C20D4DF00E6C5BB /* ErasedNavigationStackController.swift in Sources */, + E9CD5B762C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift in Sources */, CA9D70542C1FB4FA003B672A /* BasicsNavigationViewController.swift in Sources */, DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */, + E9CD5B7E2C6D14DC00CE7947 /* AppKitCaseStudies.swift in Sources */, CA473838272F0D860012CAC3 /* OptionalNavigation.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index de03e4bd48..d1ee6d68b3 100644 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -14,4 +14,14 @@ extension NSControl: NSTargetActionProtocol { } } +extension NSControl { + public convenience init(action: @escaping (Self) -> Void) { + self.init(frame: .zero) + createActionHandlerIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } +} + #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 85a5cd29d2..0525f42582 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -67,7 +67,7 @@ extension NSFontManager { bind(font, to: \._selectedFont) } - private var _selectedFont: NSFont { + @objc private var _selectedFont: NSFont { set { setSelectedFont(newValue, isMultiple: false) } get { convert(.systemFont(ofSize: 0)) } } diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 295ff6b670..3412960d4d 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -12,25 +12,24 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { @MainActor internal class NSTargetActionHandler: NSObject { - let action: (NSControl) -> Void + typealias Action = (Any?) -> Void + var actions: [Action] = [] var originTarget: AnyObject? - + var originAction: Selector? - - init(action: @escaping (NSControl) -> Void) { - self.action = action - } - @objc func invokeAction(_ sender: NSControl) { + @objc func invokeAction(_ sender: Any?) { if let originTarget, let originAction { NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) } - action(sender) - + actions.forEach { $0(sender) } } -} + func addAction(_ action: @escaping Action) { + actions.append(action) + } +} extension NSTargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. @@ -50,11 +49,27 @@ extension NSTargetActionProtocol { } } - - internal var actionHandler: NSTargetActionHandler? { - set { objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - get { objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler } + set { + objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler + } + } + + internal func createActionHandlerIfNeeded() -> NSTargetActionHandler { + if let actionHandler { + return actionHandler + } else { + let actionHandler = NSTargetActionHandler() + actionHandler.originTarget = appkitNavigationTarget + actionHandler.originAction = appkitNavigationAction + self.actionHandler = actionHandler + appkitNavigationTarget = actionHandler + appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) + return actionHandler + } } /// Establishes a two-way connection between a source of truth and a property of this control. @@ -74,13 +89,11 @@ extension NSTargetActionProtocol { set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void ) -> ObservationToken { unbind(keyPath) - let actionHandler = NSTargetActionHandler { [weak self] _ in + let actionHandler = createActionHandlerIfNeeded() + actionHandler.addAction { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } - self.actionHandler = actionHandler - appkitNavigationTarget = actionHandler - appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) let isSetting = LockIsolated(false) let token = observe { [weak self] transaction in @@ -134,6 +147,8 @@ extension NSTargetActionProtocol { } } + + @MainActor private let observationTokensKey = malloc(1)! @MainActor diff --git a/Sources/AppKitNavigation/Navigation/NSAlert.swift b/Sources/AppKitNavigation/Navigation/NSAlert.swift index 09229b6e57..ed7dbe924b 100644 --- a/Sources/AppKitNavigation/Navigation/NSAlert.swift +++ b/Sources/AppKitNavigation/Navigation/NSAlert.swift @@ -28,14 +28,10 @@ extension NSAlert { ) { let button = addButton(withTitle: String(state: buttonState.label)) - let actionHandler = NSTargetActionHandler { _ in + button.createActionHandlerIfNeeded().addAction { _ in buttonState.withAction(handler) } - actionHandler.originTarget = button.target - actionHandler.originAction = button.action - button.target = actionHandler - button.action = #selector(NSTargetActionHandler.invokeAction(_:)) - button.actionHandler = actionHandler + if buttonState.role == .destructive, #available(macOS 11.0, *) { button.hasDestructiveAction = true } diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index bc9c377ea6..50e4bf93c3 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -128,9 +128,8 @@ extension NSViewController { guard let self else { return } if presentedViewControllers != nil { self.dismiss(nil) - onDismiss?() - self.present(child, for: style) - + onDismiss?() + self.present(child, for: style) } else { self.present(child, for: style) } @@ -140,7 +139,6 @@ extension NSViewController { } } - public enum TransitionStyle { case sheet case modalWindow @@ -154,9 +152,9 @@ extension NSViewController { presentAsSheet(viewControllerToPresent) case .modalWindow: presentAsModalWindow(viewControllerToPresent) - case .popover(let rect, let view, let preferredEdge, let behavior): + case let .popover(rect, view, preferredEdge, behavior): present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) - case .custom(let animator): + case let .custom(animator): present(viewControllerToPresent, animator: animator) } } @@ -292,7 +290,7 @@ extension NSViewController { } } childController.onDismiss = onDismiss - + self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) let work = { withUITransaction(transaction) { diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift new file mode 100644 index 0000000000..199de83e28 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -0,0 +1,228 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol WindowProviderAdapter: NSObject { + var currentWindow: NSWindow? { get } + func beginSheet(for provider: WindowProviderAdapter) async + func endSheet(for provider: WindowProviderAdapter) +} + +extension WindowProviderAdapter { + public func beginSheet(for provider: any WindowProviderAdapter) async { + if let sheetedWindow = provider.currentWindow { + await currentWindow?.beginSheet(sheetedWindow) + } + } + + public func endSheet(for provider: any WindowProviderAdapter) { + if let sheetedWindow = provider.currentWindow { + currentWindow?.endSheet(sheetedWindow) + } + } +} + +extension NSWindow: WindowProviderAdapter { + public var currentWindow: NSWindow? { self } +} + +extension NSWindowController: WindowProviderAdapter { + public var currentWindow: NSWindow? { window } +} + +extension NSViewController: WindowProviderAdapter { + public var currentWindow: NSWindow? { view.window } +} + +extension NSAlert: WindowProviderAdapter { + public var currentWindow: NSWindow? { window } + + public func beginSheet(for provider: any WindowProviderAdapter) async { + guard let parentWindow = provider.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for provider: any WindowProviderAdapter) { + provider.currentWindow?.endSheet(window) + } +} + +extension WindowProviderAdapter { + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> WindowProviderAdapter + ) -> ObservationToken { + sheet(item: item, id: id) { $item in + content($item) + } beginSheet: { [weak self] child, transaction in + guard let self else { return } + if let attachedSheetWindow = currentWindow?.attachedSheet { + self.endSheet(for: attachedSheetWindow) + onDismiss?() + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndSheet?.invoke() + child.onEndSheet = nil + } + } else { + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndSheet?.invoke() + child.onEndSheet = nil + } + } + } endSheet: { [weak self] provider, transaction in + self?.endSheet(for: provider) + onDismiss?() + } + } + + private func sheet( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> WindowProviderAdapter, + beginSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + sheet( + item: item, + id: { $0[keyPath: id] }, + content: content, + beginSheet: beginSheet, + endSheet: endSheet + ) + } + + private func sheet( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> WindowProviderAdapter, + beginSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = sheetedByID[key] { + guard let presentationID = presented.sheetID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let childController = content(unwrappedItem) + let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + childController.onEndSheet = onEndSheet + + self.sheetedByID[key] = Sheeted(childController, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + beginSheet(childController, transaction) + } + } +// if hasViewAppeared { + work() +// } else { +// onViewAppear.append(work) +// } + } else if let presented = sheetedByID[key] { + if let controller = presented.provider { + endSheet(controller, transaction) + } + self.sheetedByID[key] = nil + } + } + } + + private var sheetedByID: [UIBindingIdentifier: Sheeted] { + get { + (objc_getAssociatedObject(self, sheetedKey) + as? [UIBindingIdentifier: Sheeted]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, sheetedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private var onEndSheet: ClosureHolder? { + set { + objc_setAssociatedObject( + self, onEndSheetKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + get { + objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder + } + } + +// func modal() {} +} + +private class ClosureHolder: NSObject { + let closure: () -> Void + init(closure: @escaping () -> Void) { + self.closure = closure + } + func invoke() { + closure() + } +} + +private let onEndSheetKey = malloc(1)! +private let sheetedKey = malloc(1)! + +@MainActor +private class Sheeted { + weak var provider: WindowProviderAdapter? + let sheetID: AnyHashable? + deinit { + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + self.provider?.currentWindow?.endSheeted() + } + } + + init(_ provider: WindowProviderAdapter? = nil, id: AnyHashable?) { + self.provider = provider + self.sheetID = id + } +} + +extension NSWindow { + func endSheeted() { + guard sheetParent != nil else { + return + } + sheetParent?.endSheet(self) + } +} + + +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index 59e3fcbb23..23190e08bf 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -1,9 +1,9 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @_spi(Internals) import SwiftNavigation - import AppKit +@_spi(Internals) import SwiftNavigation +import AppKit - @MainActor - extension NSObject { +@MainActor +extension NSObject { /// Observe access to properties of an observable (or perceptible) object. /// /// This tool allows you to set up an observation loop so that you can access fields from an @@ -107,7 +107,7 @@ /// - Returns: A cancellation token. @discardableResult public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { - observe { _ in apply() } + observe { _ in apply() } } /// Observe access to properties of an observable (or perceptible) object. @@ -119,59 +119,59 @@ /// - Returns: A cancellation token. @discardableResult public func observe( - _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void + _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void ) -> ObservationToken { - let token = SwiftNavigation.observe { transaction in - MainActor._assumeIsolated { - withUITransaction(transaction) { - #if os(watchOS) - apply(transaction) - #else - if transaction.appKit.disablesAnimations { - NSView.performWithoutAnimation { apply(transaction) } - for completion in transaction.appKit.animationCompletions { - completion(true) - } - } else if let animation = transaction.appKit.animation { - return animation.perform( - { apply(transaction) }, - completion: transaction.appKit.animationCompletions.isEmpty - ? nil - : { - for completion in transaction.appKit.animationCompletions { - completion($0) - } + let token = SwiftNavigation.observe { transaction in + MainActor._assumeIsolated { + withUITransaction(transaction) { + #if os(watchOS) + apply(transaction) + #else + if transaction.appKit.disablesAnimations { + NSView.performWithoutAnimation { apply(transaction) } + for completion in transaction.appKit.animationCompletions { + completion(true) + } + } else if let animation = transaction.appKit.animation { + return animation.perform( + { apply(transaction) }, + completion: transaction.appKit.animationCompletions.isEmpty + ? nil + : { + for completion in transaction.appKit.animationCompletions { + completion($0) + } + } + ) + } else { + apply(transaction) + for completion in transaction.appKit.animationCompletions { + completion(true) + } } - ) - } else { - apply(transaction) - for completion in transaction.appKit.animationCompletions { - completion(true) + #endif } - } - #endif - } - } - } task: { transaction, work in - DispatchQueue.main.async { - withUITransaction(transaction, work) + } + } task: { transaction, work in + DispatchQueue.main.async { + withUITransaction(transaction, work) + } } - } - tokens.append(token) - return token + tokens.append(token) + return token } fileprivate var tokens: [Any] { - get { - objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] - } - set { - objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } + get { + objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] + } + set { + objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } } private static let tokensKey = malloc(1)! - } +} extension NSView { static func performWithoutAnimation(_ block: () -> Void) { From 590bb2bf6ad6fc190110e4f8ea2da4c45bc6e175 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Thu, 15 Aug 2024 19:14:25 +0800 Subject: [PATCH 05/42] WIP --- ...ppKit+BasicsNavigationViewController.swift | 7 +- .../{Navigation => Bindings}/NSAlert.swift | 0 .../AppKitNavigation/Navigation/Modal.swift | 195 ++++++++++++++++++ .../AppKitNavigation/Navigation/Sheet.swift | 50 ++--- 4 files changed, 225 insertions(+), 27 deletions(-) rename Sources/AppKitNavigation/{Navigation => Bindings}/NSAlert.swift (100%) create mode 100644 Sources/AppKitNavigation/Navigation/Modal.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index eeede2e017..d5497efe8a 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -20,6 +20,9 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS let showSheetButton = NSButton { [weak self] _ in self?.model.sheet = .random(in: 1 ... 1_000) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self?.model.sheet = nil + } } let showSheetFromBooleanButton = NSButton { [weak self] _ in @@ -60,8 +63,8 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS // alert.addAction(UIAlertAction(title: "OK", style: .default)) // return alert // } - sheet(item: $model.sheet, id: \.self) { count in - NSAlert(error: CocoaError.error(.coderInvalidValue)) + modal(item: $model.sheet, id: \.self) { count in + NSWindow(contentViewController: XiblessViewController()) } // present(item: $model.sheet, id: \.self, style: .sheet) { count in //// let vc = NSHostingController( diff --git a/Sources/AppKitNavigation/Navigation/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift similarity index 100% rename from Sources/AppKitNavigation/Navigation/NSAlert.swift rename to Sources/AppKitNavigation/Bindings/NSAlert.swift diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift new file mode 100644 index 0000000000..bd341b3529 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -0,0 +1,195 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +@objc +public protocol ModalRepresentable where Self: NSObject { + @objc @discardableResult func runModal() -> NSApplication.ModalResponse + @objc var window: NSWindow { get } +} + +extension NSWindow: ModalRepresentable { + + public var window: NSWindow { self } + + public func runModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} + +extension NSAlert: ModalRepresentable {} + +@MainActor +class WindowsObserver: NSObject { + static let shared = WindowsObserver() + + var windowsCancellable: [NSWindow: AnyCancellable] = [:] + + func observeWindow(_ window: NSWindow) { + windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { [weak self] _ in + guard let self else { return } + if NSApplication.shared.modalWindow === window { + NSApplication.shared.stopModal() + } + windowsCancellable.removeValue(forKey: window) + } + } +} + +@MainActor +extension NSObject { + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> ModalRepresentable + ) -> ObservationToken { + modal(item: item, id: id) { $item in + content($item) + } beginSheet: { modalRepresentable, _ in + if NSApplication.shared.modalWindow != nil { + NSApplication.shared.stopModal() + onDismiss?() + DispatchQueue.main.async { + WindowsObserver.shared.observeWindow(modalRepresentable.window) + modalRepresentable.runModal() + modalRepresentable.onEndModal?.invoke() + modalRepresentable.onEndModal = nil + } + + } else { + DispatchQueue.main.async { + WindowsObserver.shared.observeWindow(modalRepresentable.window) + modalRepresentable.runModal() + modalRepresentable.onEndModal?.invoke() + modalRepresentable.onEndModal = nil + } + } + } endSheet: { provider, _ in + NSApplication.shared.stopModal() + onDismiss?() + } + } + + private func modal( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> ModalRepresentable, + beginSheet: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + modal( + item: item, + id: { $0[keyPath: id] }, + content: content, + beginModal: beginSheet, + endModal: endSheet + ) + } + + private func modal( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> ModalRepresentable, + beginModal: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void, + endModal: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = modaledByID[key] { + guard let presentationID = presented.modalID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let modalRepresentable = content(unwrappedItem) + let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + modalRepresentable.onEndModal = onEndSheet + + self.modaledByID[key] = Modaled(modalRepresentable, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + beginModal(modalRepresentable, transaction) + } + } + work() + } else if let modaled = modaledByID[key] { + if let modalRepresentable = modaled.provider { + endModal(modalRepresentable, transaction) + } + self.modaledByID[key] = nil + } + } + } + + private var modaledByID: [UIBindingIdentifier: Modaled] { + get { + (objc_getAssociatedObject(self, modaledKey) + as? [UIBindingIdentifier: Modaled]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, modaledKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private var onEndModal: ClosureHolder? { + set { + objc_setAssociatedObject( + self, onEndModalKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + get { + objc_getAssociatedObject(self, onEndModalKey) as? ClosureHolder + } + } +} + +private let onEndModalKey = malloc(1)! +private let modaledKey = malloc(1)! + +@MainActor +private class Modaled { + weak var provider: ModalRepresentable? + let modalID: AnyHashable? + deinit { + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + NSApplication.shared.stopModal() + } + } + + init(_ provider: ModalRepresentable? = nil, id: AnyHashable?) { + self.provider = provider + self.modalID = id + } +} +#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 199de83e28..348902992f 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -3,58 +3,58 @@ import AppKit @MainActor -public protocol WindowProviderAdapter: NSObject { +public protocol SheetRepresentable: NSObject { var currentWindow: NSWindow? { get } - func beginSheet(for provider: WindowProviderAdapter) async - func endSheet(for provider: WindowProviderAdapter) + func beginSheet(for provider: SheetRepresentable) async + func endSheet(for provider: SheetRepresentable) } -extension WindowProviderAdapter { - public func beginSheet(for provider: any WindowProviderAdapter) async { +extension SheetRepresentable { + public func beginSheet(for provider: any SheetRepresentable) async { if let sheetedWindow = provider.currentWindow { await currentWindow?.beginSheet(sheetedWindow) } } - public func endSheet(for provider: any WindowProviderAdapter) { + public func endSheet(for provider: any SheetRepresentable) { if let sheetedWindow = provider.currentWindow { currentWindow?.endSheet(sheetedWindow) } } } -extension NSWindow: WindowProviderAdapter { +extension NSWindow: SheetRepresentable { public var currentWindow: NSWindow? { self } } -extension NSWindowController: WindowProviderAdapter { +extension NSWindowController: SheetRepresentable { public var currentWindow: NSWindow? { window } } -extension NSViewController: WindowProviderAdapter { +extension NSViewController: SheetRepresentable { public var currentWindow: NSWindow? { view.window } } -extension NSAlert: WindowProviderAdapter { +extension NSAlert: SheetRepresentable { public var currentWindow: NSWindow? { window } - public func beginSheet(for provider: any WindowProviderAdapter) async { + public func beginSheet(for provider: any SheetRepresentable) async { guard let parentWindow = provider.currentWindow else { return } await beginSheetModal(for: parentWindow) } - public func endSheet(for provider: any WindowProviderAdapter) { + public func endSheet(for provider: any SheetRepresentable) { provider.currentWindow?.endSheet(window) } } -extension WindowProviderAdapter { +extension SheetRepresentable { @discardableResult public func sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> WindowProviderAdapter + content: @escaping (UIBinding) -> SheetRepresentable ) -> ObservationToken { sheet(item: item, id: id) { $item in content($item) @@ -84,13 +84,13 @@ extension WindowProviderAdapter { private func sheet( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> WindowProviderAdapter, + content: @escaping (UIBinding) -> SheetRepresentable, beginSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -106,13 +106,13 @@ extension WindowProviderAdapter { private func sheet( item: UIBinding, id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> WindowProviderAdapter, + content: @escaping (UIBinding) -> SheetRepresentable, beginSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -179,15 +179,15 @@ extension WindowProviderAdapter { objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder } } - -// func modal() {} } -private class ClosureHolder: NSObject { +internal class ClosureHolder: NSObject { let closure: () -> Void + init(closure: @escaping () -> Void) { self.closure = closure } + func invoke() { closure() } @@ -198,7 +198,7 @@ private let sheetedKey = malloc(1)! @MainActor private class Sheeted { - weak var provider: WindowProviderAdapter? + weak var provider: SheetRepresentable? let sheetID: AnyHashable? deinit { // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to @@ -209,7 +209,7 @@ private class Sheeted { } } - init(_ provider: WindowProviderAdapter? = nil, id: AnyHashable?) { + init(_ provider: SheetRepresentable? = nil, id: AnyHashable?) { self.provider = provider self.sheetID = id } From cb5aa023844151a0f36f84dc2759b5eb96363f0c Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Thu, 15 Aug 2024 22:27:21 +0800 Subject: [PATCH 06/42] WIP --- .../AppKitNavigation/AppKitAnimation.swift | 21 +-- .../AppKitNavigation/Bindings/NSAlert.swift | 7 +- .../Internal/ClosureHolder.swift | 17 +++ .../Internal/ModalWindowsObserver.swift | 24 ++++ .../AppKitNavigation/Navigation/Modal.swift | 52 ++----- .../Navigation/ModalRepresentable.swift | 23 +++ .../Navigation/Presentation.swift | 14 +- .../AppKitNavigation/Navigation/Sheet.swift | 135 +++++++++++------- .../Navigation/SheetRepresentable.swift | 51 +++++++ Sources/AppKitNavigation/Observe.swift | 22 ++- 10 files changed, 225 insertions(+), 141 deletions(-) create mode 100644 Sources/AppKitNavigation/Internal/ClosureHolder.swift create mode 100644 Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalRepresentable.swift create mode 100644 Sources/AppKitNavigation/Navigation/SheetRepresentable.swift diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 75e40b8f86..0d8230c8fb 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -80,21 +80,6 @@ extension AppKitAnimation { /// - Parameters: /// - duration: The total duration of the animations, measured in seconds. If you specify a /// negative value or `0`, the changes are made without animating them. - /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. - /// Specify a value of `0` to begin the animations immediately. - /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent - /// state. - /// - /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a - /// damping ratio closer to zero to increase oscillation. - /// - velocity: The initial spring velocity. For smooth start to the animation, match this - /// value to the view's velocity as it was prior to attachment. - /// - /// A value of `1` corresponds to the total animation distance traversed in one second. For - /// example, if the total animation distance is 200 points and you want the start of the - /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. - /// - options: A mask of options indicating how you want to perform the animations. For a list - /// of valid constants, see `UIView.AnimationOptions`. /// - Returns: An animation using a timing curve corresponding to the motion of a physical /// spring. public static func animate( @@ -111,11 +96,7 @@ extension AppKitAnimation { /// A default animation instance. public static var `default`: Self { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - return .animate() - } else { - return .animate(withDuration: 0.35) - } + return .animate() } } #endif diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift index ed7dbe924b..37d7bb6ba3 100644 --- a/Sources/AppKitNavigation/Bindings/NSAlert.swift +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -2,7 +2,7 @@ import AppKit extension NSAlert { - /// Creates and returns a view controller for displaying an alert using a data description. + /// Creates and returns a alert for displaying an alert using a data description. /// /// - Parameters: /// - state: A data description of the alert. @@ -38,7 +38,10 @@ extension NSAlert { if buttonState.role == .cancel { button.keyEquivalent = "\u{1b}" } -// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } + + if #available(macOS 12, *) { + button.setAccessibilityLabel(buttonState.label.accessibilityLabel.map { String(state: $0) }) + } } } #endif diff --git a/Sources/AppKitNavigation/Internal/ClosureHolder.swift b/Sources/AppKitNavigation/Internal/ClosureHolder.swift new file mode 100644 index 0000000000..814e884dff --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ClosureHolder.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +internal class ClosureHolder: NSObject { + let closure: () -> Void + + init(closure: @escaping () -> Void) { + self.closure = closure + } + + func invoke() { + closure() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift new file mode 100644 index 0000000000..4fb5f90e74 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift @@ -0,0 +1,24 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +class ModalWindowsObserver: NSObject { + static let shared = ModalWindowsObserver() + + var windowsCancellable: [NSWindow: AnyCancellable] = [:] + + func observeWindow(_ window: NSWindow) { + windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { [weak self] _ in + guard let self else { return } + if NSApplication.shared.modalWindow === window { + NSApplication.shared.stopModal() + } + windowsCancellable.removeValue(forKey: window) + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index bd341b3529..1acc9e7ec1 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -3,42 +3,6 @@ import AppKit import Combine -@MainActor -@objc -public protocol ModalRepresentable where Self: NSObject { - @objc @discardableResult func runModal() -> NSApplication.ModalResponse - @objc var window: NSWindow { get } -} - -extension NSWindow: ModalRepresentable { - - public var window: NSWindow { self } - - public func runModal() -> NSApplication.ModalResponse { - NSApplication.shared.runModal(for: self) - } -} - -extension NSAlert: ModalRepresentable {} - -@MainActor -class WindowsObserver: NSObject { - static let shared = WindowsObserver() - - var windowsCancellable: [NSWindow: AnyCancellable] = [:] - - func observeWindow(_ window: NSWindow) { - windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) - .sink { [weak self] _ in - guard let self else { return } - if NSApplication.shared.modalWindow === window { - NSApplication.shared.stopModal() - } - windowsCancellable.removeValue(forKey: window) - } - } -} - @MainActor extension NSObject { @discardableResult @@ -50,12 +14,12 @@ extension NSObject { ) -> ObservationToken { modal(item: item, id: id) { $item in content($item) - } beginSheet: { modalRepresentable, _ in + } beginModal: { modalRepresentable, _ in if NSApplication.shared.modalWindow != nil { NSApplication.shared.stopModal() onDismiss?() DispatchQueue.main.async { - WindowsObserver.shared.observeWindow(modalRepresentable.window) + ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) modalRepresentable.runModal() modalRepresentable.onEndModal?.invoke() modalRepresentable.onEndModal = nil @@ -63,13 +27,13 @@ extension NSObject { } else { DispatchQueue.main.async { - WindowsObserver.shared.observeWindow(modalRepresentable.window) + ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) modalRepresentable.runModal() modalRepresentable.onEndModal?.invoke() modalRepresentable.onEndModal = nil } } - } endSheet: { provider, _ in + } endModal: { provider, _ in NSApplication.shared.stopModal() onDismiss?() } @@ -79,11 +43,11 @@ extension NSObject { item: UIBinding, id: KeyPath, content: @escaping (UIBinding) -> ModalRepresentable, - beginSheet: @escaping ( + beginModal: @escaping ( _ child: ModalRepresentable, _ transaction: UITransaction ) -> Void, - endSheet: @escaping ( + endModal: @escaping ( _ child: ModalRepresentable, _ transaction: UITransaction ) -> Void @@ -92,8 +56,8 @@ extension NSObject { item: item, id: { $0[keyPath: id] }, content: content, - beginModal: beginSheet, - endModal: endSheet + beginModal: beginModal, + endModal: endModal ) } diff --git a/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift b/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift new file mode 100644 index 0000000000..f3526ea73e --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift @@ -0,0 +1,23 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +@objc +public protocol ModalRepresentable where Self: NSObject { + @objc @discardableResult func runModal() -> NSApplication.ModalResponse + @objc var window: NSWindow { get } +} + +extension NSWindow: ModalRepresentable { + + public var window: NSWindow { self } + + public func runModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} + +extension NSAlert: ModalRepresentable {} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 50e4bf93c3..8a9f6a52a2 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -7,7 +7,7 @@ import AppKitNavigationShim extension NSViewController { /// Presents a view controller modally when a binding to a Boolean value you provide is true. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - isPresented: A binding to a Boolean value that determines whether to present the view @@ -27,7 +27,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -50,7 +50,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -74,7 +74,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -101,7 +101,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -332,9 +332,9 @@ private class Presented { weak var controller: NSViewController? let presentationID: AnyHashable? deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // NB: This can only be assumed because it is held in a NSViewController and is guaranteed to // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. + // to be a NSViewController as well, to ensure this functionality. MainActor._assumeIsolated { self.controller?.dismiss(nil) } diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 348902992f..935a2182d4 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -2,53 +2,95 @@ import AppKit -@MainActor -public protocol SheetRepresentable: NSObject { - var currentWindow: NSWindow? { get } - func beginSheet(for provider: SheetRepresentable) async - func endSheet(for provider: SheetRepresentable) -} - extension SheetRepresentable { - public func beginSheet(for provider: any SheetRepresentable) async { - if let sheetedWindow = provider.currentWindow { - await currentWindow?.beginSheet(sheetedWindow) - } + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - isSheeted: A binding to a Boolean value that determines whether to sheet the representable + /// - onDismiss: The closure to execute when dismissing the representable. + /// - content: A closure that returns the representable to display over the current window content. + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> SheetRepresentable + ) -> ObservationToken { + sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } - public func endSheet(for provider: any SheetRepresentable) { - if let sheetedWindow = provider.currentWindow { - currentWindow?.endSheet(sheetedWindow) - } + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> SheetRepresentable + ) -> ObservationToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } -} - -extension NSWindow: SheetRepresentable { - public var currentWindow: NSWindow? { self } -} - -extension NSWindowController: SheetRepresentable { - public var currentWindow: NSWindow? { window } -} - -extension NSViewController: SheetRepresentable { - public var currentWindow: NSWindow? { view.window } -} - -extension NSAlert: SheetRepresentable { - public var currentWindow: NSWindow? { window } - public func beginSheet(for provider: any SheetRepresentable) async { - guard let parentWindow = provider.currentWindow else { return } - await beginSheetModal(for: parentWindow) + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> SheetRepresentable + ) -> ObservationToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } - public func endSheet(for provider: any SheetRepresentable) { - provider.currentWindow?.endSheet(window) + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> SheetRepresentable + ) -> ObservationToken { + sheet(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } } -} -extension SheetRepresentable { @discardableResult public func sheet( item: UIBinding, @@ -142,11 +184,7 @@ extension SheetRepresentable { beginSheet(childController, transaction) } } -// if hasViewAppeared { work() -// } else { -// onViewAppear.append(work) -// } } else if let presented = sheetedByID[key] { if let controller = presented.provider { endSheet(controller, transaction) @@ -181,18 +219,6 @@ extension SheetRepresentable { } } -internal class ClosureHolder: NSObject { - let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - func invoke() { - closure() - } -} - private let onEndSheetKey = malloc(1)! private let sheetedKey = malloc(1)! @@ -224,5 +250,4 @@ extension NSWindow { } } - #endif diff --git a/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift b/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift new file mode 100644 index 0000000000..03b16aede2 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift @@ -0,0 +1,51 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol SheetRepresentable: NSObject { + var currentWindow: NSWindow? { get } + func beginSheet(for provider: SheetRepresentable) async + func endSheet(for provider: SheetRepresentable) +} + +extension SheetRepresentable { + public func beginSheet(for provider: any SheetRepresentable) async { + if let sheetedWindow = provider.currentWindow { + await currentWindow?.beginSheet(sheetedWindow) + } + } + + public func endSheet(for provider: any SheetRepresentable) { + if let sheetedWindow = provider.currentWindow { + currentWindow?.endSheet(sheetedWindow) + } + } +} + +extension NSWindow: SheetRepresentable { + public var currentWindow: NSWindow? { self } +} + +extension NSWindowController: SheetRepresentable { + public var currentWindow: NSWindow? { window } +} + +extension NSViewController: SheetRepresentable { + public var currentWindow: NSWindow? { view.window } +} + +extension NSAlert: SheetRepresentable { + public var currentWindow: NSWindow? { window } + + public func beginSheet(for provider: any SheetRepresentable) async { + guard let parentWindow = provider.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for provider: any SheetRepresentable) { + provider.currentWindow?.endSheet(window) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index 23190e08bf..8032874494 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -10,7 +10,7 @@ extension NSObject { /// observable model in order to populate your view, and also automatically track changes to /// any accessed fields so that the view is always up-to-date. /// - /// It is most useful when dealing with non-SwiftUI views, such as UIKit views and controller. + /// It is most useful when dealing with non-SwiftUI views, such as AppKit views and controller. /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all /// the view elements: /// @@ -18,16 +18,16 @@ extension NSObject { /// override func viewDidLoad() { /// super.viewDidLoad() /// - /// let countLabel = UILabel() - /// let incrementButton = UIButton(primaryAction: UIAction { [weak self] _ in + /// let countLabel = NSTextField(labelWithString: "") + /// let incrementButton = NSButton { [weak self] _ in /// self?.model.incrementButtonTapped() - /// }) + /// } /// /// observe { [weak self] in /// guard let self /// else { return } /// - /// countLabel.text = "\(model.count)" + /// countLabel.stringValue = "\(model.count)" /// } /// } /// ``` @@ -38,7 +38,7 @@ extension NSObject { /// again. /// /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your - /// view, such as `viewDidLoad` for `UIViewController`. This works even if you have many UI + /// view, such as `viewDidLoad` for `NSViewController`. This works even if you have many UI /// components to update: /// /// ```swift @@ -51,9 +51,9 @@ extension NSObject { /// /// countLabel.isHidden = model.isObservingCount /// if !countLabel.isHidden { - /// countLabel.text = "\(model.count)" + /// countLabel.stringValue = "\(model.count)" /// } - /// factLabel.text = model.fact + /// factLabel.stringValue = model.fact /// } /// } /// ``` @@ -124,9 +124,6 @@ extension NSObject { let token = SwiftNavigation.observe { transaction in MainActor._assumeIsolated { withUITransaction(transaction) { - #if os(watchOS) - apply(transaction) - #else if transaction.appKit.disablesAnimations { NSView.performWithoutAnimation { apply(transaction) } for completion in transaction.appKit.animationCompletions { @@ -149,7 +146,6 @@ extension NSObject { completion(true) } } - #endif } } } task: { transaction, work in @@ -174,7 +170,7 @@ extension NSObject { } extension NSView { - static func performWithoutAnimation(_ block: () -> Void) { + fileprivate static func performWithoutAnimation(_ block: () -> Void) { NSAnimationContext.runAnimationGroup { context in context.allowsImplicitAnimation = false block() From 26ef0fa75c47d5f82083f351748d72cb69c6e3bc Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 16 Aug 2024 18:38:19 +0800 Subject: [PATCH 07/42] Refactoring, can't compile for now --- ...ppKit+BasicsNavigationViewController.swift | 4 +- .../AppKitNavigation/Navigation/Modal.swift | 140 +++++------------- .../Navigation/ModalContent.swift | 41 +++++ .../Navigation/ModalRepresentable.swift | 23 --- .../Navigation/NavigationContent.swift | 82 ++++++++++ .../Navigation/NavigationObserver.swift | 86 +++++++++++ .../Navigation/Presentation.swift | 94 +++++++++--- .../AppKitNavigation/Navigation/Sheet.swift | 85 +++++++---- .../Navigation/SheetContent.swift | 90 +++++++++++ .../Navigation/SheetRepresentable.swift | 51 ------- 10 files changed, 462 insertions(+), 234 deletions(-) create mode 100644 Sources/AppKitNavigation/Navigation/ModalContent.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalRepresentable.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/SheetContent.swift delete mode 100644 Sources/AppKitNavigation/Navigation/SheetRepresentable.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index d5497efe8a..8ec32db007 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -64,7 +64,9 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS // return alert // } modal(item: $model.sheet, id: \.self) { count in - NSWindow(contentViewController: XiblessViewController()) + let vc = XiblessViewController() + vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) + return NSWindow(contentViewController: vc) } // present(item: $model.sheet, id: \.self, style: .sheet) { count in //// let vc = NSHostingController( diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 1acc9e7ec1..2ce2d03ad6 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -6,154 +6,82 @@ import Combine @MainActor extension NSObject { @discardableResult - public func modal( + public func modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> ModalRepresentable + content: @escaping (UIBinding) -> Content ) -> ObservationToken { modal(item: item, id: id) { $item in content($item) - } beginModal: { modalRepresentable, _ in + } beginModal: { modalContent, _ in if NSApplication.shared.modalWindow != nil { NSApplication.shared.stopModal() onDismiss?() DispatchQueue.main.async { - ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) - modalRepresentable.runModal() - modalRepresentable.onEndModal?.invoke() - modalRepresentable.onEndModal = nil + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.runModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil } } else { DispatchQueue.main.async { - ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) - modalRepresentable.runModal() - modalRepresentable.onEndModal?.invoke() - modalRepresentable.onEndModal = nil + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.runModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil } } - } endModal: { provider, _ in + } endModal: { _, _ in NSApplication.shared.stopModal() onDismiss?() } } - private func modal( + private func modal( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> ModalRepresentable, + content: @escaping (UIBinding) -> Content, beginModal: @escaping ( - _ child: ModalRepresentable, + _ child: Content, _ transaction: UITransaction ) -> Void, endModal: @escaping ( - _ child: ModalRepresentable, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - modal( + let modalObserver: ModalObserver = modalObserver() + return modalObserver.observe( item: item, id: { $0[keyPath: id] }, content: content, - beginModal: beginModal, - endModal: endModal + begin: beginModal, + end: endModal ) } - private func modal( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> ModalRepresentable, - beginModal: @escaping ( - _ child: ModalRepresentable, - _ transaction: UITransaction - ) -> Void, - endModal: @escaping ( - _ child: ModalRepresentable, - _ transaction: UITransaction - ) -> Void - ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = modaledByID[key] { - guard let presentationID = presented.modalID, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let modalRepresentable = content(unwrappedItem) - let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { - item.wrappedValue = nil - } - } - modalRepresentable.onEndModal = onEndSheet - - self.modaledByID[key] = Modaled(modalRepresentable, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - beginModal(modalRepresentable, transaction) - } - } - work() - } else if let modaled = modaledByID[key] { - if let modalRepresentable = modaled.provider { - endModal(modalRepresentable, transaction) - } - self.modaledByID[key] = nil - } - } - } - - private var modaledByID: [UIBindingIdentifier: Modaled] { - get { - (objc_getAssociatedObject(self, modaledKey) - as? [UIBindingIdentifier: Modaled]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, modaledKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - private var onEndModal: ClosureHolder? { - set { - objc_setAssociatedObject( - self, onEndModalKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - get { - objc_getAssociatedObject(self, onEndModalKey) as? ClosureHolder + private func modalObserver() -> ModalObserver { + if let observer = objc_getAssociatedObject(self, modalObserverKey) as? ModalObserver { + return observer + } else { + let modalObserver = ModalObserver(owner: self) + objc_setAssociatedObject(self, modalObserverKey, modalObserver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return modalObserver } } } -private let onEndModalKey = malloc(1)! -private let modaledKey = malloc(1)! - +private let modalObserverKey = malloc(1)! @MainActor -private class Modaled { - weak var provider: ModalRepresentable? - let modalID: AnyHashable? - deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - NSApplication.shared.stopModal() - } - } +private var modalObserverKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +private typealias ModalObserver = NavigationObserver - init(_ provider: ModalRepresentable? = nil, id: AnyHashable?) { - self.provider = provider - self.modalID = id +extension Navigated where Content: ModalContent { + func clearup() { + NSApplication.shared.stopModal() } } + #endif diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift new file mode 100644 index 0000000000..c141aa1f11 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol ModalContent: NavigationContent { + @discardableResult func runModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +extension NSWindow: ModalContent { + public var window: NSWindow { self } + + public func runModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } + + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +extension NSAlert: ModalContent { + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift b/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift deleted file mode 100644 index f3526ea73e..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift +++ /dev/null @@ -1,23 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -@objc -public protocol ModalRepresentable where Self: NSObject { - @objc @discardableResult func runModal() -> NSApplication.ModalResponse - @objc var window: NSWindow { get } -} - -extension NSWindow: ModalRepresentable { - - public var window: NSWindow { self } - - public func runModal() -> NSApplication.ModalResponse { - NSApplication.shared.runModal(for: self) - } -} - -extension NSAlert: ModalRepresentable {} - -#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift new file mode 100644 index 0000000000..718af62732 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -0,0 +1,82 @@ +import Foundation + +@MainActor +@objc +public protocol NavigationContent where Self: NSObject { + var onBeginNavigation: (() -> Void)? { set get } + var onEndNavigation: (() -> Void)? { set get } +} + +@MainActor +private var onBeginNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + +@MainActor +private var onEndNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +/// Hashable wrapper for any metatype value. +struct AnyHashableMetatype: Hashable { + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } + + let base: Any.Type + + init(_ base: Any.Type) { + self.base = base + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + } + // Pre Swift 4.2: + // var hashValue: Int { return ObjectIdentifier(base).hashValue } +} + +extension NavigationContent { + static var onBeginNavigationKey: UnsafeMutableRawPointer { + let key = AnyHashableMetatype(Self.self) + if let onBeginNavigationKey = onBeginNavigationKeys[key] { + return onBeginNavigationKey + } else { + let onBeginNavigationKey = malloc(1)! + onBeginNavigationKeys[key] = onBeginNavigationKey + return onBeginNavigationKey + } + } + + static var onEndNavigationKey: UnsafeMutableRawPointer { + let key = AnyHashableMetatype(Self.self) + if let onEndNavigationKey = onEndNavigationKeys[key] { + return onEndNavigationKey + } else { + let onEndNavigationKey = malloc(1)! + onEndNavigationKeys[key] = onEndNavigationKey + return onEndNavigationKey + } + } + + var _onBeginNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, Self.onBeginNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, Self.onBeginNavigationKey) as? () -> Void + } + } + + var _onEndNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, Self.onEndNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, Self.onEndNavigationKey) as? () -> Void + } + } +} + +@MainActor +protocol NavigatedProtocol: AnyObject { + associatedtype Content: NavigationContent + var content: Content? { get } + var id: AnyHashable? { get } + init(_ content: Content, id: AnyHashable?) +} diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift new file mode 100644 index 0000000000..b7e6b22b96 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftNavigation + +@MainActor +class NavigationObserver: NSObject { + + + private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] + + unowned let owner: Owner + + init(owner: Owner) { + self.owner = owner + super.init() + } + + func observe( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> Content, + begin: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void, + end: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = navigatedByID[key] { + guard let presentationID = presented.id, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let content = content(unwrappedItem) + let onEndNavigation = { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + content.onEndNavigation = onEndNavigation + + self.navigatedByID[key] = Navigated(content, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + begin(content, transaction) + } + } + commitWork(work) + } else if let navigated = navigatedByID[key] { + if let content = navigated.content { + end(content, transaction) + } + self.navigatedByID[key] = nil + } + } + } + + func commitWork(_ work: @escaping () -> Void) { + work() + } +} + +@MainActor +class Navigated { + weak var content: Content? + let id: AnyHashable? + func clearup() {} + deinit { + MainActor._assumeIsolated { + clearup() + } + } + + required init(_ content: Content, id: AnyHashable?) { + self.content = content + self.id = id + } +} diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 8a9f6a52a2..08e70ddafe 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -20,7 +20,7 @@ extension NSViewController { isPresented: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSViewController + content: @escaping () -> PresentationContent ) -> ObservationToken { present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } @@ -43,7 +43,7 @@ extension NSViewController { item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSViewController + content: @escaping (Item) -> PresentationContent ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -67,7 +67,7 @@ extension NSViewController { item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSViewController + content: @escaping (UIBinding) -> PresentationContent ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -92,7 +92,7 @@ extension NSViewController { id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSViewController + content: @escaping (Item) -> PresentationContent ) -> ObservationToken { present(item: item, id: id, style: style, onDismiss: onDismiss) { content($0.wrappedValue) @@ -120,7 +120,7 @@ extension NSViewController { id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSViewController + content: @escaping (UIBinding) -> PresentationContent ) -> ObservationToken { destination(item: item, id: id) { $item in content($item) @@ -174,10 +174,10 @@ extension NSViewController { @discardableResult public func destination( isPresented: UIBinding, - content: @escaping () -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, + content: @escaping () -> PresentationContent, + present: @escaping (PresentationContent, UITransaction) -> Void, dismiss: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -204,10 +204,10 @@ extension NSViewController { @discardableResult public func destination( item: UIBinding, - content: @escaping (UIBinding) -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, + content: @escaping (UIBinding) -> PresentationContent, + present: @escaping (PresentationContent, UITransaction) -> Void, dismiss: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -239,23 +239,25 @@ extension NSViewController { public func destination( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> NSViewController, + content: @escaping (UIBinding) -> PresentationContent, present: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void, dismiss: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - destination( - item: item, - id: { $0[keyPath: id] }, - content: content, - present: present, - dismiss: dismiss - ) +// destination( +// item: item, +// id: { $0[keyPath: id] }, +// content: content, +// present: present, +// dismiss: dismiss +// ) + presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + } private func destination( @@ -325,6 +327,54 @@ extension NSViewController { } private static let presentedKey = malloc(1)! + + private var presentationObserver: PresentationObserver { + set { + objc_setAssociatedObject(self, presentationObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let observer = objc_getAssociatedObject(self, presentationObserverKey) as? PresentationObserver { + return observer + } else { + let observer = PresentationObserver(owner: self) + self.presentationObserver = observer + return observer + } + } + } + +} +private let presentationObserverKey = malloc(1)! + +public protocol PresentationContent: NavigationContent { + func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) +} +extension NSViewController: PresentationContent {} +extension NavigationContent where Self: NSViewController { + var _onEndNavigation: (() -> Void)? { + set { + onDismiss = newValue + } + get { + onDismiss + } + } +} + +class PresentationObserver: NavigationObserver { + override func commitWork(_ work: @escaping () -> Void) { + if owner.hasViewAppeared { + work() + } else { + owner.onViewAppear.append(work) + } + } +} + +extension Navigated where Content: NSViewController { + func clearup() { + self.content?.dismiss(nil) + } } @MainActor @@ -340,7 +390,7 @@ private class Presented { } } - init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { + required init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { self.controller = controller self.presentationID = presentationID } diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 935a2182d4..350dff6d90 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -2,7 +2,7 @@ import AppKit -extension SheetRepresentable { +extension SheetContent { /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. @@ -15,7 +15,7 @@ extension SheetRepresentable { public func sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> SheetRepresentable + content: @escaping () -> SheetContent ) -> ObservationToken { sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } @@ -37,7 +37,7 @@ extension SheetRepresentable { public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetRepresentable + content: @escaping (Item) -> SheetContent ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -60,7 +60,7 @@ extension SheetRepresentable { public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetRepresentable + content: @escaping (UIBinding) -> SheetContent ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -84,7 +84,7 @@ extension SheetRepresentable { item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetRepresentable + content: @escaping (Item) -> SheetContent ) -> ObservationToken { sheet(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) @@ -96,29 +96,29 @@ extension SheetRepresentable { item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetRepresentable + content: @escaping (UIBinding) -> SheetContent ) -> ObservationToken { sheet(item: item, id: id) { $item in content($item) - } beginSheet: { [weak self] child, transaction in + } beginSheet: { [weak self] child, _ in guard let self else { return } if let attachedSheetWindow = currentWindow?.attachedSheet { self.endSheet(for: attachedSheetWindow) onDismiss?() Task { @MainActor in await self.beginSheet(for: child) - child.onEndSheet?.invoke() - child.onEndSheet = nil + child.onEndNavigation?() + child.onEndNavigation = nil } } else { Task { @MainActor in await self.beginSheet(for: child) - child.onEndSheet?.invoke() - child.onEndSheet = nil + child.onEndNavigation?() + child.onEndNavigation = nil } } - } endSheet: { [weak self] provider, transaction in - self?.endSheet(for: provider) + } endSheet: { [weak self] content, _ in + self?.endSheet(for: content) onDismiss?() } } @@ -126,35 +126,35 @@ extension SheetRepresentable { private func sheet( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> SheetRepresentable, + content: @escaping (UIBinding) -> SheetContent, beginSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - sheet( + sheetObserver.observe( item: item, id: { $0[keyPath: id] }, content: content, - beginSheet: beginSheet, - endSheet: endSheet + begin: beginSheet, + end: endSheet ) } private func sheet( item: UIBinding, id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> SheetRepresentable, + content: @escaping (UIBinding) -> SheetContent, beginSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -163,7 +163,7 @@ extension SheetRepresentable { guard let self else { return } if let unwrappedItem = UIBinding(item) { if let presented = sheetedByID[key] { - guard let presentationID = presented.sheetID, + guard let presentationID = presented.id, presentationID != id(unwrappedItem.wrappedValue) else { return @@ -186,7 +186,7 @@ extension SheetRepresentable { } work() } else if let presented = sheetedByID[key] { - if let controller = presented.provider { + if let controller = presented.content { endSheet(controller, transaction) } self.sheetedByID[key] = nil @@ -217,27 +217,44 @@ extension SheetRepresentable { objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder } } + + private var sheetObserver: SheetObserver { + set { + objc_setAssociatedObject(self, sheetObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let observer = objc_getAssociatedObject(self, sheetObserverKey) as? SheetObserver { + return observer + } else { + let observer = SheetObserver(owner: self) + self.sheetObserver = observer + return observer + } + } + } } +private typealias SheetObserver = NavigationObserver +private let sheetObserverKey = malloc(1)! private let onEndSheetKey = malloc(1)! private let sheetedKey = malloc(1)! @MainActor -private class Sheeted { - weak var provider: SheetRepresentable? - let sheetID: AnyHashable? +private class Sheeted: NavigatedProtocol { + weak var content: (any SheetContent)? + let id: AnyHashable? deinit { // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to // deinit alongside it on the main thread. If we use this other places we should force it // to be a UIViewController as well, to ensure this functionality. MainActor._assumeIsolated { - self.provider?.currentWindow?.endSheeted() + self.content?.currentWindow?.endSheeted() } } - init(_ provider: SheetRepresentable? = nil, id: AnyHashable?) { - self.provider = provider - self.sheetID = id + required init(_ content: any SheetContent, id: AnyHashable?) { + self.content = content + self.id = id } } @@ -250,4 +267,10 @@ extension NSWindow { } } +extension Navigated where Content: SheetContent { + func clearup() { + self.content?.currentWindow?.endSheeted() + } +} + #endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift new file mode 100644 index 0000000000..2fc4318bac --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -0,0 +1,90 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +@objc +public protocol SheetContent: NavigationContent { + var currentWindow: NSWindow? { get } + func beginSheet(for content: SheetContent) async + func endSheet(for content: SheetContent) +} + +extension SheetContent { + func _beginSheet(for content: any SheetContent) async { + if let sheetedWindow = content.currentWindow { + await currentWindow?.beginSheet(sheetedWindow) + } + } + + func _endSheet(for content: any SheetContent) { + if let sheetedWindow = content.currentWindow { + currentWindow?.endSheet(sheetedWindow) + } + } +} + +extension NSWindow: SheetContent { + public var currentWindow: NSWindow? { self } + public func beginSheet(for content: any SheetContent) async { + await _beginSheet(for: content) + } + public func endSheet(for content: any SheetContent) { + _endSheet(for: content) + } +} + +extension NSWindowController: SheetContent { + public var currentWindow: NSWindow? { window } + public func beginSheet(for content: any SheetContent) async { + await _beginSheet(for: content) + } + public func endSheet(for content: any SheetContent) { + _endSheet(for: content) + } + + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +extension NSViewController: SheetContent { + public var currentWindow: NSWindow? { view.window } + public func beginSheet(for content: any SheetContent) async { + await _beginSheet(for: content) + } + public func endSheet(for content: any SheetContent) { + _endSheet(for: content) + } + + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +extension NSAlert: SheetContent { + public var currentWindow: NSWindow? { window } + + public func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift b/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift deleted file mode 100644 index 03b16aede2..0000000000 --- a/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift +++ /dev/null @@ -1,51 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -public protocol SheetRepresentable: NSObject { - var currentWindow: NSWindow? { get } - func beginSheet(for provider: SheetRepresentable) async - func endSheet(for provider: SheetRepresentable) -} - -extension SheetRepresentable { - public func beginSheet(for provider: any SheetRepresentable) async { - if let sheetedWindow = provider.currentWindow { - await currentWindow?.beginSheet(sheetedWindow) - } - } - - public func endSheet(for provider: any SheetRepresentable) { - if let sheetedWindow = provider.currentWindow { - currentWindow?.endSheet(sheetedWindow) - } - } -} - -extension NSWindow: SheetRepresentable { - public var currentWindow: NSWindow? { self } -} - -extension NSWindowController: SheetRepresentable { - public var currentWindow: NSWindow? { window } -} - -extension NSViewController: SheetRepresentable { - public var currentWindow: NSWindow? { view.window } -} - -extension NSAlert: SheetRepresentable { - public var currentWindow: NSWindow? { window } - - public func beginSheet(for provider: any SheetRepresentable) async { - guard let parentWindow = provider.currentWindow else { return } - await beginSheetModal(for: parentWindow) - } - - public func endSheet(for provider: any SheetRepresentable) { - provider.currentWindow?.endSheet(window) - } -} - -#endif From 8e0e22523b1372b7083916d81adeff082ad6aeff Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:20:14 +0800 Subject: [PATCH 08/42] WIP --- ...ppKit+BasicsNavigationViewController.swift | 31 ++- .../AppKitNavigation/Navigation/Modal.swift | 10 +- .../Navigation/ModalContent.swift | 22 +- .../Navigation/NavigationContent.swift | 68 +++---- .../Navigation/Presentation.swift | 191 ++++++------------ .../AppKitNavigation/Navigation/Sheet.swift | 145 +++---------- .../Navigation/SheetContent.swift | 43 +--- 7 files changed, 141 insertions(+), 369 deletions(-) diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index 8ec32db007..5cf3b8a187 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -63,19 +63,28 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS // alert.addAction(UIAlertAction(title: "OK", style: .default)) // return alert // } - modal(item: $model.sheet, id: \.self) { count in - let vc = XiblessViewController() - vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) - return NSWindow(contentViewController: vc) - } -// present(item: $model.sheet, id: \.self, style: .sheet) { count in -//// let vc = NSHostingController( -//// rootView: Form { Text(count.description) } -//// ) + + + +// modal(item: $model.sheet, id: \.self) { count in +// let vc = XiblessViewController() +// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) +// return NSWindow(contentViewController: vc) +// } + +// sheet(item: $model.sheet, id: \.self) { count in // let vc = XiblessViewController() -// vc.preferredContentSize = .init(width: 300, height: 200) -// return vc +// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) +// return NSWindow(contentViewController: vc) // } + present(item: $model.sheet, id: \.self, style: .sheet) { count in +// let vc = NSHostingController( +// rootView: Form { Text(count.description) } +// ) + let vc = XiblessViewController() + vc.preferredContentSize = .init(width: 300, height: 200) + return vc + } present(isPresented: $model.isSheetPresented, style: .sheet) { let vc = NSHostingController( rootView: Form { Text("Hello!") } diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 2ce2d03ad6..927a64d815 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -63,19 +63,19 @@ extension NSObject { } private func modalObserver() -> ModalObserver { - if let observer = objc_getAssociatedObject(self, modalObserverKey) as? ModalObserver { + if let observer = objc_getAssociatedObject(self, modalObserverKeys.key(of: Content.self)) as? ModalObserver { return observer } else { - let modalObserver = ModalObserver(owner: self) - objc_setAssociatedObject(self, modalObserverKey, modalObserver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return modalObserver + let observer = ModalObserver(owner: self) + objc_setAssociatedObject(self, modalObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer } } } private let modalObserverKey = malloc(1)! @MainActor -private var modalObserverKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +private var modalObserverKeys = AssociatedKeys() private typealias ModalObserver = NavigationObserver extension Navigated where Content: ModalContent { diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index c141aa1f11..a372687396 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -14,28 +14,8 @@ extension NSWindow: ModalContent { public func runModal() -> NSApplication.ModalResponse { NSApplication.shared.runModal(for: self) } - - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } } -extension NSAlert: ModalContent { - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } -} +extension NSAlert: ModalContent {} #endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index 718af62732..3af25d1ecd 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -1,18 +1,33 @@ import Foundation @MainActor -@objc -public protocol NavigationContent where Self: NSObject { +public protocol NavigationContent: AnyObject { var onBeginNavigation: (() -> Void)? { set get } var onEndNavigation: (() -> Void)? { set get } } @MainActor -private var onBeginNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +private var onBeginNavigationKeys = AssociatedKeys() @MainActor -private var onEndNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] -/// Hashable wrapper for any metatype value. +private var onEndNavigationKeys = AssociatedKeys() + +struct AssociatedKeys { + var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + + mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { + let key = AnyHashableMetatype(type) + if let associatedKey = keys[key] { + return associatedKey + } else { + let associatedKey = malloc(1)! + keys[key] = associatedKey + return associatedKey + } + } +} + + struct AnyHashableMetatype: Hashable { static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { return lhs.base == rhs.base @@ -27,56 +42,25 @@ struct AnyHashableMetatype: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(base)) } - // Pre Swift 4.2: - // var hashValue: Int { return ObjectIdentifier(base).hashValue } } extension NavigationContent { - static var onBeginNavigationKey: UnsafeMutableRawPointer { - let key = AnyHashableMetatype(Self.self) - if let onBeginNavigationKey = onBeginNavigationKeys[key] { - return onBeginNavigationKey - } else { - let onBeginNavigationKey = malloc(1)! - onBeginNavigationKeys[key] = onBeginNavigationKey - return onBeginNavigationKey - } - } - - static var onEndNavigationKey: UnsafeMutableRawPointer { - let key = AnyHashableMetatype(Self.self) - if let onEndNavigationKey = onEndNavigationKeys[key] { - return onEndNavigationKey - } else { - let onEndNavigationKey = malloc(1)! - onEndNavigationKeys[key] = onEndNavigationKey - return onEndNavigationKey - } - } - var _onBeginNavigation: (() -> Void)? { + public var onBeginNavigation: (() -> Void)? { set { - objc_setAssociatedObject(self, Self.onBeginNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } get { - objc_getAssociatedObject(self, Self.onBeginNavigationKey) as? () -> Void + objc_getAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self)) as? () -> Void } } - var _onEndNavigation: (() -> Void)? { + public var onEndNavigation: (() -> Void)? { set { - objc_setAssociatedObject(self, Self.onEndNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } get { - objc_getAssociatedObject(self, Self.onEndNavigationKey) as? () -> Void + objc_getAssociatedObject(self, onEndNavigationKeys.key(of: Self.self)) as? () -> Void } } } - -@MainActor -protocol NavigatedProtocol: AnyObject { - associatedtype Content: NavigationContent - var content: Content? { get } - var id: AnyHashable? { get } - init(_ content: Content, id: AnyHashable?) -} diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 08e70ddafe..676c452c76 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -16,11 +16,11 @@ extension NSViewController { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func present( + public func present( isPresented: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping () -> PresentationContent + content: @escaping () -> Content ) -> ObservationToken { present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } @@ -39,11 +39,11 @@ extension NSViewController { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> PresentationContent + content: @escaping (Item) -> Content ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -63,11 +63,11 @@ extension NSViewController { /// controller's content. @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> PresentationContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -87,12 +87,12 @@ extension NSViewController { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> PresentationContent + content: @escaping (Item) -> Content ) -> ObservationToken { present(item: item, id: id, style: style, onDismiss: onDismiss) { content($0.wrappedValue) @@ -115,26 +115,27 @@ extension NSViewController { /// controller's content. @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> PresentationContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { destination(item: item, id: id) { $item in content($item) } present: { [weak self] child, transaction in guard let self else { return } - if presentedViewControllers != nil { - self.dismiss(nil) + if let presentedViewController = presentedViewControllers?.first { + self.dismiss(presentedViewController) onDismiss?() - self.present(child, for: style) + child.presented(from: self, style: style) } else { - self.present(child, for: style) + child.presented(from: self, style: style) } - } dismiss: { [weak self] _, transaction in - self?.dismiss(nil) + } dismiss: { [weak self] child, transaction in + guard let self else { return } + child.dismiss(from: self) onDismiss?() } } @@ -172,12 +173,12 @@ extension NSViewController { /// - present: The closure to execute when presenting the view controller. /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult - public func destination( + public func destination( isPresented: UIBinding, - content: @escaping () -> PresentationContent, - present: @escaping (PresentationContent, UITransaction) -> Void, + content: @escaping () -> Content, + present: @escaping (Content, UITransaction) -> Void, dismiss: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -202,21 +203,22 @@ extension NSViewController { /// - present: The closure to execute when presenting the view controller. /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult - public func destination( + public func destination( item: UIBinding, - content: @escaping (UIBinding) -> PresentationContent, - present: @escaping (PresentationContent, UITransaction) -> Void, + content: @escaping (UIBinding) -> Content, + present: @escaping (Content, UITransaction) -> Void, dismiss: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - destination( + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe( item: item, id: { _ in nil }, content: content, - present: present, - dismiss: dismiss + begin: present, + end: dismiss ) } @@ -236,16 +238,16 @@ extension NSViewController { /// - present: The closure to execute when presenting the view controller. /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult - public func destination( + public func destination( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> PresentationContent, + content: @escaping (UIBinding) -> Content, present: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void, dismiss: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -256,100 +258,45 @@ extension NSViewController { // present: present, // dismiss: dismiss // ) - presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) } - private func destination( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> NSViewController, - present: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void - ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = presentedByID[key] { - guard let presentationID = presented.presentationID, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let childController = content(unwrappedItem) - let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { - item.wrappedValue = nil - } - } - childController.onDismiss = onDismiss - - self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - present(childController, transaction) - } - } - if hasViewAppeared { - work() - } else { - onViewAppear.append(work) - } - } else if let presented = presentedByID[key] { - if let controller = presented.controller { - dismiss(controller, transaction) - } - self.presentedByID[key] = nil - } - } - } - - fileprivate var presentedByID: [UIBindingIdentifier: Presented] { - get { - (objc_getAssociatedObject(self, Self.presentedKey) - as? [UIBindingIdentifier: Presented]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - private static let presentedKey = malloc(1)! - private var presentationObserver: PresentationObserver { - set { - objc_setAssociatedObject(self, presentationObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - if let observer = objc_getAssociatedObject(self, presentationObserverKey) as? PresentationObserver { - return observer - } else { - let observer = PresentationObserver(owner: self) - self.presentationObserver = observer - return observer - } + private func presentationObserver() -> PresentationObserver { + if let observer = objc_getAssociatedObject(self, presentationObserverKeys.key(of: Content.self)) as? PresentationObserver { + return observer + } else { + let observer = PresentationObserver(owner: self) + objc_setAssociatedObject(self, presentationObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer } } } private let presentationObserverKey = malloc(1)! +@MainActor +private var presentationObserverKeys = AssociatedKeys() + +@MainActor public protocol PresentationContent: NavigationContent { func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) + func dismiss(from presentingViewController: NSViewController) } -extension NSViewController: PresentationContent {} + + +extension NSViewController: PresentationContent { + public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + presentingViewController.present(self, for: style) + } + + public func dismiss(from presentingViewController: NSViewController) { + presentingViewController.dismiss(self) + } +} + extension NavigationContent where Self: NSViewController { var _onEndNavigation: (() -> Void)? { set { @@ -361,7 +308,7 @@ extension NavigationContent where Self: NSViewController { } } -class PresentationObserver: NavigationObserver { +class PresentationObserver: NavigationObserver { override func commitWork(_ work: @escaping () -> Void) { if owner.hasViewAppeared { work() @@ -377,22 +324,4 @@ extension Navigated where Content: NSViewController { } } -@MainActor -private class Presented { - weak var controller: NSViewController? - let presentationID: AnyHashable? - deinit { - // NB: This can only be assumed because it is held in a NSViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a NSViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - self.controller?.dismiss(nil) - } - } - - required init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { - self.controller = controller - self.presentationID = presentationID - } -} #endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 350dff6d90..a2ff357ebd 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -12,10 +12,10 @@ extension SheetContent { /// - onDismiss: The closure to execute when dismissing the representable. /// - content: A closure that returns the representable to display over the current window content. @discardableResult - public func sheet( + public func sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> SheetContent + content: @escaping () -> Content ) -> ObservationToken { sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } @@ -34,10 +34,10 @@ extension SheetContent { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func sheet( + public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetContent + content: @escaping (Item) -> Content ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -57,10 +57,10 @@ extension SheetContent { /// controller's content. @_disfavoredOverload @discardableResult - public func sheet( + public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -80,11 +80,11 @@ extension SheetContent { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func sheet( + public func sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetContent + content: @escaping (Item) -> Content ) -> ObservationToken { sheet(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) @@ -92,11 +92,11 @@ extension SheetContent { } @discardableResult - public func sheet( + public func sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { sheet(item: item, id: id) { $item in content($item) @@ -123,20 +123,21 @@ extension SheetContent { } } - private func sheet( + private func sheet( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> SheetContent, + content: @escaping (UIBinding) -> Content, beginSheet: @escaping ( - _ child: SheetContent, + _ child: Content, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: SheetContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - sheetObserver.observe( + let sheetObserver: SheetObserver = sheetObserver() + return sheetObserver.observe( item: item, id: { $0[keyPath: id] }, content: content, @@ -145,118 +146,26 @@ extension SheetContent { ) } - private func sheet( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> SheetContent, - beginSheet: @escaping ( - _ child: SheetContent, - _ transaction: UITransaction - ) -> Void, - endSheet: @escaping ( - _ child: SheetContent, - _ transaction: UITransaction - ) -> Void - ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = sheetedByID[key] { - guard let presentationID = presented.id, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let childController = content(unwrappedItem) - let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { - item.wrappedValue = nil - } - } - childController.onEndSheet = onEndSheet - - self.sheetedByID[key] = Sheeted(childController, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - beginSheet(childController, transaction) - } - } - work() - } else if let presented = sheetedByID[key] { - if let controller = presented.content { - endSheet(controller, transaction) - } - self.sheetedByID[key] = nil - } - } - } - - private var sheetedByID: [UIBindingIdentifier: Sheeted] { - get { - (objc_getAssociatedObject(self, sheetedKey) - as? [UIBindingIdentifier: Sheeted]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, sheetedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - private var onEndSheet: ClosureHolder? { - set { - objc_setAssociatedObject( - self, onEndSheetKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - get { - objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder - } - } - - private var sheetObserver: SheetObserver { - set { - objc_setAssociatedObject(self, sheetObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - if let observer = objc_getAssociatedObject(self, sheetObserverKey) as? SheetObserver { - return observer - } else { - let observer = SheetObserver(owner: self) - self.sheetObserver = observer - return observer - } + private func sheetObserver() -> SheetObserver { + if let observer = objc_getAssociatedObject(self, sheetObserverKeys.key(of: Content.self)) as? SheetObserver { + return observer + } else { + let observer = SheetObserver(owner: self) + objc_setAssociatedObject(self, sheetObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer } } } -private typealias SheetObserver = NavigationObserver +private typealias SheetObserver = NavigationObserver private let sheetObserverKey = malloc(1)! -private let onEndSheetKey = malloc(1)! -private let sheetedKey = malloc(1)! @MainActor -private class Sheeted: NavigatedProtocol { - weak var content: (any SheetContent)? - let id: AnyHashable? - deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - self.content?.currentWindow?.endSheeted() - } - } +private var sheetObserverKeys = AssociatedKeys() - required init(_ content: any SheetContent, id: AnyHashable?) { - self.content = content - self.id = id - } -} +private let onEndSheetKey = malloc(1)! +private let sheetedKey = malloc(1)! extension NSWindow { func endSheeted() { diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 2fc4318bac..8162d88d3a 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -3,7 +3,6 @@ import AppKit @MainActor -@objc public protocol SheetContent: NavigationContent { var currentWindow: NSWindow? { get } func beginSheet(for content: SheetContent) async @@ -11,13 +10,13 @@ public protocol SheetContent: NavigationContent { } extension SheetContent { - func _beginSheet(for content: any SheetContent) async { + public func beginSheet(for content: any SheetContent) async { if let sheetedWindow = content.currentWindow { await currentWindow?.beginSheet(sheetedWindow) } } - func _endSheet(for content: any SheetContent) { + public func endSheet(for content: any SheetContent) { if let sheetedWindow = content.currentWindow { currentWindow?.endSheet(sheetedWindow) } @@ -26,52 +25,14 @@ extension SheetContent { extension NSWindow: SheetContent { public var currentWindow: NSWindow? { self } - public func beginSheet(for content: any SheetContent) async { - await _beginSheet(for: content) - } - public func endSheet(for content: any SheetContent) { - _endSheet(for: content) - } } extension NSWindowController: SheetContent { public var currentWindow: NSWindow? { window } - public func beginSheet(for content: any SheetContent) async { - await _beginSheet(for: content) - } - public func endSheet(for content: any SheetContent) { - _endSheet(for: content) - } - - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } } extension NSViewController: SheetContent { public var currentWindow: NSWindow? { view.window } - public func beginSheet(for content: any SheetContent) async { - await _beginSheet(for: content) - } - public func endSheet(for content: any SheetContent) { - _endSheet(for: content) - } - - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } } extension NSAlert: SheetContent { From 01fafbe19444c6532cd7f99240815af1172b331c Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 16 Aug 2024 23:02:53 +0800 Subject: [PATCH 09/42] WIP --- ...ppKit+BasicsNavigationViewController.swift | 49 ++-- ...+ConciseEnumNavigationViewController.swift | 242 ++++++++---------- .../AppKit/AppKitCaseStudies.swift | 2 +- .../Internal/AssociatedKeys.swift | 36 +++ .../AppKitNavigation/Navigation/Modal.swift | 98 ++++++- .../Navigation/NavigationContent.swift | 33 --- .../Navigation/NavigationObserver.swift | 2 - .../Navigation/Presentation.swift | 69 +---- .../Navigation/PresentationContent.swift | 41 +++ 9 files changed, 306 insertions(+), 266 deletions(-) create mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift create mode 100644 Sources/AppKitNavigation/Navigation/PresentationContent.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index 5cf3b8a187..a9a20fb710 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -17,7 +17,7 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS let showAlertButton = NSButton { [weak self] _ in self?.model.alert = "Hello!" } - + let showSheetButton = NSButton { [weak self] _ in self?.model.sheet = .random(in: 1 ... 1_000) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { @@ -51,45 +51,26 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" - } -// present(item: $model.alert, id: \.self) { message in -// let alert = UIAlertController( -// title: "This is an alert", -// message: message, -// preferredStyle: .alert -// ) -// alert.addAction(UIAlertAction(title: "OK", style: .default)) -// return alert -// } - - - -// modal(item: $model.sheet, id: \.self) { count in -// let vc = XiblessViewController() -// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) -// return NSWindow(contentViewController: vc) -// } - -// sheet(item: $model.sheet, id: \.self) { count in -// let vc = XiblessViewController() -// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) -// return NSWindow(contentViewController: vc) -// } + modal(item: $model.alert, id: \.self) { message in + let alert = NSAlert() + alert.messageText = "This is an alert" + alert.informativeText = message + alert.addButton(withTitle: "OK") + return alert + } + present(item: $model.sheet, id: \.self, style: .sheet) { count in -// let vc = NSHostingController( -// rootView: Form { Text(count.description) } -// ) - let vc = XiblessViewController() - vc.preferredContentSize = .init(width: 300, height: 200) - return vc + NSHostingController( + rootView: Form { Text(count.description) }.frame(width: 100, height: 100, alignment: .center) + ) } + present(isPresented: $model.isSheetPresented, style: .sheet) { - let vc = NSHostingController( - rootView: Form { Text("Hello!") } + NSHostingController( + rootView: Form { Text("Hello!") }.frame(width: 100, height: 100, alignment: .center) ) - return vc } } diff --git a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift index af08f37dfe..8abcdec54c 100644 --- a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift @@ -1,138 +1,108 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -//import SwiftUI -//import UIKit -//import UIKitNavigation -// -//class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { -// let caseStudyNavigationTitle = "Enum navigation" -// let caseStudyTitle = "Concise enum navigation" -// let readMe = """ -// This case study demonstrates how to navigate to multiple destinations from a single optional \ -// enum. -// -// This allows you to be very concise with your domain modeling by having a single enum \ -// describe all the possible destinations you can navigate to. In the case of this demo, we have \ -// four cases in the enum, which means there are exactly 5 possible states, including the case \ -// where none are active. -// -// If you were to instead model this domain with 4 optionals (or booleans), then you would have \ -// 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ -// because you can never be sure of exactly what is presented at a given time. -// """ -// @UIBindable var model = Model() -// -// override func viewDidLoad() { -// super.viewDidLoad() -// view.backgroundColor = .systemBackground -// -// let showAlertButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .alert("Hello!") -// }) -// let showSheetButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .sheet(.random(in: 1...1_000)) -// }) -// let showSheetFromBooleanButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .sheetWithoutPayload -// }) -// let drillDownButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .drillDown(.random(in: 1...1_000)) -// }) -// -// let stack = UIStackView(arrangedSubviews: [ -// showAlertButton, -// showSheetButton, -// drillDownButton, -// showSheetFromBooleanButton, -// ]) -// stack.axis = .vertical -// stack.spacing = 12 -// stack.translatesAutoresizingMaskIntoConstraints = false -// -// view.addSubview(stack) -// NSLayoutConstraint.activate([ -// stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), -// stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// ]) -// -// observe { [weak self] in -// guard let self else { return } -// -// showAlertButton.setTitle( -// "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")", -// for: .normal -// ) -// showSheetButton.setTitle( -// "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")", -// for: .normal -// ) -// showSheetFromBooleanButton.setTitle( -// "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")", -// for: .normal -// ) -// drillDownButton.setTitle( -// "Drill-down is presented: \(model.destination?.drillDown != nil ? "✅" : "❌")", -// for: .normal -// ) -// } -// -// present(item: $model.destination.alert, id: \.self) { message in -// let alert = UIAlertController( -// title: "This is an alert", -// message: message, -// preferredStyle: .alert -// ) -// alert.addAction(UIAlertAction(title: "OK", style: .default)) -// return alert -// } -// present(item: $model.destination.sheet, id: \.self) { count in -// let vc = UIHostingController( -// rootView: Form { Text(count.description) } -// ) -// vc.mediumDetents() -// return vc -// } -// present(isPresented: UIBinding($model.destination.sheetWithoutPayload)) { -// let vc = UIHostingController( -// rootView: Form { Text("Hello!") } -// ) -// vc.mediumDetents() -// return vc -// } -// navigationDestination(item: $model.destination.drillDown) { count in -// UIHostingController( -// rootView: Form { -// Text(count.description) -// } -// ) -// } -// } -// -// @Observable -// class Model { -// var destination: Destination? -// @CasePathable -// @dynamicMemberLookup -// enum Destination { -// case alert(String) -// case drillDown(Int) -// case sheet(Int) -// case sheetWithoutPayload -// } -// } -//} -// -//#Preview { -// UINavigationController( -// rootViewController: BasicsNavigationViewController() -// ) -//} +import SwiftUI +import AppKit +import AppKitNavigation + +class ConciseEnumNavigationViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyNavigationTitle = "Enum navigation" + let caseStudyTitle = "Concise enum navigation" + let readMe = """ + This case study demonstrates how to navigate to multiple destinations from a single optional \ + enum. + + This allows you to be very concise with your domain modeling by having a single enum \ + describe all the possible destinations you can navigate to. In the case of this demo, we have \ + four cases in the enum, which means there are exactly 5 possible states, including the case \ + where none are active. + + If you were to instead model this domain with 4 optionals (or booleans), then you would have \ + 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ + because you can never be sure of exactly what is presented at a given time. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let showAlertButton = NSButton { [weak self] _ in + self?.model.destination = .alert("Hello!") + } + let showSheetButton = NSButton { [weak self] _ in + self?.model.destination = .sheet(.random(in: 1 ... 1_000)) + } + let showSheetFromBooleanButton = NSButton { [weak self] _ in + self?.model.destination = .sheetWithoutPayload + } + + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + showAlertButton.title = "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")" + showSheetButton.title = "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")" + } + + modal(item: $model.destination.alert, id: \.self) { message in + let alert = NSAlert() + alert.messageText = "This is an alert" + alert.informativeText = message + alert.addButton(withTitle: "OK") + return alert + } + present(item: $model.destination.sheet, id: \.self, style: .sheet) { [unowned self] count in + + NSHostingController( + rootView: Form { + Text(count.description) + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) + } + present(isPresented: UIBinding($model.destination.sheetWithoutPayload), style: .sheet) { [unowned self] in + NSHostingController( + rootView: Form { + Text("Hello!") + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) + } + } + + @Observable + class Model { + var destination: Destination? + @CasePathable + @dynamicMemberLookup + enum Destination { + case alert(String) + case drillDown(Int) + case sheet(Int) + case sheetWithoutPayload + } + } +} + +#Preview { + ConciseEnumNavigationViewController() +} #endif diff --git a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift index acdf6cb0e2..0a13289c2b 100644 --- a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift +++ b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift @@ -17,7 +17,7 @@ struct AppKitCaseStudiesView: View { CaseStudyGroupView("Optional navigation") { BasicsNavigationViewController() // TODO: Alert/dialog state -// ConciseEnumNavigationViewController() + ConciseEnumNavigationViewController() } // CaseStudyGroupView("Stack navigation") { // StaticNavigationStackController() diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift new file mode 100644 index 0000000000..1df9f50edc --- /dev/null +++ b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift @@ -0,0 +1,36 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +struct AssociatedKeys { + var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + + mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { + let key = AnyHashableMetatype(type) + if let associatedKey = keys[key] { + return associatedKey + } else { + let associatedKey = malloc(1)! + keys[key] = associatedKey + return associatedKey + } + } +} + +struct AnyHashableMetatype: Hashable { + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } + + let base: Any.Type + + init(_ base: Any.Type) { + self.base = base + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 927a64d815..3ab909472b 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -3,8 +3,101 @@ import AppKit import Combine +@MainActor +private var modalObserverKeys = AssociatedKeys() + +private typealias ModalObserver = NavigationObserver + @MainActor extension NSObject { + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - isSheeted: A binding to a Boolean value that determines whether to sheet the representable + /// - onDismiss: The closure to execute when dismissing the representable. + /// - content: A closure that returns the representable to display over the current window content. + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObservationToken { + modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObservationToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modal(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + @discardableResult public func modal( item: UIBinding, @@ -73,11 +166,6 @@ extension NSObject { } } -private let modalObserverKey = malloc(1)! -@MainActor -private var modalObserverKeys = AssociatedKeys() -private typealias ModalObserver = NavigationObserver - extension Navigated where Content: ModalContent { func clearup() { NSApplication.shared.stopModal() diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index 3af25d1ecd..a67417c95f 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -12,40 +12,7 @@ private var onBeginNavigationKeys = AssociatedKeys() @MainActor private var onEndNavigationKeys = AssociatedKeys() -struct AssociatedKeys { - var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] - - mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { - let key = AnyHashableMetatype(type) - if let associatedKey = keys[key] { - return associatedKey - } else { - let associatedKey = malloc(1)! - keys[key] = associatedKey - return associatedKey - } - } -} - - -struct AnyHashableMetatype: Hashable { - static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { - return lhs.base == rhs.base - } - - let base: Any.Type - - init(_ base: Any.Type) { - self.base = base - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(base)) - } -} - extension NavigationContent { - public var onBeginNavigation: (() -> Void)? { set { objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift index b7e6b22b96..a666af9959 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -3,8 +3,6 @@ import SwiftNavigation @MainActor class NavigationObserver: NSObject { - - private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] unowned let owner: Owner diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 676c452c76..f8c4cc5e4c 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -4,6 +4,19 @@ import IssueReporting import AppKit import AppKitNavigationShim +@MainActor +private var presentationObserverKeys = AssociatedKeys() + +class PresentationObserver: NavigationObserver { + override func commitWork(_ work: @escaping () -> Void) { + if owner.hasViewAppeared { + work() + } else { + owner.onViewAppear.append(work) + } + } +} + extension NSViewController { /// Presents a view controller modally when a binding to a Boolean value you provide is true. /// @@ -140,26 +153,6 @@ extension NSViewController { } } - public enum TransitionStyle { - case sheet - case modalWindow - case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) - } - - fileprivate func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { - switch style { - case .sheet: - presentAsSheet(viewControllerToPresent) - case .modalWindow: - presentAsModalWindow(viewControllerToPresent) - case let .popover(rect, view, preferredEdge, behavior): - present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) - case let .custom(animator): - present(viewControllerToPresent, animator: animator) - } - } - /// Presents a view controller when a binding to a Boolean value you provide is true. /// /// This helper powers ``present(isPresented:onDismiss:content:)`` and @@ -260,10 +253,8 @@ extension NSViewController { // ) let presentationObserver: PresentationObserver = presentationObserver() return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) - } - private func presentationObserver() -> PresentationObserver { if let observer = objc_getAssociatedObject(self, presentationObserverKeys.key(of: Content.self)) as? PresentationObserver { return observer @@ -273,28 +264,6 @@ extension NSViewController { return observer } } - -} -private let presentationObserverKey = malloc(1)! - -@MainActor -private var presentationObserverKeys = AssociatedKeys() - -@MainActor -public protocol PresentationContent: NavigationContent { - func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) - func dismiss(from presentingViewController: NSViewController) -} - - -extension NSViewController: PresentationContent { - public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { - presentingViewController.present(self, for: style) - } - - public func dismiss(from presentingViewController: NSViewController) { - presentingViewController.dismiss(self) - } } extension NavigationContent where Self: NSViewController { @@ -308,19 +277,9 @@ extension NavigationContent where Self: NSViewController { } } -class PresentationObserver: NavigationObserver { - override func commitWork(_ work: @escaping () -> Void) { - if owner.hasViewAppeared { - work() - } else { - owner.onViewAppear.append(work) - } - } -} - extension Navigated where Content: NSViewController { func clearup() { - self.content?.dismiss(nil) + content?.dismiss(nil) } } diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift new file mode 100644 index 0000000000..eba08ad600 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol PresentationContent: NavigationContent { + func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) + func dismiss(from presentingViewController: NSViewController) +} + +extension NSViewController: PresentationContent { + public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + presentingViewController.present(self, for: style) + } + + public func dismiss(from presentingViewController: NSViewController) { + presentingViewController.dismiss(self) + } + + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) + } + + private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case let .popover(rect, view, preferredEdge, behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case let .custom(animator): + present(viewControllerToPresent, animator: animator) + } + } +} + +#endif From cd4422f1951113f3a35747df638d7f6309a5f0fc Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sat, 17 Aug 2024 01:31:20 +0800 Subject: [PATCH 10/42] WIP-Fix bugs and support NSSave/OpenPanel --- ...ppKit+BasicsNavigationViewController.swift | 22 ++- Package@swift-6.0.swift | 15 ++ .../Bindings/NSFontManager.swift | 1 + .../Bindings/NSOpenPanel.swift | 60 ++++++ .../Bindings/NSTargetAction.swift | 16 +- .../Internal/AssumeIsolated.swift | 5 + .../AppKitNavigation/Navigation/Modal.swift | 5 +- .../Navigation/ModalContent.swift | 20 +- .../Navigation/NavigationContent.swift | 5 + .../Navigation/NavigationObserver.swift | 15 +- .../AppKitNavigation/Navigation/Sheet.swift | 17 +- Sources/AppKitNavigationShim/include/shim.h | 28 +-- Sources/AppKitNavigationShim/shim.m | 180 +++++++++++------- 13 files changed, 281 insertions(+), 108 deletions(-) diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index a9a20fb710..c7a71253f3 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -48,17 +48,24 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS observe { [weak self] in guard let self else { return } - showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + if let url = model.url { + showAlertButton.title = "URL is: \(url)" + } else { + showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + } showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" } - modal(item: $model.alert, id: \.self) { message in - let alert = NSAlert() - alert.messageText = "This is an alert" - alert.informativeText = message - alert.addButton(withTitle: "OK") - return alert + modal(item: $model.alert, id: \.self) { [unowned self] message in +// let alert = NSAlert() +// alert.messageText = "This is an alert" +// alert.informativeText = message +// alert.addButton(withTitle: "OK") +// return alert + let openPanel = NSOpenPanel(url: $model.url) + openPanel.message = message + return openPanel } present(item: $model.sheet, id: \.self, style: .sheet) { count in @@ -79,6 +86,7 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS var alert: String? var isSheetPresented = false var sheet: Int? + var url: URL? } } diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 9ed432ff67..96bf3a52ea 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -23,6 +23,10 @@ let package = Package( name: "UIKitNavigation", targets: ["UIKitNavigation"] ), + .library( + name: "AppKitNavigation", + targets: ["AppKitNavigation"] + ), ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -73,6 +77,17 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ] + ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 0525f42582..752c4a62dc 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -1,6 +1,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit +import SwiftNavigation extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { public var appkitNavigationTarget: AnyObject? { diff --git a/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift index 893f31d20e..896135f71e 100644 --- a/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift @@ -1,5 +1,65 @@ #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) -> ObservationToken { + appKitNavigation_onFinalURL = { url in + binding.wrappedValue = url + } + + let observationToken = ObservationToken { [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]>) -> ObservationToken { + appKitNavigation_onFinalURLs = { urls in + binding.wrappedValue = urls + } + + let observationToken = ObservationToken { [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/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 3412960d4d..4c0dce7971 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -134,6 +134,21 @@ extension NSTargetActionProtocol { 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] @@ -148,7 +163,6 @@ extension NSTargetActionProtocol { } - @MainActor private let observationTokensKey = malloc(1)! @MainActor diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift index 9a7465a99f..93f1c40098 100644 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift @@ -1,3 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import Foundation extension MainActor { @@ -28,3 +30,6 @@ extension MainActor { #endif } } + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 3ab909472b..615f985e1d 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -1,7 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit -import Combine @MainActor private var modalObserverKeys = AssociatedKeys() @@ -113,7 +112,7 @@ extension NSObject { onDismiss?() DispatchQueue.main.async { ModalWindowsObserver.shared.observeWindow(modalContent.window) - modalContent.runModal() + modalContent.appKitNavigationRunModal() modalContent.onEndNavigation?() modalContent.onEndNavigation = nil } @@ -121,7 +120,7 @@ extension NSObject { } else { DispatchQueue.main.async { ModalWindowsObserver.shared.observeWindow(modalContent.window) - modalContent.runModal() + modalContent.appKitNavigationRunModal() modalContent.onEndNavigation?() modalContent.onEndNavigation = nil } diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index a372687396..00772647f2 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -4,18 +4,32 @@ import AppKit @MainActor public protocol ModalContent: NavigationContent { - @discardableResult func runModal() -> NSApplication.ModalResponse + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse var window: NSWindow { get } } extension NSWindow: ModalContent { public var window: NSWindow { self } - public func runModal() -> NSApplication.ModalResponse { + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + __appKitNavigationRunModal() + } + + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { NSApplication.shared.runModal(for: self) } } -extension NSAlert: ModalContent {} +extension NSSavePanel { + override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} + +extension NSAlert: ModalContent { + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} #endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index a67417c95f..762032cf09 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -1,3 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import Foundation @MainActor @@ -31,3 +33,6 @@ extension NavigationContent { } } } + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift index a666af9959..c9af34445f 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -1,3 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import Foundation import SwiftNavigation @@ -29,17 +31,17 @@ class NavigationObserver: NSObject return observe { [weak self] transaction in guard let self else { return } if let unwrappedItem = UIBinding(item) { - if let presented = navigatedByID[key] { - guard let presentationID = presented.id, - presentationID != id(unwrappedItem.wrappedValue) + if let navigated = navigatedByID[key] { + guard let navigationID = navigated.id, + navigationID != id(unwrappedItem.wrappedValue) else { return } } let content = content(unwrappedItem) - let onEndNavigation = { [presentationID = id(unwrappedItem.wrappedValue)] in + let onEndNavigation = { [navigationID = id(unwrappedItem.wrappedValue)] in if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { + navigationID == id(wrappedValue) { item.wrappedValue = nil } } @@ -82,3 +84,6 @@ class Navigated { self.id = id } } + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index a2ff357ebd..76d8786698 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -2,6 +2,11 @@ import AppKit +private typealias SheetObserver = NavigationObserver + +@MainActor +private var sheetObserverKeys = AssociatedKeys() + extension SheetContent { /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -146,7 +151,6 @@ extension SheetContent { ) } - private func sheetObserver() -> SheetObserver { if let observer = objc_getAssociatedObject(self, sheetObserverKeys.key(of: Content.self)) as? SheetObserver { return observer @@ -158,15 +162,6 @@ extension SheetContent { } } -private typealias SheetObserver = NavigationObserver -private let sheetObserverKey = malloc(1)! - -@MainActor -private var sheetObserverKeys = AssociatedKeys() - -private let onEndSheetKey = malloc(1)! -private let sheetedKey = malloc(1)! - extension NSWindow { func endSheeted() { guard sheetParent != nil else { @@ -178,7 +173,7 @@ extension NSWindow { extension Navigated where Content: SheetContent { func clearup() { - self.content?.currentWindow?.endSheeted() + content?.currentWindow?.endSheeted() } } diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h index 1d2054f3ca..e1844275f7 100644 --- a/Sources/AppKitNavigationShim/include/shim.h +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -1,19 +1,25 @@ #if __has_include() - #include +#include - #if __has_include() && !TARGET_OS_MACCATALYST - @import AppKit; +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; - NS_ASSUME_NONNULL_BEGIN +NS_ASSUME_NONNULL_BEGIN - @interface NSViewController (AppKitNavigation) +@interface NSViewController (AppKitNavigation) - @property BOOL hasViewAppeared; - @property (nullable) void (^onDismiss)(); - @property NSArray *onViewAppear; +@property BOOL hasViewAppeared; +@property (nullable) void (^ onDismiss)(); +@property NSArray *onViewAppear; - @end +@end - NS_ASSUME_NONNULL_END - #endif +@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 index a2fb9479a5..21f5f9c08b 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -1,106 +1,152 @@ #if __has_include() - #include +#include - #if __has_include() && !TARGET_OS_MACCATALYST - @import ObjectiveC; - @import AppKit; - #import "shim.h" +#if __has_include() && !TARGET_OS_MACCATALYST +@import ObjectiveC; +@import AppKit; +#import "shim.h" - @interface AppKitNavigationShim : NSObject +@interface AppKitNavigationShim : NSObject - @end +@end - @implementation AppKitNavigationShim +@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. +// 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( ++ (void)load { + method_exchangeImplementations( class_getInstanceMethod(NSViewController.class, @selector(viewDidAppear)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) - ); - method_exchangeImplementations( + ); + method_exchangeImplementations( class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) - ); - method_exchangeImplementations( + ); + method_exchangeImplementations( class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) - ); + ); + 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); } +} - @end +- (void)AppKitNavigation_setFinalURLs:(NSArray *)urls { + [self AppKitNavigation_setFinalURLs:urls]; + if (self.AppKitNavigation_onFinalURLs) { + self.AppKitNavigation_onFinalURLs(urls); + } +} + +@end - static void *hasViewAppearedKey = &hasViewAppearedKey; - static void *onDismissKey = &onDismissKey; - static void *onViewAppearKey = &onViewAppearKey; +static void *hasViewAppearedKey = &hasViewAppearedKey; +static void *onDismissKey = &onDismissKey; +static void *onViewAppearKey = &onViewAppearKey; - @implementation NSViewController (AppKitNavigation) +@implementation NSViewController (AppKitNavigation) - - (void)AppKitNavigation_viewDidAppear { - [self AppKitNavigation_viewDidAppear]; +- (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; - if (self.hasViewAppeared) { + if (self.hasViewAppeared) { return; - } - self.hasViewAppeared = YES; - for (void (^work)() in self.onViewAppear) { - work(); - } - self.onViewAppear = @[]; } - - (void)setBeingDismissed:(BOOL)beingDismissed { - objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); - } + self.hasViewAppeared = YES; - - (BOOL)isBeingDismissed { - return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; + for (void (^work)() in self.onViewAppear) { + work(); } - - (void)AppKitNavigation_viewDidDisappear { - [self AppKitNavigation_viewDidDisappear]; + self.onViewAppear = @[]; +} + +- (void)setBeingDismissed:(BOOL)beingDismissed { + objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); +} + +- (BOOL)isBeingDismissed { + return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; +} + +- (void)AppKitNavigation_viewDidDisappear { + [self AppKitNavigation_viewDidDisappear]; - if ((self.isBeingDismissed) && self.onDismiss != NULL) { + if ((self.isBeingDismissed) && self.onDismiss != NULL) { self.onDismiss(); self.onDismiss = nil; [self setBeingDismissed:NO]; - } } +} - - (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { - [self AppKitNavigation_dismissViewController:sender]; - [self setBeingDismissed:YES]; - } +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} - - (BOOL)hasViewAppeared { - return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; - } +- (BOOL)hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} - - (void)setHasViewAppeared:(BOOL)hasViewAppeared { - objc_setAssociatedObject( +- (void)setHasViewAppeared:(BOOL)hasViewAppeared { + objc_setAssociatedObject( self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC - ); - } + ); +} - - (void (^)())onDismiss { - return objc_getAssociatedObject(self, onDismissKey); - } +- (void (^)())onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} - - (void)setOnDismiss:(void (^)())onDismiss { - objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); - } +- (void)setOnDismiss:(void (^)())onDismiss { + objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +} - - (NSMutableArray *)onViewAppear { - id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); - return onViewAppear == nil ? @[] : onViewAppear; - } +- (NSMutableArray *)onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); - - (void)setOnViewAppear:(NSMutableArray *)onViewAppear { - objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); - } + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} - @end - #endif -#endif +@end +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ From 27c61522cf309d39386c898b03ea66a8f1323e8b Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 18 Aug 2024 22:25:00 +0800 Subject: [PATCH 11/42] WIP-Support WiFiFeature Case Study --- ...pKit+NSControlBindingsViewController.swift | 11 +- .../AppKit/AppKitCaseStudies.swift | 10 +- Examples/CaseStudies/Internal/CaseStudy.swift | 6 +- .../WiFiFeature/ConnectToNetworkFeature.swift | 113 ------ .../WiFiFeature/NetworkDetailFeature.swift | 101 ----- .../WiFiFeature/WiFiSettingsFeature.swift | 313 ---------------- ...ppKit+ConnectToNetworkViewController.swift | 177 +++++++++ .../ConnectToNetworkModel.swift | 36 ++ ...UIKit+ConnectToNetworkViewController.swift | 90 +++++ .../{UIKit => }/WiFiFeature/Network.swift | 0 .../AppKit+NetworkDetailViewController.swift | 65 ++++ .../NetworkDetailModel.swift | 32 ++ .../UIKit+NetworkDetailViewController.swift | 76 ++++ .../AppKit+WiFiSettingsViewController.swift | 352 ++++++++++++++++++ .../UIKit+WiFiSettingsViewController.swift | 199 ++++++++++ .../WiFiSettingsModel.swift | 75 ++++ .../WiFiSettingsViewController+.swift | 60 +++ Examples/Examples.xcodeproj/project.pbxproj | 82 +++- .../AppKitNavigation/Bindings/NSAlert.swift | 2 +- .../Bindings/NSColorPanel.swift | 2 +- .../AppKitNavigation/Bindings/NSControl.swift | 13 +- .../Bindings/NSFontManager.swift | 10 +- ...SOpenPanel.swift => NSSaveOpenPanel.swift} | 0 .../Bindings/NSTargetAction.swift | 70 +++- .../Bindings/NSTextField.swift | 19 +- .../Internal/ModalWindowsObserver.swift | 13 +- .../NavigationObserver.swift | 0 .../AppKitNavigation/Navigation/Modal.swift | 74 ++++ .../ModalSession.swift} | 2 + .../Navigation/ModalSessionContent.swift | 29 ++ .../Navigation/SheetContent.swift | 21 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- 32 files changed, 1463 insertions(+), 598 deletions(-) delete mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift delete mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift delete mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift create mode 100644 Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift create mode 100644 Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift rename Examples/CaseStudies/{UIKit => }/WiFiFeature/Network.swift (100%) create mode 100644 Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift create mode 100644 Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift rename Sources/AppKitNavigation/Bindings/{NSOpenPanel.swift => NSSaveOpenPanel.swift} (100%) rename Sources/AppKitNavigation/{Navigation => Internal}/NavigationObserver.swift (100%) rename Sources/AppKitNavigation/{Bindings/NSSavePanel.swift => Navigation/ModalSession.swift} (97%) create mode 100644 Sources/AppKitNavigation/Navigation/ModalSessionContent.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift index fbecc6031d..75fe38967b 100644 --- a/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift @@ -8,6 +8,15 @@ class XiblessViewController: NSViewController { override func loadView() { view = contentView } + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("") + } } class NSControlBindingsViewController: XiblessViewController, AppKitCaseStudy { @@ -117,10 +126,8 @@ extension String { } } - #Preview(traits: .fixedLayout(width: 500, height: 800)) { NSControlBindingsViewController() } - #endif diff --git a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift index 0a13289c2b..5fb7bee5c3 100644 --- a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift +++ b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift @@ -24,11 +24,11 @@ struct AppKitCaseStudiesView: View { // ErasedNavigationStackController(model: ErasedNavigationStackController.Model()) // // TODO: state restoration // } -// CaseStudyGroupView("Advanced") { -// // TODO: Deep link -// // TODO: Dismissal (show off from VCs and views) -// WiFiSettingsViewController(model: WiFiSettingsModel(foundNetworks: .mocks)) -// } + CaseStudyGroupView("Advanced") { + // TODO: Deep link + // TODO: Dismissal (show off from VCs and views) + WiFiSettingsViewController(model: WiFiSettingsModel(foundNetworks: .mocks)) + } } .navigationTitle("AppKit") } diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index 88730a9b21..b6d739cb31 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -1,6 +1,4 @@ import SwiftUI -import AppKitNavigation -import UIKitNavigation protocol CaseStudy { var readMe: String { get } @@ -12,12 +10,16 @@ protocol CaseStudy { protocol SwiftUICaseStudy: CaseStudy, View {} #if canImport(UIKit) && !os(watchOS) +import UIKitNavigation + protocol UIKitCaseStudy: CaseStudy, UIViewController {} extension UIKitCaseStudy { var usesOwnLayout: Bool { true } } #endif #if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKitNavigation + protocol AppKitCaseStudy: CaseStudy, NSViewController {} extension AppKitCaseStudy { var usesOwnLayout: Bool { true } diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift deleted file mode 100644 index fe305a2748..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift +++ /dev/null @@ -1,113 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import SwiftUI -import UIKitNavigation -import XCTestDynamicOverlay - -@Observable -@MainActor -class ConnectToNetworkModel: Identifiable { - var incorrectPasswordAlertIsPresented = false - var isConnecting = false - var onConnect: (Network) -> Void = { _ in - XCTFail("ConnectToNetworkModel.onConnect unimplemented.") - } - let network: Network - var password = "" - init(network: Network) { - self.network = network - } - func joinButtonTapped() async { - isConnecting = true - defer { isConnecting = false } - try? await Task.sleep(for: .seconds(1)) - if password == "blob" { - onConnect(network) - } else { - incorrectPasswordAlertIsPresented = true - } - } -} - -final class ConnectToNetworkViewController: UIViewController { - @UIBindable var model: ConnectToNetworkModel - - init(model: ConnectToNetworkModel) { - self.model = model - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - navigationItem.title = "Enter the password for “\(model.network.name)”" - - let passwordTextField = UITextField(text: $model.password) - passwordTextField.borderStyle = .line - passwordTextField.isSecureTextEntry = true - passwordTextField.becomeFirstResponder() - passwordTextField.placeholder = "The password is 'blob'" - let joinButton = UIButton( - type: .system, - primaryAction: UIAction { [weak self] _ in - guard let self else { return } - Task { - await self.model.joinButtonTapped() - } - }) - joinButton.setTitle("Join network", for: .normal) - let activityIndicator = UIActivityIndicatorView(style: .medium) - activityIndicator.startAnimating() - - let stack = UIStackView(arrangedSubviews: [ - passwordTextField, - joinButton, - activityIndicator, - ]) - stack.axis = .vertical - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), - stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), - stack.widthAnchor.constraint(equalToConstant: 200), - ]) - - observe { [weak self, weak passwordTextField] in - guard - let self, - let passwordTextField - else { return } - - passwordTextField.isEnabled = !model.isConnecting - joinButton.isEnabled = !model.isConnecting - activityIndicator.isHidden = !model.isConnecting - } - - present(isPresented: $model.incorrectPasswordAlertIsPresented) { [unowned self] in - let controller = UIAlertController( - title: "Incorrect password for “\(model.network.name)”", - message: nil, - preferredStyle: .alert - ) - controller.addAction(UIAlertAction(title: "OK", style: .default)) - return controller - } - } -} - -#Preview { - UIViewControllerRepresenting { - UINavigationController( - rootViewController: ConnectToNetworkViewController( - model: ConnectToNetworkModel( - network: Network(name: "Blob's WiFi") - ) - ) - ) - } -} -#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift deleted file mode 100644 index 124b64124c..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift +++ /dev/null @@ -1,101 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import SwiftUI -import UIKitNavigation -import XCTestDynamicOverlay - -@MainActor -@Observable -class NetworkDetailModel { - var forgetAlertIsPresented = false - var onConfirmForget: () -> Void = { - XCTFail("NetworkDetailModel.onConfirmForget unimplemented.") - } - let network: Network - let selectedNetworkID: Network.ID? - - init( - network: Network, - selectedNetworkID: Network.ID? - ) { - self.network = network - self.selectedNetworkID = selectedNetworkID - } - - func forgetNetworkButtonTapped() { - forgetAlertIsPresented = true - } - - func confirmForgetNetworkButtonTapped() { - onConfirmForget() - } -} - -final class NetworkDetailViewController: UIViewController { - @UIBindable var model: NetworkDetailModel - - init(model: NetworkDetailModel) { - self.model = model - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - navigationItem.title = model.network.name - - let forgetButton = UIButton( - type: .system, - primaryAction: UIAction { [weak self] _ in - self?.model.forgetNetworkButtonTapped() - }) - forgetButton.setTitle("Forget network", for: .normal) - forgetButton.setTitleColor(.red, for: .normal) - forgetButton.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(forgetButton) - NSLayoutConstraint.activate([ - forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - observe { [weak self] in - guard let self else { return } - - forgetButton.isHidden = model.network.id != model.selectedNetworkID - } - - present(isPresented: $model.forgetAlertIsPresented) { [unowned self] in - let controller = UIAlertController( - title: "Forget Wi-Fi Network “\(model.network.name)”?", - message: """ - Your iPhone and other devices using iCloud Keychain will no longer join this Wi-Fi \ - network. - """, - preferredStyle: .alert - ) - controller.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - controller.addAction( - UIAlertAction(title: "Forget", style: .destructive) { [weak self] _ in - self?.model.confirmForgetNetworkButtonTapped() - }) - return controller - } - } -} - -#Preview { - UIViewControllerRepresenting { - UINavigationController( - rootViewController: NetworkDetailViewController( - model: NetworkDetailModel( - network: Network(name: "Blob's WiFi"), - selectedNetworkID: UUID() - ) - ) - ) - } -} -#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift deleted file mode 100644 index a025d7855e..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift +++ /dev/null @@ -1,313 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import SwiftUI -import UIKitNavigation - -@Observable -@MainActor -class WiFiSettingsModel { - var destination: Destination? { - didSet { bind() } - } - var foundNetworks: [Network] - var isOn: Bool - var selectedNetworkID: Network.ID? - - @CasePathable - enum Destination { - case connect(ConnectToNetworkModel) - case detail(NetworkDetailModel) - } - - init( - foundNetworks: [Network] = [], - isOn: Bool = true, - selectedNetworkID: Network.ID? = nil - ) { - self.foundNetworks = foundNetworks - self.isOn = isOn - self.selectedNetworkID = selectedNetworkID - bind() - } - - private func bind() { - switch destination { - case .connect(let model): - model.onConnect = { [weak self] network in - guard let self else { return } - destination = nil - selectedNetworkID = network.id - } - - case .detail(let model): - model.onConfirmForget = { [weak self] in - guard let self else { return } - self.destination = nil - self.selectedNetworkID = nil - } - - case .none: - break - } - } - - func infoButtonTapped(network: Network) { - destination = .detail( - NetworkDetailModel( - network: network, - selectedNetworkID: selectedNetworkID - ) - ) - } - - func networkTapped(_ network: Network) { - if network.id == selectedNetworkID { - infoButtonTapped(network: network) - } else if network.isSecured { - destination = .connect(ConnectToNetworkModel(network: network)) - } else { - selectedNetworkID = network.id - } - } -} - -class WiFiSettingsViewController: UICollectionViewController, UIKitCaseStudy { - let caseStudyTitle = "Wi-Fi Settings" - let readMe = """ - This demo shows how to built a moderately complex feature using the tools of the library. \ - There are multiple features that communicate with each other, there are multiple navigation \ - patterns, and the root feature has a complex collection view that updates dynamically. - """ - let isPresentedInSheet = true - - @UIBindable var model: WiFiSettingsModel - var dataSource: UICollectionViewDiffableDataSource! - - init(model: WiFiSettingsModel) { - self.model = model - super.init( - collectionViewLayout: UICollectionViewCompositionalLayout.list( - using: UICollectionLayoutListConfiguration(appearance: .insetGrouped) - ) - ) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - navigationItem.title = "Wi-Fi" - - let cellRegistration = UICollectionView.CellRegistration { - [weak self] cell, indexPath, item in - - guard let self else { return } - configure(cell: cell, indexPath: indexPath, item: item) - } - - self.dataSource = UICollectionViewDiffableDataSource( - collectionView: self.collectionView - ) { collectionView, indexPath, item in - collectionView.dequeueConfiguredReusableCell( - using: cellRegistration, - for: indexPath, - item: item - ) - } - - observe { [weak self] in - guard let self else { return } - dataSource.apply( - NSDiffableDataSourceSnapshot(model: model), - animatingDifferences: true - ) - } - - present(item: $model.destination.connect) { model in - UINavigationController( - rootViewController: ConnectToNetworkViewController(model: model) - ) - } - - navigationDestination(item: $model.destination.detail) { model in - NetworkDetailViewController(model: model) - } - } - - private func configure( - cell: UICollectionViewListCell, - indexPath: IndexPath, - item: Item - ) { - var configuration = cell.defaultContentConfiguration() - defer { cell.contentConfiguration = configuration } - cell.accessories = [] - - switch item { - case .isOn: - configuration.text = "Wi-Fi" - cell.accessories = [ - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: UISwitch(isOn: $model.isOn), - placement: .trailing(displayed: .always) - ) - ) - ] - - case let .selectedNetwork(networkID): - guard let network = model.foundNetworks.first(where: { $0.id == networkID }) - else { return } - configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) - - case let .foundNetwork(network): - configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) - } - - func configureNetwork( - cell: UICollectionViewListCell, - network: Network, - indexPath: IndexPath, - item: Item - ) { - configuration.text = network.name - cell.accessories.append( - .detail(displayed: .always) { [weak self] in - guard let self else { return } - model.infoButtonTapped(network: network) - } - ) - if network.isSecured { - let image = UIImage(systemName: "lock.fill")! - let imageView = UIImageView(image: image) - imageView.tintColor = .darkText - cell.accessories.append( - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: imageView, - placement: .trailing(displayed: .always) - ) - ) - ) - } - let image = UIImage(systemName: "wifi", variableValue: network.connectivity)! - let imageView = UIImageView(image: image) - imageView.tintColor = .darkText - cell.accessories.append( - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: imageView, - placement: .trailing(displayed: .always) - ) - ) - ) - if network.id == model.selectedNetworkID { - cell.accessories.append( - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: UIImageView(image: UIImage(systemName: "checkmark")!), - placement: .leading(displayed: .always), - reservedLayoutWidth: .custom(1) - ) - ) - ) - } - } - } - - override func collectionView( - _ collectionView: UICollectionView, - shouldSelectItemAt indexPath: IndexPath - ) -> Bool { - indexPath.section != 0 || indexPath.row != 0 - } - - override func collectionView( - _ collectionView: UICollectionView, - didSelectItemAt indexPath: IndexPath - ) { - guard let network = dataSource.itemIdentifier(for: indexPath)?.foundNetwork - else { return } - model.networkTapped(network) - } - - enum Section: Hashable, Sendable { - case top - case foundNetworks - } - - @CasePathable - @dynamicMemberLookup - enum Item: Hashable, Sendable { - case isOn - case selectedNetwork(Network.ID) - case foundNetwork(Network) - } -} - -extension NSDiffableDataSourceSnapshot< - WiFiSettingsViewController.Section, - WiFiSettingsViewController.Item -> { - @MainActor - init(model: WiFiSettingsModel) { - self.init() - - appendSections([.top]) - appendItems([.isOn], toSection: .top) - - guard model.isOn - else { return } - - if let selectedNetworkID = model.selectedNetworkID { - appendItems([.selectedNetwork(selectedNetworkID)], toSection: .top) - } - - appendSections([.foundNetworks]) - appendItems( - model.foundNetworks - .sorted { lhs, rhs in - (lhs.isSecured ? 1 : 0, lhs.connectivity) - > (rhs.isSecured ? 1 : 0, rhs.connectivity) - } - .compactMap { network in - network.id == model.selectedNetworkID - ? nil - : .foundNetwork(network) - }, - toSection: .foundNetworks - ) - } -} - -#Preview { - let model = WiFiSettingsModel(foundNetworks: .mocks) - return UIViewControllerRepresenting { - UINavigationController( - rootViewController: WiFiSettingsViewController(model: model) - ) - } - .task { - while true { - try? await Task.sleep(for: .seconds(1)) - guard Bool.random() else { continue } - if Bool.random() { - guard let randomIndex = (0.. { + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.frame = .init(origin: .zero, size: .init(width: 450, height: 234)) + let wifiIconImage = NSImageView(systemSymbolName: "wifi") + wifiIconImage.contentTintColor = .systemBlue + wifiIconImage.symbolConfiguration = .init(pointSize: 60, weight: .regular) + + let titleLabel = NSTextField(labelWithString: "Enter the password for “\(model.network.name)”") + titleLabel.font = .boldSystemFont(ofSize: 13) + let detailLabel = NSTextField(wrappingLabelWithString: "You can also access this Wi-Fi network by sharing the password from a nearby iPhone, iPad, or Mac which has connected to this network and has you in their contacts.") + detailLabel.font = .systemFont(ofSize: 12) + let textStackView = NSStackView(views: [ + titleLabel, + detailLabel, + ]) + textStackView.orientation = .vertical + textStackView.alignment = .leading + + let topStackView = NSStackView(views: [ + Spacer(size: .init(width: 15, height: 0)), + wifiIconImage, + textStackView, + HorizontalMaxSpacer(), + ]) + topStackView.orientation = .horizontal + + let passwordLabel = NSTextField(labelWithString: "Password:") + + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.becomeFirstResponder() + passwordTextField.placeholderString = "The password is 'blob'" + + let centerStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + passwordLabel, + passwordTextField, + ]) + centerStackView.orientation = .horizontal + NSLayoutConstraint.activate([ + passwordTextField.widthAnchor.constraint(equalToConstant: 253), + ]) + + let cancelButton = NSButton { [weak self] _ in + guard let self else { return } + model.cancelButtonTapped() + } + cancelButton.title = "Cancel" + + let joinButton = NSButton { [weak self] _ in + guard let self else { return } + Task { + await self.model.joinButtonTapped() + } + } + joinButton.title = "Join" + + let progress = NSProgressIndicator() + progress.isIndeterminate = true + progress.style = .spinning + progress.controlSize = .small + progress.startAnimation(nil) + + let progressStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + progress, + ]) + + let bottomStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + cancelButton, + joinButton, + ]) + bottomStackView.orientation = .horizontal + + NSLayoutConstraint.activate([ + joinButton.widthAnchor.constraint(equalToConstant: 70), + cancelButton.widthAnchor.constraint(equalToConstant: 70), + ]) + + let stack = NSStackView(views: [ + topStackView, + centerStackView, + progressStackView, + bottomStackView, + ]) + + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + stack.edgeInsets = .init(top: 20, left: 0, bottom: 20, right: 20) + stack.distribution = .equalSpacing + stack.detachesHiddenViews = false + view.addSubview(stack) + progressStackView.isHidden = true + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + stack.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), + stack.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), + ]) + + observe { [weak self, weak passwordTextField] in + guard + let self, + let passwordTextField + else { return } + + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + progressStackView.isHidden = !model.isConnecting + } + + modal(isModaled: $model.incorrectPasswordAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Incorrect password for “\(model.network.name)”" + alert.addButton(withTitle: "OK") + return alert + } + } +} + +class HorizontalMaxSpacer: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setContentHuggingPriority(.fittingSizeCompression, for: .horizontal) + setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class Spacer: NSView { + init(size: NSSize) { + super.init(frame: .zero) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: size.width), + heightAnchor.constraint(equalToConstant: size.height), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#Preview(traits: .fixedLayout(width: 450, height: 262)) { + let vc = ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi") + ) + ) + vc.preferredContentSize = .init(width: 450, height: 234) + return vc +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift new file mode 100644 index 0000000000..305710387b --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift @@ -0,0 +1,36 @@ +import Foundation +import XCTestDynamicOverlay + +@Observable +@MainActor +class ConnectToNetworkModel: Identifiable { + var incorrectPasswordAlertIsPresented = false + var isConnecting = false + var onCancel: () -> Void = { + XCTFail("ConnectToNetworkModel.onCancel unimplemented.") + } + var onConnect: (Network) -> Void = { _ in + XCTFail("ConnectToNetworkModel.onConnect unimplemented.") + } + + let network: Network + var password = "" + init(network: Network) { + self.network = network + } + + func cancelButtonTapped() { + onCancel() + } + + func joinButtonTapped() async { + isConnecting = true + defer { isConnecting = false } + try? await Task.sleep(for: .seconds(1)) + if password == "blob" { + onConnect(network) + } else { + incorrectPasswordAlertIsPresented = true + } + } +} diff --git a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift new file mode 100644 index 0000000000..389a6b345f --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift @@ -0,0 +1,90 @@ +#if canImport(UIKit) && !os(watchOS) + +import SwiftUI +import UIKitNavigation + +final class ConnectToNetworkViewController: UIViewController { + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = "Enter the password for “\(model.network.name)”" + + let passwordTextField = UITextField(text: $model.password) + passwordTextField.borderStyle = .line + passwordTextField.isSecureTextEntry = true + passwordTextField.becomeFirstResponder() + passwordTextField.placeholder = "The password is 'blob'" + let joinButton = UIButton( + type: .system, + primaryAction: UIAction { [weak self] _ in + guard let self else { return } + Task { + await self.model.joinButtonTapped() + } + } + ) + joinButton.setTitle("Join network", for: .normal) + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + + let stack = UIStackView(arrangedSubviews: [ + passwordTextField, + joinButton, + activityIndicator, + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.widthAnchor.constraint(equalToConstant: 200), + ]) + + observe { [weak self, weak passwordTextField] in + guard + let self, + let passwordTextField + else { return } + + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + activityIndicator.isHidden = !model.isConnecting + } + + present(isPresented: $model.incorrectPasswordAlertIsPresented) { [unowned self] in + let controller = UIAlertController( + title: "Incorrect password for “\(model.network.name)”", + message: nil, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "OK", style: .default)) + return controller + } + } +} + +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi") + ) + ) + ) + } +} +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/Network.swift b/Examples/CaseStudies/WiFiFeature/Network.swift similarity index 100% rename from Examples/CaseStudies/UIKit/WiFiFeature/Network.swift rename to Examples/CaseStudies/WiFiFeature/Network.swift diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift new file mode 100644 index 0000000000..0c6219045a --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift @@ -0,0 +1,65 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation + +final class NetworkDetailViewController: XiblessViewController { + @UIBindable var model: NetworkDetailModel + + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + +// navigationItem.title = model.network.name + + let forgetButton = NSButton { [weak self] _ in + guard let self else { return } + model.forgetNetworkButtonTapped() + } + + forgetButton.title = "Forget network" +// forgetButton.hasDestructiveAction = true + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + +// forgetButton.isHidden = model.network.id != model.selectedNetworkID + } + + modal(isModaled: $model.forgetAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Forget Wi-Fi Network “\(model.network.name)”?" + alert.informativeText = """ + Your Mac and other devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """ + + alert.addButton(ButtonState(role: .cancel) { TextState("Cancel") }) { _ in } + alert.addButton(ButtonState(role: .destructive) { TextState("Forget") }) { [weak self] _ in + guard let self else { return } + model.confirmForgetNetworkButtonTapped() + } + return alert + } + } +} + +#Preview { + NetworkDetailViewController( + model: NetworkDetailModel( + network: Network(name: "Blob's WiFi"), + selectedNetworkID: UUID() + ) + ) +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift new file mode 100644 index 0000000000..db0101b467 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift @@ -0,0 +1,32 @@ +import Foundation +import XCTestDynamicOverlay + +@MainActor +@Observable +class NetworkDetailModel: Identifiable { + var forgetAlertIsPresented = false + var onConfirmForget: () -> Void = { + XCTFail("NetworkDetailModel.onConfirmForget unimplemented.") + } + + let network: Network + let selectedNetworkID: Network.ID? + + let id = UUID() + + init( + network: Network, + selectedNetworkID: Network.ID? + ) { + self.network = network + self.selectedNetworkID = selectedNetworkID + } + + func forgetNetworkButtonTapped() { + forgetAlertIsPresented = true + } + + func confirmForgetNetworkButtonTapped() { + onConfirmForget() + } +} diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift new file mode 100644 index 0000000000..d32e1afc05 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift @@ -0,0 +1,76 @@ +#if canImport(UIKit) && !os(watchOS) + +import SwiftUI +import UIKitNavigation + +final class NetworkDetailViewController: UIViewController { + @UIBindable var model: NetworkDetailModel + + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = model.network.name + + let forgetButton = UIButton( + type: .system, + primaryAction: UIAction { [weak self] _ in + self?.model.forgetNetworkButtonTapped() + } + ) + forgetButton.setTitle("Forget network", for: .normal) + forgetButton.setTitleColor(.red, for: .normal) + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + forgetButton.isHidden = model.network.id != model.selectedNetworkID + } + + present(isPresented: $model.forgetAlertIsPresented) { [unowned self] in + let controller = UIAlertController( + title: "Forget Wi-Fi Network “\(model.network.name)”?", + message: """ + Your iPhone and other devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + controller.addAction( + UIAlertAction(title: "Forget", style: .destructive) { [weak self] _ in + self?.model.confirmForgetNetworkButtonTapped() + }) + return controller + } + } +} + +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: NetworkDetailViewController( + model: NetworkDetailModel( + network: Network(name: "Blob's WiFi"), + selectedNetworkID: UUID() + ) + ) + ) + } +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift new file mode 100644 index 0000000000..698db6d09b --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift @@ -0,0 +1,352 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation +import SwiftUI + +class WiFiSettingsViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "Wi-Fi Settings" + let readMe = """ + This demo shows how to built a moderately complex feature using the tools of the library. \ + There are multiple features that communicate with each other, there are multiple navigation \ + patterns, and the root feature has a complex collection view that updates dynamically. + """ + let isPresentedInSheet = true + + @UIBindable var model: WiFiSettingsModel + + var dataSource: NSCollectionViewDiffableDataSource! + + let collectionView = NSCollectionView() + + init(model: WiFiSettingsModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + collectionView.autoresizingMask = [.width, .height] + contentView.documentView = collectionView + let collectionViewLayout = NSCollectionViewCompositionalLayout { sectionIndex, layoutEnvironment -> NSCollectionLayoutSection? in + // Item + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(sectionIndex == 0 ? 60 : 44)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.contentInsets = .init(top: 0, leading: sectionIndex == 0 ? 10 : 30, bottom: 0, trailing: 10) + // Group + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(sectionIndex == 0 ? 60 : 44)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + // Section + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) + let decorationItem = NSCollectionLayoutDecorationItem.background(elementKind: NSCollectionView.DecorationElementKind.background) + decorationItem.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10) + section.decorationItems = [decorationItem] + return section + } + collectionViewLayout.register(WiFiSettingsSectionBackgroundView.self, forDecorationViewOfKind: .background) + let configuration = NSCollectionViewCompositionalLayoutConfiguration() + configuration.interSectionSpacing = 20 + collectionViewLayout.configuration = configuration + collectionView.collectionViewLayout = collectionViewLayout + collectionView.register(WiFiSettingsSwitchViewItem.self, forItemWithIdentifier: .init(WiFiSettingsSwitchViewItem.self)) + collectionView.register(WiFiSettingsConnectedNetworkViewItem.self, forItemWithIdentifier: .init(WiFiSettingsConnectedNetworkViewItem.self)) + collectionView.register(WiFiSettingsFoundedNetworkViewItem.self, forItemWithIdentifier: .init(WiFiSettingsFoundedNetworkViewItem.self)) + + dataSource = NSCollectionViewDiffableDataSource( + collectionView: collectionView + ) { [unowned self] collectionView, indexPath, item in + + switch item { + case .isOn: + let switchViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsSwitchViewItem.self), for: indexPath) as! WiFiSettingsSwitchViewItem + switchViewItem.switchView.bind(isOn: $model.isOn) + return switchViewItem + case let .selectedNetwork(networkID): + guard let network = model.foundNetworks.first(where: { $0.id == networkID }) + else { return nil } + let connectedViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsConnectedNetworkViewItem.self), for: indexPath) as! WiFiSettingsConnectedNetworkViewItem + connectedViewItem.nameLabel.stringValue = network.name + connectedViewItem.securedIconImageView.isHidden = !network.isSecured + connectedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) + connectedViewItem.detailButton.addAction { _ in + self.model.infoButtonTapped(network: network) + } + return connectedViewItem + case let .foundNetwork(network): + let foundedViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsFoundedNetworkViewItem.self), for: indexPath) as! WiFiSettingsFoundedNetworkViewItem + foundedViewItem.titleLabel.stringValue = network.name + foundedViewItem.securedIconImageView.isHidden = !network.isSecured + foundedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) + foundedViewItem.connectButton.addAction { _ in + self.model.networkTapped(network) + } + return foundedViewItem + } + } + + observe { [weak self] in + guard let self else { return } + dataSource.apply( + NSDiffableDataSourceSnapshot(model: model), + animatingDifferences: true + ) + } + + modalSession(item: $model.destination.connect) { model in + let panel = NSPanel(contentViewController: ConnectToNetworkViewController(model: model)) + panel.styleMask = [.titled] + panel.animationBehavior = .none + return panel + } + + present(item: $model.destination.detail, style: .sheet) { model in + let vc = NetworkDetailViewController(model: model) + vc.preferredContentSize = .init(width: 300, height: 200) + return vc + } + } +} + +#Preview { + let model = WiFiSettingsModel(foundNetworks: .mocks) + return NSViewControllerRepresenting { + WiFiSettingsViewController(model: model) + } + .task { + while true { + try? await Task.sleep(for: .seconds(1)) + guard Bool.random() else { continue } + if Bool.random() { + guard let randomIndex = (0 ..< model.foundNetworks.count).randomElement() + else { continue } + if model.foundNetworks[randomIndex].id != model.selectedNetworkID { + model.foundNetworks.remove(at: randomIndex) + } + } else { + model.foundNetworks.append( + Network( + name: goofyWiFiNames.randomElement()!, + isSecured: !(1 ... 1000).randomElement()!.isMultiple(of: 5), + connectivity: Double((1 ... 100).randomElement()!) / 100 + ) + ) + } + } + } +} + +extension NSCollectionView.DecorationElementKind { + static let background = "BackgroundKind" +} + +class WiFiSettingsSectionBackgroundView: NSBox, NSCollectionViewElement { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + func commonInit() { + titlePosition = .noTitle + } +} + +class XiblessCollectionViewItem: NSCollectionViewItem { + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = View() + } +} + +class WiFiSettingsSwitchViewItem: XiblessCollectionViewItem { + let iconImageView = NSImageView() + + let titleLabel = NSTextField(labelWithString: "Wi-Fi") + + let switchView = NSSwitch() + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + let stackView = NSStackView() + stackView.addView(iconImageView, in: .leading) + stackView.addView(titleLabel, in: .leading) + stackView.addView(switchView, in: .trailing) + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.spacing = 15 + view.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leftAnchor.constraint(equalTo: view.leftAnchor), + stackView.rightAnchor.constraint(equalTo: view.rightAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + let image = NSImage(systemSymbolName: "wifi", accessibilityDescription: "Wi-Fi")! + iconImageView.image = image.withSymbolConfiguration(.init(pointSize: 15, weight: .regular)) + iconImageView.wantsLayer = true + iconImageView.layer?.cornerRadius = 5 + iconImageView.layer?.backgroundColor = NSColor.systemBlue.cgColor + iconImageView.contentTintColor = .white + NSLayoutConstraint.activate([ + iconImageView.widthAnchor.constraint(equalToConstant: 26), + iconImageView.heightAnchor.constraint(equalToConstant: 20), + ]) + switchView.controlSize = .large + } +} + +class WiFiSettingsConnectedNetworkViewItem: XiblessCollectionViewItem { + let nameLabel = NSTextField(labelWithString: "WiFi Name") + let statusButton = NSButton() + let securedIconImageView = NSImageView(systemSymbolName: "lock.fill") + let wifiIconImageView = NSImageView(systemSymbolName: "wifi") + let detailButton = NSButton(title: "Detail...", target: nil, action: nil) + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + let stackView = NSStackView() + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.spacing = 15 + view.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leftAnchor.constraint(equalTo: view.leftAnchor), + stackView.rightAnchor.constraint(equalTo: view.rightAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let textStackView = NSStackView() + textStackView.orientation = .vertical + textStackView.alignment = .leading + textStackView.spacing = 3 + textStackView.addArrangedSubview(nameLabel) + textStackView.addArrangedSubview(statusButton) + stackView.addView(textStackView, in: .leading) + stackView.addView(securedIconImageView, in: .trailing) + stackView.addView(wifiIconImageView, in: .trailing) + stackView.addView(detailButton, in: .trailing) + + statusButton.isBordered = false + statusButton.image = NSImage(named: NSImage.statusAvailableName) + (statusButton.cell as! NSButtonCell).highlightsBy = [] + statusButton.attributedTitle = NSAttributedString(string: "Connected", attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + ]) + statusButton.imagePosition = .imageLeft + securedIconImageView.contentTintColor = .labelColor + wifiIconImageView.contentTintColor = .labelColor + } + + override func prepareForReuse() { + super.prepareForReuse() + + detailButton.removeAllActions() + } +} + +class WiFiSettingsFoundedNetworkViewItem: XiblessCollectionViewItem { + let selectedIconImageView = NSImageView(systemSymbolName: "checkmark") + let titleLabel = NSTextField(labelWithString: "WiFi Name") + let securedIconImageView = NSImageView(systemSymbolName: "lock.fill") + let wifiIconImageView = NSImageView(systemSymbolName: "wifi") + let connectButton = NSButton(title: "Connect", target: nil, action: nil) + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + let stackView = NSStackView() + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.spacing = 10 + view.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leftAnchor.constraint(equalTo: view.leftAnchor), + stackView.rightAnchor.constraint(equalTo: view.rightAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) +// stackView.addView(selectedIconImageView, in: .leading) + stackView.addView(titleLabel, in: .leading) + stackView.addView(connectButton, in: .trailing) + stackView.addView(securedIconImageView, in: .trailing) + stackView.addView(wifiIconImageView, in: .trailing) + selectedIconImageView.symbolConfiguration = .init(pointSize: 12, weight: .heavy) + selectedIconImageView.contentTintColor = .labelColor + securedIconImageView.contentTintColor = .labelColor + wifiIconImageView.contentTintColor = .labelColor + connectButton.isHidden = true + } + + override func viewDidLayout() { + super.viewDidLayout() + view.trackingAreas.forEach(view.removeTrackingArea(_:)) + view.addTrackingArea(.init(rect: view.bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .assumeInside], owner: self)) + } + + override func mouseEntered(with event: NSEvent) { + connectButton.isHidden = false + } + + override func mouseMoved(with event: NSEvent) { + connectButton.isHidden = false + } + + override func mouseExited(with event: NSEvent) { + connectButton.isHidden = true + } + + override func prepareForReuse() { + super.prepareForReuse() + + connectButton.removeAllActions() + } +} + +@available(macOS 14.0, *) +#Preview { + let item = WiFiSettingsConnectedNetworkViewItem() + item.preferredContentSize = .init(width: 500, height: 200) + return item +} + +extension NSUserInterfaceItemIdentifier { + init(_ anyClass: AnyClass) { + self.init(rawValue: "\(anyClass)") + } +} + +extension NSImageView { + convenience init(systemSymbolName: String) { + self.init() + self.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: nil) + } + + convenience init(systemSymbolName: String, variableValue: Double) { + self.init() + self.image = NSImage(systemSymbolName: systemSymbolName, variableValue: variableValue, accessibilityDescription: nil) + } +} + +extension NSButton { + convenience init(systemSymbolName: String) { + self.init() + self.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: nil) + } +} + +#endif diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift new file mode 100644 index 0000000000..ae1081985b --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift @@ -0,0 +1,199 @@ +#if canImport(UIKit) && !os(watchOS) + +import SwiftUI +import UIKitNavigation + +class WiFiSettingsViewController: UICollectionViewController, UIKitCaseStudy { + let caseStudyTitle = "Wi-Fi Settings" + let readMe = """ + This demo shows how to built a moderately complex feature using the tools of the library. \ + There are multiple features that communicate with each other, there are multiple navigation \ + patterns, and the root feature has a complex collection view that updates dynamically. + """ + let isPresentedInSheet = true + + @UIBindable var model: WiFiSettingsModel + var dataSource: UICollectionViewDiffableDataSource! + + init(model: WiFiSettingsModel) { + self.model = model + super.init( + collectionViewLayout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration(appearance: .insetGrouped) + ) + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.title = "Wi-Fi" + + let cellRegistration = UICollectionView.CellRegistration { + [weak self] cell, indexPath, item in + + guard let self else { return } + configure(cell: cell, indexPath: indexPath, item: item) + } + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: item + ) + } + + observe { [weak self] in + guard let self else { return } + dataSource.apply( + NSDiffableDataSourceSnapshot(model: model), + animatingDifferences: true + ) + } + + present(item: $model.destination.connect) { model in + UINavigationController( + rootViewController: ConnectToNetworkViewController(model: model) + ) + } + + navigationDestination(item: $model.destination.detail) { model in + NetworkDetailViewController(model: model) + } + } + + private func configure( + cell: UICollectionViewListCell, + indexPath: IndexPath, + item: Item + ) { + var configuration = cell.defaultContentConfiguration() + defer { cell.contentConfiguration = configuration } + cell.accessories = [] + + switch item { + case .isOn: + configuration.text = "Wi-Fi" + cell.accessories = [ + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UISwitch(isOn: $model.isOn), + placement: .trailing(displayed: .always) + ) + ), + ] + + case let .selectedNetwork(networkID): + guard let network = model.foundNetworks.first(where: { $0.id == networkID }) + else { return } + configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) + + case let .foundNetwork(network): + configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) + } + + func configureNetwork( + cell: UICollectionViewListCell, + network: Network, + indexPath: IndexPath, + item: Item + ) { + configuration.text = network.name + cell.accessories.append( + .detail(displayed: .always) { [weak self] in + guard let self else { return } + model.infoButtonTapped(network: network) + } + ) + if network.isSecured { + let image = UIImage(systemName: "lock.fill")! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + } + let image = UIImage(systemName: "wifi", variableValue: network.connectivity)! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + if network.id == model.selectedNetworkID { + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UIImageView(image: UIImage(systemName: "checkmark")!), + placement: .leading(displayed: .always), + reservedLayoutWidth: .custom(1) + ) + ) + ) + } + } + } + + override func collectionView( + _ collectionView: UICollectionView, + shouldSelectItemAt indexPath: IndexPath + ) -> Bool { + indexPath.section != 0 || indexPath.row != 0 + } + + override func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + guard let network = dataSource.itemIdentifier(for: indexPath)?.foundNetwork + else { return } + model.networkTapped(network) + } +} + +#Preview { + let model = WiFiSettingsModel(foundNetworks: .mocks) + return UIViewControllerRepresenting { + UINavigationController( + rootViewController: WiFiSettingsViewController(model: model) + ) + } + .task { + while true { + try? await Task.sleep(for: .seconds(1)) + guard Bool.random() else { continue } + if Bool.random() { + guard let randomIndex = (0 ..< model.foundNetworks.count).randomElement() + else { continue } + if model.foundNetworks[randomIndex].id != model.selectedNetworkID { + model.foundNetworks.remove(at: randomIndex) + } + } else { + model.foundNetworks.append( + Network( + name: goofyWiFiNames.randomElement()!, + isSecured: !(1 ... 1000).randomElement()!.isMultiple(of: 5), + connectivity: Double((1 ... 100).randomElement()!) / 100 + ) + ) + } + } + } +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift new file mode 100644 index 0000000000..a212f71177 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift @@ -0,0 +1,75 @@ +import Foundation +import CasePaths + +@Observable +@MainActor +class WiFiSettingsModel { + var destination: Destination? { + didSet { bind() } + } + + var foundNetworks: [Network] + var isOn: Bool + var selectedNetworkID: Network.ID? + + @CasePathable + enum Destination { + case connect(ConnectToNetworkModel) + case detail(NetworkDetailModel) + } + + init( + foundNetworks: [Network] = [], + isOn: Bool = true, + selectedNetworkID: Network.ID? = nil + ) { + self.foundNetworks = foundNetworks + self.isOn = isOn + self.selectedNetworkID = selectedNetworkID + bind() + } + + private func bind() { + switch destination { + case let .connect(model): + model.onConnect = { [weak self] network in + guard let self else { return } + destination = nil + selectedNetworkID = network.id + } + model.onCancel = { [weak self] in + guard let self else { return } + destination = nil + } + + case let .detail(model): + model.onConfirmForget = { [weak self] in + guard let self else { return } + self.destination = nil + self.selectedNetworkID = nil + } + + case .none: + break + } + } + + func infoButtonTapped(network: Network) { + destination = .detail( + NetworkDetailModel( + network: network, + selectedNetworkID: selectedNetworkID + ) + ) + } + + func networkTapped(_ network: Network) { + if network.id == selectedNetworkID { + infoButtonTapped(network: network) + } else if network.isSecured { + destination = .connect(ConnectToNetworkModel(network: network)) + } else { + selectedNetworkID = network.id + } + } +} diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift new file mode 100644 index 0000000000..a94213b0e9 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift @@ -0,0 +1,60 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +import CasePaths + +extension NSDiffableDataSourceSnapshot< + WiFiSettingsViewController.Section, + WiFiSettingsViewController.Item +> { + @MainActor + init(model: WiFiSettingsModel) { + self.init() + + appendSections([.top]) + appendItems([.isOn], toSection: .top) + + guard model.isOn + else { return } + + if let selectedNetworkID = model.selectedNetworkID { + appendItems([.selectedNetwork(selectedNetworkID)], toSection: .top) + } + + appendSections([.foundNetworks]) + appendItems( + model.foundNetworks + .sorted { lhs, rhs in + (lhs.isSecured ? 1 : 0, lhs.connectivity) + > (rhs.isSecured ? 1 : 0, rhs.connectivity) + } + .compactMap { network in + network.id == model.selectedNetworkID + ? nil + : .foundNetwork(network) + }, + toSection: .foundNetworks + ) + } +} + +extension WiFiSettingsViewController { + enum Section: Hashable, Sendable { + case top + case foundNetworks + } + + @CasePathable + @dynamicMemberLookup + enum Item: Hashable, Sendable { + case isOn + case selectedNetwork(Network.ID) + case foundNetwork(Network) + } +} + diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index f3552b0cfa..ea0c563924 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -8,10 +8,10 @@ /* Begin PBXBuildFile section */ CA26036D2C20973600822CA5 /* DetentsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA26036C2C20973600822CA5 /* DetentsHelper.swift */; }; - CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9B82C0525980034B757 /* ConnectToNetworkFeature.swift */; }; - CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BA2C0525980034B757 /* NetworkDetailFeature.swift */; }; + CA3D0E322C20B27B00CCF7CD /* UIKit+ConnectToNetworkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9B82C0525980034B757 /* UIKit+ConnectToNetworkViewController.swift */; }; + CA3D0E332C20B27B00CCF7CD /* UIKit+NetworkDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BA2C0525980034B757 /* UIKit+NetworkDetailViewController.swift */; }; CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9B92C0525980034B757 /* Network.swift */; }; - CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BB2C0525980034B757 /* WiFiSettingsFeature.swift */; }; + CA3D0E352C20B27B00CCF7CD /* WiFiSettingsViewController+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BB2C0525980034B757 /* WiFiSettingsViewController+.swift */; }; CA4737CF272F09600012CAC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA4737CE272F09600012CAC3 /* Assets.xcassets */; }; CA4737F4272F09780012CAC3 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA4737F3272F09780012CAC3 /* SwiftUINavigation */; }; CA4737F9272F09D00012CAC3 /* ItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4737F5272F09D00012CAC3 /* ItemRow.swift */; }; @@ -24,7 +24,7 @@ CA473837272F0D860012CAC3 /* FactClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473831272F0D860012CAC3 /* FactClient.swift */; }; CA473838272F0D860012CAC3 /* OptionalNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473832272F0D860012CAC3 /* OptionalNavigation.swift */; }; CA48F2FA2C49645100BE2C3C /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F2F92C49645100BE2C3C /* SwiftUINavigation */; }; - CA48F2FC2C49645100BE2C3C /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F2FB2C49645100BE2C3C /* UIKitNavigation */; }; + CA48F2FC2C49645100BE2C3C /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; platformFilter = ios; productRef = CA48F2FB2C49645100BE2C3C /* UIKitNavigation */; }; CA48F3022C49650100BE2C3C /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F3012C49650100BE2C3C /* IdentifiedCollections */; }; CA48F3052C49650F00BE2C3C /* Tagged in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F3042C49650F00BE2C3C /* Tagged */; }; CA48F3082C49651700BE2C3C /* ConcurrencyExtras in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F3072C49651700BE2C3C /* ConcurrencyExtras */; }; @@ -53,8 +53,15 @@ DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */; }; DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC86E8702C208D8A003C0EC9 /* Text+Template.swift */; }; E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */; }; - E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; + E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; platformFilters = (macos, ); productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */; }; + E99B05952C70CE36003D3AFF /* WiFiSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05942C70CE36003D3AFF /* WiFiSettingsModel.swift */; }; + E99B05972C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05962C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift */; }; + E99B05992C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05982C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift */; }; + E99B059C2C70CEFE003D3AFF /* ConnectToNetworkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B059B2C70CEFE003D3AFF /* ConnectToNetworkModel.swift */; }; + E99B059E2C70CF18003D3AFF /* NetworkDetailModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B059D2C70CF18003D3AFF /* NetworkDetailModel.swift */; }; + E99B05A22C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05A12C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift */; }; + E99B05A42C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05A32C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift */; }; E9CD5B762C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */; }; E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */; }; E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */; }; @@ -107,10 +114,10 @@ CABE9FC0272F2C0000AFC150 /* EnumNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumNavigation.swift; sourceTree = ""; }; CADCA3622C1CE75500DE645F /* SwiftUICaseStudies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUICaseStudies.swift; sourceTree = ""; }; CADCA3652C1CE8BE00DE645F /* CaseStudy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudy.swift; sourceTree = ""; }; - DC5EB9B82C0525980034B757 /* ConnectToNetworkFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectToNetworkFeature.swift; sourceTree = ""; }; + DC5EB9B82C0525980034B757 /* UIKit+ConnectToNetworkViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIKit+ConnectToNetworkViewController.swift"; sourceTree = ""; }; DC5EB9B92C0525980034B757 /* Network.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; - DC5EB9BA2C0525980034B757 /* NetworkDetailFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkDetailFeature.swift; sourceTree = ""; }; - DC5EB9BB2C0525980034B757 /* WiFiSettingsFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WiFiSettingsFeature.swift; sourceTree = ""; }; + DC5EB9BA2C0525980034B757 /* UIKit+NetworkDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIKit+NetworkDetailViewController.swift"; sourceTree = ""; }; + DC5EB9BB2C0525980034B757 /* WiFiSettingsViewController+.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WiFiSettingsViewController+.swift"; sourceTree = ""; }; DC5EB9CB2C0525E80034B757 /* AssertEventually.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssertEventually.swift; sourceTree = ""; }; DC5EB9CC2C0525E80034B757 /* SetUp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetUp.swift; sourceTree = ""; }; DC5EB9CD2C0525E80034B757 /* XCTTODO.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XCTTODO.swift; sourceTree = ""; }; @@ -120,6 +127,13 @@ DC86E8702C208D8A003C0EC9 /* Text+Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Template.swift"; sourceTree = ""; }; E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+MinimalObservationViewController.swift"; sourceTree = ""; }; E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+NSControlBindingsViewController.swift"; sourceTree = ""; }; + E99B05942C70CE36003D3AFF /* WiFiSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WiFiSettingsModel.swift; sourceTree = ""; }; + E99B05962C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+WiFiSettingsViewController.swift"; sourceTree = ""; }; + E99B05982C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+WiFiSettingsViewController.swift"; sourceTree = ""; }; + E99B059B2C70CEFE003D3AFF /* ConnectToNetworkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToNetworkModel.swift; sourceTree = ""; }; + E99B059D2C70CF18003D3AFF /* NetworkDetailModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDetailModel.swift; sourceTree = ""; }; + E99B05A12C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+NetworkDetailViewController.swift"; sourceTree = ""; }; + E99B05A32C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+ConnectToNetworkViewController.swift"; sourceTree = ""; }; E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+EnumControlsViewController.swift"; sourceTree = ""; }; E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+FocusViewController.swift"; sourceTree = ""; }; E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppKit+BasicsNavigationViewController.swift"; sourceTree = ""; }; @@ -207,6 +221,7 @@ CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */, CA47382E272F0D860012CAC3 /* RootView.swift */, CA47380A272F0D340012CAC3 /* Assets.xcassets */, + DC5EB9BC2C0525980034B757 /* WiFiFeature */, CA9D70492C1F642A003B672A /* Internal */, CADCA3642C1CE80B00DE645F /* SwiftUI */, CA9D704A2C1F6431003B672A /* UIKit */, @@ -252,7 +267,6 @@ CA9D704F2C1FB431003B672A /* StaticNavigationStackController.swift */, CA9D70592C208977003B672A /* UIControlBindingsViewController.swift */, CA9D70442C1F6001003B672A /* UIKitCaseStudies.swift */, - DC5EB9BC2C0525980034B757 /* WiFiFeature */, ); path = UIKit; sourceTree = ""; @@ -273,10 +287,10 @@ DC5EB9BC2C0525980034B757 /* WiFiFeature */ = { isa = PBXGroup; children = ( - DC5EB9B82C0525980034B757 /* ConnectToNetworkFeature.swift */, DC5EB9B92C0525980034B757 /* Network.swift */, - DC5EB9BA2C0525980034B757 /* NetworkDetailFeature.swift */, - DC5EB9BB2C0525980034B757 /* WiFiSettingsFeature.swift */, + E99B05A02C70CF43003D3AFF /* ConnectToNetworkFeature */, + E99B059F2C70CF38003D3AFF /* NetworkDetailFeature */, + E99B059A2C70CEE1003D3AFF /* WiFiSettingsFeature */, ); path = WiFiFeature; sourceTree = ""; @@ -305,6 +319,37 @@ path = AppKit; sourceTree = ""; }; + E99B059A2C70CEE1003D3AFF /* WiFiSettingsFeature */ = { + isa = PBXGroup; + children = ( + E99B05942C70CE36003D3AFF /* WiFiSettingsModel.swift */, + DC5EB9BB2C0525980034B757 /* WiFiSettingsViewController+.swift */, + E99B05962C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift */, + E99B05982C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift */, + ); + path = WiFiSettingsFeature; + sourceTree = ""; + }; + E99B059F2C70CF38003D3AFF /* NetworkDetailFeature */ = { + isa = PBXGroup; + children = ( + E99B059D2C70CF18003D3AFF /* NetworkDetailModel.swift */, + E99B05A12C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift */, + DC5EB9BA2C0525980034B757 /* UIKit+NetworkDetailViewController.swift */, + ); + path = NetworkDetailFeature; + sourceTree = ""; + }; + E99B05A02C70CF43003D3AFF /* ConnectToNetworkFeature */ = { + isa = PBXGroup; + children = ( + E99B059B2C70CEFE003D3AFF /* ConnectToNetworkModel.swift */, + E99B05A32C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift */, + DC5EB9B82C0525980034B757 /* UIKit+ConnectToNetworkViewController.swift */, + ); + path = ConnectToNetworkFeature; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -466,8 +511,12 @@ CABE9FC1272F2C0000AFC150 /* EnumNavigation.swift in Sources */, E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */, E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */, + E99B059E2C70CF18003D3AFF /* NetworkDetailModel.swift in Sources */, + E99B059C2C70CEFE003D3AFF /* ConnectToNetworkModel.swift in Sources */, E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */, DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */, + E99B05992C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift in Sources */, + E99B05A42C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift in Sources */, CA9D70502C1FB431003B672A /* StaticNavigationStackController.swift in Sources */, CA473837272F0D860012CAC3 /* FactClient.swift in Sources */, E9CD5B7C2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift in Sources */, @@ -476,18 +525,21 @@ CA9D70562C2087DC003B672A /* EnumControls.swift in Sources */, CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */, CA473834272F0D860012CAC3 /* RootView.swift in Sources */, - CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */, + CA3D0E322C20B27B00CCF7CD /* UIKit+ConnectToNetworkViewController.swift in Sources */, E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */, - CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */, + CA3D0E332C20B27B00CCF7CD /* UIKit+NetworkDetailViewController.swift in Sources */, CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */, - CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */, + CA3D0E352C20B27B00CCF7CD /* WiFiSettingsViewController+.swift in Sources */, + E99B05972C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift in Sources */, CA26036D2C20973600822CA5 /* DetentsHelper.swift in Sources */, CA9D705A2C208977003B672A /* UIControlBindingsViewController.swift in Sources */, CA601BB02C236544006B0C5F /* FocusViewController.swift in Sources */, + E99B05952C70CE36003D3AFF /* WiFiSettingsModel.swift in Sources */, CA9D70472C1F6108003B672A /* AlertDialogState.swift in Sources */, CA9D70452C1F6001003B672A /* UIKitCaseStudies.swift in Sources */, CADCA3632C1CE75500DE645F /* SwiftUICaseStudies.swift in Sources */, CA9D705E2C2089DD003B672A /* EnumControlsViewController.swift in Sources */, + E99B05A22C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift in Sources */, CA601BAE2C234613006B0C5F /* AnimationsViewController.swift in Sources */, CADCA3662C1CE8BE00DE645F /* CaseStudy.swift in Sources */, CA49D9542C20D4DF00E6C5BB /* ErasedNavigationStackController.swift in Sources */, diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift index 37d7bb6ba3..ad4f4bf0df 100644 --- a/Sources/AppKitNavigation/Bindings/NSAlert.swift +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -28,7 +28,7 @@ extension NSAlert { ) { let button = addButton(withTitle: String(state: buttonState.label)) - button.createActionHandlerIfNeeded().addAction { _ in + button.createActionProxyIfNeeded().addBindingAction { _ in buttonState.withAction(handler) } diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift index e266d8bc8a..4e7a88ffae 100644 --- a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -26,7 +26,7 @@ extension NSColorPanel { 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 diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index d1ee6d68b3..02294ec437 100644 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -17,11 +17,22 @@ extension NSControl: NSTargetActionProtocol { extension NSControl { public convenience init(action: @escaping (Self) -> Void) { self.init(frame: .zero) - createActionHandlerIfNeeded().addAction { [weak self] _ in + createActionProxyIfNeeded().addAction { [weak self] _ in guard let self else { return } action(self) } } + + public func addAction(_ action: @escaping (NSControl) -> Void) { + createActionProxyIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } + + public func removeAllActions() { + createActionProxyIfNeeded().removeAllActions() + } } #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 752c4a62dc..a24aba654c 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -15,7 +15,7 @@ extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { } private static let appkitNavigationDelegateKey = malloc(1)! - + private var appkitNavigationDelegate: Delegate { set { objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) @@ -25,17 +25,17 @@ extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { return delegate } else { let delegate = Delegate() - self.target = 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) @@ -67,7 +67,7 @@ extension NSFontManager { public func bind(font: UIBinding) -> ObservationToken { bind(font, to: \._selectedFont) } - + @objc private var _selectedFont: NSFont { set { setSelectedFont(newValue, isMultiple: false) } get { convert(.systemFont(ofSize: 0)) } diff --git a/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift similarity index 100% rename from Sources/AppKitNavigation/Bindings/NSOpenPanel.swift rename to Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 4c0dce7971..6362757314 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -11,24 +11,59 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { } @MainActor -internal class NSTargetActionHandler: NSObject { +class NSTargetActionProxy: NSObject { typealias Action = (Any?) -> Void - var actions: [Action] = [] + + internal private(set) var bindingActions: [Action] = [] + + internal private(set) var actions: [Action] = [] var originTarget: AnyObject? 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(obj.object) } + actions.forEach { $0(obj.object) } + } + @objc func invokeAction(_ sender: Any?) { if let originTarget, let originAction { NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) } + bindingActions.forEach { $0(sender) } actions.forEach { $0(sender) } } func addAction(_ action: @escaping Action) { actions.append(action) } + + func removeAllActions() { + actions.removeAll() + } + + func addBindingAction(_ bindingAction: @escaping Action) { + bindingActions.append(bindingAction) + } + + func removeAllBindingActions() { + bindingActions.removeAll() + } } extension NSTargetActionProtocol { @@ -49,26 +84,22 @@ extension NSTargetActionProtocol { } } - internal var actionHandler: NSTargetActionHandler? { + var actionProxy: NSTargetActionProxy? { set { - objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler + objc_getAssociatedObject(self, actionProxyKey) as? NSTargetActionProxy } } - internal func createActionHandlerIfNeeded() -> NSTargetActionHandler { - if let actionHandler { - return actionHandler + func createActionProxyIfNeeded() -> NSTargetActionProxy { + if let actionProxy { + return actionProxy } else { - let actionHandler = NSTargetActionHandler() - actionHandler.originTarget = appkitNavigationTarget - actionHandler.originAction = appkitNavigationAction - self.actionHandler = actionHandler - appkitNavigationTarget = actionHandler - appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) - return actionHandler + let actionProxy = NSTargetActionProxy(owner: self) + self.actionProxy = actionProxy + return actionProxy } } @@ -89,8 +120,8 @@ extension NSTargetActionProtocol { set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void ) -> ObservationToken { unbind(keyPath) - let actionHandler = createActionHandlerIfNeeded() - actionHandler.addAction { [weak self] _ in + let actionProxy = createActionProxyIfNeeded() + actionProxy.addBindingAction { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } @@ -120,7 +151,7 @@ extension NSTargetActionProtocol { MainActor._assumeIsolated { self?.appkitNavigationTarget = nil self?.appkitNavigationAction = nil - self?.actionHandler = nil + self?.actionProxy = nil } token.cancel() observation.invalidate() @@ -162,10 +193,9 @@ extension NSObject { } } - @MainActor private let observationTokensKey = malloc(1)! @MainActor -private let actionHandlerKey = malloc(1)! +private let actionProxyKey = malloc(1)! #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index 64d1c0de7a..a5faaaf38f 100644 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -1,7 +1,9 @@ #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. @@ -69,14 +71,14 @@ extension NSTextField: NSTextViewDelegate { selection.wrappedValue = control.textSelection } } - + let observationToken = ObservationToken { [weak self] in -// MainActor._assumeIsolated { - editingChangedAction.cancel() - editingDidEndAction.cancel() -// } - token.cancel() - self?.textSelectionObserver = nil + MainActor._assumeIsolated { + editingChangedAction.cancel() + editingDidEndAction.cancel() + token.cancel() + self?.textSelectionObserver = nil + } } observationTokens[\NSTextField.selectedRange] = observationToken return observationToken @@ -334,4 +336,7 @@ public struct AppKitTextSelection: Hashable, Sendable { range.isEmpty } } + +extension AnyCancellable: @unchecked Sendable {} + #endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift index 4fb5f90e74..bc63b33443 100644 --- a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift +++ b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift @@ -9,13 +9,22 @@ class ModalWindowsObserver: NSObject { var windowsCancellable: [NSWindow: AnyCancellable] = [:] - func observeWindow(_ window: NSWindow) { + var modalSessionByWindow: [NSWindow: NSApplication.ModalSession] = [:] + + func observeWindow(_ window: NSWindow, modalSession: NSApplication.ModalSession? = nil) { + if let modalSession { + modalSessionByWindow[window] = modalSession + } windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) .sink { [weak self] _ in guard let self else { return } - if NSApplication.shared.modalWindow === window { + if let modalSession = modalSessionByWindow[window] { + NSApplication.shared.endModalSession(modalSession) + } else if NSApplication.shared.modalWindow === window { NSApplication.shared.stopModal() } + modalSessionByWindow.removeValue(forKey: window) + windowsCancellable[window]?.cancel() windowsCancellable.removeValue(forKey: window) } } diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Internal/NavigationObserver.swift similarity index 100% rename from Sources/AppKitNavigation/Navigation/NavigationObserver.swift rename to Sources/AppKitNavigation/Internal/NavigationObserver.swift diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 615f985e1d..95eca9b464 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -25,6 +25,15 @@ extension NSObject { ) -> ObservationToken { modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } + + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObservationToken { + modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -47,6 +56,15 @@ extension NSObject { ) -> ObservationToken { modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } + + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -70,6 +88,16 @@ extension NSObject { ) -> ObservationToken { modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } + + @_disfavoredOverload + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObservationToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -97,6 +125,18 @@ extension NSObject { } } + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modalSession(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + @discardableResult public func modal( item: UIBinding, @@ -131,6 +171,40 @@ extension NSObject { } } + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObservationToken { + modal(item: item, id: id) { $item in + content($item) + } beginModal: { modalContent, _ in + if let modaledWindow = NSApplication.shared.modalWindow, let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modaledWindow] { + NSApplication.shared.endModalSession(modalSession) + modaledWindow.window.close() + onDismiss?() + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) + } + + } else { + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) + } + } + } endModal: { modalContent, _ in + if let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modalContent.window] { + NSApplication.shared.endModalSession(modalSession) + modalContent.window.close() + onDismiss?() + } + } + } + private func modal( item: UIBinding, id: KeyPath, diff --git a/Sources/AppKitNavigation/Bindings/NSSavePanel.swift b/Sources/AppKitNavigation/Navigation/ModalSession.swift similarity index 97% rename from Sources/AppKitNavigation/Bindings/NSSavePanel.swift rename to Sources/AppKitNavigation/Navigation/ModalSession.swift index 893f31d20e..51a47075aa 100644 --- a/Sources/AppKitNavigation/Bindings/NSSavePanel.swift +++ b/Sources/AppKitNavigation/Navigation/ModalSession.swift @@ -2,4 +2,6 @@ import AppKit + + #endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift new file mode 100644 index 0000000000..5a27dab29c --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -0,0 +1,29 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol ModalSessionContent: ModalContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession +} + +extension NSWindow: ModalSessionContent { + + public func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + __appKitNavigationBeginModalSession() + } + + @objc func __appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + let modalSession = NSApplication.shared.beginModalSession(for: self) +// NSApplication.shared.runModalSession(modalSession) + return modalSession + } +} + +//extension NSSavePanel { +// override func __appKitNavigationBeginModalSession() -> NSApplication.ModalSession { +// begin +// } +//} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 8162d88d3a..eb06919cd8 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -11,15 +11,13 @@ public protocol SheetContent: NavigationContent { extension SheetContent { public func beginSheet(for content: any SheetContent) async { - if let sheetedWindow = content.currentWindow { - await currentWindow?.beginSheet(sheetedWindow) - } + guard let sheetedWindow = content.currentWindow else { return } + await currentWindow?.beginSheet(sheetedWindow) } public func endSheet(for content: any SheetContent) { - if let sheetedWindow = content.currentWindow { - currentWindow?.endSheet(sheetedWindow) - } + guard let sheetedWindow = content.currentWindow else { return } + currentWindow?.endSheet(sheetedWindow) } } @@ -48,4 +46,15 @@ extension NSAlert: SheetContent { } } +extension NSSavePanel { + public func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } +} + #endif diff --git a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index df6134abdd..d537d53be2 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" : "515f79b522918f83483068d99c68daeb5116342d", + "version" : "600.0.0-prerelease-2024-08-14" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", - "version" : "1.2.2" + "revision" : "c6809a193975cab4d5308c64bd1d51e0df467928", + "version" : "1.2.3" } } ], From 7278ccc691b1950a2c26a0e2aacb1054a987d236 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 18 Aug 2024 22:57:20 +0800 Subject: [PATCH 12/42] WIP --- Examples/CaseStudies/Internal/CaseStudy.swift | 7 +++++++ .../AppKit+WiFiSettingsViewController.swift | 13 ++++++++----- Examples/Examples.xcodeproj/project.pbxproj | 2 +- Sources/AppKitNavigation/Bindings/NSAlert.swift | 3 ++- .../Bindings/NSSaveOpenPanel.swift | 15 ++++++--------- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index b6d739cb31..c55a82c934 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -9,6 +9,7 @@ protocol CaseStudy { } protocol SwiftUICaseStudy: CaseStudy, View {} + #if canImport(UIKit) && !os(watchOS) import UIKitNavigation @@ -17,6 +18,7 @@ extension UIKitCaseStudy { var usesOwnLayout: Bool { true } } #endif + #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKitNavigation @@ -187,6 +189,11 @@ struct CaseStudyModifier: ViewModifier { .sheet(isPresented: $isAboutPresented) { Form { Text(template: caseStudy.readMe) + #if os(macOS) + Button("Close") { + isAboutPresented = false + } + #endif } .presentationDetents([.medium]) } diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift index 698db6d09b..9f72cd5b61 100644 --- a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift @@ -56,8 +56,8 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas dataSource = NSCollectionViewDiffableDataSource( collectionView: collectionView - ) { [unowned self] collectionView, indexPath, item in - + ) { [weak self] collectionView, indexPath, item in + guard let self else { return nil } switch item { case .isOn: let switchViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsSwitchViewItem.self), for: indexPath) as! WiFiSettingsSwitchViewItem @@ -70,7 +70,8 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas connectedViewItem.nameLabel.stringValue = network.name connectedViewItem.securedIconImageView.isHidden = !network.isSecured connectedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) - connectedViewItem.detailButton.addAction { _ in + connectedViewItem.detailButton.addAction { [weak self] _ in + guard let self else { return } self.model.infoButtonTapped(network: network) } return connectedViewItem @@ -79,7 +80,8 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas foundedViewItem.titleLabel.stringValue = network.name foundedViewItem.securedIconImageView.isHidden = !network.isSecured foundedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) - foundedViewItem.connectButton.addAction { _ in + foundedViewItem.connectButton.addAction { [weak self] _ in + guard let self else { return } self.model.networkTapped(network) } return foundedViewItem @@ -97,10 +99,11 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas modalSession(item: $model.destination.connect) { model in let panel = NSPanel(contentViewController: ConnectToNetworkViewController(model: model)) panel.styleMask = [.titled] + panel.title = "" panel.animationBehavior = .none return panel } - + present(item: $model.destination.detail, style: .sheet) { model in let vc = NetworkDetailViewController(model: model) vc.preferredContentSize = .init(width: 300, height: 200) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index ea0c563924..34e4997f5f 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -221,11 +221,11 @@ CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */, CA47382E272F0D860012CAC3 /* RootView.swift */, CA47380A272F0D340012CAC3 /* Assets.xcassets */, - DC5EB9BC2C0525980034B757 /* WiFiFeature */, CA9D70492C1F642A003B672A /* Internal */, CADCA3642C1CE80B00DE645F /* SwiftUI */, CA9D704A2C1F6431003B672A /* UIKit */, E9836D902C6C9E3000FC6EFD /* AppKit */, + DC5EB9BC2C0525980034B757 /* WiFiFeature */, ); path = CaseStudies; sourceTree = ""; diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift index ad4f4bf0df..812f894812 100644 --- a/Sources/AppKitNavigation/Bindings/NSAlert.swift +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -24,7 +24,7 @@ extension NSAlert { extension NSAlert { public func addButton( _ buttonState: ButtonState, - action handler: @escaping (_ action: Action?) -> Void + action handler: @escaping (_ action: Action?) -> Void = { (_: Never?) in } ) { let button = addButton(withTitle: String(state: buttonState.label)) @@ -35,6 +35,7 @@ extension NSAlert { if buttonState.role == .destructive, #available(macOS 11.0, *) { button.hasDestructiveAction = true } + if buttonState.role == .cancel { button.keyEquivalent = "\u{1b}" } diff --git a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift index 896135f71e..b781c72116 100644 --- a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift @@ -8,13 +8,13 @@ extension NSSavePanel { self.init() bind(url: url) } - + @discardableResult public func bind(url binding: UIBinding) -> ObservationToken { appKitNavigation_onFinalURL = { url in binding.wrappedValue = url } - + let observationToken = ObservationToken { [weak self] in guard let self else { return } MainActor._assumeIsolated { @@ -24,28 +24,25 @@ extension NSSavePanel { 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]>) -> ObservationToken { appKitNavigation_onFinalURLs = { urls in binding.wrappedValue = urls } - + let observationToken = ObservationToken { [weak self] in guard let self else { return } MainActor._assumeIsolated { @@ -55,7 +52,7 @@ extension NSOpenPanel { observationTokens[\NSOpenPanel.urls] = observationToken return observationToken } - + public func unbindURLs() { observationTokens[\NSOpenPanel.urls]?.cancel() observationTokens[\NSOpenPanel.urls] = nil From 61e1fff119d2a8888052a9d33c8908f46c41424e Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Mon, 19 Aug 2024 00:37:47 +0800 Subject: [PATCH 13/42] WIP --- Package.swift | 2 + .../AppKitNavigation/Bindings/NSControl.swift | 7 +- .../Bindings/NSTargetAction.swift | 62 +------------ .../Bindings/NSTargetActionProxy.swift | 88 +++++++++++++++++++ .../Internal/ClosureHolder.swift | 17 ---- .../ModalWindowsObserver.swift | 0 .../NavigationObserver.swift | 0 7 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift delete mode 100644 Sources/AppKitNavigation/Internal/ClosureHolder.swift rename Sources/AppKitNavigation/{Internal => Navigation}/ModalWindowsObserver.swift (100%) rename Sources/AppKitNavigation/{Internal => Navigation}/NavigationObserver.swift (100%) diff --git a/Package.swift b/Package.swift index 480e3861b8..fc20ea17dc 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,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.0.0"), ], targets: [ .target( @@ -83,6 +84,7 @@ let package = Package( "SwiftNavigation", "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), .target( diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 02294ec437..7edc6aaef2 100644 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -23,13 +23,18 @@ extension NSControl { } } - public func addAction(_ action: @escaping (NSControl) -> Void) { + @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() } diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 6362757314..fb6e9553fa 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -10,62 +10,6 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { var appkitNavigationAction: Selector? { set get } } -@MainActor -class NSTargetActionProxy: NSObject { - typealias Action = (Any?) -> Void - - internal private(set) var bindingActions: [Action] = [] - - internal private(set) var actions: [Action] = [] - - var originTarget: AnyObject? - - 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(obj.object) } - actions.forEach { $0(obj.object) } - } - - @objc func invokeAction(_ sender: Any?) { - if let originTarget, let originAction { - NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) - } - bindingActions.forEach { $0(sender) } - actions.forEach { $0(sender) } - } - - func addAction(_ action: @escaping Action) { - actions.append(action) - } - - func removeAllActions() { - actions.removeAll() - } - - func addBindingAction(_ bindingAction: @escaping Action) { - bindingActions.append(bindingAction) - } - - func removeAllBindingActions() { - bindingActions.removeAll() - } -} - extension NSTargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. /// @@ -121,7 +65,7 @@ extension NSTargetActionProtocol { ) -> ObservationToken { unbind(keyPath) let actionProxy = createActionProxyIfNeeded() - actionProxy.addBindingAction { [weak self] _ in + let actionID = actionProxy.addBindingAction { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } @@ -149,9 +93,7 @@ extension NSTargetActionProtocol { } let observationToken = ObservationToken { [weak self] in MainActor._assumeIsolated { - self?.appkitNavigationTarget = nil - self?.appkitNavigationAction = nil - self?.actionProxy = nil + self?.actionProxy?.removeAction(for: actionID) } token.cancel() observation.invalidate() diff --git a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift new file mode 100644 index 0000000000..2d2054d0ec --- /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/Internal/ClosureHolder.swift b/Sources/AppKitNavigation/Internal/ClosureHolder.swift deleted file mode 100644 index 814e884dff..0000000000 --- a/Sources/AppKitNavigation/Internal/ClosureHolder.swift +++ /dev/null @@ -1,17 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import Foundation - -internal class ClosureHolder: NSObject { - let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - func invoke() { - closure() - } -} - -#endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift similarity index 100% rename from Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift rename to Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift diff --git a/Sources/AppKitNavigation/Internal/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift similarity index 100% rename from Sources/AppKitNavigation/Internal/NavigationObserver.swift rename to Sources/AppKitNavigation/Navigation/NavigationObserver.swift From 2ba32eb37994d3076f1e23b12efacc1a0518838a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Mon, 19 Aug 2024 22:28:33 +0800 Subject: [PATCH 14/42] Format --- .../SwiftUI/Representable.swift | 20 ++++--- Sources/AppKitNavigation/UIBinding.swift | 14 ++--- Sources/AppKitNavigation/UITransaction.swift | 56 +++++++++---------- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/Sources/AppKitNavigation/SwiftUI/Representable.swift b/Sources/AppKitNavigation/SwiftUI/Representable.swift index 29ac8b2301..e89259acfe 100644 --- a/Sources/AppKitNavigation/SwiftUI/Representable.swift +++ b/Sources/AppKitNavigation/SwiftUI/Representable.swift @@ -1,24 +1,26 @@ #if canImport(SwiftUI) && canImport(AppKit) && !targetEnvironment(macCatalyst) - import SwiftUI - import AppKit +import SwiftUI +import AppKit - public struct NSViewControllerRepresenting< +public struct NSViewControllerRepresenting< NSViewControllerType: NSViewController - >: NSViewControllerRepresentable { +>: NSViewControllerRepresentable { private let base: NSViewControllerType public init(_ base: () -> NSViewControllerType) { - self.base = base() + self.base = base() } + public func makeNSViewController(context _: Context) -> NSViewControllerType { base } public func updateNSViewController(_: NSViewControllerType, context _: Context) {} - } +} - public struct NSViewRepresenting: NSViewRepresentable { +public struct NSViewRepresenting: NSViewRepresentable { private let base: NSViewType public init(_ base: () -> NSViewType) { - self.base = base() + self.base = base() } + public func makeNSView(context _: Context) -> NSViewType { base } public func updateNSView(_: NSViewType, context _: Context) {} - } +} #endif diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift index 4a66220b17..ea3499dec7 100644 --- a/Sources/AppKitNavigation/UIBinding.swift +++ b/Sources/AppKitNavigation/UIBinding.swift @@ -1,15 +1,15 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - import SwiftNavigation - - extension UIBinding { +import SwiftNavigation + +extension UIBinding { /// Specifies an animation to perform when the binding value changes. /// /// - Parameter animation: An animation sequence performed when the binding value changes. /// - Returns: A new binding. public func animation(_ animation: AppKitAnimation? = .default) -> Self { - var binding = self - binding.transaction.appKit.animation = animation - return binding + var binding = self + binding.transaction.appKit.animation = animation + return binding } - } +} #endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift index 88ba674b30..b4f9535f4e 100644 --- a/Sources/AppKitNavigation/UITransaction.swift +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -2,52 +2,52 @@ import SwiftNavigation - extension UITransaction { +extension UITransaction { /// Creates a transaction and assigns its animation property. /// /// - Parameter animation: The animation to perform when the current state changes. public init(animation: AppKitAnimation? = nil) { - self.init() - self.appKit.animation = animation + self.init() + appKit.animation = animation } /// AppKit-specific data associated with the current state change. public var appKit: AppKit { - get { self[AppKitKey.self] } - set { self[AppKitKey.self] = newValue } + get { self[AppKitKey.self] } + set { self[AppKitKey.self] = newValue } } private enum AppKitKey: UITransactionKey { - static let defaultValue = AppKit() + static let defaultValue = AppKit() } /// AppKit-specific data associated with a ``UITransaction``. public struct AppKit: Sendable { - /// The animation, if any, associated with the current state change. - public var animation: AppKitAnimation? - - /// A Boolean value that indicates whether views should disable animations. - public var disablesAnimations = false - - var animationCompletions: [@Sendable (Bool?) -> Void] = [] - - /// Adds a completion to run when the animations created with this transaction are all - /// complete. - /// - /// The completion callback will always be fired exactly one time. - public mutating func addAnimationCompletion( - _ completion: @escaping @Sendable (Bool?) -> Void - ) { - animationCompletions.append(completion) - } + /// The animation, if any, associated with the current state change. + public var animation: AppKitAnimation? + + /// A Boolean value that indicates whether views should disable animations. + public var disablesAnimations = false + + var animationCompletions: [@Sendable (Bool?) -> Void] = [] + + /// Adds a completion to run when the animations created with this transaction are all + /// complete. + /// + /// The completion callback will always be fired exactly one time. + public mutating func addAnimationCompletion( + _ completion: @escaping @Sendable (Bool?) -> Void + ) { + animationCompletions.append(completion) + } } - } +} - private enum AnimationCompletionsKey: UITransactionKey { +private enum AnimationCompletionsKey: UITransactionKey { static let defaultValue: [@Sendable (Bool?) -> Void] = [] - } +} - private enum DisablesAnimationsKey: UITransactionKey { +private enum DisablesAnimationsKey: UITransactionKey { static let defaultValue = false - } +} #endif From e2c5db49fe6236eca0f9fbbef9d10ddc04092af4 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Mon, 19 Aug 2024 22:38:19 +0800 Subject: [PATCH 15/42] Common --- Package.swift | 17 ++ Package@swift-6.0.swift | 17 ++ .../AppKitNavigation/AppKitAnimation.swift | 102 ++++++++++ .../Internal/AssociatedKeys.swift | 36 ++++ .../Internal/AssumeIsolated.swift | 35 ++++ .../Internal/ErrorMechanism.swift | 20 ++ .../AppKitNavigation/Internal/Exports.swift | 3 + .../Internal/ToOptionalUnit.swift | 12 ++ Sources/AppKitNavigation/Observe.swift | 181 ++++++++++++++++++ Sources/AppKitNavigation/UIBinding.swift | 15 ++ Sources/AppKitNavigation/UITransaction.swift | 53 +++++ Sources/AppKitNavigationShim/include/shim.h | 25 +++ Sources/AppKitNavigationShim/shim.m | 152 +++++++++++++++ 13 files changed, 668 insertions(+) create mode 100644 Sources/AppKitNavigation/AppKitAnimation.swift create mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift create mode 100644 Sources/AppKitNavigation/Internal/AssumeIsolated.swift create mode 100644 Sources/AppKitNavigation/Internal/ErrorMechanism.swift create mode 100644 Sources/AppKitNavigation/Internal/Exports.swift create mode 100644 Sources/AppKitNavigation/Internal/ToOptionalUnit.swift create mode 100644 Sources/AppKitNavigation/Observe.swift create mode 100644 Sources/AppKitNavigation/UIBinding.swift create mode 100644 Sources/AppKitNavigation/UITransaction.swift create mode 100644 Sources/AppKitNavigationShim/include/shim.h create mode 100644 Sources/AppKitNavigationShim/shim.m diff --git a/Package.swift b/Package.swift index 46c1d7edbe..fc20ea17dc 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,10 @@ let package = Package( name: "UIKitNavigation", targets: ["UIKitNavigation"] ), + .library( + name: "AppKitNavigation", + targets: ["AppKitNavigation"] + ), ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -31,6 +35,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.0.0"), ], targets: [ .target( @@ -73,6 +78,18 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .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 9ed432ff67..a97be3d08e 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -23,6 +23,10 @@ let package = Package( name: "UIKitNavigation", targets: ["UIKitNavigation"] ), + .library( + name: "AppKitNavigation", + targets: ["AppKitNavigation"] + ), ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -31,6 +35,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.0.0"), ], targets: [ .target( @@ -73,6 +78,18 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), + ] + ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift new file mode 100644 index 0000000000..0d8230c8fb --- /dev/null +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -0,0 +1,102 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +#if canImport(SwiftUI) +import SwiftUI +#endif + +import SwiftNavigation + +/// Executes a closure with the specified animation and returns the result. +/// +/// - Parameters: +/// - animation: An animation, set in the ``UITransaction/appKit`` property of the thread's +/// current transaction. +/// - body: A closure to execute. +/// - completion: A completion to run when the animation is complete. +/// - Returns: The result of executing the closure with the specified animation. +@MainActor +public func withAppKitAnimation( + _ animation: AppKitAnimation? = .default, + _ body: () throws -> Result, + completion: (@Sendable (Bool?) -> Void)? = nil +) rethrows -> Result { + var transaction = UITransaction() + transaction.appKit.animation = animation + if let completion { + transaction.appKit.addAnimationCompletion(completion) + } + return try withUITransaction(transaction, body) +} + +/// The way a view changes over time to create a smooth visual transition from one state to +/// another. +public struct AppKitAnimation: Hashable, Sendable { + fileprivate let framework: Framework + + @MainActor + func perform( + _ body: () throws -> Result, + completion: ((Bool?) -> Void)? = nil + ) rethrows -> Result { + switch framework { + case let .swiftUI(animation): + _ = animation + fatalError() + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.duration = animation.duration + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) + } + + return try result!._rethrowGet() + } + } + + fileprivate enum Framework: Hashable, Sendable { + case appKit(AppKit) + case swiftUI(Animation) + + fileprivate struct AppKit: Hashable, Sendable { + fileprivate var duration: TimeInterval + + func hash(into hasher: inout Hasher) { + hasher.combine(duration) + } + } + } +} + +extension AppKitAnimation { + /// Performs am animation using a timing curve corresponding to the motion of a physical spring. + /// + /// A value description of + /// `UIView.animate(withDuration:delay:dampingRatio:velocity:options:animations:completion:)` + /// that can be used with ``withAppKitAnimation(_:_:completion:)``. + /// + /// - Parameters: + /// - duration: The total duration of the animations, measured in seconds. If you specify a + /// negative value or `0`, the changes are made without animating them. + /// - Returns: An animation using a timing curve corresponding to the motion of a physical + /// spring. + public static func animate( + withDuration duration: TimeInterval = 0.25 + ) -> Self { + Self( + framework: .appKit( + Framework.AppKit( + duration: duration + ) + ) + ) + } + + /// A default animation instance. + public static var `default`: Self { + return .animate() + } +} +#endif diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift new file mode 100644 index 0000000000..1df9f50edc --- /dev/null +++ b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift @@ -0,0 +1,36 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +struct AssociatedKeys { + var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + + mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { + let key = AnyHashableMetatype(type) + if let associatedKey = keys[key] { + return associatedKey + } else { + let associatedKey = malloc(1)! + keys[key] = associatedKey + return associatedKey + } + } +} + +struct AnyHashableMetatype: Hashable { + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } + + let base: Any.Type + + init(_ base: Any.Type) { + self.base = base + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift new file mode 100644 index 0000000000..93f1c40098 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift @@ -0,0 +1,35 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +extension MainActor { + // NB: This functionality was not back-deployed in Swift 5.9 + static func _assumeIsolated( + _ operation: @MainActor () throws -> T, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows -> T { + #if swift(<5.10) + typealias YesActor = @MainActor () throws -> T + typealias NoActor = () throws -> T + + guard Thread.isMainThread else { + fatalError( + "Incorrect actor executor assumption; Expected same executor as \(self).", + file: file, + line: line + ) + } + + return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in + let rawFn = unsafeBitCast(fn, to: NoActor.self) + return try rawFn() + } + #else + return try assumeIsolated(operation, file: file, line: line) + #endif + } +} + + +#endif diff --git a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift new file mode 100644 index 0000000000..1ec4c47b0a --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift @@ -0,0 +1,20 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +@rethrows +protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output +} + +extension _ErrorMechanism { + func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + + func _rethrowGet() rethrows -> Output { + return try get() + } +} + +extension Result: _ErrorMechanism {} +#endif diff --git a/Sources/AppKitNavigation/Internal/Exports.swift b/Sources/AppKitNavigation/Internal/Exports.swift new file mode 100644 index 0000000000..554225adc1 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/Exports.swift @@ -0,0 +1,3 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +@_exported import SwiftNavigation +#endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift new file mode 100644 index 0000000000..a11cfaaf1d --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift @@ -0,0 +1,12 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +extension Bool { + struct Unit: Hashable, Identifiable { + var id: Unit { self } + } + + var toOptionalUnit: Unit? { + get { self ? Unit() : nil } + set { self = newValue != nil } + } +} +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift new file mode 100644 index 0000000000..8032874494 --- /dev/null +++ b/Sources/AppKitNavigation/Observe.swift @@ -0,0 +1,181 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +@_spi(Internals) import SwiftNavigation +import AppKit + +@MainActor +extension NSObject { + /// Observe access to properties of an observable (or perceptible) object. + /// + /// This tool allows you to set up an observation loop so that you can access fields from an + /// observable model in order to populate your view, and also automatically track changes to + /// any accessed fields so that the view is always up-to-date. + /// + /// It is most useful when dealing with non-SwiftUI views, such as AppKit views and controller. + /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all + /// the view elements: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let countLabel = NSTextField(labelWithString: "") + /// let incrementButton = NSButton { [weak self] _ in + /// self?.model.incrementButtonTapped() + /// } + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.stringValue = "\(model.count)" + /// } + /// } + /// ``` + /// + /// This closure is immediately called, allowing you to set the initial state of your UI + /// components from the feature's state. And if the `count` property in the feature's state is + /// ever mutated, this trailing closure will be called again, allowing us to update the view + /// again. + /// + /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your + /// view, such as `viewDidLoad` for `NSViewController`. This works even if you have many UI + /// components to update: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.isHidden = model.isObservingCount + /// if !countLabel.isHidden { + /// countLabel.stringValue = "\(model.count)" + /// } + /// factLabel.stringValue = model.fact + /// } + /// } + /// ``` + /// + /// This does mean that you may execute the line `factLabel.text = model.fact` even when + /// something unrelated changes, such as `store.model`, but that is typically OK for simple + /// properties of UI components. It is not a performance problem to repeatedly set the `text` of + /// a label or the `isHidden` of a button. + /// + /// However, if there is heavy work you need to perform when state changes, then it is best to + /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or + /// collection view when a collection changes: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// dataSource = model.items + /// tableView.reloadData() + /// } + /// } + /// ``` + /// + /// ## Cancellation + /// + /// The method returns an ``ObservationToken`` that can be used to cancel observation. For + /// example, if you only want to observe while a view controller is visible, you can start + /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: + /// + /// ```swift + /// var observation: ObservationToken? + /// + /// func viewWillAppear() { + /// super.viewWillAppear() + /// observation = observe { [weak self] in + /// // ... + /// } + /// } + /// func viewWillDisappear() { + /// super.viewWillDisappear() + /// observation?.cancel() + /// } + /// ``` + /// + /// - Parameter apply: A closure that contains properties to track and is invoked when the value + /// of a property changes. + /// - Returns: A cancellation token. + @discardableResult + public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { + observe { _ in apply() } + } + + /// Observe access to properties of an observable (or perceptible) object. + /// + /// A version of ``observe(_:)`` that is passed the current transaction. + /// + /// - Parameter apply: A closure that contains properties to track and is invoked when the value + /// of a property changes. + /// - Returns: A cancellation token. + @discardableResult + public func observe( + _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void + ) -> ObservationToken { + let token = SwiftNavigation.observe { transaction in + MainActor._assumeIsolated { + withUITransaction(transaction) { + if transaction.appKit.disablesAnimations { + NSView.performWithoutAnimation { apply(transaction) } + for completion in transaction.appKit.animationCompletions { + completion(true) + } + } else if let animation = transaction.appKit.animation { + return animation.perform( + { apply(transaction) }, + completion: transaction.appKit.animationCompletions.isEmpty + ? nil + : { + for completion in transaction.appKit.animationCompletions { + completion($0) + } + } + ) + } else { + apply(transaction) + for completion in transaction.appKit.animationCompletions { + completion(true) + } + } + } + } + } task: { transaction, work in + DispatchQueue.main.async { + withUITransaction(transaction, work) + } + } + tokens.append(token) + return token + } + + fileprivate var tokens: [Any] { + get { + objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] + } + set { + objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private static let tokensKey = malloc(1)! +} + +extension NSView { + fileprivate static func performWithoutAnimation(_ block: () -> Void) { + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = false + block() + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift new file mode 100644 index 0000000000..ea3499dec7 --- /dev/null +++ b/Sources/AppKitNavigation/UIBinding.swift @@ -0,0 +1,15 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftNavigation + +extension UIBinding { + /// Specifies an animation to perform when the binding value changes. + /// + /// - Parameter animation: An animation sequence performed when the binding value changes. + /// - Returns: A new binding. + public func animation(_ animation: AppKitAnimation? = .default) -> Self { + var binding = self + binding.transaction.appKit.animation = animation + return binding + } +} +#endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift new file mode 100644 index 0000000000..b4f9535f4e --- /dev/null +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -0,0 +1,53 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import SwiftNavigation + +extension UITransaction { + /// Creates a transaction and assigns its animation property. + /// + /// - Parameter animation: The animation to perform when the current state changes. + public init(animation: AppKitAnimation? = nil) { + self.init() + appKit.animation = animation + } + + /// AppKit-specific data associated with the current state change. + public var appKit: AppKit { + get { self[AppKitKey.self] } + set { self[AppKitKey.self] = newValue } + } + + private enum AppKitKey: UITransactionKey { + static let defaultValue = AppKit() + } + + /// AppKit-specific data associated with a ``UITransaction``. + public struct AppKit: Sendable { + /// The animation, if any, associated with the current state change. + public var animation: AppKitAnimation? + + /// A Boolean value that indicates whether views should disable animations. + public var disablesAnimations = false + + var animationCompletions: [@Sendable (Bool?) -> Void] = [] + + /// Adds a completion to run when the animations created with this transaction are all + /// complete. + /// + /// The completion callback will always be fired exactly one time. + public mutating func addAnimationCompletion( + _ completion: @escaping @Sendable (Bool?) -> Void + ) { + animationCompletions.append(completion) + } + } +} + +private enum AnimationCompletionsKey: UITransactionKey { + static let defaultValue: [@Sendable (Bool?) -> Void] = [] +} + +private enum DisablesAnimationsKey: UITransactionKey { + static let defaultValue = false +} +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100644 index 0000000000..e1844275f7 --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,25 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface NSViewController (AppKitNavigation) + +@property BOOL hasViewAppeared; +@property (nullable) void (^ onDismiss)(); +@property NSArray *onViewAppear; + +@end + +@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 100644 index 0000000000..21f5f9c08b --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,152 @@ +#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(NSViewController.class, @selector(viewDidAppear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) + ); + 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 + +static void *hasViewAppearedKey = &hasViewAppearedKey; +static void *onDismissKey = &onDismissKey; +static void *onViewAppearKey = &onViewAppearKey; + +@implementation NSViewController (AppKitNavigation) + +- (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; + + if (self.hasViewAppeared) { + return; + } + + self.hasViewAppeared = YES; + + for (void (^work)() in self.onViewAppear) { + work(); + } + + self.onViewAppear = @[]; +} + +- (void)setBeingDismissed:(BOOL)beingDismissed { + objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); +} + +- (BOOL)isBeingDismissed { + return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; +} + +- (void)AppKitNavigation_viewDidDisappear { + [self AppKitNavigation_viewDidDisappear]; + + if ((self.isBeingDismissed) && self.onDismiss != NULL) { + self.onDismiss(); + self.onDismiss = nil; + [self setBeingDismissed:NO]; + } +} + +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} + +- (BOOL)hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} + +- (void)setHasViewAppeared:(BOOL)hasViewAppeared { + objc_setAssociatedObject( + self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + ); +} + +- (void (^)())onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} + +- (void)setOnDismiss:(void (^)())onDismiss { + objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (NSMutableArray *)onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); + + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +@end +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ From 0c61b636b616e3fa186c7f42627add5cf1e8d0be Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 20 Aug 2024 20:39:14 +0800 Subject: [PATCH 16/42] Remove unused code --- Package.swift | 6 - Package@swift-6.0.swift | 6 - .../AppKitNavigation/AppKitAnimation.swift | 22 ---- .../Internal/AssociatedKeys.swift | 36 ------ .../Internal/ToOptionalUnit.swift | 12 -- Sources/AppKitNavigation/Observe.swift | 108 ------------------ Sources/AppKitNavigation/UIBinding.swift | 4 - Sources/AppKitNavigation/UITransaction.swift | 11 -- .../Internal/AssumeIsolated.swift | 7 +- .../Internal/ErrorMechanism.swift | 9 +- .../Internal/ToOptionalUnit.swift | 12 ++ .../Internal/AssumeIsolated.swift | 30 ----- .../Internal/ErrorMechanism.swift | 20 ---- .../Internal/ToOptionalUnit.swift | 12 -- 14 files changed, 17 insertions(+), 278 deletions(-) delete mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift delete mode 100644 Sources/AppKitNavigation/Internal/ToOptionalUnit.swift rename Sources/{AppKitNavigation => SwiftNavigation}/Internal/AssumeIsolated.swift (88%) rename Sources/{AppKitNavigation => SwiftNavigation}/Internal/ErrorMechanism.swift (55%) create mode 100644 Sources/SwiftNavigation/Internal/ToOptionalUnit.swift delete mode 100644 Sources/UIKitNavigation/Internal/AssumeIsolated.swift delete mode 100644 Sources/UIKitNavigation/Internal/ErrorMechanism.swift delete mode 100644 Sources/UIKitNavigation/Internal/ToOptionalUnit.swift diff --git a/Package.swift b/Package.swift index fc20ea17dc..3ca03ba03e 100644 --- a/Package.swift +++ b/Package.swift @@ -35,7 +35,6 @@ 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.0.0"), ], targets: [ .target( @@ -82,14 +81,9 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .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 a97be3d08e..7d316472db 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -35,7 +35,6 @@ 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.0.0"), ], targets: [ .target( @@ -82,14 +81,9 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), - .target( - name: "AppKitNavigationShim" - ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 0d8230c8fb..e2c8199a00 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -7,14 +7,6 @@ import SwiftUI import SwiftNavigation -/// Executes a closure with the specified animation and returns the result. -/// -/// - Parameters: -/// - animation: An animation, set in the ``UITransaction/appKit`` property of the thread's -/// current transaction. -/// - body: A closure to execute. -/// - completion: A completion to run when the animation is complete. -/// - Returns: The result of executing the closure with the specified animation. @MainActor public func withAppKitAnimation( _ animation: AppKitAnimation? = .default, @@ -29,8 +21,6 @@ public func withAppKitAnimation( return try withUITransaction(transaction, body) } -/// The way a view changes over time to create a smooth visual transition from one state to -/// another. public struct AppKitAnimation: Hashable, Sendable { fileprivate let framework: Framework @@ -71,17 +61,6 @@ public struct AppKitAnimation: Hashable, Sendable { } extension AppKitAnimation { - /// Performs am animation using a timing curve corresponding to the motion of a physical spring. - /// - /// A value description of - /// `UIView.animate(withDuration:delay:dampingRatio:velocity:options:animations:completion:)` - /// that can be used with ``withAppKitAnimation(_:_:completion:)``. - /// - /// - Parameters: - /// - duration: The total duration of the animations, measured in seconds. If you specify a - /// negative value or `0`, the changes are made without animating them. - /// - Returns: An animation using a timing curve corresponding to the motion of a physical - /// spring. public static func animate( withDuration duration: TimeInterval = 0.25 ) -> Self { @@ -94,7 +73,6 @@ extension AppKitAnimation { ) } - /// A default animation instance. public static var `default`: Self { return .animate() } diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift deleted file mode 100644 index 1df9f50edc..0000000000 --- a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift +++ /dev/null @@ -1,36 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -struct AssociatedKeys { - var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] - - mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { - let key = AnyHashableMetatype(type) - if let associatedKey = keys[key] { - return associatedKey - } else { - let associatedKey = malloc(1)! - keys[key] = associatedKey - return associatedKey - } - } -} - -struct AnyHashableMetatype: Hashable { - static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { - return lhs.base == rhs.base - } - - let base: Any.Type - - init(_ base: Any.Type) { - self.base = base - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(base)) - } -} - -#endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift deleted file mode 100644 index a11cfaaf1d..0000000000 --- a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift +++ /dev/null @@ -1,12 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -extension Bool { - struct Unit: Hashable, Identifiable { - var id: Unit { self } - } - - var toOptionalUnit: Unit? { - get { self ? Unit() : nil } - set { self = newValue != nil } - } -} -#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index 8032874494..6e334545ce 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -4,119 +4,11 @@ import AppKit @MainActor extension NSObject { - /// Observe access to properties of an observable (or perceptible) object. - /// - /// This tool allows you to set up an observation loop so that you can access fields from an - /// observable model in order to populate your view, and also automatically track changes to - /// any accessed fields so that the view is always up-to-date. - /// - /// It is most useful when dealing with non-SwiftUI views, such as AppKit views and controller. - /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all - /// the view elements: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// let countLabel = NSTextField(labelWithString: "") - /// let incrementButton = NSButton { [weak self] _ in - /// self?.model.incrementButtonTapped() - /// } - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// countLabel.stringValue = "\(model.count)" - /// } - /// } - /// ``` - /// - /// This closure is immediately called, allowing you to set the initial state of your UI - /// components from the feature's state. And if the `count` property in the feature's state is - /// ever mutated, this trailing closure will be called again, allowing us to update the view - /// again. - /// - /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your - /// view, such as `viewDidLoad` for `NSViewController`. This works even if you have many UI - /// components to update: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// countLabel.isHidden = model.isObservingCount - /// if !countLabel.isHidden { - /// countLabel.stringValue = "\(model.count)" - /// } - /// factLabel.stringValue = model.fact - /// } - /// } - /// ``` - /// - /// This does mean that you may execute the line `factLabel.text = model.fact` even when - /// something unrelated changes, such as `store.model`, but that is typically OK for simple - /// properties of UI components. It is not a performance problem to repeatedly set the `text` of - /// a label or the `isHidden` of a button. - /// - /// However, if there is heavy work you need to perform when state changes, then it is best to - /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or - /// collection view when a collection changes: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// dataSource = model.items - /// tableView.reloadData() - /// } - /// } - /// ``` - /// - /// ## Cancellation - /// - /// The method returns an ``ObservationToken`` that can be used to cancel observation. For - /// example, if you only want to observe while a view controller is visible, you can start - /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: - /// - /// ```swift - /// var observation: ObservationToken? - /// - /// func viewWillAppear() { - /// super.viewWillAppear() - /// observation = observe { [weak self] in - /// // ... - /// } - /// } - /// func viewWillDisappear() { - /// super.viewWillDisappear() - /// observation?.cancel() - /// } - /// ``` - /// - /// - Parameter apply: A closure that contains properties to track and is invoked when the value - /// of a property changes. - /// - Returns: A cancellation token. @discardableResult public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { observe { _ in apply() } } - /// Observe access to properties of an observable (or perceptible) object. - /// - /// A version of ``observe(_:)`` that is passed the current transaction. - /// - /// - Parameter apply: A closure that contains properties to track and is invoked when the value - /// of a property changes. - /// - Returns: A cancellation token. @discardableResult public func observe( _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift index ea3499dec7..e69009dfed 100644 --- a/Sources/AppKitNavigation/UIBinding.swift +++ b/Sources/AppKitNavigation/UIBinding.swift @@ -2,10 +2,6 @@ import SwiftNavigation extension UIBinding { - /// Specifies an animation to perform when the binding value changes. - /// - /// - Parameter animation: An animation sequence performed when the binding value changes. - /// - Returns: A new binding. public func animation(_ animation: AppKitAnimation? = .default) -> Self { var binding = self binding.transaction.appKit.animation = animation diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift index b4f9535f4e..01b798797c 100644 --- a/Sources/AppKitNavigation/UITransaction.swift +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -3,15 +3,11 @@ import SwiftNavigation extension UITransaction { - /// Creates a transaction and assigns its animation property. - /// - /// - Parameter animation: The animation to perform when the current state changes. public init(animation: AppKitAnimation? = nil) { self.init() appKit.animation = animation } - /// AppKit-specific data associated with the current state change. public var appKit: AppKit { get { self[AppKitKey.self] } set { self[AppKitKey.self] = newValue } @@ -21,20 +17,13 @@ extension UITransaction { static let defaultValue = AppKit() } - /// AppKit-specific data associated with a ``UITransaction``. public struct AppKit: Sendable { - /// The animation, if any, associated with the current state change. public var animation: AppKitAnimation? - /// A Boolean value that indicates whether views should disable animations. public var disablesAnimations = false var animationCompletions: [@Sendable (Bool?) -> Void] = [] - /// Adds a completion to run when the animations created with this transaction are all - /// complete. - /// - /// The completion callback will always be fired exactly one time. public mutating func addAnimationCompletion( _ completion: @escaping @Sendable (Bool?) -> Void ) { diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/SwiftNavigation/Internal/AssumeIsolated.swift similarity index 88% rename from Sources/AppKitNavigation/Internal/AssumeIsolated.swift rename to Sources/SwiftNavigation/Internal/AssumeIsolated.swift index 93f1c40098..97054027e7 100644 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ b/Sources/SwiftNavigation/Internal/AssumeIsolated.swift @@ -1,10 +1,8 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - import Foundation extension MainActor { // NB: This functionality was not back-deployed in Swift 5.9 - static func _assumeIsolated( + package static func _assumeIsolated( _ operation: @MainActor () throws -> T, file: StaticString = #fileID, line: UInt = #line @@ -30,6 +28,3 @@ extension MainActor { #endif } } - - -#endif diff --git a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/SwiftNavigation/Internal/ErrorMechanism.swift similarity index 55% rename from Sources/AppKitNavigation/Internal/ErrorMechanism.swift rename to Sources/SwiftNavigation/Internal/ErrorMechanism.swift index 1ec4c47b0a..36cbdd7bc4 100644 --- a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift +++ b/Sources/SwiftNavigation/Internal/ErrorMechanism.swift @@ -1,20 +1,19 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) @rethrows -protocol _ErrorMechanism { +package protocol _ErrorMechanism { associatedtype Output func get() throws -> Output } extension _ErrorMechanism { - func _rethrowError() rethrows -> Never { + package func _rethrowError() rethrows -> Never { _ = try _rethrowGet() fatalError() } - func _rethrowGet() rethrows -> Output { + package func _rethrowGet() rethrows -> Output { return try get() } } extension Result: _ErrorMechanism {} -#endif + diff --git a/Sources/SwiftNavigation/Internal/ToOptionalUnit.swift b/Sources/SwiftNavigation/Internal/ToOptionalUnit.swift new file mode 100644 index 0000000000..46fa0d119c --- /dev/null +++ b/Sources/SwiftNavigation/Internal/ToOptionalUnit.swift @@ -0,0 +1,12 @@ +extension Bool { + package struct Unit: Hashable, Identifiable { + package var id: Unit { self } + + package init() {} + } + + package var toOptionalUnit: Unit? { + get { self ? Unit() : nil } + set { self = newValue != nil } + } +} diff --git a/Sources/UIKitNavigation/Internal/AssumeIsolated.swift b/Sources/UIKitNavigation/Internal/AssumeIsolated.swift deleted file mode 100644 index fea107e3ae..0000000000 --- a/Sources/UIKitNavigation/Internal/AssumeIsolated.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -extension MainActor { - // NB: This functionality was not back-deployed in Swift 5.9 - static func _assumeIsolated( - _ operation: @MainActor () throws -> T, - file: StaticString = #fileID, - line: UInt = #line - ) rethrows -> T { - #if swift(<5.10) - typealias YesActor = @MainActor () throws -> T - typealias NoActor = () throws -> T - - guard Thread.isMainThread else { - fatalError( - "Incorrect actor executor assumption; Expected same executor as \(self).", - file: file, - line: line - ) - } - - return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in - let rawFn = unsafeBitCast(fn, to: NoActor.self) - return try rawFn() - } - #else - return try assumeIsolated(operation, file: file, line: line) - #endif - } -} diff --git a/Sources/UIKitNavigation/Internal/ErrorMechanism.swift b/Sources/UIKitNavigation/Internal/ErrorMechanism.swift deleted file mode 100644 index 18644daeba..0000000000 --- a/Sources/UIKitNavigation/Internal/ErrorMechanism.swift +++ /dev/null @@ -1,20 +0,0 @@ -#if canImport(UIKit) - @rethrows - protocol _ErrorMechanism { - associatedtype Output - func get() throws -> Output - } - - extension _ErrorMechanism { - func _rethrowError() rethrows -> Never { - _ = try _rethrowGet() - fatalError() - } - - func _rethrowGet() rethrows -> Output { - return try get() - } - } - - extension Result: _ErrorMechanism {} -#endif diff --git a/Sources/UIKitNavigation/Internal/ToOptionalUnit.swift b/Sources/UIKitNavigation/Internal/ToOptionalUnit.swift deleted file mode 100644 index ed30e639d9..0000000000 --- a/Sources/UIKitNavigation/Internal/ToOptionalUnit.swift +++ /dev/null @@ -1,12 +0,0 @@ -#if canImport(UIKit) - extension Bool { - struct Unit: Hashable, Identifiable { - var id: Unit { self } - } - - var toOptionalUnit: Unit? { - get { self ? Unit() : nil } - set { self = newValue != nil } - } - } -#endif From c2bdb0dde8a19d4cc337cc2002a1945d282ade93 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:38:40 +0800 Subject: [PATCH 17/42] Remove unused code --- Sources/AppKitNavigationShim/include/shim.h | 25 ---- Sources/AppKitNavigationShim/shim.m | 152 -------------------- 2 files changed, 177 deletions(-) delete mode 100644 Sources/AppKitNavigationShim/include/shim.h delete mode 100644 Sources/AppKitNavigationShim/shim.m diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h deleted file mode 100644 index e1844275f7..0000000000 --- a/Sources/AppKitNavigationShim/include/shim.h +++ /dev/null @@ -1,25 +0,0 @@ -#if __has_include() -#include - -#if __has_include() && !TARGET_OS_MACCATALYST -@import AppKit; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSViewController (AppKitNavigation) - -@property BOOL hasViewAppeared; -@property (nullable) void (^ onDismiss)(); -@property NSArray *onViewAppear; - -@end - -@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 deleted file mode 100644 index 21f5f9c08b..0000000000 --- a/Sources/AppKitNavigationShim/shim.m +++ /dev/null @@ -1,152 +0,0 @@ -#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(NSViewController.class, @selector(viewDidAppear)), - class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) - ); - method_exchangeImplementations( - class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), - class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) - ); - method_exchangeImplementations( - class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), - class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) - ); - 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 - -static void *hasViewAppearedKey = &hasViewAppearedKey; -static void *onDismissKey = &onDismissKey; -static void *onViewAppearKey = &onViewAppearKey; - -@implementation NSViewController (AppKitNavigation) - -- (void)AppKitNavigation_viewDidAppear { - [self AppKitNavigation_viewDidAppear]; - - if (self.hasViewAppeared) { - return; - } - - self.hasViewAppeared = YES; - - for (void (^work)() in self.onViewAppear) { - work(); - } - - self.onViewAppear = @[]; -} - -- (void)setBeingDismissed:(BOOL)beingDismissed { - objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); -} - -- (BOOL)isBeingDismissed { - return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; -} - -- (void)AppKitNavigation_viewDidDisappear { - [self AppKitNavigation_viewDidDisappear]; - - if ((self.isBeingDismissed) && self.onDismiss != NULL) { - self.onDismiss(); - self.onDismiss = nil; - [self setBeingDismissed:NO]; - } -} - -- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { - [self AppKitNavigation_dismissViewController:sender]; - [self setBeingDismissed:YES]; -} - -- (BOOL)hasViewAppeared { - return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; -} - -- (void)setHasViewAppeared:(BOOL)hasViewAppeared { - objc_setAssociatedObject( - self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC - ); -} - -- (void (^)())onDismiss { - return objc_getAssociatedObject(self, onDismissKey); -} - -- (void)setOnDismiss:(void (^)())onDismiss { - objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); -} - -- (NSMutableArray *)onViewAppear { - id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); - - return onViewAppear == nil ? @[] : onViewAppear; -} - -- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { - objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); -} - -@end -#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ -#endif /* if __has_include() */ From 8e5e4e610303017e0707fbd8791a4c5896683b7d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 22 Aug 2024 16:32:18 -0700 Subject: [PATCH 18/42] Integrate custom transaction --- Sources/AppKitNavigation/Observe.swift | 73 ------------------- Sources/AppKitNavigation/UITransaction.swift | 77 ++++++++++++++------ 2 files changed, 55 insertions(+), 95 deletions(-) delete mode 100644 Sources/AppKitNavigation/Observe.swift diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift deleted file mode 100644 index 6e334545ce..0000000000 --- a/Sources/AppKitNavigation/Observe.swift +++ /dev/null @@ -1,73 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -@_spi(Internals) import SwiftNavigation -import AppKit - -@MainActor -extension NSObject { - @discardableResult - public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { - observe { _ in apply() } - } - - @discardableResult - public func observe( - _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void - ) -> ObservationToken { - let token = SwiftNavigation.observe { transaction in - MainActor._assumeIsolated { - withUITransaction(transaction) { - if transaction.appKit.disablesAnimations { - NSView.performWithoutAnimation { apply(transaction) } - for completion in transaction.appKit.animationCompletions { - completion(true) - } - } else if let animation = transaction.appKit.animation { - return animation.perform( - { apply(transaction) }, - completion: transaction.appKit.animationCompletions.isEmpty - ? nil - : { - for completion in transaction.appKit.animationCompletions { - completion($0) - } - } - ) - } else { - apply(transaction) - for completion in transaction.appKit.animationCompletions { - completion(true) - } - } - } - } - } task: { transaction, work in - DispatchQueue.main.async { - withUITransaction(transaction, work) - } - } - tokens.append(token) - return token - } - - fileprivate var tokens: [Any] { - get { - objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] - } - set { - objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - private static let tokensKey = malloc(1)! -} - -extension NSView { - fileprivate static func performWithoutAnimation(_ block: () -> Void) { - NSAnimationContext.runAnimationGroup { context in - context.allowsImplicitAnimation = false - block() - } - } -} - -#endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift index 01b798797c..99559dbc48 100644 --- a/Sources/AppKitNavigation/UITransaction.swift +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -1,42 +1,75 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) + import AppKit + import SwiftNavigation -import SwiftNavigation - -extension UITransaction { + extension UITransaction { public init(animation: AppKitAnimation? = nil) { - self.init() - appKit.animation = animation + self.init() + appKit.animation = animation } public var appKit: AppKit { - get { self[AppKitKey.self] } - set { self[AppKitKey.self] = newValue } + get { self[AppKitKey.self] } + set { self[AppKitKey.self] = newValue } } - private enum AppKitKey: UITransactionKey { - static let defaultValue = AppKit() + private enum AppKitKey: _UICustomTransactionKey { + static let defaultValue = AppKit() + + static func perform( + value: AppKit, + operation: @Sendable () -> Void + ) { + MainActor._assumeIsolated { + if value.disablesAnimations { + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = false + operation() + } + for completion in value.animationCompletions { + completion(true) + } + } else if let animation = value.animation { + return animation.perform( + { operation() }, + completion: value.animationCompletions.isEmpty + ? nil + : { + for completion in value.animationCompletions { + completion($0) + } + } + ) + } else { + operation() + for completion in value.animationCompletions { + completion(true) + } + } + } + } } public struct AppKit: Sendable { - public var animation: AppKitAnimation? + public var animation: AppKitAnimation? - public var disablesAnimations = false + public var disablesAnimations = false - var animationCompletions: [@Sendable (Bool?) -> Void] = [] + var animationCompletions: [@Sendable (Bool?) -> Void] = [] - public mutating func addAnimationCompletion( - _ completion: @escaping @Sendable (Bool?) -> Void - ) { - animationCompletions.append(completion) - } + public mutating func addAnimationCompletion( + _ completion: @escaping @Sendable (Bool?) -> Void + ) { + animationCompletions.append(completion) + } } -} + } -private enum AnimationCompletionsKey: UITransactionKey { + private enum AnimationCompletionsKey: UITransactionKey { static let defaultValue: [@Sendable (Bool?) -> Void] = [] -} + } -private enum DisablesAnimationsKey: UITransactionKey { + private enum DisablesAnimationsKey: UITransactionKey { static let defaultValue = false -} + } #endif From 2b91081b304a729a5c532e43ade481b0a14264ce Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 22 Aug 2024 16:43:34 -0700 Subject: [PATCH 19/42] address fatal error --- .../AppKitNavigation/AppKitAnimation.swift | 101 ++++++++++-------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index e2c8199a00..346478c856 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -1,80 +1,95 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit + import AppKit -#if canImport(SwiftUI) -import SwiftUI -#endif + #if canImport(SwiftUI) + import SwiftUI + #endif -import SwiftNavigation + import SwiftNavigation -@MainActor -public func withAppKitAnimation( + @MainActor + public func withAppKitAnimation( _ animation: AppKitAnimation? = .default, _ body: () throws -> Result, completion: (@Sendable (Bool?) -> Void)? = nil -) rethrows -> Result { + ) rethrows -> Result { var transaction = UITransaction() transaction.appKit.animation = animation if let completion { - transaction.appKit.addAnimationCompletion(completion) + transaction.appKit.addAnimationCompletion(completion) } return try withUITransaction(transaction, body) -} + } -public struct AppKitAnimation: Hashable, Sendable { + public struct AppKitAnimation: Hashable, Sendable { fileprivate let framework: Framework @MainActor func perform( - _ body: () throws -> Result, - completion: ((Bool?) -> Void)? = nil + _ body: () throws -> Result, + completion: ((Bool?) -> Void)? = nil ) rethrows -> Result { - switch framework { - case let .swiftUI(animation): - _ = animation - fatalError() - case let .appKit(animation): - var result: Swift.Result? - NSAnimationContext.runAnimationGroup { context in - context.duration = animation.duration - result = Swift.Result(catching: body) - } completionHandler: { - completion?(true) + switch framework { + case let .swiftUI(animation): + var result: Swift.Result? + #if swift(>=6) + if #available(macOS 15, *) { + NSAnimationContext.animate(animation) { + result = Swift.Result(catching: body) + } completion: { + completion?(true) } - return try result!._rethrowGet() + } + #endif + _ = animation + fatalError() + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.duration = animation.duration + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) } + return try result!._rethrowGet() + } } fileprivate enum Framework: Hashable, Sendable { - case appKit(AppKit) - case swiftUI(Animation) + case appKit(AppKit) + case swiftUI(Animation) - fileprivate struct AppKit: Hashable, Sendable { - fileprivate var duration: TimeInterval + fileprivate struct AppKit: Hashable, Sendable { + fileprivate var duration: TimeInterval - func hash(into hasher: inout Hasher) { - hasher.combine(duration) - } + func hash(into hasher: inout Hasher) { + hasher.combine(duration) } + } + } + } + + extension AppKitAnimation { + @available(macOS 15, *) + public init(_ animation: Animation) { + self.init(framework: .swiftUI(animation)) } -} -extension AppKitAnimation { public static func animate( - withDuration duration: TimeInterval = 0.25 + withDuration duration: TimeInterval = 0.25 ) -> Self { - Self( - framework: .appKit( - Framework.AppKit( - duration: duration - ) - ) + Self( + framework: .appKit( + Framework.AppKit( + duration: duration + ) ) + ) } public static var `default`: Self { - return .animate() + return .animate() } -} + } #endif From cad947be25bb3b9bede965c2a20089abded9d69f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 23 Aug 2024 08:16:19 -0700 Subject: [PATCH 20/42] Round out animation --- .../AppKitNavigation/AppKitAnimation.swift | 56 ++++++++++++++----- .../SwiftNavigationTests/LifetimeTests.swift | 2 +- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 346478c856..2dab48e14c 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -30,6 +30,18 @@ completion: ((Bool?) -> Void)? = nil ) rethrows -> Result { switch framework { + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = true + context.duration = animation.duration + context.timingFunction = animation.timingFunction + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) + } + return try result!._rethrowGet() + case let .swiftUI(animation): var result: Swift.Result? #if swift(>=6) @@ -44,15 +56,6 @@ #endif _ = animation fatalError() - case let .appKit(animation): - var result: Swift.Result? - NSAnimationContext.runAnimationGroup { context in - context.duration = animation.duration - result = Swift.Result(catching: body) - } completionHandler: { - completion?(true) - } - return try result!._rethrowGet() } } @@ -60,8 +63,9 @@ case appKit(AppKit) case swiftUI(Animation) - fileprivate struct AppKit: Hashable, Sendable { + fileprivate struct AppKit: Hashable, @unchecked Sendable { fileprivate var duration: TimeInterval + fileprivate var timingFunction: CAMediaTimingFunction? func hash(into hasher: inout Hasher) { hasher.combine(duration) @@ -77,19 +81,45 @@ } public static func animate( - withDuration duration: TimeInterval = 0.25 + duration: TimeInterval = 0.25, + timingFunction: CAMediaTimingFunction? = nil ) -> Self { Self( framework: .appKit( Framework.AppKit( - duration: duration + duration: duration, + timingFunction: timingFunction ) ) ) } public static var `default`: Self { - return .animate() + .animate() + } + + public static var linear: Self { .linear(duration: 0.25) } + + public static func linear(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .linear)) + } + + public static var easeIn: Self { .easeIn(duration: 0.25) } + + public static func easeIn(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeIn)) + } + + public static var easeOut: Self { .easeOut(duration: 0.25) } + + public static func easeOut(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeOut)) + } + + public static var easeInOut: Self { .easeInOut(duration: 0.25) } + + public static func easeInOut(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeInEaseOut)) } } #endif diff --git a/Tests/SwiftNavigationTests/LifetimeTests.swift b/Tests/SwiftNavigationTests/LifetimeTests.swift index 66569ba62b..6c68de4637 100644 --- a/Tests/SwiftNavigationTests/LifetimeTests.swift +++ b/Tests/SwiftNavigationTests/LifetimeTests.swift @@ -29,7 +29,7 @@ @Perceptible @MainActor - class Model { + private class Model { var count = 0 } #endif From e863c54496957d76fb1dbae5a355fb3632aa9364 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:57:16 -0400 Subject: [PATCH 21/42] Update Package.swift --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8a8b002389..f3b38e8535 100644 --- a/Package.swift +++ b/Package.swift @@ -83,7 +83,6 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] ), .testTarget( From 00ed18a322665a08ca85a47955d23e841a840022 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:57:32 -0400 Subject: [PATCH 22/42] Update Package@swift-6.0.swift --- Package@swift-6.0.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 9f37971b8a..2edac05b6d 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -83,7 +83,6 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] ), .testTarget( From df6eda79c2932762f2e01e668efac142975e88cd Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 25 Aug 2024 01:16:25 +0800 Subject: [PATCH 23/42] Support AppKit Navigation --- Package.swift | 4 + Package@swift-6.0.swift | 4 + .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../Internal/AssociatedKeys.swift | 36 ++++ .../AppKitNavigation/Navigation/Modal.swift | 200 ++++++++++++++++++ .../Navigation/ModalContent.swift | 35 +++ .../Navigation/ModalSessionContent.swift | 22 ++ .../Navigation/ModalWindowsObserver.swift | 33 +++ .../Navigation/NavigationContent.swift | 38 ++++ .../Navigation/NavigationObserver.swift | 88 ++++++++ .../Navigation/Presentation.swift | 175 +++++++++++++++ .../Navigation/PresentationContent.swift | 41 ++++ .../AppKitNavigation/Navigation/Sheet.swift | 132 ++++++++++++ .../Navigation/SheetContent.swift | 60 ++++++ Sources/AppKitNavigationShim/include/shim.h | 19 ++ Sources/AppKitNavigationShim/shim.m | 110 ++++++++++ 16 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift create mode 100644 Sources/AppKitNavigation/Navigation/Modal.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalSessionContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/Presentation.swift create mode 100644 Sources/AppKitNavigation/Navigation/PresentationContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/Sheet.swift create mode 100644 Sources/AppKitNavigation/Navigation/SheetContent.swift create mode 100644 Sources/AppKitNavigationShim/include/shim.h create mode 100644 Sources/AppKitNavigationShim/shim.m diff --git a/Package.swift b/Package.swift index f3b38e8535..0210bad713 100644 --- a/Package.swift +++ b/Package.swift @@ -83,8 +83,12 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", + "AppKitNavigationShim", ] ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 2edac05b6d..1859da950d 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 2dab48e14c..b0e8a2cd85 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -43,8 +43,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/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift new file mode 100644 index 0000000000..1df9f50edc --- /dev/null +++ b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift @@ -0,0 +1,36 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +struct AssociatedKeys { + var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + + mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { + let key = AnyHashableMetatype(type) + if let associatedKey = keys[key] { + return associatedKey + } else { + let associatedKey = malloc(1)! + keys[key] = associatedKey + return associatedKey + } + } +} + +struct AnyHashableMetatype: Hashable { + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } + + let base: Any.Type + + init(_ base: Any.Type) { + self.base = base + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift new file mode 100644 index 0000000000..14681eda9e --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -0,0 +1,200 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +private var modalObserverKeys = AssociatedKeys() + +private typealias ModalObserver = NavigationObserver + +@MainActor +extension NSObject { + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modal(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modalSession(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modal(item: item, id: id) { $item in + content($item) + } beginModal: { modalContent, _ in + if NSApplication.shared.modalWindow != nil { + NSApplication.shared.stopModal() + onDismiss?() + DispatchQueue.main.async { + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.appKitNavigationRunModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil + } + + } else { + DispatchQueue.main.async { + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.appKitNavigationRunModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil + } + } + } endModal: { _, _ in + NSApplication.shared.stopModal() + onDismiss?() + } + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modal(item: item, id: id) { $item in + content($item) + } beginModal: { modalContent, _ in + if let modaledWindow = NSApplication.shared.modalWindow, let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modaledWindow] { + NSApplication.shared.endModalSession(modalSession) + modaledWindow.window.close() + onDismiss?() + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) + } + + } else { + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) + } + } + } endModal: { modalContent, _ in + if let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modalContent.window] { + NSApplication.shared.endModalSession(modalSession) + modalContent.window.close() + onDismiss?() + } + } + } + + private func modal( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + beginModal: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + endModal: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let modalObserver: ModalObserver = modalObserver() + return modalObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginModal, + end: endModal + ) + } + + private func modalObserver() -> ModalObserver { + if let observer = objc_getAssociatedObject(self, modalObserverKeys.key(of: Content.self)) as? ModalObserver { + return observer + } else { + let observer = ModalObserver(owner: self) + objc_setAssociatedObject(self, modalObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer + } + } +} + +extension Navigated where Content: ModalContent { + func clearup() { + NSApplication.shared.stopModal() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift new file mode 100644 index 0000000000..00772647f2 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -0,0 +1,35 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol ModalContent: NavigationContent { + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +extension NSWindow: ModalContent { + public var window: NSWindow { self } + + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + __appKitNavigationRunModal() + } + + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} + +extension NSSavePanel { + override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} + +extension NSAlert: ModalContent { + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift new file mode 100644 index 0000000000..9132638de7 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -0,0 +1,22 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol ModalSessionContent: ModalContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession +} + +extension NSWindow: ModalSessionContent { + + public func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + __appKitNavigationBeginModalSession() + } + + @objc func __appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + let modalSession = NSApplication.shared.beginModalSession(for: self) + return modalSession + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift new file mode 100644 index 0000000000..bc63b33443 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift @@ -0,0 +1,33 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +class ModalWindowsObserver: NSObject { + static let shared = ModalWindowsObserver() + + var windowsCancellable: [NSWindow: AnyCancellable] = [:] + + var modalSessionByWindow: [NSWindow: NSApplication.ModalSession] = [:] + + func observeWindow(_ window: NSWindow, modalSession: NSApplication.ModalSession? = nil) { + if let modalSession { + modalSessionByWindow[window] = modalSession + } + windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { [weak self] _ in + guard let self else { return } + if let modalSession = modalSessionByWindow[window] { + NSApplication.shared.endModalSession(modalSession) + } else if NSApplication.shared.modalWindow === window { + NSApplication.shared.stopModal() + } + modalSessionByWindow.removeValue(forKey: window) + windowsCancellable[window]?.cancel() + windowsCancellable.removeValue(forKey: window) + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift new file mode 100644 index 0000000000..762032cf09 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -0,0 +1,38 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +@MainActor +public protocol NavigationContent: AnyObject { + var onBeginNavigation: (() -> Void)? { set get } + var onEndNavigation: (() -> Void)? { set get } +} + +@MainActor +private var onBeginNavigationKeys = AssociatedKeys() + +@MainActor +private var onEndNavigationKeys = AssociatedKeys() + +extension NavigationContent { + public var onBeginNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self)) as? () -> Void + } + } + + public var onEndNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, onEndNavigationKeys.key(of: Self.self)) as? () -> Void + } + } +} + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift new file mode 100644 index 0000000000..488cf6b4b9 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -0,0 +1,88 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation +import SwiftNavigation + +@MainActor +class NavigationObserver: NSObject { + private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] + + unowned let owner: Owner + + init(owner: Owner) { + self.owner = owner + super.init() + } + + func observe( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> Content, + begin: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void, + end: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let navigated = navigatedByID[key] { + guard let navigationID = navigated.id, + navigationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let content = content(unwrappedItem) + let onEndNavigation = { [navigationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + navigationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + content.onEndNavigation = onEndNavigation + + self.navigatedByID[key] = Navigated(content, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + begin(content, transaction) + } + } + commitWork(work) + } else if let navigated = navigatedByID[key] { + if let content = navigated.content { + end(content, transaction) + } + self.navigatedByID[key] = nil + } + } + } + + func commitWork(_ work: @escaping () -> Void) { + work() + } +} + +@MainActor +class Navigated { + weak var content: Content? + let id: AnyHashable? + func clearup() {} + deinit { + MainActor._assumeIsolated { + clearup() + } + } + + required init(_ content: Content, id: AnyHashable?) { + self.content = content + self.id = id + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift new file mode 100644 index 0000000000..5b2c0662ca --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -0,0 +1,175 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftNavigation +import AppKit +import AppKitNavigationShim + +@MainActor +private var presentationObserverKeys = AssociatedKeys() + +class PresentationObserver: NavigationObserver { + override func commitWork(_ work: @escaping () -> Void) { + if owner.hasViewAppeared { + work() + } else { + owner.onViewAppear.append(work) + } + } +} + +extension NSViewController { + @discardableResult + public func present( + isPresented: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func present( + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + present(item: item, id: id, style: style, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + destination(item: item, id: id) { $item in + content($item) + } present: { [weak self] child, transaction in + guard let self else { return } + if let presentedViewController = presentedViewControllers?.first { + self.dismiss(presentedViewController) + onDismiss?() + child.presented(from: self, style: style) + } else { + child.presented(from: self, style: style) + } + } dismiss: { [weak self] child, transaction in + guard let self else { return } + child.dismiss(from: self) + onDismiss?() + } + } + + @discardableResult + public func destination( + isPresented: UIBinding, + content: @escaping () -> Content, + present: @escaping (Content, UITransaction) -> Void, + dismiss: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) + } + + @discardableResult + public func destination( + item: UIBinding, + content: @escaping (UIBinding) -> Content, + present: @escaping (Content, UITransaction) -> Void, + dismiss: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe( + item: item, + id: { _ in nil }, + content: content, + begin: present, + end: dismiss + ) + } + + @discardableResult + public func destination( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + present: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + } + + private func presentationObserver() -> PresentationObserver { + if let observer = objc_getAssociatedObject(self, presentationObserverKeys.key(of: Content.self)) as? PresentationObserver { + return observer + } else { + let observer = PresentationObserver(owner: self) + objc_setAssociatedObject(self, presentationObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer + } + } +} + +extension NavigationContent where Self: NSViewController { + var _onEndNavigation: (() -> Void)? { + set { + onDismiss = newValue + } + get { + onDismiss + } + } +} + +extension Navigated where Content: NSViewController { + func clearup() { + content?.dismiss(nil) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift new file mode 100644 index 0000000000..eba08ad600 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol PresentationContent: NavigationContent { + func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) + func dismiss(from presentingViewController: NSViewController) +} + +extension NSViewController: PresentationContent { + public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + presentingViewController.present(self, for: style) + } + + public func dismiss(from presentingViewController: NSViewController) { + presentingViewController.dismiss(self) + } + + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) + } + + private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case let .popover(rect, view, preferredEdge, behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case let .custom(animator): + present(viewControllerToPresent, animator: animator) + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift new file mode 100644 index 0000000000..d7bc4fcae2 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -0,0 +1,132 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +private typealias SheetObserver = NavigationObserver + +@MainActor +private var sheetObserverKeys = AssociatedKeys() + +extension SheetContent { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + sheet(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + sheet(item: item, id: id) { $item in + content($item) + } beginSheet: { [weak self] child, _ in + guard let self else { return } + if let attachedSheetWindow = currentWindow?.attachedSheet { + self.endSheet(for: attachedSheetWindow) + onDismiss?() + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndNavigation?() + child.onEndNavigation = nil + } + } else { + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndNavigation?() + child.onEndNavigation = nil + } + } + } endSheet: { [weak self] content, _ in + self?.endSheet(for: content) + onDismiss?() + } + } + + private func sheet( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + beginSheet: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let sheetObserver: SheetObserver = sheetObserver() + return sheetObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginSheet, + end: endSheet + ) + } + + private func sheetObserver() -> SheetObserver { + if let observer = objc_getAssociatedObject(self, sheetObserverKeys.key(of: Content.self)) as? SheetObserver { + return observer + } else { + let observer = SheetObserver(owner: self) + objc_setAssociatedObject(self, sheetObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer + } + } +} + +extension NSWindow { + func endSheeted() { + guard sheetParent != nil else { + return + } + sheetParent?.endSheet(self) + } +} + +extension Navigated where Content: SheetContent { + func clearup() { + content?.currentWindow?.endSheeted() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift new file mode 100644 index 0000000000..eb06919cd8 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -0,0 +1,60 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol SheetContent: NavigationContent { + var currentWindow: NSWindow? { get } + func beginSheet(for content: SheetContent) async + func endSheet(for content: SheetContent) +} + +extension SheetContent { + public func beginSheet(for content: any SheetContent) async { + guard let sheetedWindow = content.currentWindow else { return } + await currentWindow?.beginSheet(sheetedWindow) + } + + public func endSheet(for content: any SheetContent) { + guard let sheetedWindow = content.currentWindow else { return } + currentWindow?.endSheet(sheetedWindow) + } +} + +extension NSWindow: SheetContent { + public var currentWindow: NSWindow? { self } +} + +extension NSWindowController: SheetContent { + public var currentWindow: NSWindow? { window } +} + +extension NSViewController: SheetContent { + public var currentWindow: NSWindow? { view.window } +} + +extension NSAlert: SheetContent { + public var currentWindow: NSWindow? { window } + + public func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } +} + +extension NSSavePanel { + public func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } +} + +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100644 index 0000000000..5a4e070ea3 --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,19 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface NSViewController (AppKitNavigation) + +@property BOOL hasViewAppeared; +@property (nullable) void (^ onDismiss)(); +@property NSArray *onViewAppear; + +@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 100644 index 0000000000..14e82c72dd --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,110 @@ +#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(NSViewController.class, @selector(viewDidAppear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) + ); +} + +@end + +static void *hasViewAppearedKey = &hasViewAppearedKey; +static void *onDismissKey = &onDismissKey; +static void *onViewAppearKey = &onViewAppearKey; + +@implementation NSViewController (AppKitNavigation) + +- (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; + + if (self.hasViewAppeared) { + return; + } + + self.hasViewAppeared = YES; + + for (void (^work)() in self.onViewAppear) { + work(); + } + + self.onViewAppear = @[]; +} + +- (void)setBeingDismissed:(BOOL)beingDismissed { + objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); +} + +- (BOOL)isBeingDismissed { + return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; +} + +- (void)AppKitNavigation_viewDidDisappear { + [self AppKitNavigation_viewDidDisappear]; + + if ((self.isBeingDismissed) && self.onDismiss != NULL) { + self.onDismiss(); + self.onDismiss = nil; + [self setBeingDismissed:NO]; + } +} + +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} + +- (BOOL)hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} + +- (void)setHasViewAppeared:(BOOL)hasViewAppeared { + objc_setAssociatedObject( + self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + ); +} + +- (void (^)())onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} + +- (void)setOnDismiss:(void (^)())onDismiss { + objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (NSMutableArray *)onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); + + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +@end +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ From 2228ad119c9339d2219dc0c8aa6f153757534b6b Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 28 Aug 2024 00:59:52 +0800 Subject: [PATCH 24/42] Update Sources/AppKitNavigationShim/include/shim.h Co-authored-by: Stephen Celis --- Sources/AppKitNavigationShim/include/shim.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h index 5a4e070ea3..a19cbd8f53 100644 --- a/Sources/AppKitNavigationShim/include/shim.h +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -8,9 +8,9 @@ NS_ASSUME_NONNULL_BEGIN @interface NSViewController (AppKitNavigation) -@property BOOL hasViewAppeared; -@property (nullable) void (^ onDismiss)(); -@property NSArray *onViewAppear; +@property BOOL _AppKitNavigation_hasViewAppeared; +@property (nullable) void (^ _AppKitNavigation_onDismiss)(); +@property NSArray *_AppKitNavigation_onViewAppear; @end From 47ac7b3df1f0cdb2d47c518d6a599de4c762e283 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 28 Aug 2024 01:00:02 +0800 Subject: [PATCH 25/42] Update Sources/AppKitNavigation/Navigation/Sheet.swift Co-authored-by: Stephen Celis --- Sources/AppKitNavigation/Navigation/Sheet.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index d7bc4fcae2..6ec8b0d806 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -114,18 +114,10 @@ extension SheetContent { } } -extension NSWindow { - func endSheeted() { - guard sheetParent != nil else { - return - } - sheetParent?.endSheet(self) - } -} - extension Navigated where Content: SheetContent { func clearup() { - content?.currentWindow?.endSheeted() + guard let window = content?.currentWindow else { return } + window.sheetParent?.endSheet(window) } } From 6111e32c433b5940486320e0bf69aa254ed31776 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 28 Aug 2024 01:00:20 +0800 Subject: [PATCH 26/42] Update Sources/AppKitNavigation/Navigation/ModalSessionContent.swift Co-authored-by: Stephen Celis --- .../AppKitNavigation/Navigation/ModalSessionContent.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift index 9132638de7..cc50c0afdf 100644 --- a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -10,12 +10,7 @@ public protocol ModalSessionContent: ModalContent { extension NSWindow: ModalSessionContent { public func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { - __appKitNavigationBeginModalSession() - } - - @objc func __appKitNavigationBeginModalSession() -> NSApplication.ModalSession { - let modalSession = NSApplication.shared.beginModalSession(for: self) - return modalSession + NSApplication.shared.beginModalSession(for: self) } } From 3832d5118ed814210669cd382be52323e6d82913 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:32:43 +0800 Subject: [PATCH 27/42] Fixes building errors --- .../Navigation/Presentation.swift | 8 ++--- Sources/AppKitNavigationShim/shim.m | 30 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 5b2c0662ca..e5f0197e7e 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -8,10 +8,10 @@ private var presentationObserverKeys = AssociatedKeys() class PresentationObserver: NavigationObserver { override func commitWork(_ work: @escaping () -> Void) { - if owner.hasViewAppeared { + if owner._AppKitNavigation_hasViewAppeared { work() } else { - owner.onViewAppear.append(work) + owner._AppKitNavigation_onViewAppear.append(work) } } } @@ -158,10 +158,10 @@ extension NSViewController { extension NavigationContent where Self: NSViewController { var _onEndNavigation: (() -> Void)? { set { - onDismiss = newValue + _AppKitNavigation_onDismiss = newValue } get { - onDismiss + _AppKitNavigation_onDismiss } } } diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m index 14e82c72dd..9ebf4859ba 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -41,17 +41,17 @@ @implementation NSViewController (AppKitNavigation) - (void)AppKitNavigation_viewDidAppear { [self AppKitNavigation_viewDidAppear]; - if (self.hasViewAppeared) { + if (self._AppKitNavigation_hasViewAppeared) { return; } - self.hasViewAppeared = YES; + self._AppKitNavigation_hasViewAppeared = YES; - for (void (^work)() in self.onViewAppear) { + for (void (^work)() in self._AppKitNavigation_onViewAppear) { work(); } - self.onViewAppear = @[]; + self._AppKitNavigation_onViewAppear = @[]; } - (void)setBeingDismissed:(BOOL)beingDismissed { @@ -65,9 +65,9 @@ - (BOOL)isBeingDismissed { - (void)AppKitNavigation_viewDidDisappear { [self AppKitNavigation_viewDidDisappear]; - if ((self.isBeingDismissed) && self.onDismiss != NULL) { - self.onDismiss(); - self.onDismiss = nil; + if ((self.isBeingDismissed) && self._AppKitNavigation_onDismiss != NULL) { + self._AppKitNavigation_onDismiss(); + self._AppKitNavigation_onDismiss = nil; [self setBeingDismissed:NO]; } } @@ -77,31 +77,31 @@ - (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { [self setBeingDismissed:YES]; } -- (BOOL)hasViewAppeared { +- (BOOL)_AppKitNavigation_hasViewAppeared { return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; } -- (void)setHasViewAppeared:(BOOL)hasViewAppeared { +- (void)set_AppKitNavigation_hasViewAppeared:(BOOL)_AppKitNavigation_hasViewAppeared { objc_setAssociatedObject( - self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + self, hasViewAppearedKey, @(_AppKitNavigation_hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC ); } -- (void (^)())onDismiss { +- (void (^)())_AppKitNavigation_onDismiss { return objc_getAssociatedObject(self, onDismissKey); } -- (void)setOnDismiss:(void (^)())onDismiss { - objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +- (void)set_AppKitNavigation_onDismiss:(void (^)())_AppKitNavigation_onDismiss { + objc_setAssociatedObject(self, onDismissKey, [_AppKitNavigation_onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); } -- (NSMutableArray *)onViewAppear { +- (NSMutableArray *)_AppKitNavigation_onViewAppear { id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); return onViewAppear == nil ? @[] : onViewAppear; } -- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { +- (void)set_AppKitNavigation_onViewAppear:(NSMutableArray *)onViewAppear { objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); } 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 28/42] 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 2194c6fcfc..2fb60be222 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 490fe55fcd..169fd95403 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 096761ab34..7d469824ea 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 0000000000..812f894812 --- /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 0000000000..d2287caf93 --- /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 0000000000..71eab79865 --- /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 0000000000..7edc6aaef2 --- /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 0000000000..17e3d26ecd --- /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 0000000000..9797d82ae6 --- /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 0000000000..25d7dd5c0a --- /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 0000000000..fda06940be --- /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 0000000000..8220b89367 --- /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 0000000000..25b1bdd441 --- /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 0000000000..7931a6d921 --- /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 0000000000..59a679d71f --- /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 0000000000..60a92b14fa --- /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 0000000000..1d72abb4eb --- /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 0000000000..2d2054d0ec --- /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 0000000000..897efc8417 --- /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 0000000000..478253a51e --- /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 0000000000..9bd7111902 --- /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 0000000000..8101d55352 --- /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 23b8079026..e5c147398a 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" } } ], From 2479dbaa7e4e8042ee3429c0984cb1c97cba531a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 11:40:01 +0800 Subject: [PATCH 29/42] Translate some protocol to internal --- .../AppKitNavigation/Navigation/Modal.swift | 153 ++++++++++++++---- .../Navigation/ModalContent.swift | 10 +- .../Navigation/ModalSessionContent.swift | 5 +- .../Navigation/NavigationContent.swift | 6 +- .../Navigation/Presentation.swift | 63 ++++---- .../Navigation/PresentationContent.swift | 6 +- .../AppKitNavigation/Navigation/Sheet.swift | 22 +-- .../Navigation/SheetContent.swift | 22 +-- 8 files changed, 185 insertions(+), 102 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 14681eda9e..79075a67be 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -9,94 +9,139 @@ private typealias ModalObserver = NavigationObserver( + public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + content: @escaping () -> NSWindow ) -> ObserveToken { modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } - @discardableResult - public func modalSession( - isModaled: UIBinding, + public func modal( + item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } + @_disfavoredOverload @discardableResult - public func modal( + public func modal( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (UIBinding) -> NSWindow ) -> ObserveToken { modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } @discardableResult - public func modalSession( + public func modal( item: UIBinding, + id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + modal(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } } - @_disfavoredOverload @discardableResult - public func modal( + public func modal( item: UIBinding, + id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping (UIBinding) -> NSWindow ) -> ObserveToken { - modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: item, id: id, onDismiss: onDismiss, content: content) } + // MARK: - Modal - NSSavePanel - @_disfavoredOverload @discardableResult - public func modalSession( - item: UIBinding, + public func modal( + isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping () -> NSSavePanel + ) -> ObserveToken { + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + // MARK: - Modal - NSAlert + + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSAlert + ) -> ObserveToken { + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + // MARK: - Modal Session - NSWindow + + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + // MARK: - Private Modal + + @discardableResult + private func _modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content ) -> ObserveToken { - modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } - + @discardableResult - public func modal( + private func _modal( item: UIBinding, - id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - modal(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + private func _modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } @discardableResult - public func modalSession( + private func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - modalSession(item: item, id: id, onDismiss: onDismiss) { + _modal(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) } } @discardableResult - public func modal( + private func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - modal(item: item, id: id) { $item in + _modal(item: item, id: id) { $item in content($item) } beginModal: { modalContent, _ in if NSApplication.shared.modalWindow != nil { @@ -122,15 +167,55 @@ extension NSObject { onDismiss?() } } + // MARK: - Modal Session + @discardableResult + private func _modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + private func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + private func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + private func _modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } @discardableResult - public func modalSession( + private func _modalSession( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - modal(item: item, id: id) { $item in + _modal(item: item, id: id) { $item in content($item) } beginModal: { modalContent, _ in if let modaledWindow = NSApplication.shared.modalWindow, let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modaledWindow] { @@ -157,7 +242,7 @@ extension NSObject { } } - private func modal( + private func _modal( item: UIBinding, id: KeyPath, content: @escaping (UIBinding) -> Content, diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index 00772647f2..676b8857b8 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -3,18 +3,18 @@ import AppKit @MainActor -public protocol ModalContent: NavigationContent { +protocol ModalContent: NavigationContent { @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse var window: NSWindow { get } } extension NSWindow: ModalContent { - public var window: NSWindow { self } + var window: NSWindow { self } - public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + func appKitNavigationRunModal() -> NSApplication.ModalResponse { __appKitNavigationRunModal() } - + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { NSApplication.shared.runModal(for: self) } @@ -27,7 +27,7 @@ extension NSSavePanel { } extension NSAlert: ModalContent { - public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + func appKitNavigationRunModal() -> NSApplication.ModalResponse { runModal() } } diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift index cc50c0afdf..a897018c9e 100644 --- a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -3,13 +3,12 @@ import AppKit @MainActor -public protocol ModalSessionContent: ModalContent { +protocol ModalSessionContent: ModalContent { func appKitNavigationBeginModalSession() -> NSApplication.ModalSession } extension NSWindow: ModalSessionContent { - - public func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { NSApplication.shared.beginModalSession(for: self) } } diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index 762032cf09..2e35321c1b 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -3,7 +3,7 @@ import Foundation @MainActor -public protocol NavigationContent: AnyObject { +protocol NavigationContent: AnyObject { var onBeginNavigation: (() -> Void)? { set get } var onEndNavigation: (() -> Void)? { set get } } @@ -15,7 +15,7 @@ private var onBeginNavigationKeys = AssociatedKeys() private var onEndNavigationKeys = AssociatedKeys() extension NavigationContent { - public var onBeginNavigation: (() -> Void)? { + var onBeginNavigation: (() -> Void)? { set { objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } @@ -24,7 +24,7 @@ extension NavigationContent { } } - public var onEndNavigation: (() -> Void)? { + var onEndNavigation: (() -> Void)? { set { objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index e5f0197e7e..a9e28256fd 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -3,10 +3,7 @@ import SwiftNavigation import AppKit import AppKitNavigationShim -@MainActor -private var presentationObserverKeys = AssociatedKeys() - -class PresentationObserver: NavigationObserver { +class PresentationObserver: NavigationObserver { override func commitWork(_ work: @escaping () -> Void) { if owner._AppKitNavigation_hasViewAppeared { work() @@ -18,43 +15,43 @@ class PresentationObserver: NavigationObserver( + public func present( isPresented: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + content: @escaping () -> NSViewController ) -> ObserveToken { present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (Item) -> NSViewController ) -> ObserveToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping (UIBinding) -> NSViewController ) -> ObserveToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (Item) -> NSViewController ) -> ObserveToken { present(item: item, id: id, style: style, onDismiss: onDismiss) { content($0.wrappedValue) @@ -63,12 +60,12 @@ extension NSViewController { @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping (UIBinding) -> NSViewController ) -> ObserveToken { destination(item: item, id: id) { $item in content($item) @@ -89,12 +86,12 @@ extension NSViewController { } @discardableResult - public func destination( + public func destination( isPresented: UIBinding, - content: @escaping () -> Content, - present: @escaping (Content, UITransaction) -> Void, + content: @escaping () -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, dismiss: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void ) -> ObserveToken { @@ -107,16 +104,16 @@ extension NSViewController { } @discardableResult - public func destination( + public func destination( item: UIBinding, - content: @escaping (UIBinding) -> Content, - present: @escaping (Content, UITransaction) -> Void, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, dismiss: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void ) -> ObserveToken { - let presentationObserver: PresentationObserver = presentationObserver() + let presentationObserver: PresentationObserver = presentationObserver() return presentationObserver.observe( item: item, id: { _ in nil }, @@ -127,29 +124,31 @@ extension NSViewController { } @discardableResult - public func destination( + public func destination( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> Content, + content: @escaping (UIBinding) -> NSViewController, present: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void, dismiss: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void ) -> ObserveToken { - let presentationObserver: PresentationObserver = presentationObserver() + let presentationObserver: PresentationObserver = presentationObserver() return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) } - private func presentationObserver() -> PresentationObserver { - if let observer = objc_getAssociatedObject(self, presentationObserverKeys.key(of: Content.self)) as? PresentationObserver { + private static var presentationObserverKey = malloc(1)! + + private func presentationObserver() -> PresentationObserver { + if let observer = objc_getAssociatedObject(self, Self.presentationObserverKey) as? PresentationObserver { return observer } else { - let observer = PresentationObserver(owner: self) - objc_setAssociatedObject(self, presentationObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + let observer = PresentationObserver(owner: self) + objc_setAssociatedObject(self, Self.presentationObserverKey, observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) return observer } } diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift index eba08ad600..93de778e57 100644 --- a/Sources/AppKitNavigation/Navigation/PresentationContent.swift +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -3,17 +3,17 @@ import AppKit @MainActor -public protocol PresentationContent: NavigationContent { +protocol PresentationContent: NavigationContent { func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) func dismiss(from presentingViewController: NSViewController) } extension NSViewController: PresentationContent { - public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + func presented(from presentingViewController: NSViewController, style: TransitionStyle) { presentingViewController.present(self, for: style) } - public func dismiss(from presentingViewController: NSViewController) { + func dismiss(from presentingViewController: NSViewController) { presentingViewController.dismiss(self) } diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 6ec8b0d806..ee0cda49ab 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -9,53 +9,53 @@ private var sheetObserverKeys = AssociatedKeys() extension SheetContent { @discardableResult - public func sheet( + private func _sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content ) -> ObserveToken { - sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } @discardableResult - public func sheet( + private func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @_disfavoredOverload @discardableResult - public func sheet( + private func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @discardableResult - public func sheet( + private func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - sheet(item: item, id: id, onDismiss: onDismiss) { + _sheet(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) } } @discardableResult - public func sheet( + private func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - sheet(item: item, id: id) { $item in + _sheet(item: item, id: id) { $item in content($item) } beginSheet: { [weak self] child, _ in guard let self else { return } @@ -80,7 +80,7 @@ extension SheetContent { } } - private func sheet( + private func _sheet( item: UIBinding, id: KeyPath, content: @escaping (UIBinding) -> Content, diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index eb06919cd8..25d27c466b 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -3,56 +3,56 @@ import AppKit @MainActor -public protocol SheetContent: NavigationContent { +protocol SheetContent: NavigationContent { var currentWindow: NSWindow? { get } func beginSheet(for content: SheetContent) async func endSheet(for content: SheetContent) } extension SheetContent { - public func beginSheet(for content: any SheetContent) async { + func beginSheet(for content: any SheetContent) async { guard let sheetedWindow = content.currentWindow else { return } await currentWindow?.beginSheet(sheetedWindow) } - public func endSheet(for content: any SheetContent) { + func endSheet(for content: any SheetContent) { guard let sheetedWindow = content.currentWindow else { return } currentWindow?.endSheet(sheetedWindow) } } extension NSWindow: SheetContent { - public var currentWindow: NSWindow? { self } + var currentWindow: NSWindow? { self } } extension NSWindowController: SheetContent { - public var currentWindow: NSWindow? { window } + var currentWindow: NSWindow? { window } } extension NSViewController: SheetContent { - public var currentWindow: NSWindow? { view.window } + var currentWindow: NSWindow? { view.window } } extension NSAlert: SheetContent { - public var currentWindow: NSWindow? { window } + var currentWindow: NSWindow? { window } - public func beginSheet(for content: any SheetContent) async { + func beginSheet(for content: any SheetContent) async { guard let parentWindow = content.currentWindow else { return } await beginSheetModal(for: parentWindow) } - public func endSheet(for content: any SheetContent) { + func endSheet(for content: any SheetContent) { content.currentWindow?.endSheet(window) } } extension NSSavePanel { - public func beginSheet(for content: any SheetContent) async { + func beginSheet(for content: any SheetContent) async { guard let parentWindow = content.currentWindow else { return } await beginSheetModal(for: parentWindow) } - public func endSheet(for content: any SheetContent) { + func endSheet(for content: any SheetContent) { content.currentWindow?.endSheet(window) } } From 863c8d2d6c37462a012b78ad654fc6c6c9d274c5 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:23:03 +0800 Subject: [PATCH 30/42] Completion --- .../AppKitNavigation/Navigation/Modal.swift | 189 +++++++++++++++--- .../Navigation/Presentation.swift | 26 ++- .../Navigation/PresentationContent.swift | 41 ---- .../AppKitNavigation/Navigation/Sheet.swift | 161 ++++++++++++++- .../Navigation/SheetContent.swift | 8 - 5 files changed, 339 insertions(+), 86 deletions(-) delete mode 100644 Sources/AppKitNavigation/Navigation/PresentationContent.swift diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 79075a67be..0d053d6f0d 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -7,25 +7,79 @@ private var modalObserverKeys = AssociatedKeys() private typealias ModalObserver = NavigationObserver +// MARK: - Modal Session - NSWindow @MainActor extension NSObject { - // MARK: - Modal - NSWindow + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _modalSession(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +// MARK: - Modal - NSWindow + +@MainActor +extension NSObject { @discardableResult public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> NSWindow ) -> ObserveToken { - modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) } + @discardableResult public func modal( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: item, onDismiss: onDismiss, content: content) } @_disfavoredOverload @@ -35,7 +89,7 @@ extension NSObject { onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> NSWindow ) -> ObserveToken { - modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: item, onDismiss: onDismiss, content: content) } @discardableResult @@ -45,9 +99,7 @@ extension NSObject { onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modal(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } + _modal(item: item, id: id, onDismiss: onDismiss, content: content) } @discardableResult @@ -59,43 +111,120 @@ extension NSObject { ) -> ObserveToken { _modal(item: item, id: id, onDismiss: onDismiss, content: content) } - // MARK: - Modal - NSSavePanel +} +// MARK: - Modal - NSAlert + +@MainActor +extension NSObject { @discardableResult public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSSavePanel + content: @escaping () -> NSAlert ) -> ObserveToken { - _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) } - // MARK: - Modal - NSAlert + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +// MARK: - Modal - NSSavePanel + +@MainActor +extension NSObject { @discardableResult public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSAlert + content: @escaping () -> NSSavePanel ) -> ObserveToken { - _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) } - // MARK: - Modal Session - NSWindow + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + @_disfavoredOverload @discardableResult - public func modalSession( - isModaled: UIBinding, + public func modal( + item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSWindow + content: @escaping (UIBinding) -> NSSavePanel ) -> ObserveToken { - _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) } - // MARK: - Private Modal + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +// MARK: - Private Modal +@MainActor +extension NSObject { @discardableResult - private func _modal( + fileprivate func _modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content @@ -104,7 +233,7 @@ extension NSObject { } @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content @@ -114,7 +243,7 @@ extension NSObject { @_disfavoredOverload @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content @@ -123,7 +252,7 @@ extension NSObject { } @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -135,7 +264,7 @@ extension NSObject { } @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -167,9 +296,11 @@ extension NSObject { onDismiss?() } } + // MARK: - Modal Session + @discardableResult - private func _modalSession( + fileprivate func _modalSession( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content @@ -178,7 +309,7 @@ extension NSObject { } @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content @@ -188,7 +319,7 @@ extension NSObject { @_disfavoredOverload @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content @@ -197,7 +328,7 @@ extension NSObject { } @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -209,7 +340,7 @@ extension NSObject { } @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index a9e28256fd..fbd5f46777 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -74,13 +74,13 @@ extension NSViewController { if let presentedViewController = presentedViewControllers?.first { self.dismiss(presentedViewController) onDismiss?() - child.presented(from: self, style: style) + self.present(child, for: style) } else { - child.presented(from: self, style: style) + self.present(child, for: style) } } dismiss: { [weak self] child, transaction in guard let self else { return } - child.dismiss(from: self) + self.dismiss(child) onDismiss?() } } @@ -152,6 +152,26 @@ extension NSViewController { return observer } } + + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) + } + + private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case let .popover(rect, view, preferredEdge, behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case let .custom(animator): + present(viewControllerToPresent, animator: animator) + } + } } extension NavigationContent where Self: NSViewController { diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift deleted file mode 100644 index 93de778e57..0000000000 --- a/Sources/AppKitNavigation/Navigation/PresentationContent.swift +++ /dev/null @@ -1,41 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -protocol PresentationContent: NavigationContent { - func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) - func dismiss(from presentingViewController: NSViewController) -} - -extension NSViewController: PresentationContent { - func presented(from presentingViewController: NSViewController, style: TransitionStyle) { - presentingViewController.present(self, for: style) - } - - func dismiss(from presentingViewController: NSViewController) { - presentingViewController.dismiss(self) - } - - public enum TransitionStyle { - case sheet - case modalWindow - case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) - } - - private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { - switch style { - case .sheet: - presentAsSheet(viewControllerToPresent) - case .modalWindow: - presentAsModalWindow(viewControllerToPresent) - case let .popover(rect, view, preferredEdge, behavior): - present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) - case let .custom(animator): - present(viewControllerToPresent, animator: animator) - } - } -} - -#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index ee0cda49ab..ba9cbd1846 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -7,9 +7,160 @@ private typealias SheetObserver, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +extension NSWindow { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSAlert + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +extension NSWindow { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSSavePanel + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + + extension SheetContent { @discardableResult - private func _sheet( + fileprivate func _sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content @@ -18,7 +169,7 @@ extension SheetContent { } @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content @@ -28,7 +179,7 @@ extension SheetContent { @_disfavoredOverload @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content @@ -37,7 +188,7 @@ extension SheetContent { } @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -49,7 +200,7 @@ extension SheetContent { } @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 25d27c466b..3da396258f 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -25,14 +25,6 @@ extension NSWindow: SheetContent { var currentWindow: NSWindow? { self } } -extension NSWindowController: SheetContent { - var currentWindow: NSWindow? { window } -} - -extension NSViewController: SheetContent { - var currentWindow: NSWindow? { view.window } -} - extension NSAlert: SheetContent { var currentWindow: NSWindow? { window } From 2963dd1faae5afd40e71ebc0a54b1b6916e0b66a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:50:33 +0800 Subject: [PATCH 31/42] Fixes --- Package.swift | 2 +- Package@swift-6.0.swift | 2 + .../Bindings/NSColorPanel.swift | 6 +- .../AppKitNavigation/Bindings/NSControl.swift | 12 +- .../Bindings/NSFontManager.swift | 202 ++++++++++++++++-- .../Bindings/NSMenuItem.swift | 12 +- .../Bindings/NSTextField.swift | 4 +- .../Bindings/NSToolbarItem.swift | 17 -- ...ction.swift => TargetActionProtocol.swift} | 35 ++- ...ionProxy.swift => TargetActionProxy.swift} | 14 +- 10 files changed, 219 insertions(+), 87 deletions(-) delete mode 100755 Sources/AppKitNavigation/Bindings/NSToolbarItem.swift rename Sources/AppKitNavigation/Bindings/{NSTargetAction.swift => TargetActionProtocol.swift} (79%) rename Sources/AppKitNavigation/Bindings/{NSTargetActionProxy.swift => TargetActionProxy.swift} (85%) diff --git a/Package.swift b/Package.swift index 2fb60be222..d8e88c6c6d 100644 --- a/Package.swift +++ b/Package.swift @@ -85,7 +85,7 @@ let package = Package( dependencies: [ "SwiftNavigation", "AppKitNavigationShim", - .product(name: "IdentifiedCollections", package: "swift-identified-collections") + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), .target( diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 169fd95403..5db189cb1a 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.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( @@ -84,6 +85,7 @@ let package = Package( dependencies: [ "SwiftNavigation", "AppKitNavigationShim", + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), .target( diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift index d2287caf93..36715c56cd 100755 --- a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -2,13 +2,13 @@ import AppKit -extension NSColorPanel: NSTargetActionProtocol { - public var appkitNavigationTarget: AnyObject? { +extension NSColorPanel: TargetActionProtocol { + public var target: AnyObject? { set { setTarget(newValue) } get { value(forKeyPath: "target") as? AnyObject } } - public var appkitNavigationAction: Selector? { + public var action: Selector? { set { setAction(newValue) } get { value(forKeyPath: "action") as? Selector } } diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 7edc6aaef2..05b5f7fa13 100755 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -2,17 +2,7 @@ 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: TargetActionProtocol {} extension NSControl { public convenience init(action: @escaping (Self) -> Void) { diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 9797d82ae6..51944e85ac 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -1,19 +1,12 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) +import ConcurrencyExtras +@_spi(Internals) import SwiftNavigation 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 } - } +import IdentifiedCollections +extension NSFontManager: @unchecked Sendable { + private static let appkitNavigationDelegateKey = malloc(1)! private var appkitNavigationDelegate: Delegate { @@ -74,4 +67,189 @@ extension NSFontManager { } } + +@MainActor +extension NSFontManager { + /// 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 + } + } + + private var actionProxy: FontManagerProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionProxyKey) as? FontManagerProxy + } + } + + private func createActionProxyIfNeeded() -> FontManagerProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = FontManagerProxy(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: NSFontManager, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObserveToken { + 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 = ObserveToken { [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 + } +} + + +@MainActor +private let observationTokensKey = malloc(1)! +@MainActor +private let actionProxyKey = malloc(1)! + +@MainActor +private class FontManagerProxy: 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: NSFontManager? + + required init(owner: NSFontManager) { + self.owner = owner + super.init() + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) + } + + @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/NSMenuItem.swift b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift index 25d7dd5c0a..01bb9ac88b 100755 --- a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -2,16 +2,6 @@ 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 } - } -} +extension NSMenuItem: TargetActionProtocol, @unchecked Sendable {} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index 897efc8417..96a45cf170 100755 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -72,7 +72,7 @@ extension NSTextField: NSTextViewDelegate { } } - let observationToken = ObservationToken { [weak self] in + let observationToken = ObserveToken { [weak self] in MainActor._assumeIsolated { editingChangedAction.cancel() editingDidEndAction.cancel() @@ -242,7 +242,7 @@ extension NSTextField: NSTextViewDelegate { break } } - let outerToken = ObservationToken { + let outerToken = ObserveToken { editingDidBeginAction.cancel() editingDidEndAction.cancel() innerToken.cancel() diff --git a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift deleted file mode 100755 index 478253a51e..0000000000 --- a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift +++ /dev/null @@ -1,17 +0,0 @@ -#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/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift similarity index 79% rename from Sources/AppKitNavigation/Bindings/NSTargetAction.swift rename to Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift index 1d72abb4eb..c7bbedb5c6 100755 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift @@ -5,12 +5,12 @@ 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 } +public protocol TargetActionProtocol: NSObject, Sendable { + var target: AnyObject? { set get } + var action: Selector? { set get } } -extension NSTargetActionProtocol { +extension TargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. /// /// - Parameters: @@ -28,20 +28,20 @@ extension NSTargetActionProtocol { } } - var actionProxy: NSTargetActionProxy? { + var actionProxy: TargetActionProxy? { set { objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - objc_getAssociatedObject(self, actionProxyKey) as? NSTargetActionProxy + objc_getAssociatedObject(self, actionProxyKey) as? TargetActionProxy } } - func createActionProxyIfNeeded() -> NSTargetActionProxy { + func createActionProxyIfNeeded() -> TargetActionProxy { if let actionProxy { return actionProxy } else { - let actionProxy = NSTargetActionProxy(owner: self) + let actionProxy = TargetActionProxy(owner: self) self.actionProxy = actionProxy return actionProxy } @@ -62,7 +62,7 @@ extension NSTargetActionProtocol { _ binding: UIBinding, to keyPath: KeyPath, set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObservationToken { + ) -> ObserveToken { unbind(keyPath) let actionProxy = createActionProxyIfNeeded() let actionID = actionProxy.addBindingAction { [weak self] _ in @@ -91,7 +91,7 @@ extension NSTargetActionProtocol { binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] } } - let observationToken = ObservationToken { [weak self] in + let observationToken = ObserveToken { [weak self] in MainActor._assumeIsolated { self?.actionProxy?.removeAction(for: actionID) } @@ -107,24 +107,13 @@ extension NSTargetActionProtocol { 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] { + var observationTokens: [AnyKeyPath: ObserveToken] { get { - objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObserveToken] ?? [:] } set { diff --git a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift similarity index 85% rename from Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift rename to Sources/AppKitNavigation/Bindings/TargetActionProxy.swift index 2d2054d0ec..c7e82fb291 100755 --- a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -4,7 +4,7 @@ import AppKit import IdentifiedCollections @MainActor -class NSTargetActionProxy: NSObject { +class TargetActionProxy: NSObject { typealias ActionClosure = (Any?) -> Void typealias ActionIdentifier = UUID @@ -27,15 +27,15 @@ class NSTargetActionProxy: NSObject { private var originAction: Selector? - weak var owner: NSTargetActionProtocol? + weak var owner: TargetActionProtocol? - required init(owner: NSTargetActionProtocol) { + required init(owner: TargetActionProtocol) { self.owner = owner super.init() - self.originTarget = owner.appkitNavigationTarget - self.originAction = owner.appkitNavigationAction - owner.appkitNavigationTarget = self - owner.appkitNavigationAction = #selector(invokeAction(_:)) + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) if let textField = owner as? NSTextField { NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) } From 9d0df7e6732ee86bf6e53dc122930c0a65b87aad Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 1 Dec 2024 02:20:55 +0800 Subject: [PATCH 32/42] Fix errors --- Sources/AppKitNavigation/Bindings/NSColorPanel.swift | 1 + Sources/AppKitNavigation/Bindings/NSControl.swift | 1 + Sources/AppKitNavigation/Bindings/NSFontManager.swift | 5 +++-- Sources/AppKitNavigation/Bindings/NSMenuItem.swift | 2 +- Sources/AppKitNavigation/Bindings/NSTextField.swift | 5 +++-- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift index 36715c56cd..79adfd1851 100755 --- a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -2,6 +2,7 @@ import AppKit +extension NSColorPanel: @retroactive Sendable {} extension NSColorPanel: TargetActionProtocol { public var target: AnyObject? { set { setTarget(newValue) } diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 05b5f7fa13..33ae9d4348 100755 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -2,6 +2,7 @@ import AppKit +extension NSControl: @retroactive Sendable {} extension NSControl: TargetActionProtocol {} extension NSControl { diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 51944e85ac..01bc6b00ca 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -5,9 +5,10 @@ import ConcurrencyExtras import AppKit import IdentifiedCollections -extension NSFontManager: @unchecked Sendable { +@MainActor +extension NSFontManager: @unchecked @retroactive Sendable { - private static let appkitNavigationDelegateKey = malloc(1)! + private static let appkitNavigationDelegateKey = malloc(1)! private var appkitNavigationDelegate: Delegate { set { diff --git a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift index 01bb9ac88b..663c1302e9 100755 --- a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -2,6 +2,6 @@ import AppKit -extension NSMenuItem: TargetActionProtocol, @unchecked Sendable {} +extension NSMenuItem: TargetActionProtocol, @unchecked @retroactive Sendable {} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index 96a45cf170..8b9182aa08 100755 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -4,7 +4,8 @@ import Combine import SwiftNavigation @MainActor -extension NSTextField: NSTextViewDelegate { +extension NSTextField: @retroactive NSTextDelegate {} +extension NSTextField: @retroactive NSTextViewDelegate { /// Creates a new text field with the specified frame and registers the binding against its /// text. /// @@ -337,6 +338,6 @@ public struct AppKitTextSelection: Hashable, Sendable { } } -extension AnyCancellable: @unchecked Sendable {} +extension AnyCancellable: @unchecked @retroactive Sendable {} #endif From 567a607430cf40f796f6a300f3464ff63fbc6f66 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 4 Feb 2025 15:36:43 +0800 Subject: [PATCH 33/42] Merge upstream main branch --- Examples/Examples.xcodeproj/project.pbxproj | 1 - .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../Bindings/NSTargetAction.swift | 143 ---- .../Bindings/NSTargetActionProxy.swift | 88 --- .../Bindings/NSToolbarItem.swift | 13 +- .../Bindings/TargetActionProxy.swift | 126 +-- .../Internal/AssociatedKeys.swift | 42 +- .../Internal/AssumeIsolated.swift | 35 - .../Internal/ErrorMechanism.swift | 20 - .../AppKitNavigation/Internal/Exports.swift | 2 +- .../Internal/ModalWindowsObserver.swift | 33 + .../Internal/ToOptionalUnit.swift | 12 - .../AppKitNavigation/Navigation/Modal.swift | 740 +++++++++--------- .../Navigation/ModalContent.swift | 41 +- .../Navigation/ModalSession.swift | 7 - .../Navigation/ModalSessionContent.swift | 16 - .../Navigation/ModalWindowsObserver.swift | 33 - .../Navigation/NavigationContent.swift | 33 +- .../Navigation/NavigationObserver.swift | 130 +-- .../Navigation/Presentation.swift | 304 ++++--- .../Navigation/PresentationContent.swift | 52 +- .../AppKitNavigation/Navigation/Sheet.swift | 455 ++++++----- .../Navigation/SheetContent.swift | 58 +- Sources/AppKitNavigation/Observe.swift | 181 ----- Sources/AppKitNavigationShim/shim.m | 2 + .../xcshareddata/swiftpm/Package.resolved | 39 +- 26 files changed, 1044 insertions(+), 1564 deletions(-) delete mode 100644 Sources/AppKitNavigation/Bindings/NSTargetAction.swift delete mode 100644 Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift delete mode 100644 Sources/AppKitNavigation/Internal/AssumeIsolated.swift delete mode 100644 Sources/AppKitNavigation/Internal/ErrorMechanism.swift create mode 100644 Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift delete mode 100644 Sources/AppKitNavigation/Internal/ToOptionalUnit.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalSession.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalSessionContent.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift delete mode 100644 Sources/AppKitNavigation/Observe.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 74a5badb07..ac06aec67b 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1176,7 +1176,6 @@ isa = XCSwiftPackageProductDependency; productName = SwiftUINavigation; }; ->>>>>>> origin/appkit-navigation-bindings /* End XCSwiftPackageProductDependency section */ }; rootObject = CA47378C272F08EF0012CAC3 /* Project object */; diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 7d469824ea..096761ab34 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/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift deleted file mode 100644 index fb6e9553fa..0000000000 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ /dev/null @@ -1,143 +0,0 @@ -#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 - ) -> ObservationToken { - 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 deleted file mode 100644 index 2d2054d0ec..0000000000 --- a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift +++ /dev/null @@ -1,88 +0,0 @@ -#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/NSToolbarItem.swift b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift index 478253a51e..e90fec934d 100644 --- a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift +++ b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift @@ -2,16 +2,7 @@ import AppKit -extension NSToolbarItem: NSTargetActionProtocol { - public var appkitNavigationTarget: AnyObject? { - set { target = newValue } - get { target } - } - - public var appkitNavigationAction: Selector? { - set { action = newValue } - get { action } - } -} +extension NSToolbarItem: @retroactive Sendable {} +extension NSToolbarItem: TargetActionProtocol {} #endif diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift index c7e82fb291..961235ff1e 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -5,84 +5,84 @@ import IdentifiedCollections @MainActor class TargetActionProxy: NSObject { - typealias ActionClosure = (Any?) -> Void + typealias ActionClosure = (Any?) -> Void - typealias ActionIdentifier = UUID + typealias ActionIdentifier = UUID - private struct Action: Identifiable { - let id = UUID() + private struct Action: Identifiable { + let id = UUID() - var closure: ActionClosure + var closure: ActionClosure - func invoke(_ sender: Any?) { - closure(sender) - } + func invoke(_ sender: Any?) { + closure(sender) } + } - private var bindingActions: IdentifiedArrayOf = [] + private var bindingActions: IdentifiedArrayOf = [] - private var actions: IdentifiedArrayOf = [] + private var actions: IdentifiedArrayOf = [] - private var originTarget: AnyObject? + private var originTarget: AnyObject? - private var originAction: Selector? + private var originAction: Selector? - weak var owner: TargetActionProtocol? + weak var owner: TargetActionProtocol? - required init(owner: TargetActionProtocol) { - self.owner = owner - super.init() - self.originTarget = owner.target - self.originAction = owner.action - owner.target = self - owner.action = #selector(invokeAction(_:)) - if let textField = owner as? NSTextField { - NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) - } + required init(owner: TargetActionProtocol) { + self.owner = owner + super.init() + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #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) - } + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0.invoke(obj.object) } + actions.forEach { $0.invoke(obj.object) } + } - func removeAllBindingActions() { - bindingActions.removeAll() + @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/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift index 1df9f50edc..f1398d8c8c 100644 --- a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift +++ b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift @@ -3,34 +3,34 @@ import AppKit struct AssociatedKeys { - var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] - - mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { - let key = AnyHashableMetatype(type) - if let associatedKey = keys[key] { - return associatedKey - } else { - let associatedKey = malloc(1)! - keys[key] = associatedKey - return associatedKey - } + var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + + mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { + let key = AnyHashableMetatype(type) + if let associatedKey = keys[key] { + return associatedKey + } else { + let associatedKey = malloc(1)! + keys[key] = associatedKey + return associatedKey } + } } struct AnyHashableMetatype: Hashable { - static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { - return lhs.base == rhs.base - } + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } - let base: Any.Type + let base: Any.Type - init(_ base: Any.Type) { - self.base = base - } + init(_ base: Any.Type) { + self.base = base + } - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(base)) - } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + } } #endif diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift deleted file mode 100644 index 93f1c40098..0000000000 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ /dev/null @@ -1,35 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import Foundation - -extension MainActor { - // NB: This functionality was not back-deployed in Swift 5.9 - static func _assumeIsolated( - _ operation: @MainActor () throws -> T, - file: StaticString = #fileID, - line: UInt = #line - ) rethrows -> T { - #if swift(<5.10) - typealias YesActor = @MainActor () throws -> T - typealias NoActor = () throws -> T - - guard Thread.isMainThread else { - fatalError( - "Incorrect actor executor assumption; Expected same executor as \(self).", - file: file, - line: line - ) - } - - return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in - let rawFn = unsafeBitCast(fn, to: NoActor.self) - return try rawFn() - } - #else - return try assumeIsolated(operation, file: file, line: line) - #endif - } -} - - -#endif diff --git a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift deleted file mode 100644 index 1ec4c47b0a..0000000000 --- a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift +++ /dev/null @@ -1,20 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -@rethrows -protocol _ErrorMechanism { - associatedtype Output - func get() throws -> Output -} - -extension _ErrorMechanism { - func _rethrowError() rethrows -> Never { - _ = try _rethrowGet() - fatalError() - } - - func _rethrowGet() rethrows -> Output { - return try get() - } -} - -extension Result: _ErrorMechanism {} -#endif diff --git a/Sources/AppKitNavigation/Internal/Exports.swift b/Sources/AppKitNavigation/Internal/Exports.swift index 6f94b2acad..554225adc1 100644 --- a/Sources/AppKitNavigation/Internal/Exports.swift +++ b/Sources/AppKitNavigation/Internal/Exports.swift @@ -1,3 +1,3 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @_exported import SwiftNavigation +@_exported import SwiftNavigation #endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift new file mode 100644 index 0000000000..a95607191e --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift @@ -0,0 +1,33 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +class ModalWindowsObserver: NSObject { + static let shared = ModalWindowsObserver() + + var windowsCancellable: [NSWindow: AnyCancellable] = [:] + + var modalSessionByWindow: [NSWindow: NSApplication.ModalSession] = [:] + + func observeWindow(_ window: NSWindow, modalSession: NSApplication.ModalSession? = nil) { + if let modalSession { + modalSessionByWindow[window] = modalSession + } + windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { [weak self] _ in + guard let self else { return } + if let modalSession = modalSessionByWindow[window] { + NSApplication.shared.endModalSession(modalSession) + } else if NSApplication.shared.modalWindow === window { + NSApplication.shared.stopModal() + } + modalSessionByWindow.removeValue(forKey: window) + windowsCancellable[window]?.cancel() + windowsCancellable.removeValue(forKey: window) + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift deleted file mode 100644 index a11cfaaf1d..0000000000 --- a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift +++ /dev/null @@ -1,12 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -extension Bool { - struct Unit: Hashable, Identifiable { - var id: Unit { self } - } - - var toOptionalUnit: Unit? { - get { self ? Unit() : nil } - set { self = newValue != nil } - } -} -#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 0d053d6f0d..6ac702ac14 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -3,414 +3,414 @@ import AppKit @MainActor -private var modalObserverKeys = AssociatedKeys() +private var ModalObserverKeys = AssociatedKeys() private typealias ModalObserver = NavigationObserver // MARK: - Modal Session - NSWindow + @MainActor extension NSObject { - - @discardableResult - public func modalSession( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSWindow - ) -> ObserveToken { - _modalSession(isModaled: isModaled, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modalSession( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _modalSession(item: item, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - public func modalSession( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _modalSession(item: item, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modalSession( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modalSession( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _modalSession(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) + } } // MARK: - Modal - NSWindow @MainActor extension NSObject { - @discardableResult - public func modal( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSWindow - ) -> ObserveToken { - _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } } // MARK: - Modal - NSAlert @MainActor extension NSObject { - @discardableResult - public func modal( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSAlert - ) -> ObserveToken { - _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSAlert - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSAlert - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSAlert - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSAlert - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSAlert + ) -> ObserveToken { + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } } // MARK: - Modal - NSSavePanel @MainActor extension NSObject { - @discardableResult - public func modal( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSSavePanel - ) -> ObserveToken { - _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSSavePanel - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSSavePanel - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSSavePanel - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSSavePanel - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSSavePanel + ) -> ObserveToken { + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } } // MARK: - Private Modal + @MainActor extension NSObject { - - @discardableResult - fileprivate func _modal( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content - ) -> ObserveToken { - _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } - } - - @discardableResult - fileprivate func _modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - fileprivate func _modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @discardableResult - fileprivate func _modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) + @discardableResult + fileprivate func _modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + fileprivate func _modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + fileprivate func _modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + fileprivate func _modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + fileprivate func _modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modal(item: item, id: id) { $item in + content($item) + } beginModal: { modalContent, _ in + if NSApplication.shared.modalWindow != nil { + NSApplication.shared.stopModal() + onDismiss?() + DispatchQueue.main.async { + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.appKitNavigationRunModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil } - } - @discardableResult - fileprivate func _modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _modal(item: item, id: id) { $item in - content($item) - } beginModal: { modalContent, _ in - if NSApplication.shared.modalWindow != nil { - NSApplication.shared.stopModal() - onDismiss?() - DispatchQueue.main.async { - ModalWindowsObserver.shared.observeWindow(modalContent.window) - modalContent.appKitNavigationRunModal() - modalContent.onEndNavigation?() - modalContent.onEndNavigation = nil - } - - } else { - DispatchQueue.main.async { - ModalWindowsObserver.shared.observeWindow(modalContent.window) - modalContent.appKitNavigationRunModal() - modalContent.onEndNavigation?() - modalContent.onEndNavigation = nil - } - } - } endModal: { _, _ in - NSApplication.shared.stopModal() - onDismiss?() + } else { + DispatchQueue.main.async { + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.appKitNavigationRunModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil } - } - - // MARK: - Modal Session - - @discardableResult - fileprivate func _modalSession( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content - ) -> ObserveToken { - _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } - } - - @discardableResult - fileprivate func _modalSession( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - fileprivate func _modalSession( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @discardableResult - fileprivate func _modalSession( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _modalSession(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } - } - - @discardableResult - fileprivate func _modalSession( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _modal(item: item, id: id) { $item in - content($item) - } beginModal: { modalContent, _ in - if let modaledWindow = NSApplication.shared.modalWindow, let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modaledWindow] { - NSApplication.shared.endModalSession(modalSession) - modaledWindow.window.close() - onDismiss?() - DispatchQueue.main.async { - let modalSession = modalContent.appKitNavigationBeginModalSession() - ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) - } - - } else { - DispatchQueue.main.async { - let modalSession = modalContent.appKitNavigationBeginModalSession() - ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) - } - } - } endModal: { modalContent, _ in - if let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modalContent.window] { - NSApplication.shared.endModalSession(modalSession) - modalContent.window.close() - onDismiss?() - } + } + } endModal: { _, _ in + NSApplication.shared.stopModal() + onDismiss?() + } + } + + // MARK: - Modal Session + + @discardableResult + fileprivate func _modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + fileprivate func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + fileprivate func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + fileprivate func _modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + fileprivate func _modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modal(item: item, id: id) { $item in + content($item) + } beginModal: { modalContent, _ in + if let modaledWindow = NSApplication.shared.modalWindow, let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modaledWindow] { + NSApplication.shared.endModalSession(modalSession) + modaledWindow.window.close() + onDismiss?() + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) } - } - - private func _modal( - item: UIBinding, - id: KeyPath, - content: @escaping (UIBinding) -> Content, - beginModal: @escaping ( - _ child: Content, - _ transaction: UITransaction - ) -> Void, - endModal: @escaping ( - _ child: Content, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - let modalObserver: ModalObserver = modalObserver() - return modalObserver.observe( - item: item, - id: { $0[keyPath: id] }, - content: content, - begin: beginModal, - end: endModal - ) - } - private func modalObserver() -> ModalObserver { - if let observer = objc_getAssociatedObject(self, modalObserverKeys.key(of: Content.self)) as? ModalObserver { - return observer - } else { - let observer = ModalObserver(owner: self) - objc_setAssociatedObject(self, modalObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return observer + } else { + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) } - } + } + } endModal: { modalContent, _ in + if let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modalContent.window] { + NSApplication.shared.endModalSession(modalSession) + modalContent.window.close() + onDismiss?() + } + } + } + + private func _modal( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + beginModal: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + endModal: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let modalObserver: ModalObserver = modalObserver() + return modalObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginModal, + end: endModal + ) + } + + private func modalObserver() -> ModalObserver { + if let observer = objc_getAssociatedObject(self, ModalObserverKeys.key(of: Content.self)) as? ModalObserver { + return observer + } else { + let observer = ModalObserver(owner: self) + objc_setAssociatedObject(self, ModalObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer + } + } } extension Navigated where Content: ModalContent { - func clearup() { - NSApplication.shared.stopModal() - } + func clearup() { + NSApplication.shared.stopModal() + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index 676b8857b8..3098e6a589 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -4,32 +4,43 @@ import AppKit @MainActor protocol ModalContent: NavigationContent { - @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse - var window: NSWindow { get } + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +@MainActor +protocol ModalSessionContent: ModalContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession } extension NSWindow: ModalContent { - var window: NSWindow { self } + var window: NSWindow { self } - func appKitNavigationRunModal() -> NSApplication.ModalResponse { - __appKitNavigationRunModal() - } + func appKitNavigationRunModal() -> NSApplication.ModalResponse { + __appKitNavigationRunModal() + } + + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} - @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { - NSApplication.shared.runModal(for: self) - } +extension NSWindow: ModalSessionContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + NSApplication.shared.beginModalSession(for: self) + } } extension NSSavePanel { - override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { - runModal() - } + override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } } extension NSAlert: ModalContent { - func appKitNavigationRunModal() -> NSApplication.ModalResponse { - runModal() - } + func appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSession.swift b/Sources/AppKitNavigation/Navigation/ModalSession.swift deleted file mode 100644 index 51a47075aa..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalSession.swift +++ /dev/null @@ -1,7 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - - - -#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift deleted file mode 100644 index a897018c9e..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift +++ /dev/null @@ -1,16 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -protocol ModalSessionContent: ModalContent { - func appKitNavigationBeginModalSession() -> NSApplication.ModalSession -} - -extension NSWindow: ModalSessionContent { - func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { - NSApplication.shared.beginModalSession(for: self) - } -} - -#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift deleted file mode 100644 index bc63b33443..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift +++ /dev/null @@ -1,33 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit -import Combine - -@MainActor -class ModalWindowsObserver: NSObject { - static let shared = ModalWindowsObserver() - - var windowsCancellable: [NSWindow: AnyCancellable] = [:] - - var modalSessionByWindow: [NSWindow: NSApplication.ModalSession] = [:] - - func observeWindow(_ window: NSWindow, modalSession: NSApplication.ModalSession? = nil) { - if let modalSession { - modalSessionByWindow[window] = modalSession - } - windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) - .sink { [weak self] _ in - guard let self else { return } - if let modalSession = modalSessionByWindow[window] { - NSApplication.shared.endModalSession(modalSession) - } else if NSApplication.shared.modalWindow === window { - NSApplication.shared.stopModal() - } - modalSessionByWindow.removeValue(forKey: window) - windowsCancellable[window]?.cancel() - windowsCancellable.removeValue(forKey: window) - } - } -} - -#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index 2e35321c1b..8c10f8c664 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -4,8 +4,8 @@ import Foundation @MainActor protocol NavigationContent: AnyObject { - var onBeginNavigation: (() -> Void)? { set get } - var onEndNavigation: (() -> Void)? { set get } + var onBeginNavigation: (() -> Void)? { set get } + var onEndNavigation: (() -> Void)? { set get } } @MainActor @@ -15,24 +15,23 @@ private var onBeginNavigationKeys = AssociatedKeys() private var onEndNavigationKeys = AssociatedKeys() extension NavigationContent { - var onBeginNavigation: (() -> Void)? { - set { - objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) - } - get { - objc_getAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self)) as? () -> Void - } + var onBeginNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } + get { + objc_getAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self)) as? () -> Void + } + } - var onEndNavigation: (() -> Void)? { - set { - objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) - } - get { - objc_getAssociatedObject(self, onEndNavigationKeys.key(of: Self.self)) as? () -> Void - } + var onEndNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, onEndNavigationKeys.key(of: Self.self)) as? () -> Void } + } } - #endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift index 488cf6b4b9..ce952a080a 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -5,84 +5,84 @@ import SwiftNavigation @MainActor class NavigationObserver: NSObject { - private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] + private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] - unowned let owner: Owner + unowned let owner: Owner - init(owner: Owner) { - self.owner = owner - super.init() - } + init(owner: Owner) { + self.owner = owner + super.init() + } - func observe( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> Content, - begin: @escaping ( - _ content: Content, - _ transaction: UITransaction - ) -> Void, - end: @escaping ( - _ content: Content, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let navigated = navigatedByID[key] { - guard let navigationID = navigated.id, - navigationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let content = content(unwrappedItem) - let onEndNavigation = { [navigationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - navigationID == id(wrappedValue) { - item.wrappedValue = nil - } - } - content.onEndNavigation = onEndNavigation + func observe( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> Content, + begin: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void, + end: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let navigated = navigatedByID[key] { + guard let navigationID = navigated.id, + navigationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let content = content(unwrappedItem) + let onEndNavigation = { [navigationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + navigationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + content.onEndNavigation = onEndNavigation - self.navigatedByID[key] = Navigated(content, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - begin(content, transaction) - } - } - commitWork(work) - } else if let navigated = navigatedByID[key] { - if let content = navigated.content { - end(content, transaction) - } - self.navigatedByID[key] = nil - } + self.navigatedByID[key] = Navigated(content, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + begin(content, transaction) + } + } + commitWork(work) + } else if let navigated = navigatedByID[key] { + if let content = navigated.content { + end(content, transaction) } + self.navigatedByID[key] = nil + } } + } - func commitWork(_ work: @escaping () -> Void) { - work() - } + func commitWork(_ work: @escaping () -> Void) { + work() + } } @MainActor class Navigated { - weak var content: Content? - let id: AnyHashable? - func clearup() {} - deinit { - MainActor._assumeIsolated { - clearup() - } + weak var content: Content? + let id: AnyHashable? + func clearup() {} + deinit { + MainActor._assumeIsolated { + clearup() } + } - required init(_ content: Content, id: AnyHashable?) { - self.content = content - self.id = id - } + required init(_ content: Content, id: AnyHashable?) { + self.content = content + self.id = id + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index fbd5f46777..f90ec118a3 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -4,191 +4,171 @@ import AppKit import AppKitNavigationShim class PresentationObserver: NavigationObserver { - override func commitWork(_ work: @escaping () -> Void) { - if owner._AppKitNavigation_hasViewAppeared { - work() - } else { - owner._AppKitNavigation_onViewAppear.append(work) - } + override func commitWork(_ work: @escaping () -> Void) { + if owner._AppKitNavigation_hasViewAppeared { + work() + } else { + owner._AppKitNavigation_onViewAppear.append(work) } + } } extension NSViewController { - @discardableResult - public func present( - isPresented: UIBinding, - style: TransitionStyle, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSViewController - ) -> ObserveToken { - present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } - } + @discardableResult + public func present( + isPresented: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> ObserveToken { + present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } + } - @discardableResult - public func present( - item: UIBinding, - style: TransitionStyle, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSViewController - ) -> ObserveToken { - present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) - } + @discardableResult + public func present( + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) + } - @_disfavoredOverload - @discardableResult - public func present( - item: UIBinding, - style: TransitionStyle, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSViewController - ) -> ObserveToken { - present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) - } + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) + } - @discardableResult - public func present( - item: UIBinding, - id: KeyPath, - style: TransitionStyle, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSViewController - ) -> ObserveToken { - present(item: item, id: id, style: style, onDismiss: onDismiss) { - content($0.wrappedValue) - } + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + present(item: item, id: id, style: style, onDismiss: onDismiss) { + content($0.wrappedValue) } + } - @_disfavoredOverload - @discardableResult - public func present( - item: UIBinding, - id: KeyPath, - style: TransitionStyle, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSViewController - ) -> ObserveToken { - destination(item: item, id: id) { $item in - content($item) - } present: { [weak self] child, transaction in - guard let self else { return } - if let presentedViewController = presentedViewControllers?.first { - self.dismiss(presentedViewController) - onDismiss?() - self.present(child, for: style) - } else { - self.present(child, for: style) - } - } dismiss: { [weak self] child, transaction in - guard let self else { return } - self.dismiss(child) - onDismiss?() - } + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + destination(item: item, id: id) { $item in + content($item) + } present: { [weak self] child, transaction in + guard let self else { return } + if let presentedViewController = presentedViewControllers?.first { + self.dismiss(presentedViewController) + onDismiss?() + self.present(child, for: style) + } else { + self.present(child, for: style) + } + } dismiss: { [weak self] child, transaction in + guard let self else { return } + self.dismiss(child) + onDismiss?() } + } - @discardableResult - public func destination( - isPresented: UIBinding, - content: @escaping () -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - destination( - item: isPresented.toOptionalUnit, - content: { _ in content() }, - present: present, - dismiss: dismiss - ) - } + @discardableResult + public func destination( + isPresented: UIBinding, + content: @escaping () -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) + } - @discardableResult - public func destination( - item: UIBinding, - content: @escaping (UIBinding) -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - let presentationObserver: PresentationObserver = presentationObserver() - return presentationObserver.observe( - item: item, - id: { _ in nil }, - content: content, - begin: present, - end: dismiss - ) - } + @discardableResult + public func destination( + item: UIBinding, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe( + item: item, + id: { _ in nil }, + content: content, + begin: present, + end: dismiss + ) + } - @discardableResult - public func destination( - item: UIBinding, - id: KeyPath, - content: @escaping (UIBinding) -> NSViewController, - present: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - let presentationObserver: PresentationObserver = presentationObserver() - return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) - } + @discardableResult + public func destination( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + } - private static var presentationObserverKey = malloc(1)! - - private func presentationObserver() -> PresentationObserver { - if let observer = objc_getAssociatedObject(self, Self.presentationObserverKey) as? PresentationObserver { - return observer - } else { - let observer = PresentationObserver(owner: self) - objc_setAssociatedObject(self, Self.presentationObserverKey, observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return observer - } - } - - public enum TransitionStyle { - case sheet - case modalWindow - case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) - } + private static var presentationObserverKey = malloc(1)! - private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { - switch style { - case .sheet: - presentAsSheet(viewControllerToPresent) - case .modalWindow: - presentAsModalWindow(viewControllerToPresent) - case let .popover(rect, view, preferredEdge, behavior): - present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) - case let .custom(animator): - present(viewControllerToPresent, animator: animator) - } + private func presentationObserver() -> PresentationObserver { + if let observer = objc_getAssociatedObject(self, Self.presentationObserverKey) as? PresentationObserver { + return observer + } else { + let observer = PresentationObserver(owner: self) + objc_setAssociatedObject(self, Self.presentationObserverKey, observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer } + } } extension NavigationContent where Self: NSViewController { - var _onEndNavigation: (() -> Void)? { - set { - _AppKitNavigation_onDismiss = newValue - } - get { - _AppKitNavigation_onDismiss - } + var _onEndNavigation: (() -> Void)? { + set { + _AppKitNavigation_onDismiss = newValue } + get { + _AppKitNavigation_onDismiss + } + } } extension Navigated where Content: NSViewController { - func clearup() { - content?.dismiss(nil) - } + func clearup() { + content?.dismiss(nil) + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift index eba08ad600..907536422c 100644 --- a/Sources/AppKitNavigation/Navigation/PresentationContent.swift +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -3,39 +3,39 @@ import AppKit @MainActor -public protocol PresentationContent: NavigationContent { - func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) - func dismiss(from presentingViewController: NSViewController) +protocol PresentationContent: NavigationContent { + func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) + func dismiss(from presentingViewController: NSViewController) } extension NSViewController: PresentationContent { - public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { - presentingViewController.present(self, for: style) - } + func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + presentingViewController.present(self, for: style) + } - public func dismiss(from presentingViewController: NSViewController) { - presentingViewController.dismiss(self) - } + func dismiss(from presentingViewController: NSViewController) { + presentingViewController.dismiss(self) + } - public enum TransitionStyle { - case sheet - case modalWindow - case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) - } + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) + } - private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { - switch style { - case .sheet: - presentAsSheet(viewControllerToPresent) - case .modalWindow: - presentAsModalWindow(viewControllerToPresent) - case let .popover(rect, view, preferredEdge, behavior): - present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) - case let .custom(animator): - present(viewControllerToPresent, animator: animator) - } + func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case let .popover(rect, view, preferredEdge, behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case let .custom(animator): + present(viewControllerToPresent, animator: animator) } + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index ba9cbd1846..565c991704 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -8,268 +8,267 @@ private typealias SheetObserver, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSWindow - ) -> ObserveToken { - _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _sheet(item: item, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } - @_disfavoredOverload - @discardableResult - public func sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _sheet(item: item, onDismiss: onDismiss, content: content) - } + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _sheet(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _sheet(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } } extension NSWindow { - @discardableResult - public func sheet( - isSheeted: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSAlert - ) -> ObserveToken { - _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSAlert + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSAlert - ) -> ObserveToken { - _sheet(item: item, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } - @_disfavoredOverload - @discardableResult - public func sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSAlert - ) -> ObserveToken { - _sheet(item: item, onDismiss: onDismiss, content: content) - } + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSAlert - ) -> ObserveToken { - _sheet(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSAlert - ) -> ObserveToken { - _sheet(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } } extension NSWindow { - @discardableResult - public func sheet( - isSheeted: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSSavePanel - ) -> ObserveToken { - _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSSavePanel + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSSavePanel - ) -> ObserveToken { - _sheet(item: item, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } - @_disfavoredOverload - @discardableResult - public func sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSSavePanel - ) -> ObserveToken { - _sheet(item: item, onDismiss: onDismiss, content: content) - } + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSSavePanel - ) -> ObserveToken { - _sheet(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } - @discardableResult - public func sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSSavePanel - ) -> ObserveToken { - _sheet(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } } - extension SheetContent { - @discardableResult - fileprivate func _sheet( - isSheeted: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content - ) -> ObserveToken { - _sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } - } + @discardableResult + fileprivate func _sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } - @discardableResult - fileprivate func _sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) - } + @discardableResult + fileprivate func _sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } - @_disfavoredOverload - @discardableResult - fileprivate func _sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) - } + @_disfavoredOverload + @discardableResult + fileprivate func _sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } - @discardableResult - fileprivate func _sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _sheet(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } + @discardableResult + fileprivate func _sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) } + } - @discardableResult - fileprivate func _sheet( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _sheet(item: item, id: id) { $item in - content($item) - } beginSheet: { [weak self] child, _ in - guard let self else { return } - if let attachedSheetWindow = currentWindow?.attachedSheet { - self.endSheet(for: attachedSheetWindow) - onDismiss?() - Task { @MainActor in - await self.beginSheet(for: child) - child.onEndNavigation?() - child.onEndNavigation = nil - } - } else { - Task { @MainActor in - await self.beginSheet(for: child) - child.onEndNavigation?() - child.onEndNavigation = nil - } - } - } endSheet: { [weak self] content, _ in - self?.endSheet(for: content) - onDismiss?() + @discardableResult + fileprivate func _sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _sheet(item: item, id: id) { $item in + content($item) + } beginSheet: { [weak self] child, _ in + guard let self else { return } + if let attachedSheetWindow = currentWindow?.attachedSheet { + self.endSheet(for: attachedSheetWindow) + onDismiss?() + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndNavigation?() + child.onEndNavigation = nil + } + } else { + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndNavigation?() + child.onEndNavigation = nil } + } + } endSheet: { [weak self] content, _ in + self?.endSheet(for: content) + onDismiss?() } + } - private func _sheet( - item: UIBinding, - id: KeyPath, - content: @escaping (UIBinding) -> Content, - beginSheet: @escaping ( - _ child: Content, - _ transaction: UITransaction - ) -> Void, - endSheet: @escaping ( - _ child: Content, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - let sheetObserver: SheetObserver = sheetObserver() - return sheetObserver.observe( - item: item, - id: { $0[keyPath: id] }, - content: content, - begin: beginSheet, - end: endSheet - ) - } + private func _sheet( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + beginSheet: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let sheetObserver: SheetObserver = sheetObserver() + return sheetObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginSheet, + end: endSheet + ) + } - private func sheetObserver() -> SheetObserver { - if let observer = objc_getAssociatedObject(self, sheetObserverKeys.key(of: Content.self)) as? SheetObserver { - return observer - } else { - let observer = SheetObserver(owner: self) - objc_setAssociatedObject(self, sheetObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return observer - } + private func sheetObserver() -> SheetObserver { + if let observer = objc_getAssociatedObject(self, sheetObserverKeys.key(of: Content.self)) as? SheetObserver { + return observer + } else { + let observer = SheetObserver(owner: self) + objc_setAssociatedObject(self, sheetObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer } + } } extension Navigated where Content: SheetContent { - func clearup() { - guard let window = content?.currentWindow else { return } - window.sheetParent?.endSheet(window) - } + func clearup() { + guard let window = content?.currentWindow else { return } + window.sheetParent?.endSheet(window) + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 3da396258f..b8ce7a2245 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -4,49 +4,49 @@ import AppKit @MainActor protocol SheetContent: NavigationContent { - var currentWindow: NSWindow? { get } - func beginSheet(for content: SheetContent) async - func endSheet(for content: SheetContent) + var currentWindow: NSWindow? { get } + func beginSheet(for content: SheetContent) async + func endSheet(for content: SheetContent) } extension SheetContent { - func beginSheet(for content: any SheetContent) async { - guard let sheetedWindow = content.currentWindow else { return } - await currentWindow?.beginSheet(sheetedWindow) - } - - func endSheet(for content: any SheetContent) { - guard let sheetedWindow = content.currentWindow else { return } - currentWindow?.endSheet(sheetedWindow) - } + func beginSheet(for content: any SheetContent) async { + guard let sheetedWindow = content.currentWindow else { return } + await currentWindow?.beginSheet(sheetedWindow) + } + + func endSheet(for content: any SheetContent) { + guard let sheetedWindow = content.currentWindow else { return } + currentWindow?.endSheet(sheetedWindow) + } } extension NSWindow: SheetContent { - var currentWindow: NSWindow? { self } + var currentWindow: NSWindow? { self } } extension NSAlert: SheetContent { - var currentWindow: NSWindow? { window } + var currentWindow: NSWindow? { window } - func beginSheet(for content: any SheetContent) async { - guard let parentWindow = content.currentWindow else { return } - await beginSheetModal(for: parentWindow) - } + func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } - func endSheet(for content: any SheetContent) { - content.currentWindow?.endSheet(window) - } + func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } } extension NSSavePanel { - func beginSheet(for content: any SheetContent) async { - guard let parentWindow = content.currentWindow else { return } - await beginSheetModal(for: parentWindow) - } - - func endSheet(for content: any SheetContent) { - content.currentWindow?.endSheet(window) - } + func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } } #endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift deleted file mode 100644 index 8032874494..0000000000 --- a/Sources/AppKitNavigation/Observe.swift +++ /dev/null @@ -1,181 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -@_spi(Internals) import SwiftNavigation -import AppKit - -@MainActor -extension NSObject { - /// Observe access to properties of an observable (or perceptible) object. - /// - /// This tool allows you to set up an observation loop so that you can access fields from an - /// observable model in order to populate your view, and also automatically track changes to - /// any accessed fields so that the view is always up-to-date. - /// - /// It is most useful when dealing with non-SwiftUI views, such as AppKit views and controller. - /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all - /// the view elements: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// let countLabel = NSTextField(labelWithString: "") - /// let incrementButton = NSButton { [weak self] _ in - /// self?.model.incrementButtonTapped() - /// } - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// countLabel.stringValue = "\(model.count)" - /// } - /// } - /// ``` - /// - /// This closure is immediately called, allowing you to set the initial state of your UI - /// components from the feature's state. And if the `count` property in the feature's state is - /// ever mutated, this trailing closure will be called again, allowing us to update the view - /// again. - /// - /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your - /// view, such as `viewDidLoad` for `NSViewController`. This works even if you have many UI - /// components to update: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// countLabel.isHidden = model.isObservingCount - /// if !countLabel.isHidden { - /// countLabel.stringValue = "\(model.count)" - /// } - /// factLabel.stringValue = model.fact - /// } - /// } - /// ``` - /// - /// This does mean that you may execute the line `factLabel.text = model.fact` even when - /// something unrelated changes, such as `store.model`, but that is typically OK for simple - /// properties of UI components. It is not a performance problem to repeatedly set the `text` of - /// a label or the `isHidden` of a button. - /// - /// However, if there is heavy work you need to perform when state changes, then it is best to - /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or - /// collection view when a collection changes: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// dataSource = model.items - /// tableView.reloadData() - /// } - /// } - /// ``` - /// - /// ## Cancellation - /// - /// The method returns an ``ObservationToken`` that can be used to cancel observation. For - /// example, if you only want to observe while a view controller is visible, you can start - /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: - /// - /// ```swift - /// var observation: ObservationToken? - /// - /// func viewWillAppear() { - /// super.viewWillAppear() - /// observation = observe { [weak self] in - /// // ... - /// } - /// } - /// func viewWillDisappear() { - /// super.viewWillDisappear() - /// observation?.cancel() - /// } - /// ``` - /// - /// - Parameter apply: A closure that contains properties to track and is invoked when the value - /// of a property changes. - /// - Returns: A cancellation token. - @discardableResult - public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { - observe { _ in apply() } - } - - /// Observe access to properties of an observable (or perceptible) object. - /// - /// A version of ``observe(_:)`` that is passed the current transaction. - /// - /// - Parameter apply: A closure that contains properties to track and is invoked when the value - /// of a property changes. - /// - Returns: A cancellation token. - @discardableResult - public func observe( - _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void - ) -> ObservationToken { - let token = SwiftNavigation.observe { transaction in - MainActor._assumeIsolated { - withUITransaction(transaction) { - if transaction.appKit.disablesAnimations { - NSView.performWithoutAnimation { apply(transaction) } - for completion in transaction.appKit.animationCompletions { - completion(true) - } - } else if let animation = transaction.appKit.animation { - return animation.perform( - { apply(transaction) }, - completion: transaction.appKit.animationCompletions.isEmpty - ? nil - : { - for completion in transaction.appKit.animationCompletions { - completion($0) - } - } - ) - } else { - apply(transaction) - for completion in transaction.appKit.animationCompletions { - completion(true) - } - } - } - } - } task: { transaction, work in - DispatchQueue.main.async { - withUITransaction(transaction, work) - } - } - tokens.append(token) - return token - } - - fileprivate var tokens: [Any] { - get { - objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] - } - set { - objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - private static let tokensKey = malloc(1)! -} - -extension NSView { - fileprivate static func performWithoutAnimation(_ block: () -> Void) { - NSAnimationContext.runAnimationGroup { context in - context.allowsImplicitAnimation = false - block() - } - } -} - -#endif diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m index bbb814a0c9..e36845fde7 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -27,6 +27,8 @@ + (void)load { method_exchangeImplementations( class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) + ); + method_exchangeImplementations( class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURL:")), class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURL:)) ); diff --git a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index e5c147398a..4e7a6a30d1 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { + "originHash" : "fd561772dbb7da88879cfbd7ad587c7c0f94bd076b6d0e00f7d2d67ac20cb3a8", "pins" : [ { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", - "version" : "1.0.2" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" } }, { @@ -14,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", - "version" : "1.5.6" + "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", + "version" : "1.6.1" } }, { @@ -23,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", - "version" : "1.0.5" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -39,10 +40,10 @@ { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" } }, { @@ -59,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", - "version" : "1.4.1" + "revision" : "52b5e1a09dc016e64ce253e19ab3124b7fae9ac9", + "version" : "1.7.0" } }, { @@ -84,10 +85,10 @@ { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", - "version" : "1.1.0" + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" } }, { @@ -95,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" + "revision" : "21811d6230a625fa0f2e6ffa85be857075cc02c4", + "version" : "1.5.0" } }, { @@ -122,10 +123,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", - "version" : "1.4.2" + "revision" : "b444594f79844b0d6d76d70fbfb3f7f71728f938", + "version" : "1.5.1" } } ], - "version" : 2 + "version" : 3 } From b57ed0fa07cb91684f565cc216ee999cfcb963aa Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 8 Oct 2025 20:02:32 +0800 Subject: [PATCH 34/42] Add NSProgressIndicator binding support --- ...ppKit+BasicsNavigationViewController.swift | 137 +++--- ...+ConciseEnumNavigationViewController.swift | 172 ++++---- .../AppKit+EnumControlsViewController.swift | 191 ++++---- .../AppKit/AppKit+FocusViewController.swift | 237 +++++----- ...ppKit+ConnectToNetworkViewController.swift | 312 ++++++------- .../AppKit+NetworkDetailViewController.swift | 83 ++-- Package.resolved | 39 +- .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../AppKitNavigation/Bindings/NSControl.swift | 26 +- .../Bindings/NSFontManager.swift | 411 +++++++++--------- .../Bindings/NSProgressIndicator.swift | 32 ++ .../Bindings/TargetActionProtocol.swift | 209 ++++----- .../AppKitNavigation/Navigation/Sheet.swift | 54 ++- .../Navigation/SheetContent.swift | 19 + Sources/AppKitNavigationShim/shim.m | 2 +- Sources/UIKitNavigation/UIKitAnimation.swift | 2 +- .../xcschemes/AppKitNavigation.xcscheme | 67 +++ 17 files changed, 1087 insertions(+), 908 deletions(-) create mode 100644 Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift create mode 100644 SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index c7a71253f3..6140afcab0 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -4,93 +4,96 @@ import AppKit import AppKitNavigation class BasicsNavigationViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyTitle = "Basics" - let readMe = """ - This case study demonstrates how to perform every major form of navigation in UIKit (alerts, \ - sheets, drill-downs) by driving navigation off of optional and boolean state. - """ - @UIBindable var model = Model() + let caseStudyTitle = "Basics" + let readMe = """ + This case study demonstrates how to perform every major form of navigation in UIKit (alerts, \ + sheets, drill-downs) by driving navigation off of optional and boolean state. + """ + @UIBindable var model = Model() - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidLoad() { + super.viewDidLoad() - let showAlertButton = NSButton { [weak self] _ in - self?.model.alert = "Hello!" - } + let showAlertButton = NSButton() + showAlertButton.addAction { [weak self] _ in + self?.model.alert = "Hello!" + } - let showSheetButton = NSButton { [weak self] _ in - self?.model.sheet = .random(in: 1 ... 1_000) - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self?.model.sheet = nil - } - } + let showSheetButton = NSButton() + showSheetButton.addAction { [weak self] _ in + self?.model.sheet = .random(in: 1 ... 1_000) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self?.model.sheet = nil + } + } - let showSheetFromBooleanButton = NSButton { [weak self] _ in - self?.model.isSheetPresented = true - } + let showSheetFromBooleanButton = NSButton() + showSheetFromBooleanButton.addAction { [weak self] _ in + self?.model.isSheetPresented = true + } - let stack = NSStackView(views: [ - showAlertButton, - showSheetButton, - showSheetFromBooleanButton, - ]) - stack.orientation = .vertical - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) - observe { [weak self] in - guard let self else { return } + observe { [weak self] in + guard let self else { return } - if let url = model.url { - showAlertButton.title = "URL is: \(url)" - } else { - showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" - } - showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" - showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" - } + if let url = model.url { + showAlertButton.title = "URL is: \(url)" + } else { + showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + } + showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" + } - modal(item: $model.alert, id: \.self) { [unowned self] message in + modal(item: $model.alert, id: \.self) { [unowned self] message in // let alert = NSAlert() // alert.messageText = "This is an alert" // alert.informativeText = message // alert.addButton(withTitle: "OK") // return alert - let openPanel = NSOpenPanel(url: $model.url) - openPanel.message = message - return openPanel - } + let openPanel = NSOpenPanel(url: $model.url) + openPanel.message = message + return openPanel + } - present(item: $model.sheet, id: \.self, style: .sheet) { count in - NSHostingController( - rootView: Form { Text(count.description) }.frame(width: 100, height: 100, alignment: .center) - ) - } - - present(isPresented: $model.isSheetPresented, style: .sheet) { - NSHostingController( - rootView: Form { Text("Hello!") }.frame(width: 100, height: 100, alignment: .center) - ) - } + present(item: $model.sheet, id: \.self, style: .sheet) { count in + NSHostingController( + rootView: Form { Text(count.description) }.frame(width: 100, height: 100, alignment: .center) + ) } - @Observable - class Model { - var alert: String? - var isSheetPresented = false - var sheet: Int? - var url: URL? + present(isPresented: $model.isSheetPresented, style: .sheet) { + NSHostingController( + rootView: Form { Text("Hello!") }.frame(width: 100, height: 100, alignment: .center) + ) } + } + + @Observable + class Model { + var alert: String? + var isSheetPresented = false + var sheet: Int? + var url: URL? + } } #Preview { - BasicsNavigationViewController() + BasicsNavigationViewController() } #endif diff --git a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift index 8abcdec54c..dadfc4e8ad 100644 --- a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift @@ -4,105 +4,107 @@ import AppKit import AppKitNavigation class ConciseEnumNavigationViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyNavigationTitle = "Enum navigation" - let caseStudyTitle = "Concise enum navigation" - let readMe = """ - This case study demonstrates how to navigate to multiple destinations from a single optional \ - enum. + let caseStudyNavigationTitle = "Enum navigation" + let caseStudyTitle = "Concise enum navigation" + let readMe = """ + This case study demonstrates how to navigate to multiple destinations from a single optional \ + enum. - This allows you to be very concise with your domain modeling by having a single enum \ - describe all the possible destinations you can navigate to. In the case of this demo, we have \ - four cases in the enum, which means there are exactly 5 possible states, including the case \ - where none are active. + This allows you to be very concise with your domain modeling by having a single enum \ + describe all the possible destinations you can navigate to. In the case of this demo, we have \ + four cases in the enum, which means there are exactly 5 possible states, including the case \ + where none are active. - If you were to instead model this domain with 4 optionals (or booleans), then you would have \ - 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ - because you can never be sure of exactly what is presented at a given time. - """ - @UIBindable var model = Model() + If you were to instead model this domain with 4 optionals (or booleans), then you would have \ + 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ + because you can never be sure of exactly what is presented at a given time. + """ + @UIBindable var model = Model() - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidLoad() { + super.viewDidLoad() - let showAlertButton = NSButton { [weak self] _ in - self?.model.destination = .alert("Hello!") - } - let showSheetButton = NSButton { [weak self] _ in - self?.model.destination = .sheet(.random(in: 1 ... 1_000)) - } - let showSheetFromBooleanButton = NSButton { [weak self] _ in - self?.model.destination = .sheetWithoutPayload - } + let showAlertButton = NSButton() + showAlertButton.addAction { [weak self] _ in + self?.model.destination = .alert("Hello!") + } + let showSheetButton = NSButton() + showSheetButton.addAction { [weak self] _ in + self?.model.destination = .sheet(.random(in: 1 ... 1_000)) + } + let showSheetFromBooleanButton = NSButton() + showSheetFromBooleanButton.addAction { [weak self] _ in + self?.model.destination = .sheetWithoutPayload + } - let stack = NSStackView(views: [ - showAlertButton, - showSheetButton, - showSheetFromBooleanButton, - ]) - stack.orientation = .vertical - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) - observe { [weak self] in - guard let self else { return } + observe { [weak self] in + guard let self else { return } - showAlertButton.title = "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")" - showSheetButton.title = "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")" - showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")" - } + showAlertButton.title = "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")" + showSheetButton.title = "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")" + } - modal(item: $model.destination.alert, id: \.self) { message in - let alert = NSAlert() - alert.messageText = "This is an alert" - alert.informativeText = message - alert.addButton(withTitle: "OK") - return alert - } - present(item: $model.destination.sheet, id: \.self, style: .sheet) { [unowned self] count in - - NSHostingController( - rootView: Form { - Text(count.description) - Button("Close") { - self.model.destination = nil - } - }.frame(width: 200, height: 200, alignment: .center) - ) - } - present(isPresented: UIBinding($model.destination.sheetWithoutPayload), style: .sheet) { [unowned self] in - NSHostingController( - rootView: Form { - Text("Hello!") - Button("Close") { - self.model.destination = nil - } - }.frame(width: 200, height: 200, alignment: .center) - ) - } + modal(item: $model.destination.alert, id: \.self) { message in + let alert = NSAlert() + alert.messageText = "This is an alert" + alert.informativeText = message + alert.addButton(withTitle: "OK") + return alert + } + present(item: $model.destination.sheet, id: \.self, style: .sheet) { [unowned self] count in + NSHostingController( + rootView: Form { + Text(count.description) + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) + } + present(isPresented: UIBinding($model.destination.sheetWithoutPayload), style: .sheet) { [unowned self] in + NSHostingController( + rootView: Form { + Text("Hello!") + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) } + } - @Observable - class Model { - var destination: Destination? - @CasePathable - @dynamicMemberLookup - enum Destination { - case alert(String) - case drillDown(Int) - case sheet(Int) - case sheetWithoutPayload - } + @Observable + class Model { + var destination: Destination? + @CasePathable + @dynamicMemberLookup + enum Destination { + case alert(String) + case drillDown(Int) + case sheet(Int) + case sheetWithoutPayload } + } } #Preview { - ConciseEnumNavigationViewController() + ConciseEnumNavigationViewController() } #endif diff --git a/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift index f6aada2f04..9ec9f3613c 100644 --- a/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift @@ -4,114 +4,115 @@ import AppKit import AppKitNavigation class EnumControlsViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyNavigationTitle = "Enum controls" - let caseStudyTitle = "Concise enum controls" - let readMe = """ - This case study demonstrates how to drive form controls from bindings to enum state. In this \ - example, a single `Status` enum holds two cases: - - • An integer quantity for when an item is in stock, which can drive a stepper. - • A Boolean for whether an item is on back order when it is _not_ in stock, which can drive a \ - switch. - - This library provides tools to chain deeper into a binding's case by applying the \ - `@CasePathable` macro. - """ - - @CasePathable - enum Status { - case inStock(quantity: Int) - case outOfStock(isOnBackOrder: Bool) + let caseStudyNavigationTitle = "Enum controls" + let caseStudyTitle = "Concise enum controls" + let readMe = """ + This case study demonstrates how to drive form controls from bindings to enum state. In this \ + example, a single `Status` enum holds two cases: + + • An integer quantity for when an item is in stock, which can drive a stepper. + • A Boolean for whether an item is on back order when it is _not_ in stock, which can drive a \ + switch. + + This library provides tools to chain deeper into a binding's case by applying the \ + `@CasePathable` macro. + """ + + @CasePathable + enum Status { + case inStock(quantity: Int) + case outOfStock(isOnBackOrder: Bool) + } + + @UIBinding var status: Status = .inStock(quantity: 100) + + override func viewDidLoad() { + super.viewDidLoad() + + let quantityLabel = NSTextField(labelWithString: "") + let quantityStepper = NSStepper() + quantityStepper.maxValue = .infinity + let quantityStack = NSStackView(views: [ + quantityLabel, + quantityStepper, + ]) + let outOfStockButton = NSButton() + outOfStockButton.addAction { [weak self] _ in + self?.status = .outOfStock(isOnBackOrder: false) + } + outOfStockButton.title = "Out of stock" + let inStockStack = NSStackView(views: [ + quantityStack, + outOfStockButton, + ]) + inStockStack.orientation = .vertical + + let isOnBackOrderLabel = NSTextField(labelWithString: "Is on back order?") + let isOnBackOrderSwitch = NSSwitch() + let isOnBackOrderStack = NSStackView(views: [ + isOnBackOrderLabel, + isOnBackOrderSwitch, + ]) + let backInStockButton = NSButton() + backInStockButton.addAction { [weak self] _ in + self?.status = .inStock(quantity: 100) } - @UIBinding var status: Status = .inStock(quantity: 100) - - override func viewDidLoad() { - super.viewDidLoad() - - let quantityLabel = NSTextField(labelWithString: "") - let quantityStepper = NSStepper() - quantityStepper.maxValue = .infinity - let quantityStack = NSStackView(views: [ - quantityLabel, - quantityStepper, - ]) - let outOfStockButton = NSButton { [weak self] _ in - self?.status = .outOfStock(isOnBackOrder: false) - } - outOfStockButton.title = "Out of stock" - let inStockStack = NSStackView(views: [ - quantityStack, - outOfStockButton, - ]) - inStockStack.orientation = .vertical - - let isOnBackOrderLabel = NSTextField(labelWithString: "Is on back order?") - let isOnBackOrderSwitch = NSSwitch() - let isOnBackOrderStack = NSStackView(views: [ - isOnBackOrderLabel, - isOnBackOrderSwitch, - ]) - let backInStockButton = NSButton { [weak self] _ in - self?.status = .inStock(quantity: 100) + backInStockButton.title = "Back in stock!" + let outOfStockStack = NSStackView(views: [ + isOnBackOrderStack, + backInStockButton, + ]) + outOfStockStack.orientation = .vertical + + let stack = NSStackView(views: [ + inStockStack, + outOfStockStack, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + inStockStack.isHidden = !status.is(\.inStock) + outOfStockStack.isHidden = !status.is(\.outOfStock) + + switch status { + case .inStock: + if let quantity = $status.inStock { + quantityLabel.stringValue = "Quantity: \(quantity.wrappedValue)" + quantityStepper.bind(value: quantity.asDouble) } - backInStockButton.title = "Back in stock!" - let outOfStockStack = NSStackView(views: [ - isOnBackOrderStack, - backInStockButton, - ]) - outOfStockStack.orientation = .vertical - - let stack = NSStackView(views: [ - inStockStack, - outOfStockStack, - ]) - stack.orientation = .vertical - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(stack) - - NSLayoutConstraint.activate([ - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - - observe { [weak self] in - guard let self else { return } - - inStockStack.isHidden = !status.is(\.inStock) - outOfStockStack.isHidden = !status.is(\.outOfStock) - - switch status { - case .inStock: - if let quantity = $status.inStock { - quantityLabel.stringValue = "Quantity: \(quantity.wrappedValue)" - quantityStepper.bind(value: quantity.asDouble) - } - - case .outOfStock: - if let isOnBackOrder = $status.outOfStock { - isOnBackOrderSwitch.bind(isOn: isOnBackOrder) - } - } + case .outOfStock: + if let isOnBackOrder = $status.outOfStock { + isOnBackOrderSwitch.bind(isOn: isOnBackOrder) } + } } + } } @available(macOS 14.0, *) #Preview { - EnumControlsViewController() + EnumControlsViewController() } - extension Int { - fileprivate var asDouble: Double { - get { Double(self) } - set { self = Int(newValue) } - } + fileprivate var asDouble: Double { + get { Double(self) } + set { self = Int(newValue) } + } } #endif diff --git a/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift index 9008fdf714..7623580bbf 100644 --- a/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift @@ -4,128 +4,133 @@ import AppKit import AppKitNavigation class FocusViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyTitle = "Focus" - let readMe = """ - This case study demonstrates how to handle `UITextField` focus in a state-driven manner. The \ - focus in the UI is kept in sync with the focus value held in an observable model so that \ - changes in one are immediately reflected in the other. - """ - @UIBindable var model = Model() - - override func viewDidLoad() { - super.viewDidLoad() - - let bioTextField = NSTextField(text: $model.bio) - bioTextField.bind(focus: $model.focus, equals: .bio) - bioTextField.bezelStyle = .roundedBezel - bioTextField.placeholderString = "Bio" - let emailTextField = NSTextField(text: $model.email) - emailTextField.bind(focus: $model.focus, equals: .email) - emailTextField.bezelStyle = .roundedBezel - emailTextField.placeholderString = "Email" - let passwordTextField = NSSecureTextField(text: $model.password) - passwordTextField.bind(focus: $model.focus, equals: .password) - passwordTextField.bezelStyle = .roundedBezel - passwordTextField.placeholderString = "Password" - let usernameTextField = NSTextField(text: $model.username) - usernameTextField.bind(focus: $model.focus, equals: .username) - usernameTextField.bezelStyle = .roundedBezel - usernameTextField.placeholderString = "Username" - - let currentFocusLabel = NSTextField(labelWithString: "") - - let focusBioButton = NSButton { [weak self] _ in - self?.model.focus = .bio - } - - focusBioButton.title = "Focus bio" - let focusEmailButton = NSButton { [weak self] _ in - self?.model.focus = .email - } - - focusEmailButton.title = "Focus email" - let focusPasswordButton = NSButton { [weak self] _ in - self?.model.focus = .password - } - - focusPasswordButton.title = "Focus password" - let focusUsernameButton = NSButton { [weak self] _ in - self?.model.focus = .username - } - - focusUsernameButton.title = "Focus username" - let resignFirstResponder = NSButton { [weak self] _ in - self?.view.window?.makeFirstResponder(nil) - } - - resignFirstResponder.title = "Resign first responder" - - let stack = NSStackView(views: [ - usernameTextField, - emailTextField, - passwordTextField, - bioTextField, - currentFocusLabel, - focusUsernameButton, - focusEmailButton, - focusPasswordButton, - focusBioButton, - resignFirstResponder, - ]) - stack.orientation = .vertical - stack.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor - .constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - stack.leadingAnchor - .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), - stack.trailingAnchor - .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), - ]) - - observe { [weak self] in - guard let self else { return } - - currentFocusLabel.stringValue = "Current focus: \(model.focus?.rawValue ?? "none")" - bioTextField.backgroundColor = nil - emailTextField.backgroundColor = nil - passwordTextField.backgroundColor = nil - usernameTextField.backgroundColor = nil - switch model.focus { - case .bio: - bioTextField.backgroundColor = .lightGray - case .email: - emailTextField.backgroundColor = .lightGray - case .password: - passwordTextField.backgroundColor = .lightGray - case .username: - usernameTextField.backgroundColor = .lightGray - case .none: - break - } - } + let caseStudyTitle = "Focus" + let readMe = """ + This case study demonstrates how to handle `UITextField` focus in a state-driven manner. The \ + focus in the UI is kept in sync with the focus value held in an observable model so that \ + changes in one are immediately reflected in the other. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let bioTextField = NSTextField(text: $model.bio) + bioTextField.bind(focus: $model.focus, equals: .bio) + bioTextField.bezelStyle = .roundedBezel + bioTextField.placeholderString = "Bio" + let emailTextField = NSTextField(text: $model.email) + emailTextField.bind(focus: $model.focus, equals: .email) + emailTextField.bezelStyle = .roundedBezel + emailTextField.placeholderString = "Email" + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bind(focus: $model.focus, equals: .password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.placeholderString = "Password" + let usernameTextField = NSTextField(text: $model.username) + usernameTextField.bind(focus: $model.focus, equals: .username) + usernameTextField.bezelStyle = .roundedBezel + usernameTextField.placeholderString = "Username" + + let currentFocusLabel = NSTextField(labelWithString: "") + + let focusBioButton = NSButton() + focusBioButton.addAction { [weak self] _ in + self?.model.focus = .bio } - @Observable - class Model { - var bio = "" - var email = "" - var focus: Focus? - var password = "" - var username = "" - enum Focus: String { - case bio - case email - case password - case username - } + focusBioButton.title = "Focus bio" + let focusEmailButton = NSButton() + focusEmailButton.addAction { [weak self] _ in + self?.model.focus = .email } + + focusEmailButton.title = "Focus email" + let focusPasswordButton = NSButton() + focusPasswordButton.addAction { [weak self] _ in + self?.model.focus = .password + } + + focusPasswordButton.title = "Focus password" + let focusUsernameButton = NSButton() + focusUsernameButton.addAction { [weak self] _ in + self?.model.focus = .username + } + + focusUsernameButton.title = "Focus username" + let resignFirstResponder = NSButton() + resignFirstResponder.addAction { [weak self] _ in + self?.view.window?.makeFirstResponder(nil) + } + + resignFirstResponder.title = "Resign first responder" + + let stack = NSStackView(views: [ + usernameTextField, + emailTextField, + passwordTextField, + bioTextField, + currentFocusLabel, + focusUsernameButton, + focusEmailButton, + focusPasswordButton, + focusBioButton, + resignFirstResponder, + ]) + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.leadingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), + stack.trailingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + ]) + + observe { [weak self] in + guard let self else { return } + + currentFocusLabel.stringValue = "Current focus: \(model.focus?.rawValue ?? "none")" + bioTextField.backgroundColor = nil + emailTextField.backgroundColor = nil + passwordTextField.backgroundColor = nil + usernameTextField.backgroundColor = nil + switch model.focus { + case .bio: + bioTextField.backgroundColor = .lightGray + case .email: + emailTextField.backgroundColor = .lightGray + case .password: + passwordTextField.backgroundColor = .lightGray + case .username: + usernameTextField.backgroundColor = .lightGray + case .none: + break + } + } + } + + @Observable + class Model { + var bio = "" + var email = "" + var focus: Focus? + var password = "" + var username = "" + enum Focus: String { + case bio + case email + case password + case username + } + } } #Preview { - FocusViewController() + FocusViewController() } #endif diff --git a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift index 203e996393..5fbc9378f0 100644 --- a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift +++ b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift @@ -4,174 +4,176 @@ import AppKit import AppKitNavigation final class ConnectToNetworkViewController: XiblessViewController { - @UIBindable var model: ConnectToNetworkModel - - init(model: ConnectToNetworkModel) { - self.model = model - super.init(nibName: nil, bundle: nil) + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.frame = .init(origin: .zero, size: .init(width: 450, height: 234)) + let wifiIconImage = NSImageView(systemSymbolName: "wifi") + wifiIconImage.contentTintColor = .systemBlue + wifiIconImage.symbolConfiguration = .init(pointSize: 60, weight: .regular) + + let titleLabel = NSTextField(labelWithString: "Enter the password for “\(model.network.name)”") + titleLabel.font = .boldSystemFont(ofSize: 13) + let detailLabel = NSTextField(wrappingLabelWithString: "You can also access this Wi-Fi network by sharing the password from a nearby iPhone, iPad, or Mac which has connected to this network and has you in their contacts.") + detailLabel.font = .systemFont(ofSize: 12) + let textStackView = NSStackView(views: [ + titleLabel, + detailLabel, + ]) + textStackView.orientation = .vertical + textStackView.alignment = .leading + + let topStackView = NSStackView(views: [ + Spacer(size: .init(width: 15, height: 0)), + wifiIconImage, + textStackView, + HorizontalMaxSpacer(), + ]) + topStackView.orientation = .horizontal + + let passwordLabel = NSTextField(labelWithString: "Password:") + + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.becomeFirstResponder() + passwordTextField.placeholderString = "The password is 'blob'" + + let centerStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + passwordLabel, + passwordTextField, + ]) + centerStackView.orientation = .horizontal + NSLayoutConstraint.activate([ + passwordTextField.widthAnchor.constraint(equalToConstant: 253), + ]) + + let cancelButton = NSButton() + cancelButton.addAction { [weak self] _ in + guard let self else { return } + model.cancelButtonTapped() } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + cancelButton.title = "Cancel" + + let joinButton = NSButton() + joinButton.addAction { [weak self] _ in + guard let self else { return } + Task { + await self.model.joinButtonTapped() + } + } + joinButton.title = "Join" + + let progress = NSProgressIndicator() + progress.isIndeterminate = true + progress.style = .spinning + progress.controlSize = .small + progress.startAnimation(nil) + + let progressStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + progress, + ]) + + let bottomStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + cancelButton, + joinButton, + ]) + bottomStackView.orientation = .horizontal + + NSLayoutConstraint.activate([ + joinButton.widthAnchor.constraint(equalToConstant: 70), + cancelButton.widthAnchor.constraint(equalToConstant: 70), + ]) + + let stack = NSStackView(views: [ + topStackView, + centerStackView, + progressStackView, + bottomStackView, + ]) + + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + stack.edgeInsets = .init(top: 20, left: 0, bottom: 20, right: 20) + stack.distribution = .equalSpacing + stack.detachesHiddenViews = false + view.addSubview(stack) + progressStackView.isHidden = true + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + stack.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), + stack.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), + ]) + + observe { [weak self, weak passwordTextField] in + guard + let self, + let passwordTextField + else { return } + + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + progressStackView.isHidden = !model.isConnecting } - override func viewDidLoad() { - super.viewDidLoad() - view.frame = .init(origin: .zero, size: .init(width: 450, height: 234)) - let wifiIconImage = NSImageView(systemSymbolName: "wifi") - wifiIconImage.contentTintColor = .systemBlue - wifiIconImage.symbolConfiguration = .init(pointSize: 60, weight: .regular) - - let titleLabel = NSTextField(labelWithString: "Enter the password for “\(model.network.name)”") - titleLabel.font = .boldSystemFont(ofSize: 13) - let detailLabel = NSTextField(wrappingLabelWithString: "You can also access this Wi-Fi network by sharing the password from a nearby iPhone, iPad, or Mac which has connected to this network and has you in their contacts.") - detailLabel.font = .systemFont(ofSize: 12) - let textStackView = NSStackView(views: [ - titleLabel, - detailLabel, - ]) - textStackView.orientation = .vertical - textStackView.alignment = .leading - - let topStackView = NSStackView(views: [ - Spacer(size: .init(width: 15, height: 0)), - wifiIconImage, - textStackView, - HorizontalMaxSpacer(), - ]) - topStackView.orientation = .horizontal - - let passwordLabel = NSTextField(labelWithString: "Password:") - - let passwordTextField = NSSecureTextField(text: $model.password) - passwordTextField.bezelStyle = .roundedBezel - passwordTextField.becomeFirstResponder() - passwordTextField.placeholderString = "The password is 'blob'" - - let centerStackView = NSStackView(views: [ - HorizontalMaxSpacer(), - passwordLabel, - passwordTextField, - ]) - centerStackView.orientation = .horizontal - NSLayoutConstraint.activate([ - passwordTextField.widthAnchor.constraint(equalToConstant: 253), - ]) - - let cancelButton = NSButton { [weak self] _ in - guard let self else { return } - model.cancelButtonTapped() - } - cancelButton.title = "Cancel" - - let joinButton = NSButton { [weak self] _ in - guard let self else { return } - Task { - await self.model.joinButtonTapped() - } - } - joinButton.title = "Join" - - let progress = NSProgressIndicator() - progress.isIndeterminate = true - progress.style = .spinning - progress.controlSize = .small - progress.startAnimation(nil) - - let progressStackView = NSStackView(views: [ - HorizontalMaxSpacer(), - progress, - ]) - - let bottomStackView = NSStackView(views: [ - HorizontalMaxSpacer(), - cancelButton, - joinButton, - ]) - bottomStackView.orientation = .horizontal - - NSLayoutConstraint.activate([ - joinButton.widthAnchor.constraint(equalToConstant: 70), - cancelButton.widthAnchor.constraint(equalToConstant: 70), - ]) - - let stack = NSStackView(views: [ - topStackView, - centerStackView, - progressStackView, - bottomStackView, - ]) - - stack.orientation = .vertical - stack.translatesAutoresizingMaskIntoConstraints = false - stack.edgeInsets = .init(top: 20, left: 0, bottom: 20, right: 20) - stack.distribution = .equalSpacing - stack.detachesHiddenViews = false - view.addSubview(stack) - progressStackView.isHidden = true - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - stack.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), - stack.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), - ]) - - observe { [weak self, weak passwordTextField] in - guard - let self, - let passwordTextField - else { return } - - passwordTextField.isEnabled = !model.isConnecting - joinButton.isEnabled = !model.isConnecting - progressStackView.isHidden = !model.isConnecting - } - - modal(isModaled: $model.incorrectPasswordAlertIsPresented) { [unowned self] in - let alert = NSAlert() - alert.messageText = "Incorrect password for “\(model.network.name)”" - alert.addButton(withTitle: "OK") - return alert - } + modal(isModaled: $model.incorrectPasswordAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Incorrect password for “\(model.network.name)”" + alert.addButton(withTitle: "OK") + return alert } + } } class HorizontalMaxSpacer: NSView { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setContentHuggingPriority(.fittingSizeCompression, for: .horizontal) - setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setContentHuggingPriority(.fittingSizeCompression, for: .horizontal) + setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } class Spacer: NSView { - init(size: NSSize) { - super.init(frame: .zero) - NSLayoutConstraint.activate([ - widthAnchor.constraint(equalToConstant: size.width), - heightAnchor.constraint(equalToConstant: size.height), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + init(size: NSSize) { + super.init(frame: .zero) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: size.width), + heightAnchor.constraint(equalToConstant: size.height), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } #Preview(traits: .fixedLayout(width: 450, height: 262)) { - let vc = ConnectToNetworkViewController( - model: ConnectToNetworkModel( - network: Network(name: "Blob's WiFi") - ) + let vc = ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi") ) - vc.preferredContentSize = .init(width: 450, height: 234) - return vc + ) + vc.preferredContentSize = .init(width: 450, height: 234) + return vc } #endif diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift index 0c6219045a..6f7b7c61fa 100644 --- a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift @@ -4,62 +4,63 @@ import AppKit import AppKitNavigation final class NetworkDetailViewController: XiblessViewController { - @UIBindable var model: NetworkDetailModel + @UIBindable var model: NetworkDetailModel - init(model: NetworkDetailModel) { - self.model = model - super.init(nibName: nil, bundle: nil) - } + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidLoad() { + super.viewDidLoad() // navigationItem.title = model.network.name - let forgetButton = NSButton { [weak self] _ in - guard let self else { return } - model.forgetNetworkButtonTapped() - } + let forgetButton = NSButton() + forgetButton.addAction { [weak self] _ in + guard let self else { return } + model.forgetNetworkButtonTapped() + } - forgetButton.title = "Forget network" + forgetButton.title = "Forget network" // forgetButton.hasDestructiveAction = true - forgetButton.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(forgetButton) - NSLayoutConstraint.activate([ - forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) - observe { [weak self] in - guard let self else { return } + observe { [weak self] in + guard let self else { return } // forgetButton.isHidden = model.network.id != model.selectedNetworkID - } + } + + modal(isModaled: $model.forgetAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Forget Wi-Fi Network “\(model.network.name)”?" + alert.informativeText = """ + Your Mac and other devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """ - modal(isModaled: $model.forgetAlertIsPresented) { [unowned self] in - let alert = NSAlert() - alert.messageText = "Forget Wi-Fi Network “\(model.network.name)”?" - alert.informativeText = """ - Your Mac and other devices using iCloud Keychain will no longer join this Wi-Fi \ - network. - """ - - alert.addButton(ButtonState(role: .cancel) { TextState("Cancel") }) { _ in } - alert.addButton(ButtonState(role: .destructive) { TextState("Forget") }) { [weak self] _ in - guard let self else { return } - model.confirmForgetNetworkButtonTapped() - } - return alert - } + alert.addButton(ButtonState(role: .cancel) { TextState("Cancel") }) { _ in } + alert.addButton(ButtonState(role: .destructive) { TextState("Forget") }) { [weak self] _ in + guard let self else { return } + model.confirmForgetNetworkButtonTapped() + } + return alert } + } } #Preview { - NetworkDetailViewController( - model: NetworkDetailModel( - network: Network(name: "Blob's WiFi"), - selectedNetworkID: UUID() - ) + NetworkDetailViewController( + model: NetworkDetailModel( + network: Network(name: "Blob's WiFi"), + selectedNetworkID: UUID() ) + ) } #endif diff --git a/Package.resolved b/Package.resolved index 1354e7886c..3ee1e092b3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "84b747bb349cd4db8652b1d5a9a2696c12f4c9f15441533d0f2e2553cb31f019", + "originHash" : "15cd96b975d5e3807a52c2b1c6d570b25f285bb14bd48c163416ecbb6bbac386", "pins" : [ { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", - "version" : "1.6.1" + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "2eb22993b3dfd0c0d32729b357c8dabb6cd44680", - "version" : "1.4.2" + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" } }, { @@ -55,13 +55,22 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" + "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", + "version" : "1.6.0" } }, { @@ -69,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "515f79b522918f83483068d99c68daeb5116342d", - "version" : "600.0.0-prerelease-2024-08-20" + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" } }, { @@ -78,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "27d767d643fa2cf083d0a73d74fa84cacb53e85c", - "version" : "1.4.1" + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" } } ], diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 096761ab34..22e729ae84 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -36,7 +36,7 @@ @MainActor func perform( _ body: () throws -> Result, - completion: ((Bool?) -> Void)? = nil + completion: (/*@Sendable*/ (Bool?) -> Void)? = nil ) rethrows -> Result { switch framework { case let .appKit(animation): diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 33ae9d4348..1dc9a83188 100755 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -5,30 +5,6 @@ import AppKit extension NSControl: @retroactive Sendable {} extension NSControl: TargetActionProtocol {} -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() - } -} +extension NSControl {} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 01bc6b00ca..8bc2fef516 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -7,168 +7,165 @@ import IdentifiedCollections @MainActor extension NSFontManager: @unchecked @retroactive Sendable { - - 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) - } - } - } + 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)) } - } + /// 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)) } + } } - @MainActor extension NSFontManager { - /// 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 - } - } - - private var actionProxy: FontManagerProxy? { - set { - objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - objc_getAssociatedObject(self, actionProxyKey) as? FontManagerProxy - } - } - - private func createActionProxyIfNeeded() -> FontManagerProxy { - if let actionProxy { - return actionProxy - } else { - let actionProxy = FontManagerProxy(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: NSFontManager, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObserveToken { - 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 = ObserveToken { [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 - } + /// 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 + } + } + + private var actionProxy: FontManagerProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionProxyKey) as? FontManagerProxy + } + } + + private func createActionProxyIfNeeded() -> FontManagerProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = FontManagerProxy(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: NSFontManager, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObserveToken { + 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 = ObserveToken { [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 + } } - @MainActor private let observationTokensKey = malloc(1)! @MainActor @@ -176,81 +173,81 @@ private let actionProxyKey = malloc(1)! @MainActor private class FontManagerProxy: NSObject { - typealias ActionClosure = (Any?) -> Void + typealias ActionClosure = (Any?) -> Void - typealias ActionIdentifier = UUID + typealias ActionIdentifier = UUID - private struct Action: Identifiable { - let id = UUID() + private struct Action: Identifiable { + let id = UUID() - var closure: ActionClosure + var closure: ActionClosure - func invoke(_ sender: Any?) { - closure(sender) - } + func invoke(_ sender: Any?) { + closure(sender) } + } - private var bindingActions: IdentifiedArrayOf = [] + private var bindingActions: IdentifiedArrayOf = [] - private var actions: IdentifiedArrayOf = [] + private var actions: IdentifiedArrayOf = [] - private var originTarget: AnyObject? + private var originTarget: AnyObject? - private var originAction: Selector? + private var originAction: Selector? - weak var owner: NSFontManager? + weak var owner: NSFontManager? - required init(owner: NSFontManager) { - self.owner = owner - super.init() - self.originTarget = owner.target - self.originAction = owner.action - owner.target = self - owner.action = #selector(invokeAction(_:)) - } + required init(owner: NSFontManager) { + self.owner = owner + super.init() + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) + } - @objc func controlTextDidChange(_ obj: Notification) { - bindingActions.forEach { $0.invoke(obj.object) } - actions.forEach { $0.invoke(obj.object) } - } + @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) } + @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 - } + @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 removeAction(for id: ActionIdentifier) { + actions.remove(id: id) + } - func removeAllActions() { - actions.removeAll() - } + func removeAllActions() { + actions.removeAll() + } - @discardableResult - func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { - let bindingAction = Action(closure: bindingActionClosure) - bindingActions.append(bindingAction) - return bindingAction.id - } + @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 removeBindingAction(for id: ActionIdentifier) { + bindingActions.remove(id: id) + } - func removeAllBindingActions() { - bindingActions.removeAll() - } + func removeAllBindingActions() { + bindingActions.removeAll() + } } #endif diff --git a/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift new file mode 100644 index 0000000000..1719443cb2 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift @@ -0,0 +1,32 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import SwiftNavigation + +extension NSProgressIndicator { + + public convenience init(frame: CGRect = .zero, isAnimated: UIBinding) { + self.init(frame: frame) + bind(isAnimated: isAnimated) + } + + @discardableResult + public func bind(isAnimated: UIBinding) -> ObserveToken { + let token = observe { [weak self] in + guard let self else { return } + let isAnimated = isAnimated.wrappedValue + if isAnimated { + startAnimation(nil) + } else { + stopAnimation(nil) + } + } + + let observeToken = ObserveToken { + token.cancel() + } + return observeToken + } + +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift index c7bbedb5c6..a9ab01f311 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift @@ -6,122 +6,137 @@ import AppKit /// A protocol used to extend `NSControl, NSMenuItem...`. @MainActor public protocol TargetActionProtocol: NSObject, Sendable { - var target: AnyObject? { set get } - var action: Selector? { set get } + var target: AnyObject? { set get } + var action: Selector? { set get } } extension TargetActionProtocol { - /// 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 - } + /// 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: TargetActionProxy? { - set { - objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - objc_getAssociatedObject(self, actionProxyKey) as? TargetActionProxy - } + var actionProxy: TargetActionProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + get { + objc_getAssociatedObject(self, actionProxyKey) as? TargetActionProxy + } + } - func createActionProxyIfNeeded() -> TargetActionProxy { - if let actionProxy { - return actionProxy - } else { - let actionProxy = TargetActionProxy(owner: self) - self.actionProxy = actionProxy - return actionProxy - } + func createActionProxyIfNeeded() -> TargetActionProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = TargetActionProxy(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 - ) -> ObserveToken { - unbind(keyPath) - let actionProxy = createActionProxyIfNeeded() - let actionID = actionProxy.addBindingAction { [weak self] _ in - guard let self else { return } - binding.wrappedValue = self[keyPath: keyPath] - } + /// 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 + ) -> ObserveToken { + 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 = ObserveToken { [weak self] in - MainActor._assumeIsolated { - self?.actionProxy?.removeAction(for: actionID) - } - token.cancel() - observation.invalidate() - } - observationTokens[keyPath] = observationToken - return observationToken + 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 = ObserveToken { [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 + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } + + @discardableResult + public func addAction(_ action: @escaping (Self) -> 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() + } } @MainActor extension NSObject { - var observationTokens: [AnyKeyPath: ObserveToken] { - get { - objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObserveToken] - ?? [:] - } - set { - objc_setAssociatedObject( - self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } + var observationTokens: [AnyKeyPath: ObserveToken] { + get { + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObserveToken] + ?? [:] + } + set { + objc_setAssociatedObject( + self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) } + } } @MainActor diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 565c991704..93a36f1a3e 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -157,6 +157,56 @@ extension NSWindow { } } +extension NSViewController { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + extension SheetContent { @discardableResult fileprivate func _sheet( @@ -209,8 +259,8 @@ extension SheetContent { content($item) } beginSheet: { [weak self] child, _ in guard let self else { return } - if let attachedSheetWindow = currentWindow?.attachedSheet { - self.endSheet(for: attachedSheetWindow) + if let attachedContent = attachedContent { + self.endSheet(for: attachedContent) onDismiss?() Task { @MainActor in await self.beginSheet(for: child) diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index b8ce7a2245..b8549782a5 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -5,11 +5,14 @@ import AppKit @MainActor protocol SheetContent: NavigationContent { var currentWindow: NSWindow? { get } + var attachedContent: SheetContent? { get } func beginSheet(for content: SheetContent) async func endSheet(for content: SheetContent) } extension SheetContent { + var attachedContent: (any SheetContent)? { currentWindow?.attachedSheet } + func beginSheet(for content: any SheetContent) async { guard let sheetedWindow = content.currentWindow else { return } await currentWindow?.beginSheet(sheetedWindow) @@ -49,4 +52,20 @@ extension NSSavePanel { } } +extension NSViewController: SheetContent { + var currentWindow: NSWindow? { view.window } + + var attachedContent: (any SheetContent)? { currentWindow?.attachedSheet?.contentViewController } + + func beginSheet(for content: any SheetContent) async { + guard let viewController = content as? NSViewController else { return } + presentAsSheet(viewController) + } + + func endSheet(for content: any SheetContent) { + guard let viewController = content as? NSViewController else { return } + dismiss(viewController) + } +} + #endif diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m index e36845fde7..5f422637ab 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -28,7 +28,7 @@ + (void)load { class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) ); - method_exchangeImplementations( + method_exchangeImplementations( class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURL:")), class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURL:)) ); diff --git a/Sources/UIKitNavigation/UIKitAnimation.swift b/Sources/UIKitNavigation/UIKitAnimation.swift index f0fef59eae..425cae34ef 100644 --- a/Sources/UIKitNavigation/UIKitAnimation.swift +++ b/Sources/UIKitNavigation/UIKitAnimation.swift @@ -43,7 +43,7 @@ if #available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) { var result: Swift.Result? UIView.animate( - with: animation, + animation, changes: { result = Swift.Result(catching: body) }, completion: completion.map { completion in { completion(true) } } ) diff --git a/SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme b/SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme new file mode 100644 index 0000000000..74e470a162 --- /dev/null +++ b/SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + From 15d12e3ba5f98ffba88827900821b12ae3f2dc88 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 11 Jan 2026 21:09:23 +0800 Subject: [PATCH 35/42] Fix error --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 79912487dd..9a264e3830 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.6"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.2.0"), .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"..<"3.0.0"), + .package(url: "https://github.com/pointfreeco/swift-perception", "1.3.4"..<"3.0.0"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.1"), ], From 48582044f3f6e784f513b085954b7ebf5b3cc5c8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 24 Jun 2026 17:18:35 +0800 Subject: [PATCH 36/42] Fix AppKitNavigation build under upstream changes Adapt AppKitNavigation to the current state of the package after upstream removed `swift-concurrency-extras`, adopted `InternalImportsByDefault`, and enabled `ExistentialAny`. - Replace `import ConcurrencyExtras` with SwiftNavigation's internal `LockIsolated` (via `@_spi(Internals) public import SwiftNavigation`); switch `withValue`/`@UncheckedSendable` to `withLock`/`nonisolated(unsafe)` to match UIKitNavigation's migration. - Promote `import AppKit` to `public import AppKit` in files that expose AppKit types through public API (NSAlert/NSColorPanel/NSColorWell/ NSDatePicker/NSPathControl/NSProgressIndicator/NSSaveOpenPanel/ NSSegmentedControl/NSSlider/NSStepper/NSSwitch/NSTextField/Modal/ Presentation/PresentationContent/Sheet). - Add `any` to protocol-as-type uses (SheetContent, TargetActionProtocol, NSViewControllerPresentationAnimator). - Drop the redundant `import IssueReporting` in NSSegmentedControl, which caused an ambiguous `reportIssue` call under Xcode's stricter resolution. - Split NSAlert.addButton into two overloads to silence the "default expression for inference" warning. - Clean up Package.swift duplicates (xctest-dynamic-overlay dependency and AppKitNavigation target) and propagate AppKitNavigationShim + IdentifiedCollections to Package@swift-6.0/6.1 manifests so 6.0/6.1 toolchains also build. --- Package.resolved | 57 +++++++++++-------- Package.swift | 7 --- Package@swift-6.0.swift | 8 ++- Package@swift-6.1.swift | 8 ++- .../AppKitNavigation/Bindings/NSAlert.swift | 10 +++- .../Bindings/NSColorPanel.swift | 2 +- .../Bindings/NSColorWell.swift | 2 +- .../Bindings/NSDatePicker.swift | 2 +- .../Bindings/NSFontManager.swift | 17 +++--- .../Bindings/NSPathControl.swift | 2 +- .../Bindings/NSProgressIndicator.swift | 4 +- .../Bindings/NSSaveOpenPanel.swift | 2 +- .../Bindings/NSSegmentedControl.swift | 3 +- .../AppKitNavigation/Bindings/NSSlider.swift | 2 +- .../AppKitNavigation/Bindings/NSStepper.swift | 2 +- .../AppKitNavigation/Bindings/NSSwitch.swift | 2 +- .../Bindings/NSTextField.swift | 4 +- .../Bindings/TargetActionProtocol.swift | 17 +++--- .../Bindings/TargetActionProxy.swift | 4 +- .../AppKitNavigation/Navigation/Modal.swift | 2 +- .../Navigation/Presentation.swift | 4 +- .../Navigation/PresentationContent.swift | 4 +- .../AppKitNavigation/Navigation/Sheet.swift | 2 +- .../Navigation/SheetContent.swift | 6 +- .../xcshareddata/swiftpm/Package.resolved | 24 +++++++- 25 files changed, 116 insertions(+), 81 deletions(-) diff --git a/Package.resolved b/Package.resolved index 3ee1e092b3..03415a25d2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "15cd96b975d5e3807a52c2b1c6d570b25f285bb14bd48c163416ecbb6bbac386", + "originHash" : "5416cdb1da2655616e71aeb08bc276c32d79f63f560ff62523660acf3baf4a2c", "pins" : [ { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" + "revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da", + "version" : "1.8.0" } }, { @@ -15,17 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -33,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" } }, { @@ -42,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", - "version" : "1.4.5" + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" } }, { @@ -64,13 +55,31 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing", + "state" : { + "revision" : "2e494c632d510715c96a694ff25e2a8d4ac3f64b", + "version" : "0.6.5" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", - "version" : "1.6.0" + "revision" : "25ac73741c3436605d61eceb5207e896973918e7", + "version" : "2.0.10" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", + "version" : "1.19.2" } }, { @@ -78,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" } }, { @@ -87,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", - "version" : "1.7.0" + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" } } ], diff --git a/Package.swift b/Package.swift index 993e8b9d05..e6768f3c02 100644 --- a/Package.swift +++ b/Package.swift @@ -65,7 +65,6 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.1"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.8.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.1"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"605.0.0"), ], @@ -203,12 +202,6 @@ let package = Package( .product(name: "IssueReportingTestSupport", package: "xctest-dynamic-overlay"), ] ), - .target( - name: "AppKitNavigation", - dependencies: [ - "SwiftNavigation" - ] - ), ], swiftLanguageModes: [.v6] ) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index c179388e32..2d692daf37 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -35,6 +35,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.6.0"), .package(url: "https://github.com/pointfreeco/swift-perception", "1.3.4"..<"3.0.0"), + .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.8.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.1"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -114,9 +115,14 @@ let package = Package( .target( name: "AppKitNavigation", dependencies: [ - "SwiftNavigation" + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), + .target( + name: "AppKitNavigationShim" + ), ], swiftLanguageModes: [.v6] ) diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index f2a5e4dc9a..100cf6c2a5 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -62,6 +62,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.6.0"), .package(url: "https://github.com/pointfreeco/swift-perception", "1.3.4"..<"3.0.0"), + .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.8.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.1"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -192,9 +193,14 @@ let package = Package( .target( name: "AppKitNavigation", dependencies: [ - "SwiftNavigation" + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), + .target( + name: "AppKitNavigationShim" + ), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift index 812f894812..24cc2c2b01 100644 --- a/Sources/AppKitNavigation/Bindings/NSAlert.swift +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -1,5 +1,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSAlert { /// Creates and returns a alert for displaying an alert using a data description. @@ -22,9 +22,15 @@ extension NSAlert { } extension NSAlert { + public func addButton( + _ buttonState: ButtonState + ) { + addButton(buttonState) { _ in } + } + public func addButton( _ buttonState: ButtonState, - action handler: @escaping (_ action: Action?) -> Void = { (_: Never?) in } + action handler: @escaping (_ action: Action?) -> Void ) { let button = addButton(withTitle: String(state: buttonState.label)) diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift index 79adfd1851..b6c8092242 100755 --- a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSColorPanel: @retroactive Sendable {} extension NSColorPanel: TargetActionProtocol { diff --git a/Sources/AppKitNavigation/Bindings/NSColorWell.swift b/Sources/AppKitNavigation/Bindings/NSColorWell.swift index 71eab79865..b2c99fc415 100755 --- a/Sources/AppKitNavigation/Bindings/NSColorWell.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorWell.swift @@ -1,5 +1,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSColorWell { /// Creates a new color well with the specified frame and registers the binding against the diff --git a/Sources/AppKitNavigation/Bindings/NSDatePicker.swift b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift index 17e3d26ecd..cbb8b46881 100755 --- a/Sources/AppKitNavigation/Bindings/NSDatePicker.swift +++ b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift @@ -1,5 +1,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSDatePicker { /// Creates a new date picker with the specified frame and registers the binding against the diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 8bc2fef516..8c28c5bc6a 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -1,8 +1,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import ConcurrencyExtras -@_spi(Internals) import SwiftNavigation -import AppKit +@_spi(Internals) public import SwiftNavigation +public import AppKit import IdentifiedCollections @MainActor @@ -131,8 +130,8 @@ extension NSFontManager { 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 } } + isSetting.withLock { $0 = true } + defer { isSetting.withLock { $0 = false } } set( self, binding.wrappedValue, @@ -142,11 +141,11 @@ extension NSFontManager { ) } // 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 } + nonisolated(unsafe) let uncheckedKeyPath = keyPath + let observation = observe(keyPath) { control, _ in + guard isSetting.withLock({ !$0 }) else { return } MainActor._assumeIsolated { - binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + binding.wrappedValue = control[keyPath: uncheckedKeyPath] } } let observationToken = ObserveToken { [weak self] in diff --git a/Sources/AppKitNavigation/Bindings/NSPathControl.swift b/Sources/AppKitNavigation/Bindings/NSPathControl.swift index fda06940be..3a9b53f9c0 100755 --- a/Sources/AppKitNavigation/Bindings/NSPathControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSPathControl.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSPathControl { /// Creates a new path control with the specified frame and registers the binding against the diff --git a/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift index 1719443cb2..d3d6d05d8b 100644 --- a/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift +++ b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit -import SwiftNavigation +public import AppKit +public import SwiftNavigation extension NSProgressIndicator { diff --git a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift index 8220b89367..aca52dab7c 100755 --- a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit import AppKitNavigationShim extension NSSavePanel { diff --git a/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift index 25b1bdd441..929c5744e3 100755 --- a/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift @@ -1,6 +1,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import IssueReporting -import AppKit +public import AppKit extension NSSegmentedControl { /// Creates a new color well with the specified frame and registers the binding against the diff --git a/Sources/AppKitNavigation/Bindings/NSSlider.swift b/Sources/AppKitNavigation/Bindings/NSSlider.swift index 7931a6d921..1e091af00b 100755 --- a/Sources/AppKitNavigation/Bindings/NSSlider.swift +++ b/Sources/AppKitNavigation/Bindings/NSSlider.swift @@ -1,5 +1,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSSlider { /// Creates a new slider with the specified frame and registers the binding against the value. diff --git a/Sources/AppKitNavigation/Bindings/NSStepper.swift b/Sources/AppKitNavigation/Bindings/NSStepper.swift index 59a679d71f..75eaa1e52f 100755 --- a/Sources/AppKitNavigation/Bindings/NSStepper.swift +++ b/Sources/AppKitNavigation/Bindings/NSStepper.swift @@ -1,5 +1,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSStepper { /// Creates a new stepper with the specified frame and registers the binding against the value. diff --git a/Sources/AppKitNavigation/Bindings/NSSwitch.swift b/Sources/AppKitNavigation/Bindings/NSSwitch.swift index 60a92b14fa..e643487ec0 100755 --- a/Sources/AppKitNavigation/Bindings/NSSwitch.swift +++ b/Sources/AppKitNavigation/Bindings/NSSwitch.swift @@ -1,5 +1,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit extension NSSwitch { /// Creates a new switch with the specified frame and registers the binding against whether or diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index 8b9182aa08..0e3ed6730b 100755 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -1,7 +1,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit import Combine -import SwiftNavigation +public import SwiftNavigation @MainActor extension NSTextField: @retroactive NSTextDelegate {} diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift index a9ab01f311..acc34394fd 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift @@ -1,7 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import ConcurrencyExtras -@_spi(Internals) import SwiftNavigation -import AppKit +@_spi(Internals) public import SwiftNavigation +public import AppKit /// A protocol used to extend `NSControl, NSMenuItem...`. @MainActor @@ -73,8 +72,8 @@ extension TargetActionProtocol { 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 } } + isSetting.withLock { $0 = true } + defer { isSetting.withLock { $0 = false } } set( self, binding.wrappedValue, @@ -84,11 +83,11 @@ extension TargetActionProtocol { ) } // 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 } + nonisolated(unsafe) let uncheckedKeyPath = keyPath + let observation = observe(keyPath) { control, _ in + guard isSetting.withLock({ !$0 }) else { return } MainActor._assumeIsolated { - binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + binding.wrappedValue = control[keyPath: uncheckedKeyPath] } } let observationToken = ObserveToken { [weak self] in diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift index 961235ff1e..239e32672f 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -27,9 +27,9 @@ class TargetActionProxy: NSObject { private var originAction: Selector? - weak var owner: TargetActionProtocol? + weak var owner: (any TargetActionProtocol)? - required init(owner: TargetActionProtocol) { + required init(owner: any TargetActionProtocol) { self.owner = owner super.init() self.originTarget = owner.target diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 6ac702ac14..46ed6d42f5 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit @MainActor private var ModalObserverKeys = AssociatedKeys() diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index f90ec118a3..eb2abef201 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import SwiftNavigation -import AppKit +public import SwiftNavigation +public import AppKit import AppKitNavigationShim class PresentationObserver: NavigationObserver { diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift index 907536422c..fb6b85ad1a 100644 --- a/Sources/AppKitNavigation/Navigation/PresentationContent.swift +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit @MainActor protocol PresentationContent: NavigationContent { @@ -21,7 +21,7 @@ extension NSViewController: PresentationContent { case sheet case modalWindow case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) + case custom(any NSViewControllerPresentationAnimator) } func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 93a36f1a3e..5bb6f70211 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -1,6 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit +public import AppKit private typealias SheetObserver = NavigationObserver diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index b8549782a5..299a4145df 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -5,9 +5,9 @@ import AppKit @MainActor protocol SheetContent: NavigationContent { var currentWindow: NSWindow? { get } - var attachedContent: SheetContent? { get } - func beginSheet(for content: SheetContent) async - func endSheet(for content: SheetContent) + var attachedContent: (any SheetContent)? { get } + func beginSheet(for content: any SheetContent) async + func endSheet(for content: any SheetContent) } extension SheetContent { diff --git a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4e7a6a30d1..065c79528b 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fd561772dbb7da88879cfbd7ad587c7c0f94bd076b6d0e00f7d2d67ac20cb3a8", + "originHash" : "5459de6c7e9e8daaaec6ad4ddafe2158d2f46f43942a81d83636d6cdffc599af", "pins" : [ { "identity" : "combine-schedulers", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", - "version" : "1.6.1" + "revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da", + "version" : "1.8.0" } }, { @@ -91,6 +91,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing", + "state" : { + "revision" : "2e494c632d510715c96a694ff25e2a8d4ac3f64b", + "version" : "0.6.5" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", @@ -100,6 +109,15 @@ "version" : "1.5.0" } }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", + "version" : "1.19.2" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", From eb2eacadb466afd2389944346c0ac36c1d240af9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 24 Jun 2026 17:18:46 +0800 Subject: [PATCH 37/42] Add `popover` navigation for AppKit Introduce `NSView.popover(...)` driven by `NSPopover` (not `NSViewController.present(_:asPopoverRelativeTo:...)`), mirroring the existing `PopoverContent` / `PopoverObserver` shape used by Sheet, Modal, and Presentation. - `PopoverContent` protocol + default `show`/`close` extension that wires `NSPopover.didCloseNotification` into the binding lifecycle so transient popovers dismissed by clicking outside reset the source-of-truth. - Fileprivate `_PopoverPresentation` wraps a user-supplied `NSViewController` with an internally-managed `NSPopover` so the public `content` closure stays VC-only; popover knobs (`behavior`, `animates`) move onto the `popover(...)` method itself. - `PopoverPresentationsObserver` singleton retains active popovers (an `NSPopover` is not self-retained by `show`) and clears them on close, mirroring `ModalWindowsObserver`. - Public API: 5 overloads on `NSView` (`isPresented`, `item: Identifiable` value/binding, `item:id:KeyPath` value/binding) with `relativeTo`, `preferredEdge`, `behavior`, `animates`, `onDismiss`. --- .../PopoverPresentationsObserver.swift | 24 ++ .../AppKitNavigation/Navigation/Popover.swift | 305 ++++++++++++++++++ .../Navigation/PopoverContent.swift | 25 ++ 3 files changed, 354 insertions(+) create mode 100644 Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/Popover.swift create mode 100644 Sources/AppKitNavigation/Navigation/PopoverContent.swift diff --git a/Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift b/Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift new file mode 100644 index 0000000000..043449fb5f --- /dev/null +++ b/Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift @@ -0,0 +1,24 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +final class PopoverPresentationsObserver: NSObject { + static let shared = PopoverPresentationsObserver() + + var popoverCancellables: [NSPopover: AnyCancellable] = [:] + + func observePopover(_ popover: NSPopover, onDidClose: @escaping @MainActor () -> Void) { + popoverCancellables[popover] = NotificationCenter.default + .publisher(for: NSPopover.didCloseNotification, object: popover) + .sink { [weak self] _ in + MainActor._assumeIsolated { + onDidClose() + self?.popoverCancellables.removeValue(forKey: popover) + } + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Popover.swift b/Sources/AppKitNavigation/Navigation/Popover.swift new file mode 100644 index 0000000000..b3b301f034 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Popover.swift @@ -0,0 +1,305 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +public import AppKit + +@MainActor +fileprivate final class _PopoverPresentation: NSObject, PopoverContent { + let popover: NSPopover + + init( + viewController: NSViewController, + behavior: NSPopover.Behavior, + animates: Bool + ) { + let popover = NSPopover() + popover.contentViewController = viewController + popover.behavior = behavior + popover.animates = animates + self.popover = popover + super.init() + } +} + +private typealias PopoverObserver = NavigationObserver + +@MainActor +private let popoverObserverKey = malloc(1)! + +extension NSView { + @discardableResult + public func popover( + isPresented: UIBinding, + relativeTo rect: NSRect = .zero, + preferredEdge: NSRectEdge = .minY, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> ObserveToken { + _popover( + isPresented: isPresented, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + public func popover( + item: UIBinding, + relativeTo rect: NSRect = .zero, + preferredEdge: NSRectEdge = .minY, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @_disfavoredOverload + @discardableResult + public func popover( + item: UIBinding, + relativeTo rect: NSRect = .zero, + preferredEdge: NSRectEdge = .minY, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + public func popover( + item: UIBinding, + id: KeyPath, + relativeTo rect: NSRect = .zero, + preferredEdge: NSRectEdge = .minY, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: id, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + public func popover( + item: UIBinding, + id: KeyPath, + relativeTo rect: NSRect = .zero, + preferredEdge: NSRectEdge = .minY, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: id, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } +} + +extension NSView { + @discardableResult + fileprivate func _popover( + isPresented: UIBinding, + relativeTo rect: NSRect, + preferredEdge: NSRectEdge, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> ObserveToken { + _popover( + item: isPresented.toOptionalUnit, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss + ) { _ in content() } + } + + @discardableResult + fileprivate func _popover( + item: UIBinding, + relativeTo rect: NSRect, + preferredEdge: NSRectEdge, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: \.id, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @_disfavoredOverload + @discardableResult + fileprivate func _popover( + item: UIBinding, + relativeTo rect: NSRect, + preferredEdge: NSRectEdge, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: \.id, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + fileprivate func _popover( + item: UIBinding, + id: KeyPath, + relativeTo rect: NSRect, + preferredEdge: NSRectEdge, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: id, + relativeTo: rect, + preferredEdge: preferredEdge, + behavior: behavior, + animates: animates, + onDismiss: onDismiss + ) { + content($0.wrappedValue) + } + } + + @discardableResult + fileprivate func _popover( + item: UIBinding, + id: KeyPath, + relativeTo rect: NSRect, + preferredEdge: NSRectEdge, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover(item: item, id: id) { binding in + _PopoverPresentation( + viewController: content(binding), + behavior: behavior, + animates: animates + ) + } beginPopover: { [weak self] presentation, _ in + guard let self else { return } + presentation.appKitNavigationShowPopover( + relativeTo: rect, + of: self, + preferredEdge: preferredEdge + ) + } endPopover: { presentation, _ in + presentation.appKitNavigationClosePopover() + onDismiss?() + } + } + + private func _popover( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> _PopoverPresentation, + beginPopover: @escaping ( + _ child: _PopoverPresentation, + _ transaction: UITransaction + ) -> Void, + endPopover: @escaping ( + _ child: _PopoverPresentation, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let popoverObserver = popoverObserver() + return popoverObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginPopover, + end: endPopover + ) + } + + private func popoverObserver() -> PopoverObserver { + if let observer = objc_getAssociatedObject(self, popoverObserverKey) as? PopoverObserver { + return observer + } else { + let observer = PopoverObserver(owner: self) + objc_setAssociatedObject( + self, + popoverObserverKey, + observer, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return observer + } + } +} + +extension Navigated where Content: PopoverContent { + func clearup() { + content?.appKitNavigationClosePopover() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/PopoverContent.swift b/Sources/AppKitNavigation/Navigation/PopoverContent.swift new file mode 100644 index 0000000000..451b817ca1 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/PopoverContent.swift @@ -0,0 +1,25 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +protocol PopoverContent: NavigationContent { + var popover: NSPopover { get } + func appKitNavigationShowPopover(relativeTo rect: NSRect, of view: NSView, preferredEdge: NSRectEdge) + func appKitNavigationClosePopover() +} + +extension PopoverContent { + func appKitNavigationShowPopover(relativeTo rect: NSRect, of view: NSView, preferredEdge: NSRectEdge) { + PopoverPresentationsObserver.shared.observePopover(popover) { [weak self] in + self?.onEndNavigation?() + } + popover.show(relativeTo: rect, of: view, preferredEdge: preferredEdge) + } + + func appKitNavigationClosePopover() { + popover.close() + } +} + +#endif From 97b2ae15bb00a9f29299cada48fdbb2785cbd1bb Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 25 Jun 2026 11:19:27 +0800 Subject: [PATCH 38/42] Add `popover` support for `NSToolbarItem` (macOS 14+) `NSPopover.show(relativeTo:)` was added in macOS 14 to anchor a popover to a toolbar item. Expose it through the same family of `popover(...)` bindings already provided on `NSView`, omitting the `rect` and `preferredEdge` parameters that the toolbar-item API does not accept. --- .../AppKitNavigation/Navigation/Popover.swift | 237 ++++++++++++++++++ .../Navigation/PopoverContent.swift | 8 + 2 files changed, 245 insertions(+) diff --git a/Sources/AppKitNavigation/Navigation/Popover.swift b/Sources/AppKitNavigation/Navigation/Popover.swift index b3b301f034..0849287468 100644 --- a/Sources/AppKitNavigation/Navigation/Popover.swift +++ b/Sources/AppKitNavigation/Navigation/Popover.swift @@ -302,4 +302,241 @@ extension Navigated where Content: PopoverContent { } } +@available(macOS 14.0, *) +private typealias ToolbarItemPopoverObserver = NavigationObserver + +@MainActor +private let toolbarItemPopoverObserverKey = malloc(1)! + +@available(macOS 14.0, *) +extension NSToolbarItem { + @discardableResult + public func popover( + isPresented: UIBinding, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> ObserveToken { + _popover( + isPresented: isPresented, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + public func popover( + item: UIBinding, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @_disfavoredOverload + @discardableResult + public func popover( + item: UIBinding, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + public func popover( + item: UIBinding, + id: KeyPath, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: id, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + public func popover( + item: UIBinding, + id: KeyPath, + behavior: NSPopover.Behavior = .transient, + animates: Bool = true, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: id, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } +} + +@available(macOS 14.0, *) +extension NSToolbarItem { + @discardableResult + fileprivate func _popover( + isPresented: UIBinding, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> ObserveToken { + _popover( + item: isPresented.toOptionalUnit, + behavior: behavior, + animates: animates, + onDismiss: onDismiss + ) { _ in content() } + } + + @discardableResult + fileprivate func _popover( + item: UIBinding, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: \.id, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @_disfavoredOverload + @discardableResult + fileprivate func _popover( + item: UIBinding, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: \.id, + behavior: behavior, + animates: animates, + onDismiss: onDismiss, + content: content + ) + } + + @discardableResult + fileprivate func _popover( + item: UIBinding, + id: KeyPath, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _popover( + item: item, + id: id, + behavior: behavior, + animates: animates, + onDismiss: onDismiss + ) { + content($0.wrappedValue) + } + } + + @discardableResult + fileprivate func _popover( + item: UIBinding, + id: KeyPath, + behavior: NSPopover.Behavior, + animates: Bool, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _popover(item: item, id: id) { binding in + _PopoverPresentation( + viewController: content(binding), + behavior: behavior, + animates: animates + ) + } beginPopover: { [weak self] presentation, _ in + guard let self else { return } + presentation.appKitNavigationShowPopover(relativeTo: self) + } endPopover: { presentation, _ in + presentation.appKitNavigationClosePopover() + onDismiss?() + } + } + + private func _popover( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> _PopoverPresentation, + beginPopover: @escaping ( + _ child: _PopoverPresentation, + _ transaction: UITransaction + ) -> Void, + endPopover: @escaping ( + _ child: _PopoverPresentation, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let popoverObserver = popoverObserver() + return popoverObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginPopover, + end: endPopover + ) + } + + private func popoverObserver() -> ToolbarItemPopoverObserver { + if let observer = objc_getAssociatedObject(self, toolbarItemPopoverObserverKey) as? ToolbarItemPopoverObserver { + return observer + } else { + let observer = ToolbarItemPopoverObserver(owner: self) + objc_setAssociatedObject( + self, + toolbarItemPopoverObserverKey, + observer, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return observer + } + } +} + #endif diff --git a/Sources/AppKitNavigation/Navigation/PopoverContent.swift b/Sources/AppKitNavigation/Navigation/PopoverContent.swift index 451b817ca1..0131e45ed0 100644 --- a/Sources/AppKitNavigation/Navigation/PopoverContent.swift +++ b/Sources/AppKitNavigation/Navigation/PopoverContent.swift @@ -17,6 +17,14 @@ extension PopoverContent { popover.show(relativeTo: rect, of: view, preferredEdge: preferredEdge) } + @available(macOS 14.0, *) + func appKitNavigationShowPopover(relativeTo toolbarItem: NSToolbarItem) { + PopoverPresentationsObserver.shared.observePopover(popover) { [weak self] in + self?.onEndNavigation?() + } + popover.show(relativeTo: toolbarItem) + } + func appKitNavigationClosePopover() { popover.close() } From 3c81b1006da473b8b50e2e118585080c6b2193c8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 25 Jun 2026 11:52:34 +0800 Subject: [PATCH 39/42] Keep `_PopoverPresentation` alive across `NSPopover` lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Navigated.content` is held weakly and `NSPopover` does not retain its `PopoverContent` host, so `_PopoverPresentation` was deallocated as soon as `begin(...)` returned. The `didCloseNotification` sink captured the host weakly, so `onEndNavigation` ran on a nil reference, the binding was never reset to `false`, and `navigatedByID` retained the prior id — blocking any subsequent presentation. Capture `self` strongly in the close subscription; lifetime stays bounded because the sink removes the cancellable from `PopoverPresentationsObserver.shared` once the close notification fires. --- .../AppKitNavigation/Navigation/PopoverContent.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/PopoverContent.swift b/Sources/AppKitNavigation/Navigation/PopoverContent.swift index 0131e45ed0..59e4037205 100644 --- a/Sources/AppKitNavigation/Navigation/PopoverContent.swift +++ b/Sources/AppKitNavigation/Navigation/PopoverContent.swift @@ -11,16 +11,20 @@ protocol PopoverContent: NavigationContent { extension PopoverContent { func appKitNavigationShowPopover(relativeTo rect: NSRect, of view: NSView, preferredEdge: NSRectEdge) { - PopoverPresentationsObserver.shared.observePopover(popover) { [weak self] in - self?.onEndNavigation?() + // `Navigated` holds `self` weakly, and `NSPopover` does not retain its + // `PopoverContent` host, so we must keep `self` alive through the close + // subscription. Otherwise `onEndNavigation` runs on a deallocated `self` + // and the binding is never reset, leaving the popover unable to reopen. + PopoverPresentationsObserver.shared.observePopover(popover) { [self] in + self.onEndNavigation?() } popover.show(relativeTo: rect, of: view, preferredEdge: preferredEdge) } @available(macOS 14.0, *) func appKitNavigationShowPopover(relativeTo toolbarItem: NSToolbarItem) { - PopoverPresentationsObserver.shared.observePopover(popover) { [weak self] in - self?.onEndNavigation?() + PopoverPresentationsObserver.shared.observePopover(popover) { [self] in + self.onEndNavigation?() } popover.show(relativeTo: toolbarItem) } From ad18e298db21474f9516c6d5d014942d05661dff Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Jul 2026 22:06:34 +0800 Subject: [PATCH 40/42] Make AppKitNavigation imports `public` for library evolution Expose `AppKit`, `Combine`, and `IdentifiedCollections` via `public import` so the types they surface satisfy access-level-on-imports checks, matching the recently enabled `CheckImplementationOnly` feature. `Package.resolved` picks up the newly pinned `swift-collections` and `swift-identified-collections`. --- Package.resolved | 20 ++++++++++++++++++- .../Bindings/NSFontManager.swift | 2 +- .../Bindings/TargetActionProxy.swift | 4 ++-- .../Internal/ModalWindowsObserver.swift | 4 ++-- .../PopoverPresentationsObserver.swift | 4 ++-- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Package.resolved b/Package.resolved index 7ae7ea869d..bde244cd2b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fdc10853c1416eddaab5f368180b2890aa34ef46ff72e4ae1e58d0d63371abe5", + "originHash" : "a5f49799d3a165929f0773ae888b60c9cc72bdbcd87aab4044f78366d5c28585", "pins" : [ { "identity" : "swift-case-paths", @@ -10,6 +10,15 @@ "version" : "1.8.0" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", @@ -37,6 +46,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, { "identity" : "swift-macro-testing", "kind" : "remoteSourceControl", diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 8c28c5bc6a..9100f81d4d 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -2,7 +2,7 @@ @_spi(Internals) public import SwiftNavigation public import AppKit -import IdentifiedCollections +public import IdentifiedCollections @MainActor extension NSFontManager: @unchecked @retroactive Sendable { diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift index 239e32672f..f2dec4ec5c 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -1,7 +1,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit -import IdentifiedCollections +public import AppKit +public import IdentifiedCollections @MainActor class TargetActionProxy: NSObject { diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift index a95607191e..55de75fb8f 100644 --- a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift +++ b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift @@ -1,7 +1,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit -import Combine +public import AppKit +public import Combine @MainActor class ModalWindowsObserver: NSObject { diff --git a/Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift b/Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift index 043449fb5f..f03a8e6c32 100644 --- a/Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift +++ b/Sources/AppKitNavigation/Internal/PopoverPresentationsObserver.swift @@ -1,7 +1,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit -import Combine +public import AppKit +public import Combine @MainActor final class PopoverPresentationsObserver: NSObject { From 00ff13d4cffc9690b8127d1ab35cf2c54a3eaf4c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 5 Sep 2026 13:56:39 +0800 Subject: [PATCH 41/42] fix(appkit): keep the progress indicator's isAnimated binding alive `bind(isAnimated:)` wrapped its observation in a freshly created `ObserveToken` and returned it without retaining it anywhere. Since the method is marked `@discardableResult`, the ordinary call site dropped that wrapper, it deallocated at the end of the statement, and its `deinit` cancelled the observation behind it. The indicator therefore honoured the binding once, synchronously, and never reacted to a change again. `init(frame:isAnimated:)` discards the token internally, so it had the same defect. Every other binding in the module already stores its token on the receiver via an associated object; this one was the only exception. Store it the same way and add `unbindIsAnimated()` to match `unbindURL()` and `unbind(_:)`. Adds an `AppKitNavigationTests` target, which the module did not have. The regression test fails against the previous implementation with `("[false]") is not equal to ("[false, true]")`. --- Package.swift | 7 ++ .../Bindings/NSProgressIndicator.swift | 44 +++++++++--- .../NSProgressIndicatorTests.swift | 72 +++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 Tests/AppKitNavigationTests/NSProgressIndicatorTests.swift diff --git a/Package.swift b/Package.swift index 3b03a0f9e1..5db7314082 100644 --- a/Package.swift +++ b/Package.swift @@ -202,6 +202,13 @@ let package = Package( .product(name: "IssueReportingTestSupport", package: "xctest-dynamic-overlay"), ] ), + .testTarget( + name: "AppKitNavigationTests", + dependencies: [ + "AppKitNavigation", + .product(name: "IssueReportingTestSupport", package: "xctest-dynamic-overlay"), + ] + ), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift index d3d6d05d8b..b0655848bc 100644 --- a/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift +++ b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift @@ -3,30 +3,56 @@ public import AppKit public import SwiftNavigation extension NSProgressIndicator { - + /// Creates a new progress indicator with the specified frame and registers the binding against + /// its animation state. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - isAnimated: The binding to read from for whether the indicator should animate. public convenience init(frame: CGRect = .zero, isAnimated: UIBinding) { self.init(frame: frame) bind(isAnimated: isAnimated) } - + + /// Establishes a connection between a binding and the progress indicator's animation state. + /// + /// - Parameter isAnimated: The binding to read from for whether the indicator should animate. + /// - Returns: A cancel token. @discardableResult public func bind(isAnimated: UIBinding) -> ObserveToken { + unbindIsAnimated() let token = observe { [weak self] in guard let self else { return } - let isAnimated = isAnimated.wrappedValue - if isAnimated { + if isAnimated.wrappedValue { startAnimation(nil) } else { stopAnimation(nil) } } - - let observeToken = ObserveToken { - token.cancel() + // NB: The token has to be retained by the progress indicator itself. Returning a freshly + // created token would cancel the observation the moment the caller discarded it, which + // 'bind' is marked '@discardableResult' to encourage. + let observationToken = ObserveToken { token.cancel() } + isAnimatedToken = observationToken + return observationToken + } + + /// Cancels the connection established by ``bind(isAnimated:)``. + public func unbindIsAnimated() { + isAnimatedToken?.cancel() + isAnimatedToken = nil + } + + private var isAnimatedToken: ObserveToken? { + get { objc_getAssociatedObject(self, Self.isAnimatedTokenKey) as? ObserveToken } + set { + objc_setAssociatedObject( + self, Self.isAnimatedTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) } - return observeToken } - + + private static let isAnimatedTokenKey = malloc(1)! } #endif diff --git a/Tests/AppKitNavigationTests/NSProgressIndicatorTests.swift b/Tests/AppKitNavigationTests/NSProgressIndicatorTests.swift new file mode 100644 index 0000000000..abb9721ea3 --- /dev/null +++ b/Tests/AppKitNavigationTests/NSProgressIndicatorTests.swift @@ -0,0 +1,72 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import AppKitNavigation + import XCTest + + final class NSProgressIndicatorTests: XCTestCase { + /// Records animation requests instead of running them, so the binding can be observed without + /// a window or a run loop. + private final class SpyProgressIndicator: NSProgressIndicator { + nonisolated(unsafe) var animationRequests: [Bool] = [] + + override func startAnimation(_ sender: Any?) { + animationRequests.append(true) + } + + override func stopAnimation(_ sender: Any?) { + animationRequests.append(false) + } + } + + /// The binding used to stop working as soon as the caller discarded the returned token: the + /// token was freshly created and retained by nobody, so it deallocated at the end of the + /// statement and cancelled the observation behind it. + @MainActor + func testKeepsObservingWhenTheTokenIsDiscarded() async { + @UIBinding var isAnimated = false + let progressIndicator = SpyProgressIndicator() + + progressIndicator.bind(isAnimated: $isAnimated) + XCTAssertEqual(progressIndicator.animationRequests, [false]) + + isAnimated = true + await Task.yield() + XCTAssertEqual(progressIndicator.animationRequests, [false, true]) + + isAnimated = false + await Task.yield() + XCTAssertEqual(progressIndicator.animationRequests, [false, true, false]) + } + + @MainActor + func testUnbindStopsObserving() async { + @UIBinding var isAnimated = false + let progressIndicator = SpyProgressIndicator() + + progressIndicator.bind(isAnimated: $isAnimated) + progressIndicator.unbindIsAnimated() + + isAnimated = true + await Task.yield() + XCTAssertEqual(progressIndicator.animationRequests, [false]) + } + + @MainActor + func testRebindingReplacesThePreviousBinding() async { + @UIBinding var first = false + @UIBinding var second = false + let progressIndicator = SpyProgressIndicator() + + progressIndicator.bind(isAnimated: $first) + progressIndicator.bind(isAnimated: $second) + progressIndicator.animationRequests.removeAll() + + first = true + await Task.yield() + XCTAssertEqual(progressIndicator.animationRequests, [], "the replaced binding must be silent") + + second = true + await Task.yield() + XCTAssertEqual(progressIndicator.animationRequests, [true]) + } + } +#endif From f7b57240a1472e6fb4eae70ce2375b3adfe6b87c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 5 Sep 2026 13:56:52 +0800 Subject: [PATCH 42/42] feat(appkit): register an additional target-action pair on macOS 27 `TargetActionProxy` took over a control's single `target` / `action` pair, saving the previous one and forwarding to it. That keeps a pair installed *before* the first binding working, but assigning `target` or `action` afterwards silently unhooks the proxy, and every binding and closure action on that control stops firing. AppKit has stored several target-action pairs per control since macOS 11 (`addTarget:action:forControlEvents:`), which removes the need to seize the pair at all. The API is only published in the macOS 27 SDK, so declare it in the shim behind an SDK check that compiles away once that SDK is in use. The events are not as old as the API, though. Measured on macOS 26.6, a button click delivers only `trackingBegan` and `trackingEndedInside`; `valueChanged` and `primaryActionTriggered` are never sent, and tracking events do not stand for "the action fired" (a keyboard-driven button never tracks). So the proxy registers as an additional target from macOS 27 on and keeps taking the pair over below that. Text fields deliberately do not register `valueChanged`: their per-keystroke changes already arrive through `textDidChangeNotification` on every version, and registering both would deliver each edit twice. Two things to know when touching the shim: - Do not copy `NS_SWIFT_NAME(NSControl.Events)` from the SDK. Nesting a type inside a class owned by a different module makes the Swift importer drop the whole declaration, methods included. - Never spell the option set at the call site. It is `NSControlEvents` from the shim and `NSControl.Events` from the macOS 27 SDK; an array literal infers under both. Built against both the macOS 26 and macOS 27 SDKs. The macOS 27 path itself is unverified at runtime for want of a machine running it; the tests are written as cross-path contracts so running them there confirms it. --- .../Bindings/TargetActionProxy.swift | 54 ++++++++- Sources/AppKitNavigationShim/include/shim.h | 63 ++++++++++ .../TargetActionTests.swift | 108 ++++++++++++++++++ 3 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 Tests/AppKitNavigationTests/TargetActionTests.swift diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift index f2dec4ec5c..aedc7b2a09 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -2,6 +2,7 @@ public import AppKit public import IdentifiedCollections +import AppKitNavigationShim @MainActor class TargetActionProxy: NSObject { @@ -27,17 +28,56 @@ class TargetActionProxy: NSObject { private var originAction: Selector? + /// Whether this proxy registered itself as an *additional* target rather than taking over the + /// control's single `target` / `action` pair. + /// + /// AppKit has stored several target-action pairs per control since macOS 11, but the events that + /// stand for "the control's action fired" — `valueChanged` and `primaryActionTriggered` — are + /// only delivered from macOS 27 on. Below that, only tracking events are sent, which is not the + /// same thing (a keyboard-driven button never tracks), so the proxy still has to take the pair + /// over. + private let registersAsAdditionalTarget: Bool + weak var owner: (any TargetActionProtocol)? required init(owner: any TargetActionProtocol) { self.owner = owner + if #available(macOS 27, *) { + registersAsAdditionalTarget = owner is NSControl + } else { + registersAsAdditionalTarget = false + } super.init() - self.originTarget = owner.target - self.originAction = owner.action - owner.target = self - owner.action = #selector(invokeAction(_:)) + + if #available(macOS 27, *), registersAsAdditionalTarget, let control = owner as? NSControl { + // NB: A text field's per-keystroke changes arrive through the notification below on every + // OS version, so 'valueChanged' is deliberately left out for text fields — registering + // both would deliver each edit twice. + // + // The event set is spelled as a literal on purpose: the option set is named + // 'NSControlEvents' when it comes from the shim and 'NSControl.Events' when it comes + // from the macOS 27 SDK, and inference makes both spellings compile. + if control is NSTextField { + control.addTarget(self, action: #selector(invokeAction(_:)), for: [.primaryActionTriggered]) + } else { + control.addTarget( + self, action: #selector(invokeAction(_:)), for: [.primaryActionTriggered, .valueChanged] + ) + } + } else { + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) + } + if let textField = owner as? NSTextField { - NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) + NotificationCenter.default.addObserver( + self, + selector: #selector(controlTextDidChange(_:)), + name: NSControl.textDidChangeNotification, + object: textField + ) } } @@ -47,7 +87,9 @@ class TargetActionProxy: NSObject { } @objc func invokeAction(_ sender: Any?) { - if let originTarget, let originAction { + // NB: When registered as an additional target the original pair is still installed on the + // control, so AppKit delivers to it directly and forwarding here would double up. + if !registersAsAdditionalTarget, let originTarget, let originAction { NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) } bindingActions.forEach { $0.invoke(sender) } diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h index 9dc496b6da..afe2026ce5 100644 --- a/Sources/AppKitNavigationShim/include/shim.h +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -19,6 +19,69 @@ NS_ASSUME_NONNULL_BEGIN @property (nullable) void (^ AppKitNavigation_onFinalURLs)(NSArray *); @end +// MARK: - Multiple target-action pairs + +// NB: AppKit has stored several target-action pairs per control since macOS 11, but only published +// the API in the macOS 27 SDK. Redeclaring it here lets the binding layer register additional +// actions without taking over a control's single 'target' / 'action' pair, while still building +// against an older SDK. The whole block compiles away once the SDK in use declares it, so +// nothing has to be deleted by hand — but the redeclaration should be dropped altogether once +// the macOS 27 SDK is the minimum supported one. +// +// Note that the events themselves are not all as old as the API: on macOS 26 a button click +// only delivers NSControlEventTrackingBegan and NSControlEventTrackingEndedInside, and +// NSControlEventValueChanged / NSControlEventPrimaryActionTriggered are never sent. Anything +// relying on those has to stay behind an availability check. +// +// The SDK spells the type 'NS_SWIFT_NAME(NSControl.Events)'. That must NOT be repeated here: +// nesting a type inside a class that belongs to a *different* module makes the Swift importer +// drop the whole declaration, taking the two methods with it, and Swift then sees neither +// 'NSControlEvents' nor 'NSControl.Events'. Without the attribute Swift imports the type under +// its Objective-C name and the methods as 'addTarget(_:action:for:)'. +#if !defined(MAC_OS_VERSION_27_0) || __MAC_OS_X_VERSION_MAX_ALLOWED < MAC_OS_VERSION_27_0 + +typedef NS_OPTIONS(NSUInteger, NSControlEvents) { + NSControlEventTrackingBegan = 1 << 0, + NSControlEventTrackingRepeated API_AVAILABLE(macos(27.0)) = 1 << 1, + NSControlEventTrackingInside = 1 << 2, + NSControlEventTrackingOutside = 1 << 3, + NSControlEventTrackingEntered = 1 << 4, + NSControlEventTrackingExited = 1 << 5, + NSControlEventTrackingEndedInside = 1 << 6, + NSControlEventTrackingEndedOutside = 1 << 7, + NSControlEventTrackingCancelled = 1 << 8, + + NSControlEventValueChanged API_AVAILABLE(macos(27.0)) = 1 << 12, + NSControlEventPrimaryActionTriggered API_AVAILABLE(macos(27.0)) = 1 << 13, + NSControlEventMenuActionTriggered API_AVAILABLE(macos(27.0)) = 1 << 14, + + NSControlEventAllTrackingEvents = 0x00000FFF, + NSControlEventApplicationReserved API_AVAILABLE(macos(27.0)) = 0x0F000000, + NSControlEventSystemReserved = 0xF0000000, + NSControlEventAllEvents = 0xFFFFFFFF +} API_AVAILABLE(macos(11.0)); + +@interface NSControl (AppKitNavigationControlEvents) + +/// Associates a target object and action method with one or more control events. +/// +/// Unlike the 'target' and 'action' properties, this may be called several times to register +/// several pairs. The control does not retain the target. +- (void)addTarget:(nullable id)target + action:(SEL)action + forControlEvents:(NSControlEvents)controlEvents API_AVAILABLE(macos(11.0)); + +/// Stops the delivery of the specified events to the given target object. +/// +/// Pass 'nil' as the action to remove all actions for the target. +- (void)removeTarget:(nullable id)target + action:(nullable SEL)action + forControlEvents:(NSControlEvents)controlEvents API_AVAILABLE(macos(11.0)); + +@end + +#endif /* macOS 27 SDK redeclaration */ + NS_ASSUME_NONNULL_END #endif #endif /* if __has_include() */ diff --git a/Tests/AppKitNavigationTests/TargetActionTests.swift b/Tests/AppKitNavigationTests/TargetActionTests.swift new file mode 100644 index 0000000000..ed91e337d8 --- /dev/null +++ b/Tests/AppKitNavigationTests/TargetActionTests.swift @@ -0,0 +1,108 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import AppKitNavigation + import XCTest + + final class TargetActionTests: XCTestCase { + override func setUp() { + super.setUp() + // NB: 'performClick' routes through 'NSApplication.sendAction(_:to:from:)', which silently + // does nothing when no application object exists yet. XCTest runs 'setUp' on the main + // thread, so assuming main-actor isolation here is sound. + MainActor.assumeIsolated { + _ = NSApplication.shared + } + } + + private final class ActionRecipient: NSObject { + nonisolated(unsafe) var callCount = 0 + + @objc func recordAction(_ sender: Any?) { + callCount += 1 + } + } + + /// Whichever mechanism the proxy picks, a target-action pair installed *before* the first + /// closure action has to keep firing, and exactly once. + @MainActor + func testExistingTargetActionKeepsFiringAlongsideClosureActions() { + let recipient = ActionRecipient() + let button = NSButton() + button.target = recipient + button.action = #selector(ActionRecipient.recordAction(_:)) + + var closureCallCount = 0 + button.addAction { _ in closureCallCount += 1 } + + button.performClick(nil) + + XCTAssertEqual(recipient.callCount, 1) + XCTAssertEqual(closureCallCount, 1) + } + + @MainActor + func testClosureActionFiresWithoutAnyTargetAction() { + let button = NSButton() + + var closureCallCount = 0 + button.addAction { _ in closureCallCount += 1 } + + button.performClick(nil) + button.performClick(nil) + + XCTAssertEqual(closureCallCount, 2) + } + + @MainActor + func testRemoveActionStopsTheClosure() { + let button = NSButton() + + var closureCallCount = 0 + let id = button.addAction { _ in closureCallCount += 1 } + button.removeAction(for: id) + + button.performClick(nil) + + XCTAssertEqual(closureCallCount, 0) + } + + /// From macOS 27 on, AppKit delivers `primaryActionTriggered` / `valueChanged` to additional + /// targets, so the proxy no longer has to seize the control's own pair. Below that it does, + /// because only tracking events are sent and those do not stand for "the action fired". + @MainActor + func testProxyOnlySeizesTargetActionBelowMacOS27() { + let recipient = ActionRecipient() + let button = NSButton() + button.target = recipient + button.action = #selector(ActionRecipient.recordAction(_:)) + + button.addAction { _ in } + + if #available(macOS 27, *) { + XCTAssertIdentical(button.target as AnyObject?, recipient) + XCTAssertEqual(button.action, #selector(ActionRecipient.recordAction(_:))) + } else { + XCTAssertNotIdentical(button.target as AnyObject?, recipient) + } + } + + /// A text field reports every keystroke through the notification on every OS version, which is + /// why `valueChanged` is deliberately not registered for text fields. + @MainActor + func testTextFieldWritesBackOnEveryEdit() { + @UIBinding var text = "" + let textField = NSTextField(text: $text) + + textField.stringValue = "h" + NotificationCenter.default.post( + name: NSControl.textDidChangeNotification, object: textField + ) + XCTAssertEqual(text, "h") + + textField.stringValue = "hi" + NotificationCenter.default.post( + name: NSControl.textDidChangeNotification, object: textField + ) + XCTAssertEqual(text, "hi") + } + } +#endif