From 0274e7ad843627aa097f77c531f4baf399247c5d Mon Sep 17 00:00:00 2001 From: Corey Quinn Date: Mon, 29 Jun 2026 15:31:07 -0700 Subject: [PATCH] macos: fix quick terminal restoring stale size after display reconnect The quick terminal caches its last-closed window frame per display so it can restore the user's size when reopened. The cache entry was considered valid whenever the current screen was the same size *or larger* than when the frame was saved ("persist when screens grow"). This has led to a pattern that was simply maddening. To wit: That rule breaks across display changes. When an external display is disconnected and later reconnected at a different resolution (common after traveling with a laptop) the same display can come back larger than when the frame was cached. The stale frame is still treated as valid and restored, so the quick terminal no longer fills the screen (it appears at a partial width/height). Because the cache is persisted, restarting Ghostty does not clear it, and the user is slowly driven mad. Only treat a cached frame as valid when the screen geometry matches exactly (both backing scale factor and frame size). On any mismatch we drop the entry and fall back to the configured quick-terminal-size. Manual resizes are still remembered across toggles within a stable display configuration. Fixes the regression reported in #12348. --- .../QuickTerminalScreenStateCache.swift | 18 +++--- .../QuickTerminalScreenStateCacheTests.swift | 61 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 macos/Tests/QuickTerminalScreenStateCacheTests.swift diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift index 301865561..7bae50863 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift @@ -69,12 +69,12 @@ class QuickTerminalScreenStateCache { for screen in screens { guard let key = screen.displayUUID else { continue } if var entry = stateByDisplay[key] { - // Drop on dimension/scale change that makes the entry invalid + // Drop on dimension/scale change that makes the entry invalid. The + // saved frame is only meaningful for the exact geometry it was + // captured on, so a present screen that no longer matches is dropped. if !entry.isValid(for: screen) { stateByDisplay.removeValue(forKey: key) } else { - // Update the screen size if it grew (keep entry valid for larger screens) - entry.screenSize = screen.frame.size entry.lastSeen = now stateByDisplay[key] = entry } @@ -106,11 +106,15 @@ class QuickTerminalScreenStateCache { var lastSeen: Date /// Returns true if this entry is still valid for the given screen. - /// Valid if the scale matches and the cached size is not larger than the current screen size. - /// This allows entries to persist when screens grow, but invalidates them when screens shrink. + /// + /// An entry is only valid for the exact screen geometry it was captured on: both the + /// backing scale factor and the frame size must match. A saved frame is meaningless once + /// the display's resolution changes, which commonly happens when an external display is + /// disconnected and later reconnected at a different resolution (e.g. after travel). In + /// that case we drop the entry and fall back to the configured `quick-terminal-size`, + /// rather than restoring a stale frame that no longer fills the screen as expected. func isValid(for screen: NSScreen) -> Bool { - guard scale == screen.backingScaleFactor else { return false } - return screenSize.width <= screen.frame.size.width && screenSize.height <= screen.frame.size.height + scale == screen.backingScaleFactor && screenSize == screen.frame.size } } } diff --git a/macos/Tests/QuickTerminalScreenStateCacheTests.swift b/macos/Tests/QuickTerminalScreenStateCacheTests.swift new file mode 100644 index 000000000..0254f0940 --- /dev/null +++ b/macos/Tests/QuickTerminalScreenStateCacheTests.swift @@ -0,0 +1,61 @@ +import Testing +import AppKit +@testable import Ghostty + +struct QuickTerminalScreenStateCacheTests { + private typealias DisplayEntry = QuickTerminalScreenStateCache.DisplayEntry + + private func entry(screenSize: CGSize, scale: CGFloat) -> DisplayEntry { + DisplayEntry( + frame: NSRect(x: 0, y: 0, width: screenSize.width, height: 400), + screenSize: screenSize, + scale: scale, + lastSeen: Date(timeIntervalSince1970: 0)) + } + + @Test func validWhenGeometryMatches() { + let entry = entry(screenSize: .init(width: 1920, height: 1080), scale: 2) + let screen = MockSizedScreen(frame: .init(x: 0, y: 0, width: 1920, height: 1080), scale: 2) + #expect(entry.isValid(for: screen)) + } + + /// A frame cached on a smaller display must not be reused when the same display + /// reconnects at a larger resolution, otherwise the quick terminal restores at a + /// partial size instead of filling the screen. + @Test func invalidWhenScreenGrows() { + let entry = entry(screenSize: .init(width: 1512, height: 982), scale: 2) + let screen = MockSizedScreen(frame: .init(x: 0, y: 0, width: 3440, height: 1440), scale: 2) + #expect(!entry.isValid(for: screen)) + } + + @Test func invalidWhenScreenShrinks() { + let entry = entry(screenSize: .init(width: 3440, height: 1440), scale: 2) + let screen = MockSizedScreen(frame: .init(x: 0, y: 0, width: 1512, height: 982), scale: 2) + #expect(!entry.isValid(for: screen)) + } + + @Test func invalidWhenScaleDiffers() { + let entry = entry(screenSize: .init(width: 1920, height: 1080), scale: 1) + let screen = MockSizedScreen(frame: .init(x: 0, y: 0, width: 1920, height: 1080), scale: 2) + #expect(!entry.isValid(for: screen)) + } +} + +/// Mock NSScreen exposing a fixed frame and backing scale factor. +private final class MockSizedScreen: NSScreen { + private let mockFrame: NSRect + private let mockScale: CGFloat + + init(frame: NSRect, scale: CGFloat) { + self.mockFrame = frame + self.mockScale = scale + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var frame: NSRect { mockFrame } + override var backingScaleFactor: CGFloat { mockScale } +}