Files
ghostty/macos/Tests/QuickTerminalScreenStateCacheTests.swift
Corey Quinn 0274e7ad84 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.
2026-06-29 15:31:07 -07:00

62 lines
2.3 KiB
Swift

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