Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions macos/GhosttyUITests/GhosttyWindowPositionUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
11 changes: 8 additions & 3 deletions macos/Sources/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,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)
Expand Down Expand Up @@ -734,8 +734,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) {
Expand Down Expand Up @@ -970,7 +975,7 @@ class AppDelegate: NSObject,
@IBAction func newTab(_ sender: Any?) {
_ = TerminalController.newTab(
ghostty,
from: TerminalController.preferredParent?.window
from: TerminalController.preferredNewTabParent?.window
)
}

Expand Down
17 changes: 11 additions & 6 deletions macos/Sources/Features/App Intents/NewTerminalIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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: await TerminalEntity(view: 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
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ extension NSApplication {

parentWindow = resolvedWindow
} else {
parentWindow = TerminalController.preferredParent?.window
parentWindow = TerminalController.preferredNewTabParent?.window
}

guard let createdController = TerminalController.newTab(
Expand Down
2 changes: 1 addition & 1 deletion macos/Sources/Features/Services/ServiceProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class ServiceProvider: NSObject {
case .tab:
_ = TerminalController.newTab(
delegate.ghostty,
from: TerminalController.preferredParent?.window,
from: TerminalController.preferredNewTabParent?.window,
withBaseConfig: config)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,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.
Expand Down Expand Up @@ -838,7 +841,7 @@ class BaseTerminalController: NSWindowController,
_ = TerminalController.newWindow(
ghostty,
tree: newTree,
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
position: position,
confirmUndo: false,
inheritBackgroundOpacity: isBackgroundOpaque)
}
Expand Down
166 changes: 86 additions & 80 deletions macos/Sources/Features/Terminal/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// The preferred parent terminal controller for new window.
private static var preferredNewWindowParent: TerminalController? {
preferredParent(on: .main)
}

private static func applyCascade(to window: NSWindow, hasFixedPos: Bool) {
if hasFixedPos { return }
// 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)
}

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 split.
static var preferredNewSplitParent: TerminalController? {
preferredNewTabParent
}

// The preferred parent terminal controller.
static var preferredParent: TerminalController? {
all.first {
$0.window?.isMainWindow ?? false
} ?? lastMain ?? all.last
/// 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"
Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -292,22 +319,27 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// - Creating a window via `New Ghostty Window Here` service.
c.showWindowSafely(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
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect after positioning in `showWindow`. 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 {
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
}
}
defer {
// All new_window actions force our app to be active, so that the new
// window is focused and visible.
NSApp.activate(ignoringOtherApps: true)
}

// All new_window actions force our app to be active, so that the new
// window is focused and visible.
NSApp.activate(ignoringOtherApps: true)
// Only cascade if we aren't fullscreen, have a parent and don't have fixed position in the config.
guard
let window = c.window,
!window.styleMask.contains(.fullScreen),
let parent,
!(c.derivedConfig.windowPositionX != nil && c.derivedConfig.windowPositionY != nil)
else {
return
}
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect after positioning in `showWindow`. 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 {
window.cascadeTopLeft(from: parent.topLeftForNextWindow())
}
}

// Setup our undo
Expand All @@ -330,7 +362,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
_ = TerminalController.newWindow(
ghostty,
withBaseConfig: baseConfig,
withParent: explicitParent)
withParent: explicitParent,
)
}
}
}
Expand All @@ -348,7 +381,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 {
Expand All @@ -374,13 +407,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()
}
}
}
Expand All @@ -407,6 +435,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
_ = TerminalController.newWindow(
ghostty,
tree: tree,
position: position,
inheritBackgroundOpacity: inheritBackgroundOpacity
)
}
Expand Down Expand Up @@ -503,16 +532,22 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// take effect after position in `showWindow`. 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)
defer {
// All new_window actions force our app to be active, so that the new
// window is focused and visible.
NSApp.activate(ignoringOtherApps: true)
}

// Only cascade if we aren't fullscreen, have a parent and don't have fixed position in the config.
guard
!window.styleMask.contains(.fullScreen),
window.tabGroup?.windows.count ?? 1 == 1,
!(controller.derivedConfig.windowPositionX != nil && controller.derivedConfig.windowPositionY != nil)
else {
return
}

// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
window.cascadeTopLeft(from: parent.topLeftForNextWindow())
}

// It takes an event loop cycle until the macOS tabGroup state becomes
Expand Down Expand Up @@ -1228,35 +1263,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) {
Expand Down
17 changes: 17 additions & 0 deletions macos/Sources/Helpers/Extensions/NSWindow+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,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
}
}
Loading
Loading