298 lines
9.4 KiB
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)
|
|
}
|
|
}
|