ghostty/macos/Sources/Features/Terminal/TerminalRestorable.swift
2024-01-14 15:53:33 -08:00

138 lines
5.4 KiB
Swift

import Cocoa
/// The state stored for terminal window restoration.
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 2
let focusedSurface: String?
let surfaceTree: Ghostty.SplitNode?
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.uuid.uuidString
self.surfaceTree = controller.surfaceTree
}
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
}
func encode(with coder: NSCoder) {
coder.encode(Self.version, forKey: Self.versionKey)
coder.encode(CodableBridge(self), forKey: Self.selfKey)
}
}
enum TerminalRestoreError: Error {
case delegateInvalid
case identifierUnknown
case stateDecodeFailed
case windowDidNotLoad
}
/// The NSWindowRestoration implementation that is called when a terminal window needs to be restored.
/// The encoding of a terminal window is handled elsewhere (usually NSWindowDelegate).
class TerminalWindowRestoration: NSObject, NSWindowRestoration {
static func restoreWindow(
withIdentifier identifier: NSUserInterfaceItemIdentifier,
state: NSCoder,
completionHandler: @escaping (NSWindow?, Error?) -> Void
) {
// Verify the identifier is what we expect
guard identifier == .init(String(describing: Self.self)) else {
completionHandler(nil, TerminalRestoreError.identifierUnknown)
return
}
// The app delegate is definitely setup by now. If it isn't our AppDelegate
// then something is royally fucked up but protect against it anyhow.
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else {
completionHandler(nil, TerminalRestoreError.delegateInvalid)
return
}
// If our configuration is "never" then we never restore the state
// no matter what.
if (appDelegate.terminalManager.ghostty.config.windowSaveState == "never") {
completionHandler(nil, nil)
return
}
// Decode the state. If we can't decode the state, then we can't restore.
guard let state = TerminalRestorableState(coder: state) else {
completionHandler(nil, TerminalRestoreError.stateDecodeFailed)
return
}
// The window creation has to go through our terminalManager so that it
// can be found for events from libghostty. This uses the low-level
// createWindow so that AppKit can place the window wherever it should
// be.
let c = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree)
guard let window = c.window else {
completionHandler(nil, TerminalRestoreError.windowDidNotLoad)
return
}
// Setup our restored state on the controller
if let focusedStr = state.focusedSurface,
let focusedUUID = UUID(uuidString: focusedStr),
let view = c.surfaceTree?.findUUID(uuid: focusedUUID) {
c.focusedSurface = view
restoreFocus(to: view, inWindow: window)
}
completionHandler(window, nil)
}
/// This restores the focus state of the surfaceview within the given window. When restoring,
/// the view isn't immediately attached to the window since we have to wait for SwiftUI to
/// catch up. Therefore, we sit in an async loop waiting for the attachment to happen.
private static func restoreFocus(to: Ghostty.SurfaceView, inWindow: NSWindow, attempts: Int = 0) {
// For the first attempt, we schedule it immediately. Subsequent events wait a bit
// so we don't just spin the CPU at 100%. Give up after some period of time.
let after: DispatchTime
if (attempts == 0) {
after = .now()
} else if (attempts > 40) {
// 2 seconds, give up
return
} else {
after = .now() + .milliseconds(50)
}
DispatchQueue.main.asyncAfter(deadline: after) {
// If the view is not attached to a window yet then we repeat.
guard let viewWindow = to.window else {
restoreFocus(to: to, inWindow: inWindow, attempts: attempts + 1)
return
}
// If the view is attached to some other window, we give up
guard viewWindow == inWindow else { return }
inWindow.makeFirstResponder(to)
// If the window is main, then we also make sure it comes forward. This
// prevents a bug found in #1177 where sometimes on restore the windows
// would be behind other applications.
if (viewWindow.isMainWindow) {
viewWindow.orderFront(nil)
}
}
}
}