Files
downterm/CommandNotch/CommandNotch/Models/ScreenContext.swift
2026-03-13 21:26:06 +11:00

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)
}
}