From 4c5800734b7fed0a9604a8a33daabff3ce727075 Mon Sep 17 00:00:00 2001 From: Friedrich Stoltzfus Date: Sat, 21 Jun 2025 10:46:30 -0400 Subject: [PATCH] macOS: enable quick terminal manual resizing You can now resize the quick terminal both vertically and horizontally. To incorporate adjusting the custom secondary size on the quick terminal we needed to have the ability to resize the width (if from top, bottom, or center), and height (if from right, left, or center). The quick terminal will retain the user's manually adjusted size while the app is open. A new feature with this is that when the secondary size is adjusted (or primary if the quick terminal is center), the size will increase or decrease on both sides of the terminal. --- .../QuickTerminalController.swift | 61 ++++++++++++-- .../QuickTerminal/QuickTerminalPosition.swift | 83 ++++++++++++++----- 2 files changed, 113 insertions(+), 31 deletions(-) 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 + } + } }