mirror of
				https://github.com/ghostty-org/ghostty.git
				synced 2025-11-04 01:34:21 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			317 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			317 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
import Cocoa
 | 
						|
import SwiftUI
 | 
						|
import GhosttyKit
 | 
						|
import Combine
 | 
						|
 | 
						|
/// Manages a set of terminal windows. This is effectively an array of TerminalControllers.
 | 
						|
/// This abstraction helps manage tabs and multi-window scenarios.
 | 
						|
class TerminalManager {
 | 
						|
    struct Window {
 | 
						|
        let controller: TerminalController
 | 
						|
        let closePublisher: AnyCancellable
 | 
						|
    }
 | 
						|
 | 
						|
    let ghostty: Ghostty.App
 | 
						|
 | 
						|
    /// The currently focused surface of the main window.
 | 
						|
    var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface }
 | 
						|
 | 
						|
    /// The set of windows we currently have.
 | 
						|
    var windows: [Window] = []
 | 
						|
 | 
						|
    // Keep track of the last point that our window was launched at so that new
 | 
						|
    // windows "cascade" over each other and don't just launch directly on top
 | 
						|
    // of each other.
 | 
						|
    private static var lastCascadePoint = NSPoint(x: 0, y: 0)
 | 
						|
 | 
						|
    /// Returns the main window of the managed window stack. If there is no window
 | 
						|
    /// then an arbitrary window will be chosen.
 | 
						|
    private var mainWindow: Window? {
 | 
						|
        for window in windows {
 | 
						|
            if (window.controller.window?.isMainWindow ?? false) {
 | 
						|
                return window
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        // If we have no main window, just use the last window.
 | 
						|
        return windows.last
 | 
						|
    }
 | 
						|
 | 
						|
    init(_ ghostty: Ghostty.App) {
 | 
						|
        self.ghostty = ghostty
 | 
						|
 | 
						|
        let center = NotificationCenter.default
 | 
						|
        center.addObserver(
 | 
						|
            self,
 | 
						|
            selector: #selector(onNewTab),
 | 
						|
            name: Ghostty.Notification.ghosttyNewTab,
 | 
						|
            object: nil)
 | 
						|
        center.addObserver(
 | 
						|
            self,
 | 
						|
            selector: #selector(onNewWindow),
 | 
						|
            name: Ghostty.Notification.ghosttyNewWindow,
 | 
						|
            object: nil)
 | 
						|
    }
 | 
						|
 | 
						|
    deinit {
 | 
						|
        let center = NotificationCenter.default
 | 
						|
        center.removeObserver(self)
 | 
						|
    }
 | 
						|
 | 
						|
    // MARK: - Window Management
 | 
						|
 | 
						|
    /// Create a new terminal window.
 | 
						|
    func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
 | 
						|
        let c = createWindow(withBaseConfig: base)
 | 
						|
        let window = c.window!
 | 
						|
 | 
						|
        // If the previous focused window was native fullscreen, the new window also
 | 
						|
        // becomes native fullscreen.
 | 
						|
        if let parent = focusedSurface?.window,
 | 
						|
            parent.styleMask.contains(.fullScreen) {
 | 
						|
            window.toggleFullScreen(nil)
 | 
						|
        } else if ghostty.config.windowFullscreen {
 | 
						|
            switch (ghostty.config.windowFullscreenMode) {
 | 
						|
            case .native:
 | 
						|
                // Native has to be done immediately so that our stylemask contains
 | 
						|
                // fullscreen for the logic later in this method.
 | 
						|
                c.toggleFullscreen(mode: .native)
 | 
						|
 | 
						|
            case .nonNative, .nonNativeVisibleMenu:
 | 
						|
                // If we're non-native then we have to do it on a later loop
 | 
						|
                // so that the content view is setup.
 | 
						|
                DispatchQueue.main.async {
 | 
						|
                    c.toggleFullscreen(mode: self.ghostty.config.windowFullscreenMode)
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        // If our app isn't active, we make it active. All new_window actions
 | 
						|
        // force our app to be active.
 | 
						|
        if !NSApp.isActive {
 | 
						|
            NSApp.activate(ignoringOtherApps: true)
 | 
						|
        }
 | 
						|
 | 
						|
        // We're dispatching this async because otherwise the lastCascadePoint doesn't
 | 
						|
        // take effect. Our best theory is there is some next-event-loop-tick logic
 | 
						|
        // that Cocoa is doing that we need to be after.
 | 
						|
        DispatchQueue.main.async {
 | 
						|
            // Only cascade if we aren't fullscreen.
 | 
						|
            if (!window.styleMask.contains(.fullScreen)) {
 | 
						|
                Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
 | 
						|
            }
 | 
						|
 | 
						|
            c.showWindow(self)
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /// Creates a new tab in the current main window. If there are no windows, a window
 | 
						|
    /// is created.
 | 
						|
    func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
 | 
						|
        // If there is no main window, just create a new window
 | 
						|
        guard let parent = mainWindow?.controller.window else {
 | 
						|
            newWindow(withBaseConfig: base)
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        // Create a new window and add it to the parent
 | 
						|
        newTab(to: parent, withBaseConfig: base)
 | 
						|
    }
 | 
						|
 | 
						|
    private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) {
 | 
						|
        // If our parent is in non-native fullscreen, then new tabs do not work.
 | 
						|
        // See: https://github.com/mitchellh/ghostty/issues/392
 | 
						|
        if let controller = parent.windowController as? TerminalController,
 | 
						|
           let fullscreenStyle = controller.fullscreenStyle,
 | 
						|
           fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
 | 
						|
            let alert = NSAlert()
 | 
						|
            alert.messageText = "Cannot Create New Tab"
 | 
						|
            alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
 | 
						|
            alert.addButton(withTitle: "OK")
 | 
						|
            alert.alertStyle = .warning
 | 
						|
            alert.beginSheetModal(for: parent)
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        // Create a new window and add it to the parent
 | 
						|
        let controller = createWindow(withBaseConfig: base)
 | 
						|
        let window = controller.window!
 | 
						|
 | 
						|
        // If the parent is miniaturized, then macOS exhibits really strange behaviors
 | 
						|
        // so we have to bring it back out.
 | 
						|
        if (parent.isMiniaturized) { parent.deminiaturize(self) }
 | 
						|
 | 
						|
        // If our parent tab group already has this window, macOS added it and
 | 
						|
        // we need to remove it so we can set the correct order in the next line.
 | 
						|
        // If we don't do this, macOS gets really confused and the tabbedWindows
 | 
						|
        // state becomes incorrect.
 | 
						|
        //
 | 
						|
        // At the time of writing this code, the only known case this happens
 | 
						|
        // is when the "+" button is clicked in the tab bar.
 | 
						|
        if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil {
 | 
						|
            tg.removeWindow(window)
 | 
						|
        }
 | 
						|
 | 
						|
        // Our windows start out invisible. We need to make it visible. If we
 | 
						|
        // don't do this then various features such as window blur won't work because
 | 
						|
        // the macOS APIs only work on a visible window.
 | 
						|
        controller.showWindow(self)
 | 
						|
 | 
						|
        // If we have the "hidden" titlebar style we want to create new
 | 
						|
        // tabs as windows instead, so just skip adding it to the parent.
 | 
						|
        if (ghostty.config.macosTitlebarStyle != "hidden") {
 | 
						|
            // Add the window to the tab group and show it.
 | 
						|
            switch ghostty.config.windowNewTabPosition {
 | 
						|
            case "end":
 | 
						|
                // If we already have a tab group and we want the new tab to open at the end,
 | 
						|
                // then we use the last window in the tab group as the parent.
 | 
						|
                if let last = parent.tabGroup?.windows.last {
 | 
						|
                    last.addTabbedWindow(window, ordered: .above)
 | 
						|
                } else {
 | 
						|
                    fallthrough
 | 
						|
                }
 | 
						|
            case "current": fallthrough
 | 
						|
            default:
 | 
						|
                parent.addTabbedWindow(window, ordered: .above)
 | 
						|
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        window.makeKeyAndOrderFront(self)
 | 
						|
 | 
						|
        // It takes an event loop cycle until the macOS tabGroup state becomes
 | 
						|
        // consistent which causes our tab labeling to be off when the "+" button
 | 
						|
        // is used in the tab bar. This fixes that. If we can find a more robust
 | 
						|
        // solution we should do that.
 | 
						|
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() }
 | 
						|
    }
 | 
						|
 | 
						|
    /// Creates a window controller, adds it to our managed list, and returns it.
 | 
						|
    func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
 | 
						|
                      withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController {
 | 
						|
        // Initialize our controller to load the window
 | 
						|
        let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree)
 | 
						|
 | 
						|
        // Create a listener for when the window is closed so we can remove it.
 | 
						|
        let pubClose = NotificationCenter.default.publisher(
 | 
						|
            for: NSWindow.willCloseNotification,
 | 
						|
            object: c.window!
 | 
						|
        ).sink { notification in
 | 
						|
            guard let window = notification.object as? NSWindow else { return }
 | 
						|
            guard let c = window.windowController as? TerminalController else { return }
 | 
						|
            self.removeWindow(c)
 | 
						|
        }
 | 
						|
 | 
						|
        // Keep track of every window we manage
 | 
						|
        windows.append(Window(
 | 
						|
            controller: c,
 | 
						|
            closePublisher: pubClose
 | 
						|
        ))
 | 
						|
 | 
						|
        return c
 | 
						|
    }
 | 
						|
 | 
						|
    func removeWindow(_ controller: TerminalController) {
 | 
						|
        // Remove it from our managed set
 | 
						|
        guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return }
 | 
						|
        let w = self.windows[idx]
 | 
						|
        self.windows.remove(at: idx)
 | 
						|
 | 
						|
        // Ensure any publishers we have are cancelled
 | 
						|
        w.closePublisher.cancel()
 | 
						|
 | 
						|
        // If we remove a window, we reset the cascade point to the key window so that
 | 
						|
        // the next window cascade's from that one.
 | 
						|
        if let focusedWindow = NSApplication.shared.keyWindow {
 | 
						|
            // If we are NOT the focused window, then we are a tabbed window. If we
 | 
						|
            // are closing a tabbed window, we want to set the cascade point to be
 | 
						|
            // the next cascade point from this window.
 | 
						|
            if focusedWindow != controller.window {
 | 
						|
                Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
 | 
						|
                return
 | 
						|
            }
 | 
						|
 | 
						|
            // If we are the focused window, then we set the last cascade point to
 | 
						|
            // our own frame so that it shows up in the same spot.
 | 
						|
            let frame = focusedWindow.frame
 | 
						|
            Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
 | 
						|
        }
 | 
						|
 | 
						|
        // I don't think we strictly have to do this but if a window is
 | 
						|
        // closed I want to make sure that the app state is invalided so
 | 
						|
        // we don't reopen closed windows.
 | 
						|
        NSApplication.shared.invalidateRestorableState()
 | 
						|
    }
 | 
						|
 | 
						|
    /// Close all windows, asking for confirmation if necessary.
 | 
						|
    func closeAllWindows() {
 | 
						|
        var needsConfirm: Bool = false
 | 
						|
        for w in self.windows {
 | 
						|
            if (w.controller.surfaceTree?.needsConfirmQuit() ?? false) {
 | 
						|
                needsConfirm = true
 | 
						|
                break
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        if (!needsConfirm) {
 | 
						|
            for w in self.windows {
 | 
						|
                w.controller.close()
 | 
						|
            }
 | 
						|
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        // If we don't have a main window, we just close all windows because
 | 
						|
        // we have no window to show the modal on top of. I'm sure there's a way
 | 
						|
        // to do an app-level alert but I don't know how and this case should never
 | 
						|
        // really happen.
 | 
						|
        guard let alertWindow = mainWindow?.controller.window else {
 | 
						|
            for w in self.windows {
 | 
						|
                w.controller.close()
 | 
						|
            }
 | 
						|
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        // If we need confirmation by any, show one confirmation for all windows
 | 
						|
        let alert = NSAlert()
 | 
						|
        alert.messageText = "Close All Windows?"
 | 
						|
        alert.informativeText = "All terminal sessions will be terminated."
 | 
						|
        alert.addButton(withTitle: "Close All Windows")
 | 
						|
        alert.addButton(withTitle: "Cancel")
 | 
						|
        alert.alertStyle = .warning
 | 
						|
        alert.beginSheetModal(for: alertWindow, completionHandler: { response in
 | 
						|
            if (response == .alertFirstButtonReturn) {
 | 
						|
                for w in self.windows {
 | 
						|
                    w.controller.close()
 | 
						|
                }
 | 
						|
            }
 | 
						|
        })
 | 
						|
    }
 | 
						|
 | 
						|
    /// Relabels all the tabs with the proper keyboard shortcut.
 | 
						|
    func relabelAllTabs() {
 | 
						|
        for w in windows {
 | 
						|
            w.controller.relabelTabs()
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    // MARK: - Notifications
 | 
						|
 | 
						|
    @objc private func onNewWindow(notification: SwiftUI.Notification) {
 | 
						|
        let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
 | 
						|
        let config = configAny as? Ghostty.SurfaceConfiguration
 | 
						|
        self.newWindow(withBaseConfig: config)
 | 
						|
    }
 | 
						|
 | 
						|
    @objc private func onNewTab(notification: SwiftUI.Notification) {
 | 
						|
        guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
 | 
						|
        guard let window = surfaceView.window else { return }
 | 
						|
 | 
						|
        let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
 | 
						|
        let config = configAny as? Ghostty.SurfaceConfiguration
 | 
						|
 | 
						|
        self.newTab(to: window, withBaseConfig: config)
 | 
						|
    }
 | 
						|
}
 |