292 lines
9.4 KiB
Swift
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))
|
|
}
|
|
}
|