import AppKit import SwiftUI typealias ScreenID = String /// Observable screen-local UI state for one physical display. @MainActor final class ScreenContext: ObservableObject, Identifiable { 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 id: ScreenID @Published var workspaceID: WorkspaceID @Published var notchState: NotchState = .closed @Published var notchSize: CGSize @Published var closedNotchSize: CGSize @Published var isHovering = false @Published var isCloseTransitionActive = false @Published var suppressHoverOpenUntilHoverExit = false @Published var isUserResizing = false @Published var isPresetResizing = false @Published private(set) var suppressCloseOnFocusLoss = false var requestOpen: (() -> Void)? var requestClose: (() -> Void)? var requestWindowResize: (() -> Void)? var requestTerminalFocus: (() -> Void)? private let settingsController: AppSettingsController private let screenProvider: @MainActor (ScreenID) -> NSScreen? init( id: ScreenID, workspaceID: WorkspaceID, settingsController: AppSettingsController? = nil, screenProvider: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in NSScreen.screens.first { $0.displayUUID == screenID } } ) { self.id = id self.workspaceID = workspaceID self.settingsController = settingsController ?? AppSettingsController.shared self.screenProvider = screenProvider let closed = Self.resolveClosedNotchSize( for: id, using: self.settingsController.settings.display, screenProvider: screenProvider ) self.closedNotchSize = closed self.notchSize = closed } var openAnimation: Animation { let animation = settingsController.settings.animation return .spring( response: animation.openSpringResponse, dampingFraction: animation.openSpringDamping ) } var closeAnimation: Animation { let animation = settingsController.settings.animation return .spring( response: animation.closeSpringResponse, dampingFraction: animation.closeSpringDamping ) } var openAnimationDuration: TimeInterval { max(0.05, settingsController.settings.animation.resizeAnimationDuration) } func open() { notchSize = openNotchSize notchState = .open } func close() { refreshClosedSize() notchSize = closedNotchSize notchState = .closed } func updateWorkspace(id: WorkspaceID) { guard workspaceID != id else { return } workspaceID = id } func refreshClosedSize() { closedNotchSize = Self.resolveClosedNotchSize( for: id, using: settingsController.settings.display, screenProvider: screenProvider ) } var openNotchSize: CGSize { let display = settingsController.settings.display return clampedOpenSize( CGSize(width: display.openWidth, height: display.openHeight) ) } func beginInteractiveResize() { isUserResizing = true } func resizeOpenNotch(to proposedSize: CGSize) { let clampedSize = clampedOpenSize(proposedSize) if notchState == .open { notchSize = clampedSize } requestWindowResize?() } func endInteractiveResize() { if notchState == .open { settingsController.update { $0.display.openWidth = notchSize.width $0.display.openHeight = notchSize.height } } isUserResizing = false } func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) { setOpenSize(preset.size, notifyWindowResize: notifyWindowResize) } @discardableResult func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize { let clampedSize = clampedOpenSize(proposedSize) settingsController.update { $0.display.openWidth = clampedSize.width $0.display.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 = resolvedScreen() ?? NSScreen.main else { return Self.minimumOpenWidth } return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding) } private var maximumAllowedHeight: CGFloat { guard let screen = resolvedScreen() ?? NSScreen.main else { return Self.minimumOpenHeight } return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding) } var closeInteractionLockDuration: TimeInterval { max(settingsController.settings.animation.closeSpringResponse + 0.2, 0.35) } func beginCloseTransition() { isCloseTransitionActive = true if isHovering { suppressHoverOpenUntilHoverExit = true } } func cancelCloseTransition() { isCloseTransitionActive = false } func endCloseTransition() { isCloseTransitionActive = false } func clearHoverOpenSuppression() { suppressHoverOpenUntilHoverExit = false } func setCloseOnFocusLossSuppressed(_ suppressed: Bool) { suppressCloseOnFocusLoss = suppressed } private func resolvedScreen() -> NSScreen? { screenProvider(id) } private static func resolveClosedNotchSize( for screenID: ScreenID, using settings: AppSettings.DisplaySettings, screenProvider: @escaping @MainActor (ScreenID) -> NSScreen? ) -> CGSize { let screen = screenProvider(screenID) ?? NSScreen.main return screen?.closedNotchSize(using: settings) ?? CGSize(width: 220, height: 32) } } private extension CGFloat { func clamped(to range: ClosedRange) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } }