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 growth1.2.x
parent
d3128243ec
commit
61f74158be
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue