macos: remove TerminalManager
All logic related to TerminalController is now in TerminalController.pull/7535/head
parent
3b77a16b63
commit
33d128bcff
|
|
@ -71,7 +71,6 @@
|
|||
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; };
|
||||
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; };
|
||||
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; };
|
||||
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; };
|
||||
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
|
||||
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
|
||||
A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; };
|
||||
|
|
@ -179,7 +178,6 @@
|
|||
A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = "<group>"; };
|
||||
A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||
A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
|
||||
A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = "<group>"; };
|
||||
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -467,7 +465,6 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
A59630992AEE1C6400D64628 /* Terminal.xib */,
|
||||
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */,
|
||||
A596309B2AEE1C9E00D64628 /* TerminalController.swift */,
|
||||
A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */,
|
||||
A596309D2AEE1D6C00D64628 /* TerminalView.swift */,
|
||||
|
|
@ -710,7 +707,6 @@
|
|||
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
|
||||
A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */,
|
||||
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
|
||||
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
|
||||
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
||||
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
|
||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -87,9 +87,6 @@ class AppDelegate: NSObject,
|
|||
/// The ghostty global state. Only one per process.
|
||||
let ghostty: Ghostty.App = Ghostty.App()
|
||||
|
||||
/// Manages our terminal windows.
|
||||
let terminalManager: TerminalManager
|
||||
|
||||
/// The global undo manager for app-level state such as window restoration.
|
||||
lazy var undoManager = ExpiringUndoManager()
|
||||
|
||||
|
|
@ -119,7 +116,6 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
override init() {
|
||||
terminalManager = TerminalManager(ghostty)
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
// Important: we must not start the updater here because we need to read our configuration
|
||||
// first to determine whether we're automatically checking, downloading, etc. The updater
|
||||
|
|
@ -202,6 +198,16 @@ class AppDelegate: NSObject,
|
|||
name: .ghosttyBellDidRing,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyNewWindow(_:)),
|
||||
name: Ghostty.Notification.ghosttyNewWindow,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyNewTab(_:)),
|
||||
name: Ghostty.Notification.ghosttyNewTab,
|
||||
object: nil)
|
||||
|
||||
// Configure user notifications
|
||||
let actions = [
|
||||
|
|
@ -253,8 +259,8 @@ class AppDelegate: NSObject,
|
|||
// is possible to have other windows in a few scenarios:
|
||||
// - if we're opening a URL since `application(_:openFile:)` is called before this.
|
||||
// - if we're restoring from persisted state
|
||||
if terminalManager.windows.count == 0 && derivedConfig.initialWindow {
|
||||
terminalManager.newWindow()
|
||||
if TerminalController.all.isEmpty && derivedConfig.initialWindow {
|
||||
_ = TerminalController.newWindow(ghostty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -339,10 +345,10 @@ class AppDelegate: NSObject,
|
|||
// This is possible with flag set to false if there a race where the
|
||||
// window is still initializing and is not visible but the user clicked
|
||||
// the dock icon.
|
||||
guard terminalManager.windows.count == 0 else { return true }
|
||||
guard TerminalController.all.isEmpty else { return true }
|
||||
|
||||
// No visible windows, open a new one.
|
||||
terminalManager.newWindow()
|
||||
_ = TerminalController.newWindow(ghostty)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -358,16 +364,17 @@ class AppDelegate: NSObject,
|
|||
var config = Ghostty.SurfaceConfiguration()
|
||||
|
||||
if (isDirectory.boolValue) {
|
||||
// When opening a directory, create a new tab in the main window with that as the working directory.
|
||||
// When opening a directory, create a new tab in the main
|
||||
// window with that as the working directory.
|
||||
// If no windows exist, a new one will be created.
|
||||
config.workingDirectory = filename
|
||||
terminalManager.newTab(withBaseConfig: config)
|
||||
_ = TerminalController.newTab(ghostty, withBaseConfig: config)
|
||||
} else {
|
||||
// When opening a file, open a new window with that file as the command,
|
||||
// and its parent directory as the working directory.
|
||||
config.command = filename
|
||||
config.workingDirectory = (filename as NSString).deletingLastPathComponent
|
||||
terminalManager.newWindow(withBaseConfig: config)
|
||||
_ = TerminalController.newWindow(ghostty, withBaseConfig: config)
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -456,10 +463,6 @@ class AppDelegate: NSObject,
|
|||
menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers)
|
||||
}
|
||||
|
||||
private func focusedSurface() -> ghostty_surface_t? {
|
||||
return terminalManager.focusedSurface?.surface
|
||||
}
|
||||
|
||||
// MARK: Notifications and Events
|
||||
|
||||
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get
|
||||
|
|
@ -592,6 +595,22 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyNewWindow(_ notification: Notification) {
|
||||
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||
let config = configAny as? Ghostty.SurfaceConfiguration
|
||||
_ = TerminalController.newWindow(ghostty, withBaseConfig: config)
|
||||
}
|
||||
|
||||
@objc private func ghosttyNewTab(_ notification: 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
|
||||
|
||||
_ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config)
|
||||
}
|
||||
|
||||
private func setDockBadge(_ label: String? = "•") {
|
||||
NSApp.dockTile.badgeLabel = label
|
||||
NSApp.dockTile.display()
|
||||
|
|
@ -627,7 +646,7 @@ class AppDelegate: NSObject,
|
|||
|
||||
// Config could change keybindings, so update everything that depends on that
|
||||
syncMenuShortcuts(config)
|
||||
terminalManager.relabelAllTabs()
|
||||
TerminalController.all.forEach { $0.relabelTabs() }
|
||||
|
||||
// Config could change window appearance. We wrap this in an async queue because when
|
||||
// this is called as part of application launch it can deadlock with an internal
|
||||
|
|
@ -756,8 +775,8 @@ class AppDelegate: NSObject,
|
|||
//MARK: - GhosttyAppDelegate
|
||||
|
||||
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
|
||||
for c in terminalManager.windows {
|
||||
for view in c.controller.surfaceTree {
|
||||
for c in TerminalController.all {
|
||||
for view in c.surfaceTree {
|
||||
if view.uuid == uuid {
|
||||
return view
|
||||
}
|
||||
|
|
@ -811,7 +830,7 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@IBAction func newWindow(_ sender: Any?) {
|
||||
terminalManager.newWindow()
|
||||
_ = TerminalController.newWindow(ghostty)
|
||||
|
||||
// We also activate our app so that it becomes front. This may be
|
||||
// necessary for the dock menu.
|
||||
|
|
@ -819,7 +838,7 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@IBAction func newTab(_ sender: Any?) {
|
||||
terminalManager.newTab()
|
||||
_ = TerminalController.newTab(ghostty)
|
||||
|
||||
// We also activate our app so that it becomes front. This may be
|
||||
// necessary for the dock menu.
|
||||
|
|
@ -827,7 +846,7 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@IBAction func closeAllWindows(_ sender: Any?) {
|
||||
terminalManager.closeAllWindows()
|
||||
TerminalController.closeAllWindows()
|
||||
AboutController.shared.hide()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ class ServiceProvider: NSObject {
|
|||
error: AutoreleasingUnsafeMutablePointer<NSString>
|
||||
) {
|
||||
guard let delegate = NSApp.delegate as? AppDelegate else { return }
|
||||
let terminalManager = delegate.terminalManager
|
||||
|
||||
guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
|
||||
error.pointee = Self.errorNoString
|
||||
|
|
@ -53,10 +52,10 @@ class ServiceProvider: NSObject {
|
|||
|
||||
switch (target) {
|
||||
case .window:
|
||||
terminalManager.newWindow(withBaseConfig: config)
|
||||
_ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config)
|
||||
|
||||
case .tab:
|
||||
terminalManager.newTab(withBaseConfig: config)
|
||||
_ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ class BaseTerminalController: NSWindowController,
|
|||
/// This will also insert the proper undo stack information in.
|
||||
func closeSurfaceNode(
|
||||
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
||||
withConfirmation: Bool = true
|
||||
withConfirmation: Bool = true,
|
||||
) {
|
||||
// This node must be part of our tree
|
||||
guard surfaceTree.contains(node) else { return }
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController {
|
|||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
|
||||
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
|
||||
parent: NSWindow? = nil
|
||||
) {
|
||||
// The window we manage is not restorable if we've specified a command
|
||||
// to execute. We do this because the restored window is meaningless at the
|
||||
|
|
@ -137,6 +138,159 @@ class TerminalController: BaseTerminalController {
|
|||
syncAppearance(focusedSurface.derivedConfig)
|
||||
}
|
||||
|
||||
// MARK: Terminal Creation
|
||||
|
||||
/// Returns all the available terminal controllers present in the app currently.
|
||||
static var all: [TerminalController] {
|
||||
return NSApplication.shared.windows.compactMap {
|
||||
$0.windowController as? TerminalController
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// The preferred parent terminal controller.
|
||||
private static var preferredParent: TerminalController? {
|
||||
all.first {
|
||||
$0.window?.isMainWindow ?? false
|
||||
} ?? all.last
|
||||
}
|
||||
|
||||
/// The "new window" action.
|
||||
static func newWindow(
|
||||
_ ghostty: Ghostty.App,
|
||||
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil,
|
||||
withParent explicitParent: NSWindow? = nil
|
||||
) -> TerminalController {
|
||||
let c = TerminalController.init(ghostty, withBaseConfig: baseConfig)
|
||||
|
||||
// Get our parent. Our parent is the one explicitly given to us,
|
||||
// otherwise the focused terminal, otherwise an arbitrary one.
|
||||
let parent: NSWindow? = explicitParent ?? preferredParent?.window
|
||||
|
||||
if let parent {
|
||||
if parent.styleMask.contains(.fullScreen) {
|
||||
parent.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, .nonNativePaddedNotch:
|
||||
// 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: ghostty.config.windowFullscreenMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 let window = c.window {
|
||||
if (!window.styleMask.contains(.fullScreen)) {
|
||||
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
||||
}
|
||||
}
|
||||
|
||||
c.showWindow(self)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
static func newTab(
|
||||
_ ghostty: Ghostty.App,
|
||||
from parent: NSWindow? = nil,
|
||||
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil
|
||||
) -> TerminalController? {
|
||||
// Making sure that we're dealing with a TerminalController. If not,
|
||||
// then we just create a new window.
|
||||
guard let parent,
|
||||
let parentController = parent.windowController as? TerminalController else {
|
||||
return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent)
|
||||
}
|
||||
|
||||
// If our parent is in non-native fullscreen, then new tabs do not work.
|
||||
// See: https://github.com/mitchellh/ghostty/issues/392
|
||||
if let fullscreenStyle = parentController.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 nil
|
||||
}
|
||||
|
||||
// Create a new window and add it to the parent
|
||||
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
|
||||
guard let window = controller.window else { return controller }
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
//MARK: - Methods
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
|
|
@ -479,6 +633,44 @@ class TerminalController: BaseTerminalController {
|
|||
tabGroup.windows.forEach { $0.close() }
|
||||
}
|
||||
|
||||
/// Close all windows, asking for confirmation if necessary.
|
||||
static func closeAllWindows() {
|
||||
let needsConfirm: Bool = all.contains {
|
||||
$0.surfaceTree.contains { $0.needsConfirmQuit }
|
||||
}
|
||||
|
||||
if (!needsConfirm) {
|
||||
closeAllWindowsImmediately()
|
||||
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 = preferredParent?.window else {
|
||||
closeAllWindowsImmediately()
|
||||
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) {
|
||||
closeAllWindowsImmediately()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static private func closeAllWindowsImmediately() {
|
||||
all.forEach { $0.close() }
|
||||
}
|
||||
|
||||
// MARK: Undo/Redo
|
||||
|
||||
/// The state that we require to recreate a TerminalController from an undo.
|
||||
|
|
@ -709,6 +901,35 @@ class TerminalController: BaseTerminalController {
|
|||
override func windowWillClose(_ notification: Notification) {
|
||||
super.windowWillClose(notification)
|
||||
self.relabelTabs()
|
||||
|
||||
// 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 != window {
|
||||
// The cascadeTopLeft call below should NOT move the window. Starting with
|
||||
// macOS 15, we found that specifically when used with the new window snapping
|
||||
// features of macOS 15, this WOULD move the frame. So we keep track of the
|
||||
// old frame and restore it if necessary. Issue:
|
||||
// https://github.com/ghostty-org/ghostty/issues/2565
|
||||
let oldFrame = focusedWindow.frame
|
||||
|
||||
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
|
||||
|
||||
if focusedWindow.frame != oldFrame {
|
||||
focusedWindow.setFrame(oldFrame, display: true)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override func windowDidBecomeKey(_ notification: Notification) {
|
||||
|
|
|
|||
|
|
@ -1,372 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private var derivedConfig: DerivedConfig
|
||||
|
||||
init(_ ghostty: Ghostty.App) {
|
||||
self.ghostty = ghostty
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
||||
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)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
name: .ghosttyConfigDidChange,
|
||||
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 derivedConfig.windowFullscreen {
|
||||
switch (derivedConfig.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, .nonNativePaddedNotch:
|
||||
// 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.derivedConfig.windowFullscreenMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All new_window actions force our app to be active.
|
||||
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?) {
|
||||
// Making sure that we're dealing with a TerminalController
|
||||
guard parent.windowController is TerminalController else { return }
|
||||
|
||||
// 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 (derivedConfig.macosTitlebarStyle != "hidden") {
|
||||
// Add the window to the tab group and show it.
|
||||
switch derivedConfig.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: SplitTree<Ghostty.SurfaceView>? = 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 {
|
||||
// The cascadeTopLeft call below should NOT move the window. Starting with
|
||||
// macOS 15, we found that specifically when used with the new window snapping
|
||||
// features of macOS 15, this WOULD move the frame. So we keep track of the
|
||||
// old frame and restore it if necessary. Issue:
|
||||
// https://github.com/ghostty-org/ghostty/issues/2565
|
||||
let oldFrame = focusedWindow.frame
|
||||
|
||||
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
|
||||
|
||||
if focusedWindow.frame != oldFrame {
|
||||
focusedWindow.setFrame(oldFrame, display: true)
|
||||
}
|
||||
|
||||
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.contains(where: { $0.needsConfirmQuit }) {
|
||||
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)
|
||||
}
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
// We only care if the configuration is a global configuration, not a
|
||||
// surface-specific one.
|
||||
guard notification.object == nil else { return }
|
||||
|
||||
// Get our managed configuration object out
|
||||
guard let config = notification.userInfo?[
|
||||
Notification.Name.GhosttyConfigChangeKey
|
||||
] as? Ghostty.Config else { return }
|
||||
|
||||
// Update our derived config
|
||||
self.derivedConfig = DerivedConfig(config)
|
||||
}
|
||||
|
||||
private struct DerivedConfig {
|
||||
let windowFullscreen: Bool
|
||||
let windowFullscreenMode: FullscreenMode
|
||||
let macosTitlebarStyle: String
|
||||
let windowNewTabPosition: String
|
||||
|
||||
init() {
|
||||
self.windowFullscreen = false
|
||||
self.windowFullscreenMode = .native
|
||||
self.macosTitlebarStyle = "transparent"
|
||||
self.windowNewTabPosition = ""
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.windowFullscreen = config.windowFullscreen
|
||||
self.windowFullscreenMode = config.windowFullscreenMode
|
||||
self.macosTitlebarStyle = config.macosTitlebarStyle
|
||||
self.windowNewTabPosition = config.windowNewTabPosition
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -83,9 +83,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
|||
// can be found for events from libghostty. This uses the low-level
|
||||
// createWindow so that AppKit can place the window wherever it should
|
||||
// be.
|
||||
let c = appDelegate.terminalManager.createWindow(
|
||||
withSurfaceTree: state.surfaceTree
|
||||
)
|
||||
let c = TerminalController.init(
|
||||
appDelegate.ghostty,
|
||||
withSurfaceTree: state.surfaceTree)
|
||||
guard let window = c.window else {
|
||||
completionHandler(nil, TerminalRestoreError.windowDidNotLoad)
|
||||
return
|
||||
|
|
|
|||
Loading…
Reference in New Issue