ghostty/macos/Sources/Features/Terminal/TerminalRestorable.swift
Mitchell Hashimoto 35fcb1a29b macos: change config access to evented, derived config like libghostty
Previously, we would access the `ghostty.config` object from anywhere.
The issue with this is that memory lifetime access to the underlying
`ghostty_config_t` was messy. It was easy when the apprt owned every
reference but since automatic theme changes were implemented, this isn't
always true anymore.

To fix this, we move to the same pattern we use internally in the core
of ghostty: whenever the config changes, we handle an event, derive our
desired values out of the config (copy them), and then let the caller
free the config if they want to. This way, we can be sure that any
information we need from the config is always owned by us.
2024-11-21 11:36:51 -08:00

140 lines
5.5 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. Note its safe to use "ghostty.config" directly here
// because window restoration is only ever invoked on app start so we
// don't have to deal with config reloads.
if (appDelegate.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)
}
}
}
}