pull/11161/merge
Lukas 2026-06-02 21:56:29 -07:00 committed by GitHub
commit 3643c1c6f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 234 additions and 107 deletions

View File

@ -27,15 +27,15 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase {
app.launch() // window in the center
// app.menuBarItems["Window"].firstMatch.click()
// app.menuItems["_zoomTopLeft:"].firstMatch.click()
//
// // wait for the animation to finish
// try await Task.sleep(for: .seconds(0.5))
app.menuBarItems["Window"].firstMatch.click()
app.menuItems["_zoomTopLeft:"].firstMatch.click()
// wait for the animation to finish
try await Task.sleep(for: .seconds(0.5))
let window = app.windows.firstMatch
let windowFrame = window.frame
// XCTAssertEqual(windowFrame.minX, 0, "Window should be on the left")
XCTAssertEqual(windowFrame.minX, 0, "Window should be on the left")
app.typeKey("n", modifierFlags: [.command])

View File

@ -503,7 +503,7 @@ class AppDelegate: NSObject,
case .new_tab:
_ = TerminalController.newTab(
ghostty,
from: TerminalController.preferredParent?.window,
from: TerminalController.preferredNewTabParent?.window,
withBaseConfig: config
)
case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
@ -709,8 +709,13 @@ class AppDelegate: NSObject,
@objc private func ghosttyNewWindow(_ notification: Notification) {
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let surfaceView = notification.object as? NSView
let config = configAny as? Ghostty.SurfaceConfiguration
_ = TerminalController.newWindow(ghostty, withBaseConfig: config)
_ = TerminalController.newWindow(
ghostty,
withBaseConfig: config,
withParent: surfaceView?.window,
)
}
@objc private func ghosttyNewTab(_ notification: Notification) {
@ -945,7 +950,7 @@ class AppDelegate: NSObject,
@IBAction func newTab(_ sender: Any?) {
_ = TerminalController.newTab(
ghostty,
from: TerminalController.preferredParent?.window
from: TerminalController.preferredNewTabParent?.window
)
}

View File

@ -94,8 +94,6 @@ struct NewTerminalIntent: AppIntent {
}
parent = view
} else if let preferred = TerminalController.preferredParent {
parent = preferred.focusedSurface ?? preferred.surfaceTree.root?.leftmostLeaf()
} else {
parent = nil
}
@ -116,22 +114,29 @@ struct NewTerminalIntent: AppIntent {
}
case .tab:
/// If no parent is specified,
/// we find the last window in this screen as parent
let parentWindow = parent?.window ?? TerminalController.preferredNewTabParent?.window
let newController = TerminalController.newTab(
ghostty,
from: parent?.window,
from: parentWindow,
withBaseConfig: config)
if let view = newController?.surfaceTree.root?.leftmostLeaf() {
return .result(value: TerminalEntity(view))
}
case .splitLeft, .splitRight, .splitUp, .splitDown:
guard let parent,
let controller = parent.window?.windowController as? BaseTerminalController else {
// split parent is like tab parent
let splitParent = parent ??
TerminalController.preferredNewSplitParent?.focusedSurface ??
TerminalController.preferredNewSplitParent?.surfaceTree.root?.leftmostLeaf()
guard let splitParent,
let controller = splitParent.window?.windowController as? BaseTerminalController else {
throw GhosttyIntentError.surfaceNotFound
}
if let view = controller.newSplit(
at: parent,
at: splitParent,
direction: location.splitDirection!,
baseConfig: config
) {

View File

@ -260,7 +260,7 @@ extension NSApplication {
parentWindow = resolvedWindow
} else {
parentWindow = TerminalController.preferredParent?.window
parentWindow = TerminalController.preferredNewTabParent?.window
}
guard let createdController = TerminalController.newTab(

View File

@ -68,7 +68,7 @@ class ServiceProvider: NSObject {
case .tab:
_ = TerminalController.newTab(
delegate.ghostty,
from: TerminalController.preferredParent?.window,
from: TerminalController.preferredNewTabParent?.window,
withBaseConfig: config)
}
}

View File

@ -753,6 +753,9 @@ class BaseTerminalController: NSWindowController,
@objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
guard let position = notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint else {
return
}
// If our tree isn't split, then we never create a new window, because
// it is already a single split.
@ -782,7 +785,7 @@ class BaseTerminalController: NSWindowController,
_ = TerminalController.newWindow(
ghostty,
tree: newTree,
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
position: position,
confirmUndo: false,
inheritBackgroundOpacity: isBackgroundOpaque)
}

View File

@ -215,28 +215,43 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
// 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)
private static func applyCascade(to window: NSWindow, hasFixedPos: Bool) {
if hasFixedPos { return }
if all.count > 1 {
lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint)
} else {
// We assume the window frame is already correct at this point,
// so we pass .zero to let cascade use the current frame position.
lastCascadePoint = window.cascadeTopLeft(from: .zero)
}
// The preferred parent terminal controller for new window.
private static var preferredNewWindowParent: TerminalController? {
preferredParent(on: .main)
}
// The preferred parent terminal controller.
static var preferredParent: TerminalController? {
all.first {
$0.window?.isMainWindow ?? false
} ?? lastMain ?? all.last
// The preferred parent terminal controller for new tab.
static var preferredNewTabParent: TerminalController? {
// We choose a proper window on the current screen first.
// If none is found, we use existing windows on another screen.
// This could be changed to match `preferredNewWindowParent`,
// but for now, we always use an existing window.
preferredParent(on: .main) ?? preferredParent(on: nil)
}
// The preferred parent terminal controller for new split.
static var preferredNewSplitParent: TerminalController? {
preferredNewTabParent
}
/// Preferred parent terminal controller on specified screen
private static func preferredParent(on screen: NSScreen?) -> TerminalController? {
guard let screen else {
return all.first {
$0.window?.isMainWindow ?? false
} ?? lastMain ?? all.last
}
return all.last {
// find last main window in the screen first
$0.window?.screen == screen && $0.window?.isMainWindow == true
} ?? all.last {
// if no main window was found(typically out of focus)
// then just find the last visible window in the screen
// AppKit will store closed window for a while,
// We want to keep the first visible window
// in the same spot
$0.window?.screen == screen && $0.window?.isVisible == true
}
}
// The last controller to be main. We use this when paired with "preferredParent"
@ -245,7 +260,19 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// by something like an App Intent) then we prefer the most previous main.
static private(set) weak var lastMain: TerminalController?
/// The "new window" action.
/// Creates and presents a new terminal window.
///
/// The new window cascades from and inherits the style of the parent window.
/// If the parent window is fullscreen, the new window will also enter fullscreen.
/// If no parent is specified, the most recently focused window on the current screen is used.
///
/// - Parameters:
/// - ghostty: The Ghostty application instance used to configure the new window.
/// - baseConfig: An optional surface configuration to apply to the new terminal surface.
/// If `nil`, the default configuration is used.
/// - explicitParent: The parent window from which the new window should cascade and inherit its style.
/// If `nil`, the last main window on the current screen is used.
/// - Returns: The newly created `TerminalController` managing the new window.
static func newWindow(
_ ghostty: Ghostty.App,
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil,
@ -255,7 +282,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// 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
let parent: NSWindow? = explicitParent ?? preferredNewWindowParent?.window
if let parentController = parent?.windowController as? TerminalController {
c.isBackgroundOpaque = parentController.isBackgroundOpaque
}
@ -288,13 +315,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// that Cocoa is doing that we need to be after.
c.scheduleInitialPresentation {
c.showWindow(self)
// Only cascade if we aren't fullscreen.
if let window = c.window {
if !window.styleMask.contains(.fullScreen) {
let hasFixedPos = c.derivedConfig.windowPositionX != nil && c.derivedConfig.windowPositionY != nil
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
}
let hasFixedPos = c.derivedConfig.windowPositionX != nil && c.derivedConfig.windowPositionY != nil
// Only cascade if we aren't fullscreen, have a parent and don't have fixed position in the config.
if
let window = c.window,
!window.styleMask.contains(.fullScreen),
let parent,
!hasFixedPos {
c.window?.cascadeTopLeft(from: parent.topLeftForNextWindow())
}
// All new_window actions force our app to be active, so that the new
@ -322,7 +350,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
_ = TerminalController.newWindow(
ghostty,
withBaseConfig: baseConfig,
withParent: explicitParent)
withParent: explicitParent,
)
}
}
}
@ -340,7 +369,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
static func newWindow(
_ ghostty: Ghostty.App,
tree: SplitTree<Ghostty.SurfaceView>,
position: NSPoint? = nil,
position: NSPoint,
confirmUndo: Bool = true,
inheritBackgroundOpacity: Bool? = nil
) -> TerminalController {
@ -362,13 +391,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
if !window.styleMask.contains(.fullScreen) {
if let position {
window.setFrameTopLeftPoint(position)
window.constrainToScreen()
} else {
let hasFixedPos = c.derivedConfig.windowPositionX != nil && c.derivedConfig.windowPositionY != nil
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
}
window.setFrameTopLeftPoint(position)
window.constrainToScreen()
}
}
}
@ -395,6 +419,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
_ = TerminalController.newWindow(
ghostty,
tree: tree,
position: position,
inheritBackgroundOpacity: inheritBackgroundOpacity
)
}
@ -473,13 +498,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.
controller.scheduleInitialPresentation {
// Only cascade if we aren't fullscreen and are alone in the tab group.
if !window.styleMask.contains(.fullScreen) &&
window.tabGroup?.windows.count ?? 1 == 1 {
let hasFixedPos = controller.derivedConfig.windowPositionX != nil && controller.derivedConfig.windowPositionY != nil
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
}
controller.showWindow(self)
window.makeKeyAndOrderFront(self)
@ -1187,35 +1205,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
super.windowWillClose(notification)
cancelPendingInitialPresentation()
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: .zero)
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) {

View File

@ -109,3 +109,20 @@ extension NSWindow {
tabButtonHit(atScreenPoint: screenPoint)?.index
}
}
// MARK: - Cascade
extension NSWindow {
/// Return the value of `cascadeTopLeft(from: .zero)` and make sure the window will not move
func topLeftForNextWindow() -> CGPoint {
// 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 = frame
let cascaded = cascadeTopLeft(from: .zero)
setFrame(oldFrame, display: true)
return cascaded
}
}

