Files
downterm/Downterm/CommandNotch/Managers/ScreenManager.swift

298 lines
9.4 KiB
Swift

import AppKit
import Combine
import SwiftUI
/// Coordinates screen/workspace state with notch lifecycle and
/// delegates raw window work to `WindowCoordinator`.
@MainActor
final class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let screenRegistry = ScreenRegistry.shared
private let workspaceRegistry = WorkspaceRegistry.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()
}
func stop() {
cleanupAllWindows()
cancellables.removeAll()
HotkeyManager.shared.stop()
}
// MARK: - Hotkey wiring
private func setupHotkeys() {
let hotkeyManager = HotkeyManager.shared
hotkeyManager.onToggle = { [weak self] in
MainActor.assumeIsolated { self?.orchestrator.toggleOnActiveScreen() }
}
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.onNextWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) }
}
hotkeyManager.onPreviousWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) }
}
hotkeyManager.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
hotkeyManager.onApplySizePreset = { [weak self] preset in
MainActor.assumeIsolated { self?.applySizePreset(preset) }
}
hotkeyManager.onSwitchToTab = { [weak self] index in
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
}
hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in
MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) }
}
hotkeyManager.start()
}
// MARK: - Toggle
func toggleNotchOnActiveScreen() {
orchestrator.toggleOnActiveScreen()
}
// MARK: - Open / Close
func openNotch(screenID: ScreenID) {
orchestrator.open(screenID: screenID)
}
func closeNotch(screenID: ScreenID) {
orchestrator.close(screenID: screenID)
}
private func detachActiveTab() {
if let session = activeWorkspace().detachActiveTab() {
DispatchQueue.main.async {
PopoutWindowController.shared.popout(session: session)
}
}
}
private func switchWorkspace(offset: Int) {
guard let screenID = screenRegistry.activeScreenID() else { return }
let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
let nextWorkspaceID = offset >= 0
? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID)
: workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID)
guard let nextWorkspaceID else { return }
switchScreen(screenID, to: nextWorkspaceID)
}
private func switchActiveScreen(to workspaceID: WorkspaceID) {
guard let screenID = screenRegistry.activeScreenID() else { return }
switchScreen(screenID, to: workspaceID)
}
private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screenID)
guard let context = screenRegistry.screenContext(for: screenID),
context.notchState == .open else {
return
}
orchestrator.open(screenID: screenID)
}
func applySizePreset(_ preset: TerminalSizePreset) {
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 = 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()
for screen in visibleScreens() {
createWindow(for: screen)
}
}
private func createWindow(for screen: NSScreen) {
let screenID = screen.displayUUID
guard let context = screenRegistry.screenContext(for: screenID) else { return }
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
}
self.windowCoordinator.updateWindowFrame(
for: screenID,
context: context,
centerHorizontally: true
)
}
context.requestTerminalFocus = { [weak self] in
guard let self else { return }
self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
}
}
let hostingView = NSHostingView(
rootView: ContentView(
screen: context,
orchestrator: orchestrator
)
.preferredColorScheme(.dark)
)
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() {
screenRegistry.refreshConnectedScreens()
for context in screenRegistry.allScreens() {
context.refreshClosedSize()
windowCoordinator.repositionWindow(
for: context.id,
context: context,
centerHorizontally: true
)
}
}
// MARK: - Cleanup
private func cleanupAllWindows() {
orchestrator.cancelAllPendingWork()
windowCoordinator.cleanupAllWindows()
}
// 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() {
screenRegistry.refreshConnectedScreens()
let currentScreenIDs = Set(visibleScreens().map(\.displayUUID))
let knownScreenIDs = windowCoordinator.windowScreenIDs()
if currentScreenIDs != knownScreenIDs {
rebuildWindows()
} else {
repositionWindows()
}
}
private func activeWorkspace() -> WorkspaceController {
guard let screenID = screenRegistry.activeScreenID() else {
return WorkspaceRegistry.shared.defaultWorkspaceController
}
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
}
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)
}
}