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

292 lines
9.4 KiB
Swift

import AppKit
import QuartzCore
import SwiftUI
struct WindowFrameCalculator {
static let horizontalPadding: CGFloat = 40
static let verticalPadding: CGFloat = 20
static func targetFrame(
screenFrame: CGRect,
currentWindowFrame: CGRect,
notchState: NotchState,
contentSize: CGSize,
centerHorizontally: Bool
) -> CGRect {
let windowWidth = contentSize.width + horizontalPadding
let windowHeight = contentSize.height + verticalPadding
let centeredX = screenFrame.origin.x + ((screenFrame.width - windowWidth) / 2)
let x: CGFloat
if centerHorizontally || notchState == .closed {
x = centeredX
} else {
x = min(
max(currentWindowFrame.minX, screenFrame.minX),
screenFrame.maxX - windowWidth
)
}
return CGRect(
x: x,
y: screenFrame.origin.y + screenFrame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
}
}
@MainActor
final class WindowCoordinator {
private let focusRetryDelay: TimeInterval
private let presetResizeFrameInterval: TimeInterval
private let screenLookup: @MainActor (ScreenID) -> NSScreen?
private let applicationActivator: @MainActor () -> Void
private let hotkeyOpenStateHandler: @MainActor (Bool) -> Void
private(set) var windows: [ScreenID: NotchWindow] = [:]
private var presetResizeTimers: [ScreenID: Timer] = [:]
init(
focusRetryDelay: TimeInterval = 0.01,
presetResizeFrameInterval: TimeInterval = 1.0 / 60.0,
screenLookup: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
NSScreen.screens.first { $0.displayUUID == screenID }
},
applicationActivator: @escaping @MainActor () -> Void = {
NSApp.activate(ignoringOtherApps: true)
},
hotkeyOpenStateHandler: @escaping @MainActor (Bool) -> Void = { isOpen in
HotkeyManager.shared.isNotchOpen = isOpen
}
) {
self.focusRetryDelay = focusRetryDelay
self.presetResizeFrameInterval = presetResizeFrameInterval
self.screenLookup = screenLookup
self.applicationActivator = applicationActivator
self.hotkeyOpenStateHandler = hotkeyOpenStateHandler
}
func hasWindow(for screenID: ScreenID) -> Bool {
windows[screenID] != nil
}
func windowScreenIDs() -> Set<ScreenID> {
Set(windows.keys)
}
func createWindow(
on screen: NSScreen,
context: ScreenContext,
contentView: NSView,
onResignKey: @escaping () -> Void
) {
let initialFrame = WindowFrameCalculator.targetFrame(
screenFrame: screen.frame,
currentWindowFrame: .zero,
notchState: context.notchState,
contentSize: context.openNotchSize,
centerHorizontally: true
)
let window = NotchWindow(
contentRect: initialFrame,
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
window.onResignKey = onResignKey
let containerView = NSView(frame: NSRect(origin: .zero, size: initialFrame.size))
containerView.autoresizesSubviews = true
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
contentView.frame = containerView.bounds
contentView.autoresizingMask = [.width, .height]
containerView.addSubview(contentView)
window.contentView = containerView
windows[context.id] = window
updateWindowFrame(for: context.id, context: context, centerHorizontally: true)
window.orderFrontRegardless()
}
func repositionWindow(for screenID: ScreenID, context: ScreenContext, centerHorizontally: Bool = false) {
updateWindowFrame(for: screenID, context: context, centerHorizontally: centerHorizontally)
}
func updateWindowFrame(
for screenID: ScreenID,
context: ScreenContext,
contentSize: CGSize? = nil,
centerHorizontally: Bool = false
) {
guard let screen = screenLookup(screenID),
let window = windows[screenID] else {
return
}
let frame = WindowFrameCalculator.targetFrame(
screenFrame: screen.frame,
currentWindowFrame: window.frame,
notchState: context.notchState,
contentSize: resolvedContentSize(for: context, override: contentSize),
centerHorizontally: centerHorizontally
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
func animatePresetResize(
for screenID: ScreenID,
context: ScreenContext,
from startSize: CGSize,
to targetSize: CGSize,
duration: TimeInterval
) {
cancelPresetResize(for: screenID)
guard startSize != targetSize else {
context.notchSize = targetSize
updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
return
}
context.isPresetResizing = true
let startTime = CACurrentMediaTime()
let frameInterval = max(duration, presetResizeFrameInterval)
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
MainActor.assumeIsolated {
guard let self else {
timer.invalidate()
return
}
let elapsed = CACurrentMediaTime() - startTime
let progress = min(1, elapsed / frameInterval)
let easedProgress = 0.5 - (cos(.pi * progress) / 2)
let size = CGSize(
width: startSize.width + ((targetSize.width - startSize.width) * easedProgress),
height: startSize.height + ((targetSize.height - startSize.height) * easedProgress)
)
context.notchSize = size
self.updateWindowFrame(for: screenID, context: context, contentSize: size, centerHorizontally: true)
if progress >= 1 {
context.notchSize = targetSize
context.isPresetResizing = false
self.updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
self.presetResizeTimers[screenID] = nil
timer.invalidate()
}
}
}
presetResizeTimers[screenID] = timer
RunLoop.main.add(timer, forMode: .common)
timer.fire()
}
func presentOpen(
for screenID: ScreenID,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
guard let window = windows[screenID] else { return }
window.isNotchOpen = true
updateHotkeyOpenState()
applicationActivator()
window.makeKeyAndOrderFront(nil)
focusActiveTerminal(
in: screenID,
attemptsRemaining: 12,
terminalViewProvider: terminalViewProvider
)
}
func focusActiveTerminal(
for screenID: ScreenID,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
focusActiveTerminal(
in: screenID,
attemptsRemaining: 12,
terminalViewProvider: terminalViewProvider
)
}
func presentClose(for screenID: ScreenID) {
guard let window = windows[screenID] else { return }
window.isNotchOpen = false
updateHotkeyOpenState()
}
func cleanupAllWindows() {
for timer in presetResizeTimers.values {
timer.invalidate()
}
presetResizeTimers.removeAll()
for window in windows.values {
window.orderOut(nil)
window.close()
}
windows.removeAll()
updateHotkeyOpenState()
}
private func focusActiveTerminal(
in screenID: ScreenID,
attemptsRemaining: Int,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
guard let window = windows[screenID],
let terminalView = terminalViewProvider() else {
return
}
if terminalView.window === window {
window.makeFirstResponder(terminalView)
return
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
Task { @MainActor in
self?.focusActiveTerminal(
in: screenID,
attemptsRemaining: attemptsRemaining - 1,
terminalViewProvider: terminalViewProvider
)
}
}
}
private func cancelPresetResize(for screenID: ScreenID) {
presetResizeTimers[screenID]?.invalidate()
presetResizeTimers[screenID] = nil
}
private func resolvedContentSize(for context: ScreenContext, override: CGSize?) -> CGSize {
if let override {
return override
}
return context.notchState == .open ? context.notchSize : context.openNotchSize
}
private func updateHotkeyOpenState() {
hotkeyOpenStateHandler(windows.values.contains(where: \.isNotchOpen))
}
}