ghostty/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift
Thorsten Ball be114e792f macOS: fix cascading windows when using tabs and closing windows
This fixes two things:

1. Issue #294: cascade point for new windows is set when creating new tabs
2. Cascade point was *not* reset when closing windows, which lead to a
   big "gap" appearing when, say, opening 5 windows, closing 4, opening
   a new window.
2023-08-19 20:21:20 +02:00

185 lines
7.2 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 }
// In case we run into the inconsistency, let it crash in debug mode so we
// can fix our window management setup to prevent this from happening.
assert(mainManagedWindow != nil || managedWindows.isEmpty)
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_surface_config_s? = nil) {
guard let controller = createWindowController(withBaseConfig: config) else { return }
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_surface_config_s
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_surface_config_s
self.addNewTab(to: window, withBaseConfig: config)
}
private func addNewTab(to window: NSWindow, withBaseConfig config: ghostty_surface_config_s? = 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_surface_config_s? = 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)
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)
}
}
}