diff --git a/include/ghostty.h b/include/ghostty.h index fcad7af30..d69a9352b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -450,6 +450,23 @@ typedef struct { ghostty_config_color_s colors[256]; } ghostty_config_palette_s; +// config.QuickTerminalSize +typedef enum { + GHOSTTY_QUICK_TERMINAL_SIZE_NONE, + GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE, + GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS, +} ghostty_quick_terminal_size_e; + +typedef struct { + ghostty_quick_terminal_size_e type; + uint32_t value; +} ghostty_quick_terminal_size_s; + +typedef struct { + ghostty_quick_terminal_size_s primary; + ghostty_quick_terminal_size_s secondary; +} ghostty_config_quick_terminal_size_s; + // apprt.Target.Key typedef enum { GHOSTTY_TARGET_APP, diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 0c54ba693..d9fa44f08 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -104,6 +104,7 @@ A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */; }; + A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; @@ -252,6 +253,7 @@ A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Extension.swift"; sourceTree = ""; }; + A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSize.swift; sourceTree = ""; }; A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; @@ -488,6 +490,7 @@ A55B7BB429B6F4410055DE60 /* Ghostty */ = { isa = PBXGroup; children = ( + A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */, A55B7BB729B6F53A0055DE60 /* Package.swift */, A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */, @@ -939,6 +942,10 @@ A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, + A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, + A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 3bd8bc18f..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) { @@ -109,7 +117,7 @@ class QuickTerminalController: BaseTerminalController { syncAppearance() // Setup our initial size based on our configured position - position.setLoaded(window) + position.setLoaded(window, size: derivedConfig.quickTerminalSize) // Setup our content window.contentView = NSHostingView(rootView: TerminalView( @@ -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) + // 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) + 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) + 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) + 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. @@ -612,6 +657,7 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalAnimationDuration: Double let quickTerminalAutoHide: Bool let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior + let quickTerminalSize: QuickTerminalSize let backgroundOpacity: Double init() { @@ -619,6 +665,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAutoHide = true self.quickTerminalSpaceBehavior = .move + self.quickTerminalSize = QuickTerminalSize() self.backgroundOpacity = 1.0 } @@ -627,6 +674,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAutoHide = config.quickTerminalAutoHide self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior + self.quickTerminalSize = config.quickTerminalSize self.backgroundOpacity = config.backgroundOpacity } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 7ba124a30..418d8da94 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -8,94 +8,69 @@ enum QuickTerminalPosition : String { case center /// Set the loaded state for a window. - func setLoaded(_ window: NSWindow) { + func setLoaded(_ window: NSWindow, size: QuickTerminalSize) { guard let screen = window.screen ?? NSScreen.main else { return } - switch (self) { - case .top, .bottom: - window.setFrame(.init( - origin: window.frame.origin, - size: .init( - width: screen.frame.width, - height: screen.frame.height / 4) - ), display: false) - - case .left, .right: - window.setFrame(.init( - origin: window.frame.origin, - size: .init( - width: screen.frame.width / 4, - height: screen.frame.height) - ), display: false) - - case .center: - window.setFrame(.init( - origin: window.frame.origin, - size: .init( - width: screen.frame.width / 2, - height: screen.frame.height / 3) - ), display: false) - } + let dimensions = size.calculate(position: self, screenDimensions: screen.frame.size) + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: dimensions.width, + height: dimensions.height) + ), display: false) } /// Set the initial state for a window for animating out of this position. - func setInitial(in window: NSWindow, on screen: NSScreen) { + 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) + 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) { + 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) + size: configuredFrameSize(on: screen, terminalSize: terminalSize, preserveExisting: preserveSize) ), display: true) } - /// Restrict the frame size during resizing. - func restrictFrameSize(_ size: NSSize, on screen: NSScreen) -> NSSize { - var finalSize = size - switch (self) { - case .top, .bottom: - finalSize.width = screen.frame.width - - case .left, .right: - finalSize.height = screen.visibleFrame.height - - case .center: - finalSize.width = screen.frame.width / 2 - finalSize.height = screen.frame.height / 3 + /// 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. func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { case .top: - return .init(x: screen.frame.minX, y: screen.frame.maxY) + return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.frame.maxY) case .bottom: - return .init(x: screen.frame.minX, y: -window.frame.height) + return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: -window.frame.height) case .left: - return .init(x: screen.frame.minX-window.frame.width, y: 0) + return .init(x: screen.frame.minX-window.frame.width, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)) case .right: - return .init(x: screen.frame.maxX, y: 0) + return .init(x: screen.frame.maxX, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)) case .center: - return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.height - window.frame.width) + return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: screen.visibleFrame.height - window.frame.width) } } @@ -103,19 +78,19 @@ enum QuickTerminalPosition : String { func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { case .top: - return .init(x: screen.frame.minX, y: screen.visibleFrame.maxY - window.frame.height) + return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.visibleFrame.maxY - window.frame.height) case .bottom: - return .init(x: screen.frame.minX, y: screen.frame.minY) + return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.frame.minY) case .left: - return .init(x: screen.frame.minX, y: window.frame.origin.y) + return .init(x: screen.frame.minX, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)) case .right: - return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) + return .init(x: screen.visibleFrame.maxX - window.frame.width, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)) case .center: - return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2) + return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)) } } @@ -136,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: round(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: round(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: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), + y: round(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: round(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: round(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 + } + } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 241c10632..6050241f8 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -475,6 +475,14 @@ extension Ghostty { let str = String(cString: ptr) return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move } + + var quickTerminalSize: QuickTerminalSize { + guard let config = self.config else { return QuickTerminalSize() } + var v = ghostty_config_quick_terminal_size_s() + let key = "quick-terminal-size" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return QuickTerminalSize() } + return QuickTerminalSize(from: v) + } #endif var resizeOverlay: ResizeOverlay { diff --git a/macos/Sources/Ghostty/QuickTerminalSize.swift b/macos/Sources/Ghostty/QuickTerminalSize.swift new file mode 100644 index 000000000..194407014 --- /dev/null +++ b/macos/Sources/Ghostty/QuickTerminalSize.swift @@ -0,0 +1,84 @@ +import GhosttyKit + +struct QuickTerminalSize { + let primary: Size? + let secondary: Size? + + init(primary: Size? = nil, secondary: Size? = nil) { + self.primary = primary + self.secondary = secondary + } + + init(from cStruct: ghostty_config_quick_terminal_size_s) { + self.primary = Size(from: cStruct.primary) + self.secondary = Size(from: cStruct.secondary) + } + + enum Size { + case percentage(Float) + case pixels(UInt32) + + init?(from cStruct: ghostty_quick_terminal_size_s) { + switch cStruct.type { + case GHOSTTY_QUICK_TERMINAL_SIZE_NONE: + return nil + case GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE: + let floatValue = withUnsafePointer(to: cStruct.value) { ptr in + ptr.withMemoryRebound(to: Float.self, capacity: 1) { $0.pointee } + } + self = .percentage(floatValue) + case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS: + self = .pixels(cStruct.value) + default: + return nil + } + } + + func toPixels(parentDimension: CGFloat) -> CGFloat { + switch self { + case .percentage(let value): + return parentDimension * CGFloat(value) / 100.0 + case .pixels(let value): + return CGFloat(value) + } + } + } + + struct Dimensions { + let width: CGFloat + let height: CGFloat + } + + func calculate(position: QuickTerminalPosition, screenDimensions: CGSize) -> Dimensions { + let dims = Dimensions(width: screenDimensions.width, height: screenDimensions.height) + + switch position { + case .left, .right: + return Dimensions( + width: primary?.toPixels(parentDimension: dims.width) ?? 400, + height: secondary?.toPixels(parentDimension: dims.height) ?? dims.height + ) + + case .top, .bottom: + return Dimensions( + width: secondary?.toPixels(parentDimension: dims.width) ?? dims.width, + height: primary?.toPixels(parentDimension: dims.height) ?? 400 + ) + + case .center: + if dims.width >= dims.height { + // Landscape + return Dimensions( + width: primary?.toPixels(parentDimension: dims.width) ?? 800, + height: secondary?.toPixels(parentDimension: dims.height) ?? 400 + ) + } else { + // Portrait + return Dimensions( + width: secondary?.toPixels(parentDimension: dims.width) ?? 400, + height: primary?.toPixels(parentDimension: dims.height) ?? 800 + ) + } + } + } +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 1e2086876..cfdd8b880 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7063,6 +7063,42 @@ pub const QuickTerminalSize = struct { height: u32, }; + /// C API structure for QuickTerminalSize + pub const C = extern struct { + primary: CSize, + secondary: CSize, + }; + + pub const CSize = extern struct { + type: Type, + value: u32, + + pub const Type = enum(u8) { none, percentage, pixels }; + + pub const none: CSize = .{ .type = .none, .value = 0 }; + + fn percentage(v: f32) CSize { + return .{ .type = .percentage, .value = @bitCast(v) }; + } + + fn pixels(v: u32) CSize { + return .{ .type = .pixels, .value = v }; + } + }; + + pub fn cval(self: QuickTerminalSize) C { + return .{ + .primary = if (self.primary) |p| switch (p) { + .percentage => |v| .percentage(v), + .pixels => |v| .pixels(v), + } else .none, + .secondary = if (self.secondary) |s| switch (s) { + .percentage => |v| .percentage(v), + .pixels => |v| .pixels(v), + } else .none, + }; + } + pub fn calculate( self: QuickTerminalSize, position: QuickTerminalPosition,