Yep. AI rewrote the whole thing.
This commit is contained in:
@@ -1,32 +1,29 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// 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.
|
||||
/// Coordinates screen/workspace state with notch lifecycle and
|
||||
/// delegates raw window work to `WindowCoordinator`.
|
||||
@MainActor
|
||||
class ScreenManager: ObservableObject {
|
||||
|
||||
final 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 let screenRegistry = ScreenRegistry.shared
|
||||
private let windowCoordinator = WindowCoordinator()
|
||||
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {}
|
||||
|
||||
private var showOnAllDisplays: Bool {
|
||||
AppSettingsController.shared.settings.display.showOnAllDisplays
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start() {
|
||||
screenRegistry.refreshConnectedScreens()
|
||||
observeScreenChanges()
|
||||
rebuildWindows()
|
||||
setupHotkeys()
|
||||
@@ -41,94 +38,54 @@ class ScreenManager: ObservableObject {
|
||||
// MARK: - Hotkey wiring
|
||||
|
||||
private func setupHotkeys() {
|
||||
let hk = HotkeyManager.shared
|
||||
let tm = TerminalManager.shared
|
||||
let hotkeyManager = HotkeyManager.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() }
|
||||
hotkeyManager.onToggle = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.orchestrator.toggleOnActiveScreen() }
|
||||
}
|
||||
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
|
||||
hotkeyManager.onNewTab = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.activeWorkspace().newTab() }
|
||||
}
|
||||
hotkeyManager.onCloseTab = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.activeWorkspace().closeActiveTab() }
|
||||
}
|
||||
hotkeyManager.onNextTab = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.activeWorkspace().nextTab() }
|
||||
}
|
||||
hotkeyManager.onPreviousTab = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
|
||||
}
|
||||
hotkeyManager.onDetachTab = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.detachActiveTab() }
|
||||
}
|
||||
hk.onApplySizePreset = { [weak self] preset in
|
||||
hotkeyManager.onApplySizePreset = { [weak self] preset in
|
||||
MainActor.assumeIsolated { self?.applySizePreset(preset) }
|
||||
}
|
||||
hk.onSwitchToTab = { index in
|
||||
MainActor.assumeIsolated { tm.switchToTab(at: index) }
|
||||
hotkeyManager.onSwitchToTab = { [weak self] index in
|
||||
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
|
||||
}
|
||||
|
||||
hk.start()
|
||||
hotkeyManager.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)
|
||||
}
|
||||
}
|
||||
orchestrator.toggleOnActiveScreen()
|
||||
}
|
||||
|
||||
// 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 openNotch(screenID: ScreenID) {
|
||||
orchestrator.open(screenID: screenID)
|
||||
}
|
||||
|
||||
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
|
||||
func closeNotch(screenID: ScreenID) {
|
||||
orchestrator.close(screenID: screenID)
|
||||
}
|
||||
|
||||
private func detachActiveTab() {
|
||||
if let session = TerminalManager.shared.detachActiveTab() {
|
||||
if let session = activeWorkspace().detachActiveTab() {
|
||||
DispatchQueue.main.async {
|
||||
PopoutWindowController.shared.popout(session: session)
|
||||
}
|
||||
@@ -136,235 +93,105 @@ class ScreenManager: ObservableObject {
|
||||
}
|
||||
|
||||
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)
|
||||
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
|
||||
AppSettingsController.shared.update {
|
||||
$0.display.openWidth = preset.width
|
||||
$0.display.openHeight = preset.height
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let startSize = vm.notchSize
|
||||
let targetSize = vm.setStoredOpenSize(preset.size)
|
||||
animatePresetResize(for: screenUUID, from: startSize, to: targetSize, duration: vm.openAnimationDuration)
|
||||
let startSize = context.notchSize
|
||||
let targetSize = context.setStoredOpenSize(preset.size)
|
||||
windowCoordinator.animatePresetResize(
|
||||
for: context.id,
|
||||
context: context,
|
||||
from: startSize,
|
||||
to: targetSize,
|
||||
duration: context.openAnimationDuration
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Window creation
|
||||
|
||||
func rebuildWindows() {
|
||||
cleanupAllWindows()
|
||||
screenRegistry.refreshConnectedScreens()
|
||||
|
||||
let screens: [NSScreen]
|
||||
if showOnAllDisplays {
|
||||
screens = NSScreen.screens
|
||||
} else {
|
||||
screens = [NSScreen.main].compactMap { $0 }
|
||||
}
|
||||
for screen in screens {
|
||||
for screen in visibleScreens() {
|
||||
createWindow(for: screen)
|
||||
}
|
||||
}
|
||||
|
||||
private func createWindow(for screen: NSScreen) {
|
||||
let uuid = screen.displayUUID
|
||||
let vm = NotchViewModel(screenUUID: uuid)
|
||||
let initialContentSize = vm.openNotchSize
|
||||
let screenID = screen.displayUUID
|
||||
guard let context = screenRegistry.screenContext(for: screenID) else { return }
|
||||
|
||||
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
|
||||
)
|
||||
context.requestOpen = { [weak self] in
|
||||
self?.orchestrator.open(screenID: screenID)
|
||||
}
|
||||
context.requestClose = { [weak self] in
|
||||
self?.orchestrator.close(screenID: screenID)
|
||||
}
|
||||
context.requestWindowResize = { [weak self] in
|
||||
guard let self,
|
||||
let context = self.screenRegistry.screenContext(for: screenID) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Close the notch when the window loses focus
|
||||
window.onResignKey = { [weak self] in
|
||||
self?.closeNotch(screenUUID: uuid)
|
||||
self.windowCoordinator.updateWindowFrame(
|
||||
for: screenID,
|
||||
context: context,
|
||||
centerHorizontally: true
|
||||
)
|
||||
}
|
||||
context.requestTerminalFocus = { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
// 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)
|
||||
self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in
|
||||
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
|
||||
}
|
||||
}
|
||||
|
||||
let hostingView = NSHostingView(
|
||||
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
|
||||
.preferredColorScheme(.dark)
|
||||
rootView: ContentView(
|
||||
screen: context,
|
||||
orchestrator: orchestrator
|
||||
)
|
||||
.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()
|
||||
windowCoordinator.createWindow(
|
||||
on: screen,
|
||||
context: context,
|
||||
contentView: hostingView,
|
||||
onResignKey: { [weak self] in
|
||||
guard !context.suppressCloseOnFocusLoss else { return }
|
||||
self?.orchestrator.close(screenID: screenID)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
screenRegistry.refreshConnectedScreens()
|
||||
|
||||
vm.refreshClosedSize()
|
||||
|
||||
updateWindowFrame(for: uuid, on: screen, window: window, centerHorizontally: true)
|
||||
for context in screenRegistry.allScreens() {
|
||||
context.refreshClosedSize()
|
||||
windowCoordinator.repositionWindow(
|
||||
for: context.id,
|
||||
context: context,
|
||||
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()
|
||||
orchestrator.cancelAllPendingWork()
|
||||
windowCoordinator.cleanupAllWindows()
|
||||
}
|
||||
|
||||
// MARK: - Screen observation
|
||||
@@ -372,33 +199,62 @@ class ScreenManager: ObservableObject {
|
||||
private func observeScreenChanges() {
|
||||
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
|
||||
.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 {
|
||||
screenRegistry.refreshConnectedScreens()
|
||||
|
||||
let currentScreenIDs = Set(visibleScreens().map(\.displayUUID))
|
||||
let knownScreenIDs = windowCoordinator.windowScreenIDs()
|
||||
|
||||
if currentScreenIDs != knownScreenIDs {
|
||||
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 }
|
||||
private func activeWorkspace() -> WorkspaceController {
|
||||
guard let screenID = screenRegistry.activeScreenID() else {
|
||||
return WorkspaceRegistry.shared.defaultWorkspaceController
|
||||
}
|
||||
|
||||
if terminalView.window === window {
|
||||
window.makeFirstResponder(terminalView)
|
||||
return screenRegistry.workspaceController(for: screenID)
|
||||
}
|
||||
|
||||
private func visibleScreens() -> [NSScreen] {
|
||||
if showOnAllDisplays {
|
||||
return NSScreen.screens
|
||||
}
|
||||
|
||||
return [NSScreen.main].compactMap { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
extension ScreenManager: NotchPresentationHost {
|
||||
func canPresentNotch(for screenID: ScreenID) -> Bool {
|
||||
windowCoordinator.hasWindow(for: screenID)
|
||||
}
|
||||
|
||||
func performOpenPresentation(for screenID: ScreenID) {
|
||||
guard screenRegistry.screenContext(for: screenID) != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
|
||||
self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1)
|
||||
windowCoordinator.presentOpen(for: screenID) { [weak self] in
|
||||
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
|
||||
}
|
||||
}
|
||||
|
||||
func performClosePresentation(for screenID: ScreenID) {
|
||||
guard screenRegistry.screenContext(for: screenID) != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
windowCoordinator.presentClose(for: screenID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user