import SwiftUI import Combine /// Per-screen observable state that drives the notch UI. @MainActor class NotchViewModel: ObservableObject { let screenUUID: String @Published var notchState: NotchState = .closed @Published var notchSize: CGSize @Published var closedNotchSize: CGSize @Published var isHovering: Bool = false @Published var isCloseTransitionActive: Bool = false @Published var suppressHoverOpenUntilHoverExit: Bool = false let terminalManager = TerminalManager.shared /// Set by ScreenManager — routes open/close through proper /// window activation so the terminal receives keyboard input. var requestOpen: (() -> Void)? var requestClose: (() -> Void)? private var cancellables = Set() @AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth @AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight @AppStorage(NotchSettings.Keys.openSpringResponse) private var openSpringResponse = NotchSettings.Defaults.openSpringResponse @AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping @AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping private var closeTransitionTask: Task? var openAnimation: Animation { .spring(response: openSpringResponse, dampingFraction: openSpringDamping) } var closeAnimation: Animation { .spring(response: closeSpringResponse, dampingFraction: closeSpringDamping) } init(screenUUID: String) { self.screenUUID = screenUUID let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main let closed = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32) self.closedNotchSize = closed self.notchSize = closed } func open() { notchSize = CGSize(width: openWidth, height: openHeight) notchState = .open } func close() { refreshClosedSize() notchSize = closedNotchSize notchState = .closed } func refreshClosedSize() { let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main closedNotchSize = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32) } var openNotchSize: CGSize { CGSize(width: openWidth, height: openHeight) } var closeInteractionLockDuration: TimeInterval { max(closeSpringResponse + 0.2, 0.35) } func beginCloseTransition() { closeTransitionTask?.cancel() isCloseTransitionActive = true if isHovering { suppressHoverOpenUntilHoverExit = true } let delay = closeInteractionLockDuration closeTransitionTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard let self, !Task.isCancelled else { return } self.isCloseTransitionActive = false self.closeTransitionTask = nil } } func cancelCloseTransition() { closeTransitionTask?.cancel() closeTransitionTask = nil isCloseTransitionActive = false } func clearHoverOpenSuppression() { suppressHoverOpenUntilHoverExit = false } deinit { closeTransitionTask?.cancel() } }