diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index af2032a75..494694da8 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController { private var previousActiveSpace: CGSSpace? = nil /// The window frame saved when the quick terminal's surface tree becomes empty. - /// + /// /// This preserves the user's window size and position when all terminal surfaces /// are closed (e.g., via the `exit` command). When a new surface is created, /// the window will be restored to this frame, preventing SwiftUI from resetting @@ -34,6 +34,9 @@ class QuickTerminalController: BaseTerminalController { /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig + + /// Tracks if we're currently handling a manual resize to prevent recursion + private var isHandlingResize: Bool = false init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, @@ -76,6 +79,11 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(onNewTab), name: Ghostty.Notification.ghosttyNewTab, object: nil) + center.addObserver( + self, + selector: #selector(windowDidResize(_:)), + name: NSWindow.didResizeNotification, + object: nil) } required init?(coder: NSCoder) { @@ -195,10 +203,45 @@ class QuickTerminalController: BaseTerminalController { } func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { - // We use the actual screen the window is on for this, since it should - // be on the proper screen. - guard let screen = window?.screen ?? NSScreen.main else { return frameSize } - return position.restrictFrameSize(frameSize, on: screen, terminalSize: derivedConfig.quickTerminalSize) + // Allow unrestricted resizing - users have full control + return frameSize + } + + override func windowDidResize(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window == self.window, + visible, + !isHandlingResize else { return } + + // For centered positions (top, bottom, center), we need to recenter the window + // when it's manually resized to maintain proper positioning + switch position { + case .top, .bottom, .center: + recenterWindow(window) + case .left, .right: + // For side positions, we may need to adjust vertical centering + recenterWindowVertically(window) + } + } + + private func recenterWindow(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { return } + + isHandlingResize = true + defer { isHandlingResize = false } + + let newOrigin = position.centeredOrigin(for: window, on: screen) + window.setFrameOrigin(newOrigin) + } + + private func recenterWindowVertically(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { return } + + isHandlingResize = true + defer { isHandlingResize = false } + + let newOrigin = position.verticallyCenteredOrigin(for: window, on: screen) + window.setFrameOrigin(newOrigin) } // MARK: Base Controller Overrides @@ -320,13 +363,15 @@ class QuickTerminalController: BaseTerminalController { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } // Restore our previous frame if we have one + var preserveSize: NSSize? = nil if let lastClosedFrame { window.setFrame(lastClosedFrame, display: false) + preserveSize = lastClosedFrame.size self.lastClosedFrame = nil } // Move our window off screen to the top - position.setInitial(in: window, on: screen, terminalSize: derivedConfig.quickTerminalSize) + position.setInitial(in: window, on: screen, terminalSize: derivedConfig.quickTerminalSize, preserveSize: preserveSize) // We need to set our window level to a high value. In testing, only // popUpMenu and above do what we want. This gets it above the menu bar @@ -357,7 +402,7 @@ class QuickTerminalController: BaseTerminalController { NSAnimationContext.runAnimationGroup({ context in context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) - position.setFinal(in: window.animator(), on: screen, terminalSize: derivedConfig.quickTerminalSize) + position.setFinal(in: window.animator(), on: screen, terminalSize: derivedConfig.quickTerminalSize, preserveSize: preserveSize) }, completionHandler: { // There is a very minor delay here so waiting at least an event loop tick // keeps us safe from the view not being on the window. @@ -481,7 +526,7 @@ class QuickTerminalController: BaseTerminalController { NSAnimationContext.runAnimationGroup({ context in context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) - position.setInitial(in: window.animator(), on: screen, terminalSize: derivedConfig.quickTerminalSize) + position.setInitial(in: window.animator(), on: screen, terminalSize: derivedConfig.quickTerminalSize, preserveSize: window.frame.size) }, completionHandler: { // This causes the window to be removed from the screen list and macOS // handles what should be focused next. diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index a1da81758..bf7ed4b08 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -20,49 +20,38 @@ enum QuickTerminalPosition : String { } /// Set the initial state for a window for animating out of this position. - func setInitial(in window: NSWindow, on screen: NSScreen, terminalSize: QuickTerminalSize) { + func setInitial(in window: NSWindow, on screen: NSScreen, terminalSize: QuickTerminalSize, preserveSize: NSSize? = nil) { // We always start invisible window.alphaValue = 0 // Position depends window.setFrame(.init( origin: initialOrigin(for: window, on: screen), - size: restrictFrameSize(window.frame.size, on: screen, terminalSize: terminalSize) + size: configuredFrameSize(on: screen, terminalSize: terminalSize, preserveExisting: preserveSize) ), display: false) } /// Set the final state for a window in this position. - func setFinal(in window: NSWindow, on screen: NSScreen, terminalSize: QuickTerminalSize) { + func setFinal(in window: NSWindow, on screen: NSScreen, terminalSize: QuickTerminalSize, preserveSize: NSSize? = nil) { // We always end visible window.alphaValue = 1 // Position depends window.setFrame(.init( origin: finalOrigin(for: window, on: screen), - size: restrictFrameSize(window.frame.size, on: screen, terminalSize: terminalSize) + size: configuredFrameSize(on: screen, terminalSize: terminalSize, preserveExisting: preserveSize) ), display: true) } - /// Restrict the frame size during resizing. - func restrictFrameSize(_ size: NSSize, on screen: NSScreen, terminalSize: QuickTerminalSize) -> NSSize { - var finalSize = size - let dimensions = terminalSize.calculate(position: self, screenDimensions: screen.frame.size) - - switch (self) { - case .top, .bottom: - finalSize.width = dimensions.width - finalSize.height = dimensions.height - - case .left, .right: - finalSize.width = dimensions.width - finalSize.height = dimensions.height - - case .center: - finalSize.width = dimensions.width - finalSize.height = dimensions.height + /// Get the configured frame size for initial positioning and animations. + func configuredFrameSize(on screen: NSScreen, terminalSize: QuickTerminalSize, preserveExisting: NSSize? = nil) -> NSSize { + // If we have existing dimensions from manual resizing, preserve them + if let existing = preserveExisting, existing.width > 0 && existing.height > 0 { + return existing } - - return finalSize + + let dimensions = terminalSize.calculate(position: self, screenDimensions: screen.frame.size) + return NSSize(width: dimensions.width, height: dimensions.height) } /// The initial point origin for this position. @@ -122,4 +111,52 @@ enum QuickTerminalPosition : String { case .right: self == .top || self == .bottom } } + + /// Calculate the centered origin for a window, keeping it properly positioned after manual resizing + func centeredOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch self { + case .top: + return CGPoint( + x: screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2, + y: window.frame.origin.y // Keep the same Y position + ) + + case .bottom: + return CGPoint( + x: screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2, + y: window.frame.origin.y // Keep the same Y position + ) + + case .center: + return CGPoint( + x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, + y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2 + ) + + case .left, .right: + // For left/right positions, only adjust horizontal centering if needed + return window.frame.origin + } + } + + /// Calculate the vertically centered origin for side-positioned windows + func verticallyCenteredOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch self { + case .left: + return CGPoint( + x: window.frame.origin.x, // Keep the same X position + y: screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2 + ) + + case .right: + return CGPoint( + x: window.frame.origin.x, // Keep the same X position + y: screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2 + ) + + case .top, .bottom, .center: + // These positions don't need vertical recentering during resize + return window.frame.origin + } + } }