Skip to content

Commit dffa4f4

Browse files
authored
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
2 parents 3e6bda1 + ea505ec commit dffa4f4

File tree

5 files changed

+137
-27
lines changed

5 files changed

+137
-27
lines changed

macos/Ghostty.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
Features/QuickTerminal/QuickTerminalController.swift,
9797
Features/QuickTerminal/QuickTerminalPosition.swift,
9898
Features/QuickTerminal/QuickTerminalScreen.swift,
99+
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
99100
Features/QuickTerminal/QuickTerminalSize.swift,
100101
Features/QuickTerminal/QuickTerminalSpaceBehavior.swift,
101102
Features/QuickTerminal/QuickTerminalWindow.swift,

macos/Sources/Features/QuickTerminal/QuickTerminalController.swift

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,8 @@ class QuickTerminalController: BaseTerminalController {
2121
// The active space when the quick terminal was last shown.
2222
private var previousActiveSpace: CGSSpace? = nil
2323

24-
/// The saved state when the quick terminal's surface tree becomes empty.
25-
///
26-
/// This preserves the user's window size and position when all terminal surfaces
27-
/// are closed (e.g., via the `exit` command). When a new surface is created,
28-
/// the window will be restored to this frame, preventing SwiftUI from resetting
29-
/// the window to its default minimum size.
30-
private var lastClosedFrames: NSMapTable<NSScreen, LastClosedState>
24+
/// Cache for per-screen window state.
25+
private let screenStateCache = QuickTerminalScreenStateCache()
3126

3227
/// Non-nil if we have hidden dock state.
3328
private var hiddenDock: HiddenDock? = nil
@@ -45,10 +40,6 @@ class QuickTerminalController: BaseTerminalController {
4540
) {
4641
self.position = position
4742
self.derivedConfig = DerivedConfig(ghostty.config)
48-
49-
// This is a weak to strong mapping, so that our keys being NSScreens
50-
// can remove themselves when they disappear.
51-
self.lastClosedFrames = .weakToStrongObjects()
5243

5344
// Important detail here: we initialize with an empty surface tree so
5445
// that we don't start a terminal process. This gets started when the
@@ -379,17 +370,15 @@ class QuickTerminalController: BaseTerminalController {
379370
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
380371
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
381372

382-
// Grab our last closed frame to use, and clear our state since we're animating in.
383-
// We only use the last closed frame if we're opening on the same screen.
384-
let lastClosedFrame: NSRect? = lastClosedFrames.object(forKey: screen)?.frame
385-
lastClosedFrames.removeObject(forKey: screen)
373+
// Grab our last closed frame to use from the cache.
374+
let closedFrame = screenStateCache.frame(for: screen)
386375

387376
// Move our window off screen to the initial animation position.
388377
position.setInitial(
389378
in: window,
390379
on: screen,
391380
terminalSize: derivedConfig.quickTerminalSize,
392-
closedFrame: lastClosedFrame)
381+
closedFrame: closedFrame)
393382

394383
// We need to set our window level to a high value. In testing, only
395384
// popUpMenu and above do what we want. This gets it above the menu bar
@@ -424,7 +413,7 @@ class QuickTerminalController: BaseTerminalController {
424413
in: window.animator(),
425414
on: screen,
426415
terminalSize: derivedConfig.quickTerminalSize,
427-
closedFrame: lastClosedFrame)
416+
closedFrame: closedFrame)
428417
}, completionHandler: {
429418
// There is a very minor delay here so waiting at least an event loop tick
430419
// keeps us safe from the view not being on the window.
@@ -513,7 +502,7 @@ class QuickTerminalController: BaseTerminalController {
513502
// terminal is reactivated with a new surface. Without this, SwiftUI
514503
// would reset the window to its minimum content size.
515504
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
516-
lastClosedFrames.setObject(.init(frame: window.frame), forKey: screen)
505+
screenStateCache.save(frame: window.frame, for: screen)
517506
}
518507

519508
// If we hid the dock then we unhide it.
@@ -598,7 +587,6 @@ class QuickTerminalController: BaseTerminalController {
598587
alert.alertStyle = .warning
599588
alert.beginSheetModal(for: window)
600589
}
601-
602590
// MARK: First Responder
603591

604592
@IBAction override func closeWindow(_ sender: Any) {
@@ -736,14 +724,6 @@ class QuickTerminalController: BaseTerminalController {
736724
hidden = false
737725
}
738726
}
739-
740-
private class LastClosedState {
741-
let frame: NSRect
742-
743-
init(frame: NSRect) {
744-
self.frame = frame
745-
}
746-
}
747727
}
748728

