Skip to content

Commit ea505ec

Browse files
committed
macos: use stable display UUID for quick terminal screen tracking
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. 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 parent 5bf05df commit ea505ec

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)