223 lines
6.7 KiB
Swift
223 lines
6.7 KiB
Swift
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>) -> CGFloat {
|
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
|
}
|
|
}
|