749729
extension Notification.Name {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import Foundation
2+
import Cocoa
3+
4+
/// Manages cached window state per screen for the quick terminal.
5+
///
6+
/// This cache tracks the last closed window frame for each screen, allowing the quick terminal
7+
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
8+
/// to survive NSScreen garbage collection and automatically prunes stale entries.
9+
class QuickTerminalScreenStateCache {
10+
/// The maximum number of saved screen states we retain. This is to avoid some kind of
11+
/// pathological memory growth in case we get our screen state serializing wrong. I don't
12+
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
13+
private static let maxSavedScreens = 10
14+
15+
/// Time-to-live for screen entries that are no longer present (14 days).
16+
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
17+
18+
/// Keyed by display UUID to survive NSScreen garbage collection.
19+
private var stateByDisplay: [UUID: DisplayEntry] = [:]
20+
21+
init() {
22+
NotificationCenter.default.addObserver(
23+
self,
24+
selector: #selector(onScreensChanged(_:)),
25+
name: NSApplication.didChangeScreenParametersNotification,
26+
object: nil)
27+
}
28+
29+
deinit {
30+
NotificationCenter.default.removeObserver(self)
31+
}
32+
33+
/// Save the window frame for a screen.
34+
func save(frame: NSRect, for screen: NSScreen) {
35+
guard let key = screen.displayUUID else { return }
36+
let entry = DisplayEntry(
37+
frame: frame,
38+
screenSize: screen.frame.size,
39+
scale: screen.backingScaleFactor,
40+
lastSeen: Date()
41+
)
42+
stateByDisplay[key] = entry
43+
pruneCapacity()
44+
}
45+
46+
/// Retrieve the last closed frame for a screen, if valid.
47+
func frame(for screen: NSScreen) -> NSRect? {
48+
guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil }
49+
50+
// Drop on dimension/scale change that makes the entry invalid
51+
if !entry.isValid(for: screen) {
52+
stateByDisplay.removeValue(forKey: key)
53+
return nil
54+
}
55+
56+
entry.lastSeen = Date()
57+
stateByDisplay[key] = entry
58+
return entry.frame
59+
}
60+
61+
@objc private func onScreensChanged(_ note: Notification) {
62+
let screens = NSScreen.screens
63+
let now = Date()
64+
let currentIDs = Set(screens.compactMap { $0.displayUUID })
65+
66+
for screen in screens {
67+
guard let key = screen.displayUUID else { continue }
68+
if var entry = stateByDisplay[key] {
69+
// Drop on dimension/scale change that makes the entry invalid
70+
if !entry.isValid(for: screen) {
71+
stateByDisplay.removeValue(forKey: key)
72+
} else {
73+
// Update the screen size if it grew (keep entry valid for larger screens)
74+
entry.screenSize = screen.frame.size
75+
entry.lastSeen = now
76+
stateByDisplay[key] = entry
77+
}
78+
}
79+
}
80+
81+
// TTL prune for non-present screens
82+
stateByDisplay = stateByDisplay.filter { key, entry in
83+
currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL
84+
}
85+
86+
pruneCapacity()
87+
}
88+
89+
private func pruneCapacity() {
90+
guard stateByDisplay.count > Self.maxSavedScreens else { return }
91+
let toRemove = stateByDisplay
92+
.sorted { $0.value.lastSeen < $1.value.lastSeen }
93+
.prefix(stateByDisplay.count - Self.maxSavedScreens)
94+
for (key, _) in toRemove {
95+
stateByDisplay.removeValue(forKey: key)
96+
}
97+
}
98+
99+
private struct DisplayEntry {
100+
var frame: NSRect
101+
var screenSize: CGSize
102+
var scale: CGFloat
103+
var lastSeen: Date
104+
105+
/// Returns true if this entry is still valid for the given screen.
106+
/// Valid if the scale matches and the cached size is not larger than the current screen size.
107+
/// This allows entries to persist when screens grow, but invalidates them when screens shrink.
108+
func isValid(for screen: NSScreen) -> Bool {
109+
guard scale == screen.backingScaleFactor else { return false }
110+
return screenSize.width <= screen.frame.size.width && screenSize.height <= screen.frame.size.height
111+
}
112+
}
113+
}

macos/Sources/Helpers/Extensions/NSScreen+Extension.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ extension NSScreen {
55
var displayID: UInt32? {
66
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
77
}
8+
9+
/// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection.
10+
var displayUUID: UUID? {
11+
guard let displayID = displayID else { return nil }
12+
guard let cfuuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { return nil }
13+
return UUID(cfuuid)
14+
}
815

916
// Returns true if the given screen has a visible dock. This isn't
1017
// point-in-time visible, this is true if the dock is always visible
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Foundation
2+
3+
extension UUID {
4+
/// Initialize a UUID from a CFUUID.
5+
init?(_ cfuuid: CFUUID) {
6+
guard let uuidString = CFUUIDCreateString(nil, cfuuid) as String? else { return nil }
7+
self.init(uuidString: uuidString)
8+
}
9+
}

0 commit comments

Comments
 (0)