171 lines
5.9 KiB
Swift
171 lines
5.9 KiB
Swift
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
|
|
|
|
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<AnyCancellable>()
|
|
|
|
@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<Void, Never>?
|
|
|
|
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() {
|
|
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 setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize {
|
|
let clampedSize = clampedOpenSize(proposedSize)
|
|
openWidth = clampedSize.width
|
|
openHeight = clampedSize.height
|
|
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>) -> CGFloat {
|
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
|
}
|
|
}
|