View File

@ -4,7 +4,13 @@ import Cocoa
class LastWindowPosition {
static let shared = LastWindowPosition()
private let positionKey = "NSWindowLastPosition"
fileprivate static let rectsKey = "NSWindowLastRectsByScreen"
let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
@discardableResult
func save(_ window: NSWindow?) -> Bool {
@ -13,9 +19,13 @@ class LastWindowPosition {
// with the wrong one when window decorations change while creating,
// e.g. adding a toolbar affects the window's frame.
guard let window, window.isVisible else { return false }
let frame = window.frame
let rect = [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height]
UserDefaults.ghostty.set(rect, forKey: positionKey)
// We don't save the window frame when the window is in native fullscreen mode,
// since AppKit doesn't restore .fullScreen correctly.
// We should keep the behavior like first-party apps, such as Terminal and Safari.
guard !window.styleMask.contains(.fullScreen), let screenID = window.screen?.displayUUID?.uuidString else {
return false
}
savedWindowRectInfo[screenID] = window.frame
return true
}
@ -32,22 +42,22 @@ class LastWindowPosition {
func restore(_ window: NSWindow, origin restoreOrigin: Bool = true, size restoreSize: Bool = true) -> Bool {
guard restoreOrigin || restoreSize else { return false }
guard let values = UserDefaults.ghostty.array(forKey: positionKey) as? [Double],
values.count >= 2 else { return false }
let lastPosition = CGPoint(x: values[0], y: values[1])
guard let screen = window.screen ?? NSScreen.main else { return false }
guard
let screen = window.screen ?? NSScreen.main,
let screenID = screen.displayUUID?.uuidString,
let lastFrame = savedWindowRectInfo[screenID]
else {
return false
}
let visibleFrame = screen.visibleFrame
var newFrame = window.frame
if restoreOrigin {
newFrame.origin = lastPosition
newFrame.origin = lastFrame.origin
}
if restoreSize, values.count >= 4 {
newFrame.size.width = min(values[2], visibleFrame.width)
newFrame.size.height = min(values[3], visibleFrame.height)
if restoreSize {
newFrame.size.width = min(lastFrame.width, visibleFrame.width)
newFrame.size.height = min(lastFrame.height, visibleFrame.height)
}
// If the new frame is not constrained to the visible screen,
@ -65,3 +75,35 @@ class LastWindowPosition {
return true
}
}
extension LastWindowPosition {
var savedWindowRectInfo: [String: CGRect] {
get {
guard
let dict = defaults.dictionary(forKey: LastWindowPosition.rectsKey) as? [String: CFDictionary]
else {
// Restore previously saved rect on main screen
if
let rect = CGRect(valueArray: defaults.array(forKey: "NSWindowLastPosition")),
let screenID = NSScreen.main?.displayUUID?.uuidString {
return [screenID: rect]
} else {
return [:]
}
}
return dict.compactMapValues(CGRect.init(dictionaryRepresentation:))
}
set {
defaults.set(newValue.mapValues(\.dictionaryRepresentation), forKey: LastWindowPosition.rectsKey)
}
}
}
private extension CGRect {
init?(valueArray: [Any]?) {
guard let values = valueArray as? [Double], values.count >= 2 else {
return nil
}
self.init(x: values[0], y: values[1], width: values[safe: 2] ?? 0, height: values[safe: 3] ?? 0)
}
}

View File

@ -0,0 +1,66 @@
//
// LastWindowPositionTests.swift
// Ghostty
//
// Created by Lukas on 04.03.2026.
//
import Testing
import AppKit
@testable import Ghostty
@Suite(.serialized)
struct LastWindowPositionTests {
func usingTemporaryHelper(_ perform: (_ helper: LastWindowPosition) throws -> Void) rethrows {
let defaults = MockDefaults()
try perform(LastWindowPosition(defaults: defaults))
defaults.reset()
}
@Test func restorePoint() throws {
try usingTemporaryHelper { helper in
helper.defaults.set([20, 20], forKey: "NSWindowLastPosition")
let rect = try #require(helper.savedWindowRectInfo.values.first)
#expect(rect == CGRect(x: 20, y: 20, width: 0, height: 0))
}
}
@Test func restoreRect() throws {
try usingTemporaryHelper { helper in
helper.defaults.set([20, 20, 30, 30], forKey: "NSWindowLastPosition")
let rect = try #require(helper.savedWindowRectInfo.values.first)
#expect(rect == CGRect(x: 20, y: 20, width: 30, height: 30))
}
}
@Test func restoreScreenByRect() throws {
usingTemporaryHelper { helper in
helper.defaults.set([
"main": CGRect(x: 20, y: 20, width: 30, height: 30).dictionaryRepresentation
], forKey: "NSWindowLastRectsByScreen")
#expect(helper.savedWindowRectInfo == [
"main": CGRect(x: 20, y: 20, width: 30, height: 30)
])
}
}
}
class MockDefaults: UserDefaults {
private var values: [String: Any] = [:]
func reset() {
values.removeAll()
}
override func set(_ value: Any?, forKey defaultName: String) {
values[defaultName] = value
}
override func dictionary(forKey defaultName: String) -> [String: Any]? {
values[defaultName] as? [String: Any]
}
override func array(forKey defaultName: String) -> [Any]? {
values[defaultName] as? [Any]
}
}