macos: use stable display UUID for quick terminal screen tracking (#9256)

NSScreen instances can be garbage collected at any time, even for
screens that remain connected, making NSMapTable with weak keys
unreliable for tracking per-screen state.

This changes the quick terminal to use CGDisplay UUIDs as stable
identifiers, keyed in a strong dictionary. Each entry stores the window
frame along with screen dimensions, scale factor, and last-seen
timestamp.

**This should make quick terminal size restore more stable than 1.2.2.**

Rules for pruning:
- Entries are invalidated when screens shrink or change scale
- Entries persist and update when screens grow (allowing cached state to
work with larger resolutions)
- Stale entries for disconnected screens expire after 14 days.
- Maximum of 10 screen entries to prevent unbounded growth
1.2.x
Mitchell Hashimoto 2025-10-17 21:14:56 -07:00
parent d3128243ec
commit 61f74158be
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
5 changed files with 144 additions and 27 deletions

View File

@ -54,6 +54,8 @@
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A53F889C2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */; };
A53F889E2EAA932B00F1C56B /* UUID+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */; };
A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; };
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */; };
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */; };
@ -207,6 +209,8 @@
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreenStateCache.swift; sourceTree = "<group>"; };
A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UUID+Extension.swift"; sourceTree = "<group>"; };
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = "<group>"; };
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = "<group>"; };
@ -565,6 +569,7 @@
A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
);
path = Extensions;
@ -644,6 +649,7 @@
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */,
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */,
A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */,
A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */,
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */,
);
@ -908,6 +914,7 @@
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
A53F889C2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift in Sources */,
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
@ -952,6 +959,7 @@
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
A53F889E2EAA932B00F1C56B /* UUID+Extension.swift in Sources */,
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,

View File

@ -21,13 +21,8 @@ class QuickTerminalController: BaseTerminalController {
// The active space when the quick terminal was last shown.
private var previousActiveSpace: CGSSpace? = nil
/// The saved state 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
/// the window to its default minimum size.
private var lastClosedFrames: NSMapTable<NSScreen, LastClosedState>
/// Cache for per-screen window state.
private let screenStateCache = QuickTerminalScreenStateCache()
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil
@ -45,10 +40,6 @@ class QuickTerminalController: BaseTerminalController {
) {
self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
// This is a weak to strong mapping, so that our keys being NSScreens
// can remove themselves when they disappear.
self.lastClosedFrames = .weakToStrongObjects()
// Important detail here: we initialize with an empty surface tree so
// that we don't start a terminal process. This gets started when the
@ -363,17 +354,15 @@ class QuickTerminalController: BaseTerminalController {
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Grab our last closed frame to use, and clear our state since we're animating in.
// We only use the last closed frame if we're opening on the same screen.
let lastClosedFrame: NSRect? = lastClosedFrames.object(forKey: screen)?.frame
lastClosedFrames.removeObject(forKey: screen)
// Grab our last closed frame to use from the cache.
let closedFrame = screenStateCache.frame(for: screen)
// Move our window off screen to the initial animation position.
position.setInitial(
in: window,
on: screen,
terminalSize: derivedConfig.quickTerminalSize,
closedFrame: lastClosedFrame)
closedFrame: closedFrame)
// 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
@ -408,7 +397,7 @@ class QuickTerminalController: BaseTerminalController {
in: window.animator(),
on: screen,
terminalSize: derivedConfig.quickTerminalSize,
closedFrame: lastClosedFrame)
closedFrame: closedFrame)
}, 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.
@ -497,7 +486,7 @@ class QuickTerminalController: BaseTerminalController {
// terminal is reactivated with a new surface. Without this, SwiftUI
// would reset the window to its minimum content size.
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
lastClosedFrames.setObject(.init(frame: window.frame), forKey: screen)
screenStateCache.save(frame: window.frame, for: screen)
}
// If we hid the dock then we unhide it.
@ -582,7 +571,6 @@ class QuickTerminalController: BaseTerminalController {
alert.alertStyle = .warning
alert.beginSheetModal(for: window)
}
// MARK: First Responder
@IBAction override func closeWindow(_ sender: Any) {
@ -720,14 +708,6 @@ class QuickTerminalController: BaseTerminalController {
hidden = false
}
}
private class LastClosedState {
let frame: NSRect
init(frame: NSRect) {
self.frame = frame
}
}
}
extension Notification.Name {

View File

@ -0,0 +1,113 @@
import Foundation
import Cocoa
/// Manages cached window state per screen for the quick terminal.
///
/// This cache tracks the last closed window frame for each screen, allowing the quick terminal
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
/// to survive NSScreen garbage collection and automatically prunes stale entries.
class QuickTerminalScreenStateCache {
/// The maximum number of saved screen states we retain. This is to avoid some kind of
/// pathological memory growth in case we get our screen state serializing wrong. I don't
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
private static let maxSavedScreens = 10
/// Time-to-live for screen entries that are no longer present (14 days).
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
/// Keyed by display UUID to survive NSScreen garbage collection.
private var stateByDisplay: [UUID: DisplayEntry] = [:]
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(onScreensChanged(_:)),
name: NSApplication.didChangeScreenParametersNotification,
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Save the window frame for a screen.
func save(frame: NSRect, for screen: NSScreen) {
guard let key = screen.displayUUID else { return }
let entry = DisplayEntry(
frame: frame,
screenSize: screen.frame.size,
scale: screen.backingScaleFactor,
lastSeen: Date()
)
stateByDisplay[key] = entry
pruneCapacity()
}
/// Retrieve the last closed frame for a screen, if valid.
func frame(for screen: NSScreen) -> NSRect? {
guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil }
// Drop on dimension/scale change that makes the entry invalid
if !entry.isValid(for: screen) {
stateByDisplay.removeValue(forKey: key)
return nil
}
entry.lastSeen = Date()
stateByDisplay[key] = entry
return entry.frame
}
@objc private func onScreensChanged(_ note: Notification) {
let screens = NSScreen.screens
let now = Date()
let currentIDs = Set(screens.compactMap { $0.displayUUID })
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
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
}
}
}
// TTL prune for non-present screens
stateByDisplay = stateByDisplay.filter { key, entry in
currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL
}
pruneCapacity()
}
private func pruneCapacity() {
guard stateByDisplay.count > Self.maxSavedScreens else { return }
let toRemove = stateByDisplay
.sorted { $0.value.lastSeen < $1.value.lastSeen }
.prefix(stateByDisplay.count - Self.maxSavedScreens)
for (key, _) in toRemove {
stateByDisplay.removeValue(forKey: key)
}
}
private struct DisplayEntry {
var frame: NSRect
var screenSize: CGSize
var scale: CGFloat
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.
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
}
}
}

View File

@ -5,6 +5,13 @@ extension NSScreen {
var displayID: UInt32? {
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
}
/// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection.
var displayUUID: UUID? {
guard let displayID = displayID else { return nil }
guard let cfuuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { return nil }
return UUID(cfuuid)
}
// Returns true if the given screen has a visible dock. This isn't
// point-in-time visible, this is true if the dock is always visible

View File

@ -0,0 +1,9 @@
import Foundation
extension UUID {
/// Initialize a UUID from a CFUUID.
init?(_ cfuuid: CFUUID) {
guard let uuidString = CFUUIDCreateString(nil, cfuuid) as String? else { return nil }
self.init(uuidString: uuidString)
}
}