ghostty/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift
Gregory Anders 4681b95bb3 macos: force new window for "New Window" action
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.
2023-10-09 09:20:52 -05:00

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
}
}
}