mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-05-04 23:38:38 +03:00

There is a setting in the macOS System Preferences called "Prefer tabs when opening documents" (accessed through the userTabbingPreference field of NSWindow) which, when set to "Always", makes the "New Window" action open windows in tabs. Ideally, this setting would be controlled on a per-app basis in macOS, but unfortunately that is not the case. Because Ghostty explicitly offers both "New Tab" and "New Window" actions, this user setting should be ignored when creating new windows.
214 lines
8.6 KiB
Swift
214 lines
8.6 KiB
Swift
import Cocoa
|
|
import Combine
|
|
import GhosttyKit
|
|
import SwiftUI
|
|
|
|
// PrimaryWindowManager manages the windows and tabs in the primary window
|
|
// of the application. It keeps references to windows and cleans them up when
|
|
// they're cloned.
|
|
//
|
|
// If we ever have multiple tabbed window types we can make this generic but
|
|
// right now only our primary window is ever duplicated or tabbed so we're not
|
|
// doing that.
|
|
//
|
|
// It is based on the patterns presented in this blog post:
|
|
// https://christiantietze.de/posts/2019/07/nswindow-tabbing-multiple-nswindowcontroller/
|
|
class PrimaryWindowManager {
|
|
struct ManagedWindow {
|
|
let windowController: NSWindowController
|
|
let window: NSWindow
|
|
let closePublisher: AnyCancellable
|
|
}
|
|
|
|
// 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.
|
|
static var lastCascadePoint = NSPoint(x: 0, y: 0)
|
|
|
|
/// Returns the main window of the managed window stack.
|
|
/// Falls back the first element if no window is main. Note that this would
|
|
/// likely be an internal inconsistency we gracefully handle here.
|
|
var mainWindow: NSWindow? {
|
|
let mainManagedWindow = managedWindows
|
|
.first { $0.window.isMainWindow }
|
|
|
|
return (mainManagedWindow ?? managedWindows.first)
|
|
.map { $0.window }
|
|
}
|
|
|
|
private var ghostty: Ghostty.AppState
|
|
private var managedWindows: [ManagedWindow] = []
|
|
|
|
init(ghostty: Ghostty.AppState) {
|
|
self.ghostty = ghostty
|
|
|
|
// Register self as observer for the NewTab/NewWindow notifications that
|
|
// are triggered via callback from Zig code.
|
|
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 {
|
|
// Clean up the observers.
|
|
let center = NotificationCenter.default;
|
|
center.removeObserver(
|
|
self,
|
|
name: Ghostty.Notification.ghosttyNewTab,
|
|
object: nil)
|
|
center.removeObserver(
|
|
self,
|
|
name: Ghostty.Notification.ghosttyNewWindow,
|
|
object: nil)
|
|
}
|
|
|
|
/// Add the initial window for the application. This should only be called once from the AppDelegate.
|
|
func addInitialWindow() {
|
|
guard let controller = createWindowController() else { return }
|
|
controller.showWindow(self)
|
|
let result = addManagedWindow(windowController: controller)
|
|
if result == nil {
|
|
preconditionFailure("Failed to create initial window")
|
|
}
|
|
}
|
|
|
|
func newWindow() {
|
|
if let window = mainWindow as? PrimaryWindow {
|
|
// If we already have a window, we go through Zig core code, which calls back into Swift.
|
|
self.triggerNewWindow(withParent: window)
|
|
} else {
|
|
self.addNewWindow()
|
|
}
|
|
}
|
|
|
|
func triggerNewWindow(withParent window: PrimaryWindow) {
|
|
guard let surface = window.focusedSurfaceWrapper.surface else { return }
|
|
ghostty.newWindow(surface: surface)
|
|
}
|
|
|
|
func addNewWindow(withBaseConfig config: Ghostty.SurfaceConfiguration? = nil) {
|
|
guard let controller = createWindowController(withBaseConfig: config) else { return }
|
|
|
|
// For new windows, explicitly disallow tabbing with other windows.
|
|
// This overrides the value of userTabbingPreference. Rationale:
|
|
// Ghostty explicitly provides both "New Tab" and "New Window"
|
|
// functionality, so there's no reason to make "New Window" open in a
|
|
// tab.
|
|
controller.window?.tabbingMode = .disallowed;
|
|
|
|
controller.showWindow(self)
|
|
guard let newWindow = addManagedWindow(windowController: controller)?.window else { return }
|
|
newWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
@objc private func onNewWindow(notification: SwiftUI.Notification) {
|
|
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
|
let config = configAny as? Ghostty.SurfaceConfiguration
|
|
|
|
self.addNewWindow(withBaseConfig: config)
|
|
}
|
|
|
|
// triggerNewTab tells the Zig core code to create a new tab, which then calls
|
|
// back into Swift code.
|
|
func triggerNewTab(for window: PrimaryWindow) {
|
|
guard let surface = window.focusedSurfaceWrapper.surface else { return }
|
|
ghostty.newTab(surface: surface)
|
|
}
|
|
|
|
func newTab() {
|
|
if let window = mainWindow as? PrimaryWindow {
|
|
self.triggerNewTab(for: window)
|
|
} else {
|
|
self.addNewWindow()
|
|
}
|
|
}
|
|
|
|
@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.addNewTab(to: window, withBaseConfig: config)
|
|
}
|
|
|
|
func addNewTab(to window: NSWindow, withBaseConfig config: Ghostty.SurfaceConfiguration? = nil) {
|
|
guard let controller = createWindowController(withBaseConfig: config, cascade: false) else { return }
|
|
guard let newWindow = addManagedWindow(windowController: controller)?.window else { return }
|
|
window.addTabbedWindow(newWindow, ordered: .above)
|
|
newWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
private func createWindowController(withBaseConfig config: Ghostty.SurfaceConfiguration? = nil, cascade: Bool = true) -> PrimaryWindowController? {
|
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
|
|
|
|
let window = PrimaryWindow.create(ghostty: ghostty, appDelegate: appDelegate, baseConfig: config)
|
|
if (cascade) {
|
|
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
|
}
|
|
|
|
let controller = PrimaryWindowController(window: window)
|
|
controller.windowManager = self
|
|
return controller
|
|
}
|
|
|
|
private func addManagedWindow(windowController: PrimaryWindowController) -> ManagedWindow? {
|
|
guard let window = windowController.window else { return nil }
|
|
|
|
let pubClose = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window)
|
|
.sink { notification in
|
|
guard let window = notification.object as? NSWindow else { return }
|
|
self.removeWindow(window: window)
|
|
}
|
|
|
|
let managed = ManagedWindow(windowController: windowController, window: window, closePublisher: pubClose)
|
|
managedWindows.append(managed)
|
|
window.delegate = windowController
|
|
|
|
return managed
|
|
}
|
|
|
|
private func removeWindow(window: NSWindow) {
|
|
self.managedWindows.removeAll(where: { $0.window === window })
|
|
|
|
// 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 {
|
|
let frame = focusedWindow.frame
|
|
Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
|
|
}
|
|
}
|
|
|
|
/// Update the accessory view of each tab according to the keyboard
|
|
/// shortcut that activates it (if any). This is called when the key window
|
|
/// changes and when a window is closed.
|
|
func relabelTabs() {
|
|
guard let windows = self.mainWindow?.tabbedWindows else { return }
|
|
guard let cfg = ghostty.config else { return }
|
|
for (index, window) in windows.enumerated().prefix(9) {
|
|
let action = "goto_tab:\(index + 1)"
|
|
let trigger = ghostty_config_trigger(cfg, action, UInt(action.count))
|
|
guard let equiv = Ghostty.keyEquivalentLabel(key: trigger.key, mods: trigger.mods) else {
|
|
continue
|
|
}
|
|
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
.font: NSFont.labelFont(ofSize: 0),
|
|
.foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
|
]
|
|
let attributedString = NSAttributedString(string: " \(equiv) ", attributes: attributes)
|
|
let text = NSTextField(labelWithAttributedString: attributedString)
|
|
window.tab.accessoryView = text
|
|
}
|
|
}
|
|
}
|