macos: `window-width/height` is accurate even with other widgets (#9747)

Fixes #2660

Rather than calculate our window frame size based on various chrome
calculations, we now utilize SwiftUI layouts and view intrinsic content
sizes with `setContentSize` to setup our content size ignoring all our
other widgets.

I'm sure there's some edge cases I'm missing here but this should be a
whole lot more reliable on the whole.
pull/9753/head
Mitchell Hashimoto 2025-11-29 06:56:18 -08:00 committed by GitHub
commit 2fd48b433d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 105 additions and 71 deletions

View File

@ -508,55 +508,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
window.syncAppearance(surfaceConfig)
}
/// Returns the default size of the window. This is contextual based on the focused surface because
/// the focused surface may specify a different default size than others.
private var defaultSize: NSRect? {
guard let screen = window?.screen ?? NSScreen.main else { return nil }
if derivedConfig.maximize {
return screen.visibleFrame
} else if let focusedSurface,
let initialSize = focusedSurface.initialSize {
// Get the current frame of the window
guard var frame = window?.frame else { return nil }
// Calculate the chrome size (window size minus view size)
let chromeWidth = frame.size.width - focusedSurface.frame.size.width
let chromeHeight = frame.size.height - focusedSurface.frame.size.height
// Calculate the new width and height, clamping to the screen's size
let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width)
let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height)
// Update the frame size while keeping the window's position intact
frame.size.width = newWidth
frame.size.height = newHeight
// Ensure the window doesn't go outside the screen boundaries
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
return adjustForWindowPosition(frame: frame, on: screen)
}
guard let initialFrame else { return nil }
guard var frame = window?.frame else { return nil }
// Calculate the new width and height, clamping to the screen's size
let newWidth = min(initialFrame.size.width, screen.visibleFrame.width)
let newHeight = min(initialFrame.size.height, screen.visibleFrame.height)
// Update the frame size while keeping the window's position intact
frame.size.width = newWidth
frame.size.height = newHeight
// Ensure the window doesn't go outside the screen boundaries
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
return adjustForWindowPosition(frame: frame, on: screen)
}
/// Adjusts the given frame for the configured window position.
func adjustForWindowPosition(frame: NSRect, on screen: NSScreen) -> NSRect {
guard let x = derivedConfig.windowPositionX else { return frame }
@ -922,9 +873,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
super.windowDidLoad()
guard let window else { return }
// Store our initial frame so we can know our default later.
initialFrame = window.frame
// I copy this because we may change the source in the future but also because
// I regularly audit our codebase for "ghostty.config" access because generally
// you shouldn't use it. Its safe in this case because for a new window we should
@ -944,19 +892,38 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// If this is our first surface then our focused surface will be nil
// so we force the focused surface to the leaf.
focusedSurface = view
if let defaultSize {
window.setFrame(defaultSize, display: true)
}
}
// Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView(
ghostty: self.ghostty,
viewModel: self,
delegate: self
delegate: self,
))
// If we have a default size, we want to apply it.
if let defaultSize {
switch (defaultSize) {
case .frame:
// Frames can be applied immediately
defaultSize.apply(to: window)
case .contentIntrinsicSize:
// Content intrinsic size requires a short delay so that AppKit
// can layout our SwiftUI views.
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in
guard let window else { return }
defaultSize.apply(to: window)
}
}
}
// Store our initial frame so we can know our default later. This MUST
// be after the defaultSize call above so that we don't re-apply our frame.
// Note: we probably want to set this on the first frame change or something
// so it respects cascade.
initialFrame = window.frame
// In various situations, macOS automatically tabs new windows. Ghostty handles
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
// it.
@ -1144,8 +1111,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
@IBAction func returnToDefaultSize(_ sender: Any?) {
guard let defaultSize else { return }
window?.setFrame(defaultSize, display: true)
guard let window, let defaultSize else { return }
defaultSize.apply(to: window)
}
@IBAction override func closeWindow(_ sender: Any?) {
@ -1421,19 +1388,68 @@ extension TerminalController {
// If our window is already the default size or we don't have a
// default size, then disable.
guard let defaultSize,
window.frame.size != .init(
width: defaultSize.size.width,
height: defaultSize.size.height
)
else {
return false
}
return true
return defaultSize?.isChanged(for: window) ?? false
default:
return super.validateMenuItem(item)
}
}
}
// MARK: Default Size
extension TerminalController {
/// The possible default sizes for a terminal. The size can't purely be known as a
/// window frame because if we set `window-width/height` then it is based
/// on content size.
enum DefaultSize {
/// A frame, set with `window.setFrame`
case frame(NSRect)
/// A content size, set with `window.setContentSize`
case contentIntrinsicSize
func isChanged(for window: NSWindow) -> Bool {
switch self {
case .frame(let rect):
return window.frame != rect
case .contentIntrinsicSize:
guard let view = window.contentView else {
return false
}
return view.frame.size != view.intrinsicContentSize
}
}
func apply(to window: NSWindow) {
switch self {
case .frame(let rect):
window.setFrame(rect, display: true)
case .contentIntrinsicSize:
guard let size = window.contentView?.intrinsicContentSize else {
return
}
window.setContentSize(size)
window.constrainToScreen()
}
}
}
private var defaultSize: DefaultSize? {
if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main {
// Maximize takes priority, we take up the full screen we're on.
return .frame(screen.visibleFrame)
} else if focusedSurface?.initialSize != nil {
// Initial size as requested by the configuration (e.g. `window-width`)
// takes next priority.
return .contentIntrinsicSize
} else if let initialFrame {
// The initial frame we had when we started otherwise.
return .frame(initialFrame)
} else {
return nil
}
}
}

View File

@ -100,6 +100,8 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
.frame(idealWidth: lastFocusedSurface.value?.initialSize?.width,
idealHeight: lastFocusedSurface.value?.initialSize?.height)
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])

View File

@ -15,4 +15,20 @@ extension NSWindow {
guard let firstWindow = tabGroup?.windows.first else { return true }
return firstWindow === self
}
/// Adjusts the window origin if necessary to ensure the window remains visible on screen.
func constrainToScreen() {
guard let screen = screen ?? NSScreen.main else { return }
let visibleFrame = screen.visibleFrame
var windowFrame = frame
windowFrame.origin.x = max(visibleFrame.minX,
min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width))
windowFrame.origin.y = max(visibleFrame.minY,
min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height))
if windowFrame.origin != frame.origin {
setFrameOrigin(windowFrame.origin)
}
}
}