macOS: save&restore quick terminal state
parent
72747a28af
commit
d680404fae
|
|
@ -95,6 +95,7 @@
|
|||
Features/QuickTerminal/QuickTerminal.xib,
|
||||
Features/QuickTerminal/QuickTerminalController.swift,
|
||||
Features/QuickTerminal/QuickTerminalPosition.swift,
|
||||
Features/QuickTerminal/QuickTerminalRestorableState.swift,
|
||||
Features/QuickTerminal/QuickTerminalScreen.swift,
|
||||
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
|
||||
Features/QuickTerminal/QuickTerminalSize.swift,
|
||||
|
|
|
|||
|
|
@ -99,11 +99,35 @@ class AppDelegate: NSObject,
|
|||
/// The global undo manager for app-level state such as window restoration.
|
||||
lazy var undoManager = ExpiringUndoManager()
|
||||
|
||||
/// The current state of the quick terminal.
|
||||
private var quickTerminalControllerState: QuickTerminalState = .uninitialized
|
||||
|
||||
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
||||
private(set) lazy var quickController = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition
|
||||
)
|
||||
var quickController: QuickTerminalController {
|
||||
switch quickTerminalControllerState {
|
||||
case .initialized(let controller):
|
||||
return controller
|
||||
|
||||
case .pendingRestore(let state):
|
||||
let controller = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition,
|
||||
baseConfig: state.baseConfig,
|
||||
restorationState: state
|
||||
)
|
||||
quickTerminalControllerState = .initialized(controller)
|
||||
return controller
|
||||
|
||||
case .uninitialized:
|
||||
let controller = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition,
|
||||
restorationState: nil
|
||||
)
|
||||
quickTerminalControllerState = .initialized(controller)
|
||||
return controller
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages updates
|
||||
let updateController = UpdateController()
|
||||
|
|
@ -996,10 +1020,31 @@ class AppDelegate: NSObject,
|
|||
|
||||
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
|
||||
Self.logger.debug("application will save window state")
|
||||
|
||||
guard ghostty.config.windowSaveState != "never" else { return }
|
||||
|
||||
// Encode our quick terminal state if we have it.
|
||||
switch quickTerminalControllerState {
|
||||
case .initialized(let controller) where controller.restorable:
|
||||
let data = QuickTerminalRestorableState(from: controller)
|
||||
data.encode(with: coder)
|
||||
|
||||
case .pendingRestore(let state):
|
||||
state.encode(with: coder)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
|
||||
Self.logger.debug("application will restore window state")
|
||||
|
||||
// Decode our quick terminal state.
|
||||
if ghostty.config.windowSaveState != "never",
|
||||
let state = QuickTerminalRestorableState(coder: coder) {
|
||||
quickTerminalControllerState = .pendingRestore(state)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - UNUserNotificationCenterDelegate
|
||||
|
|
@ -1273,6 +1318,16 @@ extension AppDelegate: NSMenuItemValidation {
|
|||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the quick terminal controller.
|
||||
private enum QuickTerminalState {
|
||||
/// Controller has not been initialized and has no pending restoration state.
|
||||
case uninitialized
|
||||
/// Restoration state is pending; controller will use this when first accessed.
|
||||
case pendingRestore(QuickTerminalRestorableState)
|
||||
/// Controller has been initialized.
|
||||
case initialized(QuickTerminalController)
|
||||
}
|
||||
|
||||
@globalActor
|
||||
fileprivate actor AppIconActor: GlobalActor {
|
||||
static let shared = AppIconActor()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController {
|
|||
private var previousActiveSpace: CGSSpace? = nil
|
||||
|
||||
/// Cache for per-screen window state.
|
||||
private let screenStateCache = QuickTerminalScreenStateCache()
|
||||
let screenStateCache: QuickTerminalScreenStateCache
|
||||
|
||||
/// Non-nil if we have hidden dock state.
|
||||
private var hiddenDock: HiddenDock? = nil
|
||||
|
|
@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController {
|
|||
|
||||
/// Tracks if we're currently handling a manual resize to prevent recursion
|
||||
private var isHandlingResize: Bool = false
|
||||
|
||||
|
||||
/// This is set to false by init if the window managed by this controller should not be restorable.
|
||||
/// For example, terminals executing custom scripts are not restorable.
|
||||
let restorable: Bool
|
||||
private var restorationState: QuickTerminalRestorableState?
|
||||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
position: QuickTerminalPosition = .top,
|
||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
|
||||
restorationState: QuickTerminalRestorableState? = nil,
|
||||
) {
|
||||
self.position = position
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
||||
// The window we manage is not restorable if we've specified a command
|
||||
// to execute. We do this because the restored window is meaningless at the
|
||||
// time of writing this: it'd just restore to a shell in the same directory
|
||||
// as the script. We may want to revisit this behavior when we have scrollback
|
||||
// restoration.
|
||||
restorable = (base?.command ?? "") == ""
|
||||
self.restorationState = restorationState
|
||||
self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:])
|
||||
// Important detail here: we initialize with an empty surface tree so
|
||||
// that we don't start a terminal process. This gets started when the
|
||||
// first terminal is shown in `animateIn`.
|
||||
|
|
@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController {
|
|||
// window close so we can animate out.
|
||||
window.delegate = self
|
||||
|
||||
// The quick window is not restorable (yet!). "Yet" because in theory we can
|
||||
// make this restorable, but it isn't currently implemented.
|
||||
// The quick window is restored by `screenStateCache`.
|
||||
// We disable this for better control
|
||||
window.isRestorable = false
|
||||
|
||||
// Setup our configured appearance that we support.
|
||||
|
|
@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController {
|
|||
// animate out.
|
||||
if surfaceTree.isEmpty,
|
||||
let ghostty_app = ghostty.app {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||
|
||||
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||
surfaceTree = SplitTree(view: view)
|
||||
focusedSurface = view
|
||||
if let tree = restorationState?.surfaceTree, !tree.isEmpty {
|
||||
surfaceTree = tree
|
||||
let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first!
|
||||
focusedSurface = view
|
||||
// Add a short delay to check if the correct surface is focused.
|
||||
// Each SurfaceWrapper defaults its FocusedValue to itself; without this delay,
|
||||
// the tree often focuses the first surface instead of the intended one.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
if !view.focused {
|
||||
self.focusedSurface = view
|
||||
self.makeWindowKey(window)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||
|
||||
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||
surfaceTree = SplitTree(view: view)
|
||||
focusedSurface = view
|
||||
}
|
||||
}
|
||||
|
||||
// Animate the window in
|
||||
animateWindowIn(window: window, from: position)
|
||||
// Clear the restoration state after first use
|
||||
restorationState = nil
|
||||
}
|
||||
|
||||
func animateOut() {
|
||||
|
|
@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController {
|
|||
animateWindowOut(window: window, to: position)
|
||||
}
|
||||
|
||||
func saveScreenState(exitFullscreen: Bool) {
|
||||
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
|
||||
// we have th correct window.frame for the save state below.
|
||||
if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen {
|
||||
fullscreenStyle.exit()
|
||||
}
|
||||
guard let window else { return }
|
||||
// Save the current window frame before animating out. This preserves
|
||||
// the user's preferred window size and position for when the quick
|
||||
// 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 {
|
||||
screenStateCache.save(frame: window.frame, for: screen)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
|
||||
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
|
||||
|
||||
|
|
@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController {
|
|||
}
|
||||
|
||||
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
|
||||
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
|
||||
// we have th correct window.frame for the save state below.
|
||||
if let fullscreenStyle, fullscreenStyle.isFullscreen {
|
||||
fullscreenStyle.exit()
|
||||
}
|
||||
|
||||
// Save the current window frame before animating out. This preserves
|
||||
// the user's preferred window size and position for when the quick
|
||||
// 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 {
|
||||
screenStateCache.save(frame: window.frame, for: screen)
|
||||
}
|
||||
saveScreenState(exitFullscreen: true)
|
||||
|
||||
// If we hid the dock then we unhide it.
|
||||
hiddenDock = nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import Cocoa
|
||||
|
||||
struct QuickTerminalRestorableState: TerminalRestorable {
|
||||
static var version: Int { 1 }
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
let screenStateEntries: QuickTerminalScreenStateCache.Entries
|
||||
|
||||
init(from controller: QuickTerminalController) {
|
||||
controller.saveScreenState(exitFullscreen: true)
|
||||
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||
self.surfaceTree = controller.surfaceTree
|
||||
self.screenStateEntries = controller.screenStateCache.stateByDisplay
|
||||
}
|
||||
|
||||
init(copy other: QuickTerminalRestorableState) {
|
||||
self = other
|
||||
}
|
||||
|
||||
var baseConfig: Ghostty.SurfaceConfiguration? {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import Cocoa
|
|||
/// 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 {
|
||||
typealias Entries = [UUID: DisplayEntry]
|
||||
|
||||
/// 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.
|
||||
|
|
@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache {
|
|||
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
|
||||
|
||||
/// Keyed by display UUID to survive NSScreen garbage collection.
|
||||
private var stateByDisplay: [UUID: DisplayEntry] = [:]
|
||||
|
||||
init() {
|
||||
private(set) var stateByDisplay: Entries = [:]
|
||||
|
||||
init(stateByDisplay: Entries = [:]) {
|
||||
self.stateByDisplay = stateByDisplay
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(onScreensChanged(_:)),
|
||||
|
|
@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache {
|
|||
}
|
||||
}
|
||||
|
||||
private struct DisplayEntry {
|
||||
struct DisplayEntry: Codable {
|
||||
var frame: NSRect
|
||||
var screenSize: CGSize
|
||||
var scale: CGFloat
|
||||
|
|
|
|||
|
|
@ -1,10 +1,47 @@
|
|||
import Cocoa
|
||||
|
||||
protocol TerminalRestorable: Codable {
|
||||
static var selfKey: String { get }
|
||||
static var versionKey: String { get }
|
||||
static var version: Int { get }
|
||||
init(copy other: Self)
|
||||
|
||||
/// Returns a base configuration to use when restoring terminal surfaces.
|
||||
/// Override this to provide custom environment variables or other configuration.
|
||||
var baseConfig: Ghostty.SurfaceConfiguration? { get }
|
||||
}
|
||||
|
||||
extension TerminalRestorable {
|
||||
static var selfKey: String { "state" }
|
||||
static var versionKey: String { "version" }
|
||||
|
||||
/// Default implementation returns nil (no custom base config).
|
||||
var baseConfig: Ghostty.SurfaceConfiguration? { nil }
|
||||
|
||||
init?(coder aDecoder: NSCoder) {
|
||||
// If the version doesn't match then we can't decode. In the future we can perform
|
||||
// version upgrading or something but for now we only have one version so we
|
||||
// don't bother.
|
||||
guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let v = aDecoder.decodeObject(of: CodableBridge<Self>.self, forKey: Self.selfKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.init(copy: v.value)
|
||||
}
|
||||
|
||||
func encode(with coder: NSCoder) {
|
||||
coder.encode(Self.version, forKey: Self.versionKey)
|
||||
coder.encode(CodableBridge(self), forKey: Self.selfKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// The state stored for terminal window restoration.
|
||||
class TerminalRestorableState: Codable {
|
||||
static let selfKey = "state"
|
||||
static let versionKey = "version"
|
||||
static let version: Int = 7
|
||||
class TerminalRestorableState: TerminalRestorable {
|
||||
class var version: Int { 7 }
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
|
|
@ -20,28 +57,12 @@ class TerminalRestorableState: Codable {
|
|||
self.titleOverride = controller.titleOverride
|
||||
}
|
||||
|
||||
init?(coder aDecoder: NSCoder) {
|
||||
// If the version doesn't match then we can't decode. In the future we can perform
|
||||
// version upgrading or something but for now we only have one version so we
|
||||
// don't bother.
|
||||
guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let v = aDecoder.decodeObject(of: CodableBridge<Self>.self, forKey: Self.selfKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.surfaceTree = v.value.surfaceTree
|
||||
self.focusedSurface = v.value.focusedSurface
|
||||
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
|
||||
self.tabColor = v.value.tabColor
|
||||
self.titleOverride = v.value.titleOverride
|
||||
}
|
||||
|
||||
func encode(with coder: NSCoder) {
|
||||
coder.encode(Self.version, forKey: Self.versionKey)
|
||||
coder.encode(CodableBridge(self), forKey: Self.selfKey)
|
||||
required init(copy other: TerminalRestorableState) {
|
||||
self.surfaceTree = other.surfaceTree
|
||||
self.focusedSurface = other.focusedSurface
|
||||
self.effectiveFullscreenMode = other.effectiveFullscreenMode
|
||||
self.tabColor = other.tabColor
|
||||
self.titleOverride = other.titleOverride
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue