269 lines
8.3 KiB
Swift
269 lines
8.3 KiB
Swift
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<AnyCancellable>()
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|