import Combine import SwiftUI @MainActor protocol ScreenRegistryType: AnyObject { func allScreens() -> [ScreenContext] func screenContext(for id: ScreenID) -> ScreenContext? func activeScreenID() -> ScreenID? func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? @discardableResult func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? func releaseWorkspacePresentation(for screenID: ScreenID) } @MainActor protocol NotchPresentationHost: AnyObject { func canPresentNotch(for screenID: ScreenID) -> Bool func performOpenPresentation(for screenID: ScreenID) func performClosePresentation(for screenID: ScreenID) } protocol SchedulerType { @MainActor func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable } struct TaskScheduler: SchedulerType { @MainActor func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable { let task = Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) guard !Task.isCancelled else { return } action() } return AnyCancellable { task.cancel() } } } @MainActor final class NotchOrchestrator { private let screenRegistry: any ScreenRegistryType private weak var host: (any NotchPresentationHost)? private let settingsController: AppSettingsController private let scheduler: any SchedulerType private var hoverOpenTasks: [ScreenID: AnyCancellable] = [:] private var closeTransitionTasks: [ScreenID: AnyCancellable] = [:] init( screenRegistry: any ScreenRegistryType, host: any NotchPresentationHost, settingsController: AppSettingsController? = nil, scheduler: (any SchedulerType)? = nil ) { self.screenRegistry = screenRegistry self.host = host self.settingsController = settingsController ?? AppSettingsController.shared self.scheduler = scheduler ?? TaskScheduler() } func toggleOnActiveScreen() { guard let screenID = screenRegistry.activeScreenID(), host?.canPresentNotch(for: screenID) == true, let context = screenRegistry.screenContext(for: screenID) else { return } if context.notchState == .open { close(screenID: screenID) } else { open(screenID: screenID) } } func open(screenID: ScreenID) { guard host?.canPresentNotch(for: screenID) == true, let context = screenRegistry.screenContext(for: screenID) else { return } if let presentingScreenID = screenRegistry.presentingScreenID(for: context.workspaceID), presentingScreenID != screenID { close(screenID: presentingScreenID) } cancelHoverOpen(for: screenID) cancelCloseTransition(for: screenID) context.cancelCloseTransition() withAnimation(context.openAnimation) { context.open() } _ = screenRegistry.claimWorkspacePresentation(for: screenID) host?.performOpenPresentation(for: screenID) } func close(screenID: ScreenID) { guard let context = screenRegistry.screenContext(for: screenID) else { return } cancelHoverOpen(for: screenID) cancelCloseTransition(for: screenID) context.beginCloseTransition() closeTransitionTasks[screenID] = scheduler.schedule(after: context.closeInteractionLockDuration) { [weak self] in self?.finishCloseTransition(for: screenID) } withAnimation(context.closeAnimation) { context.close() } screenRegistry.releaseWorkspacePresentation(for: screenID) host?.performClosePresentation(for: screenID) } func handleHoverChange(_ hovering: Bool, for screenID: ScreenID) { guard let context = screenRegistry.screenContext(for: screenID) else { return } context.isHovering = hovering if hovering { scheduleHoverOpenIfNeeded(for: screenID) } else { cancelHoverOpen(for: screenID) context.clearHoverOpenSuppression() } } func cancelAllPendingWork() { for task in hoverOpenTasks.values { task.cancel() } for task in closeTransitionTasks.values { task.cancel() } hoverOpenTasks.removeAll() closeTransitionTasks.removeAll() } private func scheduleHoverOpenIfNeeded(for screenID: ScreenID) { cancelHoverOpen(for: screenID) guard let context = screenRegistry.screenContext(for: screenID) else { return } guard settingsController.settings.behavior.openNotchOnHover, context.notchState == .closed, !context.isCloseTransitionActive, !context.suppressHoverOpenUntilHoverExit, context.isHovering else { return } hoverOpenTasks[screenID] = scheduler.schedule(after: settingsController.settings.behavior.minimumHoverDuration) { [weak self] in guard let self, let context = self.screenRegistry.screenContext(for: screenID), context.isHovering, context.notchState == .closed, !context.isCloseTransitionActive, !context.suppressHoverOpenUntilHoverExit else { return } self.hoverOpenTasks[screenID] = nil self.open(screenID: screenID) } } private func finishCloseTransition(for screenID: ScreenID) { closeTransitionTasks[screenID] = nil guard let context = screenRegistry.screenContext(for: screenID) else { return } context.endCloseTransition() scheduleHoverOpenIfNeeded(for: screenID) } private func cancelHoverOpen(for screenID: ScreenID) { hoverOpenTasks[screenID]?.cancel() hoverOpenTasks[screenID] = nil } private func cancelCloseTransition(for screenID: ScreenID) { closeTransitionTasks[screenID]?.cancel() closeTransitionTasks[screenID] = nil } }