import AppKit import SwiftUI import Combine /// Manages one NotchWindow per connected display. /// Routes all open/close through centralized methods that handle /// window activation, key status, and first responder assignment /// so the terminal can receive keyboard input. @MainActor class ScreenManager: ObservableObject { static let shared = ScreenManager() private let focusRetryDelay: TimeInterval = 0.01 private let presetResizeFrameInterval: TimeInterval = 1.0 / 60.0 private(set) var windows: [String: NotchWindow] = [:] private(set) var viewModels: [String: NotchViewModel] = [:] private var presetResizeTimers: [String: Timer] = [:] @AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays private var cancellables = Set() private init() {} // MARK: - Lifecycle func start() { observeScreenChanges() rebuildWindows() setupHotkeys() } func stop() { cleanupAllWindows() cancellables.removeAll() HotkeyManager.shared.stop() } // MARK: - Hotkey wiring private func setupHotkeys() { let hk = HotkeyManager.shared let tm = TerminalManager.shared // Callbacks are invoked on the main thread by HotkeyManager. // MainActor.assumeIsolated lets us safely call @MainActor methods. hk.onToggle = { [weak self] in MainActor.assumeIsolated { self?.toggleNotchOnActiveScreen() } } hk.onNewTab = { MainActor.assumeIsolated { tm.newTab() } } hk.onCloseTab = { MainActor.assumeIsolated { tm.closeActiveTab() } } hk.onNextTab = { MainActor.assumeIsolated { tm.nextTab() } } hk.onPreviousTab = { MainActor.assumeIsolated { tm.previousTab() } } hk.onDetachTab = { [weak self] in MainActor.assumeIsolated { self?.detachActiveTab() } } hk.onApplySizePreset = { [weak self] preset in MainActor.assumeIsolated { self?.applySizePreset(preset) } } hk.onSwitchToTab = { index in MainActor.assumeIsolated { tm.switchToTab(at: index) } } hk.start() } // MARK: - Toggle func toggleNotchOnActiveScreen() { let mouseLocation = NSEvent.mouseLocation let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } ?? NSScreen.main guard let screen = targetScreen else { return } let uuid = screen.displayUUID // Close any other open notch first for (otherUUID, otherVM) in viewModels where otherUUID != uuid { if otherVM.notchState == .open { closeNotch(screenUUID: otherUUID) } } if let vm = viewModels[uuid] { if vm.notchState == .open { closeNotch(screenUUID: uuid) } else { openNotch(screenUUID: uuid) } } } // MARK: - Open / Close func openNotch(screenUUID: String) { guard let vm = viewModels[screenUUID], let window = windows[screenUUID] else { return } vm.cancelCloseTransition() withAnimation(vm.openAnimation) { vm.open() } window.isNotchOpen = true HotkeyManager.shared.isNotchOpen = true // Activate the app so the window can become key. NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) focusActiveTerminal(in: screenUUID) } func closeNotch(screenUUID: String) { guard let vm = viewModels[screenUUID], let window = windows[screenUUID] else { return } vm.beginCloseTransition() withAnimation(vm.closeAnimation) { vm.close() } window.isNotchOpen = false HotkeyManager.shared.isNotchOpen = false } private func detachActiveTab() { if let session = TerminalManager.shared.detachActiveTab() { DispatchQueue.main.async { PopoutWindowController.shared.popout(session: session) } } } func applySizePreset(_ preset: TerminalSizePreset) { guard let (screenUUID, vm) = viewModels.first(where: { $0.value.notchState == .open }) else { UserDefaults.standard.set(preset.width, forKey: NotchSettings.Keys.openWidth) UserDefaults.standard.set(preset.height, forKey: NotchSettings.Keys.openHeight) return } let startSize = vm.notchSize let targetSize = vm.setStoredOpenSize(preset.size) animatePresetResize(for: screenUUID, from: startSize, to: targetSize, duration: vm.openAnimationDuration) } // MARK: - Window creation func rebuildWindows() { cleanupAllWindows() let screens: [NSScreen] if showOnAllDisplays { screens = NSScreen.screens } else { screens = [NSScreen.main].compactMap { $0 } } for screen in screens { createWindow(for: screen) } } private func createWindow(for screen: NSScreen) { let uuid = screen.displayUUID let vm = NotchViewModel(screenUUID: uuid) let initialContentSize = vm.openNotchSize let window = NotchWindow( contentRect: NSRect(origin: .zero, size: CGSize(width: initialContentSize.width + 40, height: initialContentSize.height + 20)), styleMask: [.borderless, .nonactivatingPanel, .utilityWindow], backing: .buffered, defer: false ) // Close the notch when the window loses focus window.onResignKey = { [weak self] in self?.closeNotch(screenUUID: uuid) } // Wire the ViewModel callbacks so ContentView routes through us vm.requestOpen = { [weak self] in self?.openNotch(screenUUID: uuid) } vm.requestClose = { [weak self] in self?.closeNotch(screenUUID: uuid) } vm.requestWindowResize = { [weak self] in self?.updateWindowFrame(for: uuid, centerHorizontally: true) } let hostingView = NSHostingView( rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared) .preferredColorScheme(.dark) ) let containerView = NSView(frame: NSRect(origin: .zero, size: window.frame.size)) containerView.autoresizesSubviews = true containerView.wantsLayer = true containerView.layer?.backgroundColor = NSColor.clear.cgColor hostingView.frame = containerView.bounds hostingView.autoresizingMask = [.width, .height] containerView.addSubview(hostingView) window.contentView = containerView windows[uuid] = window viewModels[uuid] = vm updateWindowFrame(for: uuid, centerHorizontally: true) window.orderFrontRegardless() } // MARK: - Repositioning func repositionWindows() { for (uuid, window) in windows { guard let screen = NSScreen.screens.first(where: { $0.displayUUID == uuid }) else { continue } guard let vm = viewModels[uuid] else { continue } vm.refreshClosedSize() updateWindowFrame(for: uuid, on: screen, window: window, centerHorizontally: true) } } private func updateWindowFrame(for screenUUID: String, centerHorizontally: Bool = false) { guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }), let window = windows[screenUUID] else { return } updateWindowFrame(for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally) } private func updateWindowFrame( for screenUUID: String, on screen: NSScreen, window: NotchWindow, centerHorizontally: Bool = false ) { let frame = targetWindowFrame( for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally, contentSize: nil ) guard !window.frame.equalTo(frame) else { return } window.setFrame(frame, display: false) } private func targetWindowFrame( for screenUUID: String, on screen: NSScreen, window: NotchWindow, centerHorizontally: Bool, contentSize: CGSize? ) -> NSRect { guard let vm = viewModels[screenUUID] else { return window.frame } let shadowPadding: CGFloat = 20 let openSize = contentSize ?? vm.openNotchSize let windowWidth = openSize.width + 40 let windowHeight = openSize.height + shadowPadding let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2 let x: CGFloat = centerHorizontally || vm.notchState == .closed ? centeredX : min(max(window.frame.minX, screen.frame.minX), screen.frame.maxX - windowWidth) return NSRect( x: x, y: screen.frame.origin.y + screen.frame.height - windowHeight, width: windowWidth, height: windowHeight ) } private func animatePresetResize( for screenUUID: String, from startSize: CGSize, to targetSize: CGSize, duration: TimeInterval ) { cancelPresetResize(for: screenUUID) guard let vm = viewModels[screenUUID] else { return } guard startSize != targetSize else { vm.notchSize = targetSize updateWindowFrame(for: screenUUID, centerHorizontally: true) return } vm.isPresetResizing = true let startTime = CACurrentMediaTime() let duration = max(duration, presetResizeFrameInterval) let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in MainActor.assumeIsolated { guard let self, let vm = self.viewModels[screenUUID] else { timer.invalidate() return } let elapsed = CACurrentMediaTime() - startTime let progress = min(1, elapsed / duration) 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) ) vm.notchSize = size self.updateWindowFrame(for: screenUUID, contentSize: size, centerHorizontally: true) if progress >= 1 { vm.notchSize = targetSize vm.isPresetResizing = false self.updateWindowFrame(for: screenUUID, contentSize: targetSize, centerHorizontally: true) self.presetResizeTimers[screenUUID] = nil timer.invalidate() } } } presetResizeTimers[screenUUID] = timer RunLoop.main.add(timer, forMode: .common) timer.fire() } private func cancelPresetResize(for screenUUID: String) { presetResizeTimers[screenUUID]?.invalidate() presetResizeTimers[screenUUID] = nil viewModels[screenUUID]?.isPresetResizing = false } private func updateWindowFrame( for screenUUID: String, contentSize: CGSize, centerHorizontally: Bool = false ) { guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }), let window = windows[screenUUID] else { return } let frame = targetWindowFrame( for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally, contentSize: contentSize ) guard !window.frame.equalTo(frame) else { return } window.setFrame(frame, display: false) } // MARK: - Cleanup private func cleanupAllWindows() { for (_, timer) in presetResizeTimers { timer.invalidate() } presetResizeTimers.removeAll() for (_, window) in windows { window.orderOut(nil) window.close() } windows.removeAll() viewModels.removeAll() } // MARK: - Screen observation private func observeScreenChanges() { NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .sink { [weak self] _ in self?.handleScreenConfigurationChange() } .store(in: &cancellables) } private func handleScreenConfigurationChange() { let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID }) let knownUUIDs = Set(windows.keys) if currentUUIDs != knownUUIDs { rebuildWindows() } else { repositionWindows() } } private func focusActiveTerminal(in screenUUID: String, attemptsRemaining: Int = 12) { guard let window = windows[screenUUID], let terminalView = TerminalManager.shared.activeTab?.terminalView else { return } if terminalView.window === window { window.makeFirstResponder(terminalView) return } guard attemptsRemaining > 0 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1) } } }