diff --git a/Package.swift b/Package.swift index 68c99e66f..0210bad71 100644 --- a/Package.swift +++ b/Package.swift @@ -82,9 +82,13 @@ let package = Package( .target( name: "AppKitNavigation", dependencies: [ - "SwiftNavigation" + "SwiftNavigation", + "AppKitNavigationShim", ] ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 2edac05b6..1859da950 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 2dab48e14..b0e8a2cd8 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 000000000..1df9f50ed --- /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 000000000..0d053d6f0 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -0,0 +1,416 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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( + 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?() + } + } + + // 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?() + } + } + } + + 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 000000000..676b8857b --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -0,0 +1,35 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +protocol ModalContent: NavigationContent { + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +extension NSWindow: ModalContent { + var window: NSWindow { self } + + 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 { + 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 000000000..a897018c9 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -0,0 +1,16 @@ +#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 new file mode 100644 index 000000000..bc63b3344 --- /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 000000000..2e35321c1 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -0,0 +1,38 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +@MainActor +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 { + 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 + } + } +} + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift new file mode 100644 index 000000000..488cf6b4b --- /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 000000000..fbd5f4677 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -0,0 +1,194 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftNavigation +import AppKit +import AppKitNavigationShim + +class PresentationObserver: NavigationObserver { + 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( + 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) + } + + @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?() + } + } + + @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, + 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 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 { + var _onEndNavigation: (() -> Void)? { + set { + _AppKitNavigation_onDismiss = newValue + } + get { + _AppKitNavigation_onDismiss + } + } +} + +extension Navigated where Content: NSViewController { + func clearup() { + content?.dismiss(nil) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift new file mode 100644 index 000000000..ba9cbd184 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -0,0 +1,275 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +private typealias SheetObserver = NavigationObserver + +@MainActor +private var sheetObserverKeys = AssociatedKeys() + +extension NSWindow { + @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) + } + + @_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 + 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) + } + + @_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 (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 Navigated where Content: SheetContent { + 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 new file mode 100644 index 000000000..3da396258 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -0,0 +1,52 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +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 { + 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 } +} + +extension NSAlert: SheetContent { + var currentWindow: NSWindow? { 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) + } +} + +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) + } +} + +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100644 index 000000000..a19cbd8f5 --- /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 _AppKitNavigation_hasViewAppeared; +@property (nullable) void (^ _AppKitNavigation_onDismiss)(); +@property NSArray *_AppKitNavigation_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 000000000..9ebf4859b --- /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._AppKitNavigation_hasViewAppeared) { + return; + } + + self._AppKitNavigation_hasViewAppeared = YES; + + for (void (^work)() in self._AppKitNavigation_onViewAppear) { + work(); + } + + self._AppKitNavigation_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._AppKitNavigation_onDismiss != NULL) { + self._AppKitNavigation_onDismiss(); + self._AppKitNavigation_onDismiss = nil; + [self setBeingDismissed:NO]; + } +} + +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} + +- (BOOL)_AppKitNavigation_hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} + +- (void)set_AppKitNavigation_hasViewAppeared:(BOOL)_AppKitNavigation_hasViewAppeared { + objc_setAssociatedObject( + self, hasViewAppearedKey, @(_AppKitNavigation_hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + ); +} + +- (void (^)())_AppKitNavigation_onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} + +- (void)set_AppKitNavigation_onDismiss:(void (^)())_AppKitNavigation_onDismiss { + objc_setAssociatedObject(self, onDismissKey, [_AppKitNavigation_onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (NSMutableArray *)_AppKitNavigation_onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); + + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)set_AppKitNavigation_onViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +@end +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */