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 } +}