import SwiftUI import Combine /// Per-screen observable state that drives the notch UI. @MainActor class NotchViewModel: ObservableObject { private static let minimumOpenWidth: CGFloat = 320 private static let minimumOpenHeight: CGFloat = 140 private static let windowHorizontalPadding: CGFloat = 40 private static let windowVerticalPadding: CGFloat = 20 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 @Published var isUserResizing: Bool = false @Published var isPresetResizing: 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)? var requestWindowResize: (() -> 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 @AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeAnimationDurationSetting = NotchSettings.Defaults.resizeAnimationDuration private var closeTransitionTask: Task? var openAnimation: Animation { .spring(response: openSpringResponse, dampingFraction: openSpringDamping) } var closeAnimation: Animation { .spring(response: closeSpringResponse, dampingFraction: closeSpringDamping) } var openAnimationDuration: TimeInterval { max(0.05, resizeAnimationDurationSetting) } 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() { let size = openNotchSize openWidth = size.width openHeight = size.height notchSize = size 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 { clampedOpenSize(CGSize(width: openWidth, height: openHeight)) } func beginInteractiveResize() { isUserResizing = true } func resizeOpenNotch(to proposedSize: CGSize) { setOpenSize(proposedSize, notifyWindowResize: true) } func endInteractiveResize() { isUserResizing = false } func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) { setOpenSize(preset.size, notifyWindowResize: notifyWindowResize) } @discardableResult func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize { let clampedSize = clampedOpenSize(proposedSize) openWidth = clampedSize.width openHeight = clampedSize.height return clampedSize } @discardableResult func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize { let clampedSize = setStoredOpenSize(proposedSize) if notchState == .open { notchSize = clampedSize } if notifyWindowResize { requestWindowResize?() } return clampedSize } private func clampedOpenSize(_ size: CGSize) -> CGSize { CGSize( width: size.width.clamped(to: Self.minimumOpenWidth...maximumAllowedWidth), height: size.height.clamped(to: Self.minimumOpenHeight...maximumAllowedHeight) ) } private var maximumAllowedWidth: CGFloat { guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else { return Self.minimumOpenWidth } return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding) } private var maximumAllowedHeight: CGFloat { guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else { return Self.minimumOpenHeight } return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding) } 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() } } private extension CGFloat { func clamped(to range: ClosedRange) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } }