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(set) var windows: [String: NotchWindow] = [:] private(set) var viewModels: [String: NotchViewModel] = [:] @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.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) } } } // 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 shadowPadding: CGFloat = 20 let openSize = vm.openNotchSize let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5) let windowHeight = openSize.height + shadowPadding let windowRect = NSRect( x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2, y: screen.frame.origin.y + screen.frame.height - windowHeight, width: windowWidth, height: windowHeight ) let window = NotchWindow( contentRect: windowRect, 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) } let hostingView = NSHostingView( rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared) .preferredColorScheme(.dark) ) hostingView.frame = NSRect(origin: .zero, size: windowRect.size) window.contentView = hostingView window.setFrame(windowRect, display: true) window.orderFrontRegardless() windows[uuid] = window viewModels[uuid] = vm } // 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() let shadowPadding: CGFloat = 20 let openSize = vm.openNotchSize let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5) let windowHeight = openSize.height + shadowPadding let newFrame = NSRect( x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2, y: screen.frame.origin.y + screen.frame.height - windowHeight, width: windowWidth, height: windowHeight ) window.setFrame(newFrame, display: true) } } // MARK: - Cleanup private func cleanupAllWindows() { 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) } } }