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.
This commit is contained in:
Mitchell Hashimoto
2024-11-20 15:28:10 -08:00
parent 037d4364e5
commit 35fcb1a29b
9 changed files with 395 additions and 199 deletions

View File

@ -69,6 +69,9 @@ class AppDelegate: NSObject,
/// seconds since the process was launched. /// seconds since the process was launched.
private var applicationLaunchTime: TimeInterval = 0 private var applicationLaunchTime: TimeInterval = 0
/// This is the current configuration from the Ghostty configuration that we need.
private var derivedConfig: DerivedConfig = DerivedConfig()
/// The ghostty global state. Only one per process. /// The ghostty global state. Only one per process.
let ghostty: Ghostty.App = Ghostty.App() let ghostty: Ghostty.App = Ghostty.App()
@ -138,7 +141,7 @@ class AppDelegate: NSObject,
menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:))
// Initial config loading // Initial config loading
configDidReload(ghostty) ghosttyConfigDidChange(config: ghostty.config)
// Start our update checker. // Start our update checker.
updaterController.startUpdater() updaterController.startUpdater()
@ -162,6 +165,12 @@ class AppDelegate: NSObject,
name: .quickTerminalDidChangeVisibility, name: .quickTerminalDidChangeVisibility,
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
// Configure user notifications // Configure user notifications
let actions = [ let actions = [
@ -188,13 +197,13 @@ class AppDelegate: NSObject,
// is possible to have other windows in a few scenarios: // is possible to have other windows in a few scenarios:
// - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're opening a URL since `application(_:openFile:)` is called before this.
// - if we're restoring from persisted state // - if we're restoring from persisted state
if terminalManager.windows.count == 0 && ghostty.config.initialWindow { if terminalManager.windows.count == 0 && derivedConfig.initialWindow {
terminalManager.newWindow() terminalManager.newWindow()
} }
} }
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return ghostty.config.shouldQuitAfterLastWindowClosed return derivedConfig.shouldQuitAfterLastWindowClosed
} }
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
@ -300,52 +309,52 @@ class AppDelegate: NSObject,
} }
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
private func syncMenuShortcuts() { private func syncMenuShortcuts(_ config: Ghostty.Config) {
guard ghostty.readiness == .ready else { return } guard ghostty.readiness == .ready else { return }
syncMenuShortcut(action: "open_config", menuItem: self.menuOpenConfig) syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig)
syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig) syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig)
syncMenuShortcut(action: "quit", menuItem: self.menuQuit) syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit)
syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab) syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(action: "close_surface", menuItem: self.menuClose) syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(action: "close_window", menuItem: self.menuCloseWindow) syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(action: "close_all_windows", menuItem: self.menuCloseAllWindows) syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitRight) syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)
syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit) syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove) syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight) syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
syncMenuShortcut(action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
syncMenuShortcut(action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
syncMenuShortcut(action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
syncMenuShortcut(action: "equalize_splits", menuItem: self.menuEqualizeSplits) syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits)
syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)
syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput)
// This menu item is NOT synced with the configuration because it disables macOS // This menu item is NOT synced with the configuration because it disables macOS
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
// to work but it won't be reflected in the menu item. // to work but it won't be reflected in the menu item.
// //
// syncMenuShortcut(action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
// Dock menu // Dock menu
reloadDockMenu() reloadDockMenu()
@ -353,9 +362,9 @@ class AppDelegate: NSObject,
/// Syncs a single menu shortcut for the given action. The action string is the same /// Syncs a single menu shortcut for the given action. The action string is the same
/// action string used for the Ghostty configuration. /// action string used for the Ghostty configuration.
private func syncMenuShortcut(action: String, menuItem: NSMenuItem?) { private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) {
guard let menu = menuItem else { return } guard let menu = menuItem else { return }
guard let equiv = ghostty.config.keyEquivalent(for: action) else { guard let equiv = config.keyEquivalent(for: action) else {
// No shortcut, clear the menu item // No shortcut, clear the menu item
menu.keyEquivalent = "" menu.keyEquivalent = ""
menu.keyEquivalentModifierMask = [] menu.keyEquivalentModifierMask = []
@ -422,6 +431,98 @@ class AppDelegate: NSObject,
self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off } self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off }
} }
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a surface one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
ghosttyConfigDidChange(config: config)
}
private func ghosttyConfigDidChange(config: Ghostty.Config) {
// Update the config we need to store
self.derivedConfig = DerivedConfig(config)
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
// configuration. This is the only way to carefully control whether macOS invokes the
// state restoration system.
switch (config.windowSaveState) {
case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows")
case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows")
case "default": fallthrough
default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows")
}
// Sync our auto-update settings
updaterController.updater.automaticallyChecksForUpdates =
config.autoUpdate == .check || config.autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
config.autoUpdate == .download
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts(config)
terminalManager.relabelAllTabs()
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
// AppKit mutex on the appearance.
DispatchQueue.main.async { self.syncAppearance(config: config) }
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.errors = config.errors
if (c.errors.count > 0) {
if (c.window == nil || !c.window!.isVisible) {
c.showWindow(self)
}
}
// We need to handle our global event tap depending on if there are global
// events that we care about in Ghostty.
if (ghostty_app_has_global_keybinds(ghostty.app!)) {
if (timeSinceLaunch > 5) {
// If the process has been running for awhile we enable right away
// because no windows are likely to pop up.
GlobalEventTap.shared.enable()
} else {
// If the process just started, we wait a couple seconds to allow
// the initial windows and so on to load so our permissions dialog
// doesn't get buried.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
GlobalEventTap.shared.enable()
}
}
} else {
GlobalEventTap.shared.disable()
}
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance(config: Ghostty.Config) {
guard let theme = config.windowTheme else { return }
switch (theme) {
case "dark":
let appearance = NSAppearance(named: .darkAqua)
NSApplication.shared.appearance = appearance
case "light":
let appearance = NSAppearance(named: .aqua)
NSApplication.shared.appearance = appearance
case "auto":
let color = OSColor(config.backgroundColor)
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
NSApplication.shared.appearance = appearance
default:
NSApplication.shared.appearance = nil
}
}
//MARK: - Restorable State //MARK: - Restorable State
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways. /// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.
@ -470,88 +571,6 @@ class AppDelegate: NSObject,
return nil return nil
} }
func configDidReload(_ state: Ghostty.App) {
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
// configuration. This is the only way to carefully control whether macOS invokes the
// state restoration system.
switch (ghostty.config.windowSaveState) {
case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows")
case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows")
case "default": fallthrough
default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows")
}
// Sync our auto-update settings
updaterController.updater.automaticallyChecksForUpdates =
ghostty.config.autoUpdate == .check || ghostty.config.autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
ghostty.config.autoUpdate == .download
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts()
terminalManager.relabelAllTabs()
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
// AppKit mutex on the appearance.
DispatchQueue.main.async { self.syncAppearance() }
// Update all of our windows
terminalManager.windows.forEach { window in
window.controller.configDidReload()
}
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.errors = state.config.errors
if (c.errors.count > 0) {
if (c.window == nil || !c.window!.isVisible) {
c.showWindow(self)
}
}
// We need to handle our global event tap depending on if there are global
// events that we care about in Ghostty.
if (ghostty_app_has_global_keybinds(ghostty.app!)) {
if (timeSinceLaunch > 5) {
// If the process has been running for awhile we enable right away
// because no windows are likely to pop up.
GlobalEventTap.shared.enable()
} else {
// If the process just started, we wait a couple seconds to allow
// the initial windows and so on to load so our permissions dialog
// doesn't get buried.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
GlobalEventTap.shared.enable()
}
}
} else {
GlobalEventTap.shared.disable()
}
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance() {
guard let theme = ghostty.config.windowTheme else { return }
switch (theme) {
case "dark":
let appearance = NSAppearance(named: .darkAqua)
NSApplication.shared.appearance = appearance
case "light":
let appearance = NSAppearance(named: .aqua)
NSApplication.shared.appearance = appearance
case "auto":
let color = OSColor(ghostty.config.backgroundColor)
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
NSApplication.shared.appearance = appearance
default:
NSApplication.shared.appearance = nil
}
}
//MARK: - Dock Menu //MARK: - Dock Menu
private func reloadDockMenu() { private func reloadDockMenu() {
@ -629,7 +648,7 @@ class AppDelegate: NSObject,
if quickController == nil { if quickController == nil {
quickController = QuickTerminalController( quickController = QuickTerminalController(
ghostty, ghostty,
position: ghostty.config.quickTerminalPosition position: derivedConfig.quickTerminalPosition
) )
} }
@ -655,4 +674,22 @@ class AppDelegate: NSObject,
isVisible.toggle() isVisible.toggle()
} }
private struct DerivedConfig {
let initialWindow: Bool
let shouldQuitAfterLastWindowClosed: Bool
let quickTerminalPosition: QuickTerminalPosition
init() {
self.initialWindow = true
self.shouldQuitAfterLastWindowClosed = false
self.quickTerminalPosition = .top
}
init(_ config: Ghostty.Config) {
self.initialWindow = config.initialWindow
self.shouldQuitAfterLastWindowClosed = config.shouldQuitAfterLastWindowClosed
self.quickTerminalPosition = config.quickTerminalPosition
}
}
} }

View File

@ -18,12 +18,16 @@ class QuickTerminalController: BaseTerminalController {
/// application to the front. /// application to the front.
private var previousApp: NSRunningApplication? = nil private var previousApp: NSRunningApplication? = nil
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
init(_ ghostty: Ghostty.App, init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top, position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil, baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil surfaceTree tree: Ghostty.SplitNode? = nil
) { ) {
self.position = position self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(ghostty, baseConfig: base, surfaceTree: tree) super.init(ghostty, baseConfig: base, surfaceTree: tree)
// Setup our notifications for behaviors // Setup our notifications for behaviors
@ -35,8 +39,8 @@ class QuickTerminalController: BaseTerminalController {
object: nil) object: nil)
center.addObserver( center.addObserver(
self, self,
selector: #selector(ghosttyDidReloadConfig), selector: #selector(ghosttyConfigDidChange(_:)),
name: Ghostty.Notification.ghosttyDidReloadConfig, name: .ghosttyConfigDidChange,
object: nil) object: nil)
} }
@ -64,7 +68,7 @@ class QuickTerminalController: BaseTerminalController {
window.isRestorable = false window.isRestorable = false
// Setup our configured appearance that we support. // Setup our configured appearance that we support.
syncAppearance() syncAppearance(ghostty.config)
// Setup our initial size based on our configured position // Setup our initial size based on our configured position
position.setLoaded(window) position.setLoaded(window)
@ -186,7 +190,7 @@ class QuickTerminalController: BaseTerminalController {
} }
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = ghostty.config.quickTerminalScreen.screen else { return } guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Move our window off screen to the top // Move our window off screen to the top
position.setInitial(in: window, on: screen) position.setInitial(in: window, on: screen)
@ -197,7 +201,7 @@ class QuickTerminalController: BaseTerminalController {
// Run the animation that moves our window into the proper place and makes // Run the animation that moves our window into the proper place and makes
// it visible. // it visible.
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = ghostty.config.quickTerminalAnimationDuration context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn) context.timingFunction = .init(name: .easeIn)
position.setFinal(in: window.animator(), on: screen) position.setFinal(in: window.animator(), on: screen)
}, completionHandler: { }, completionHandler: {
@ -287,7 +291,7 @@ class QuickTerminalController: BaseTerminalController {
} }
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = ghostty.config.quickTerminalAnimationDuration context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn) context.timingFunction = .init(name: .easeIn)
position.setInitial(in: window.animator(), on: screen) position.setInitial(in: window.animator(), on: screen)
}, completionHandler: { }, completionHandler: {
@ -297,7 +301,7 @@ class QuickTerminalController: BaseTerminalController {
}) })
} }
private func syncAppearance() { private func syncAppearance(_ config: Ghostty.Config) {
guard let window else { return } guard let window else { return }
// If our window is not visible, then delay this. This is possible specifically // If our window is not visible, then delay this. This is possible specifically
@ -306,7 +310,7 @@ class QuickTerminalController: BaseTerminalController {
// APIs such as window blur have no effect unless the window is visible. // APIs such as window blur have no effect unless the window is visible.
guard window.isVisible else { guard window.isVisible else {
// Weak window so that if the window changes or is destroyed we aren't holding a ref // Weak window so that if the window changes or is destroyed we aren't holding a ref
DispatchQueue.main.async { [weak self] in self?.syncAppearance() } DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) }
return return
} }
@ -314,7 +318,7 @@ class QuickTerminalController: BaseTerminalController {
// to "native" which is typically P3. There is a lot more resources // to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden. // Ghostty defaults to sRGB but this can be overridden.
switch (ghostty.config.windowColorspace) { switch (config.windowColorspace) {
case "display-p3": case "display-p3":
window.colorSpace = .displayP3 window.colorSpace = .displayP3
case "srgb": case "srgb":
@ -324,7 +328,7 @@ class QuickTerminalController: BaseTerminalController {
} }
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (ghostty.config.backgroundOpacity < 1) { if (config.backgroundOpacity < 1) {
window.isOpaque = false window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // This is weird, but we don't use ".clear" because this creates a look that
@ -371,8 +375,35 @@ class QuickTerminalController: BaseTerminalController {
toggleFullscreen(mode: .nonNative) toggleFullscreen(mode: .nonNative)
} }
@objc private func ghosttyDidReloadConfig(notification: SwiftUI.Notification) { @objc private func ghosttyConfigDidChange(_ notification: Notification) {
syncAppearance() // We only care if the configuration is a global configuration, not a
// surface-specific one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
syncAppearance(config)
}
private struct DerivedConfig {
let quickTerminalScreen: QuickTerminalScreen
let quickTerminalAnimationDuration: Double
init() {
self.quickTerminalScreen = .main
self.quickTerminalAnimationDuration = 0.2
}
init(_ config: Ghostty.Config) {
self.quickTerminalScreen = config.quickTerminalScreen
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
}
} }
} }

View File

@ -60,6 +60,9 @@ class BaseTerminalController: NSWindowController,
/// The previous frame information from the window /// The previous frame information from the window
private var savedFrame: SavedFrame? = nil private var savedFrame: SavedFrame? = nil
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
struct SavedFrame { struct SavedFrame {
let window: NSRect let window: NSRect
let screen: NSRect let screen: NSRect
@ -74,6 +77,7 @@ class BaseTerminalController: NSWindowController,
surfaceTree tree: Ghostty.SplitNode? = nil surfaceTree tree: Ghostty.SplitNode? = nil
) { ) {
self.ghostty = ghostty self.ghostty = ghostty
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(window: nil) super.init(window: nil)
@ -93,6 +97,11 @@ class BaseTerminalController: NSWindowController,
selector: #selector(didChangeScreenParametersNotification), selector: #selector(didChangeScreenParametersNotification),
name: NSApplication.didChangeScreenParametersNotification, name: NSApplication.didChangeScreenParametersNotification,
object: nil) object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChangeBase(_:)),
name: .ghosttyConfigDidChange,
object: nil)
// Listen for local events that we need to know of outside of // Listen for local events that we need to know of outside of
// single surface handlers. // single surface handlers.
@ -191,6 +200,20 @@ class BaseTerminalController: NSWindowController,
window.setFrame(newFrame, display: true) window.setFrame(newFrame, display: true)
} }
@objc private func ghosttyConfigDidChangeBase(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a
// surface-specific one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
}
// MARK: Local Events // MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? { private func localEventHandler(_ event: NSEvent) -> NSEvent? {
@ -245,7 +268,7 @@ class BaseTerminalController: NSWindowController,
func pwdDidChange(to: URL?) { func pwdDidChange(to: URL?) {
guard let window else { return } guard let window else { return }
if ghostty.config.macosTitlebarProxyIcon == .visible { if derivedConfig.macosTitlebarProxyIcon == .visible {
// Use the 'to' URL directly // Use the 'to' URL directly
window.representedURL = to window.representedURL = to
} else { } else {
@ -255,7 +278,7 @@ class BaseTerminalController: NSWindowController,
func cellSizeDidChange(to: NSSize) { func cellSizeDidChange(to: NSSize) {
guard ghostty.config.windowStepResize else { return } guard derivedConfig.windowStepResize else { return }
self.window?.contentResizeIncrements = to self.window?.contentResizeIncrements = to
} }
@ -563,4 +586,19 @@ class BaseTerminalController: NSWindowController,
guard let surface = focusedSurface?.surface else { return } guard let surface = focusedSurface?.surface else { return }
ghostty.resetTerminal(surface: surface) ghostty.resetTerminal(surface: surface)
} }
private struct DerivedConfig {
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool
init() {
self.macosTitlebarProxyIcon = .visible
self.windowStepResize = false
}
init(_ config: Ghostty.Config) {
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
self.windowStepResize = config.windowStepResize
}
}
} }

View File

@ -20,6 +20,9 @@ class TerminalController: BaseTerminalController {
/// For example, terminals executing custom scripts are not restorable. /// For example, terminals executing custom scripts are not restorable.
private var restorable: Bool = true private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
init(_ ghostty: Ghostty.App, init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil withSurfaceTree tree: Ghostty.SplitNode? = nil
@ -31,6 +34,9 @@ class TerminalController: BaseTerminalController {
// restoration. // restoration.
self.restorable = (base?.command ?? "") == "" self.restorable = (base?.command ?? "") == ""
// Setup our initial derived config based on the current app config
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(ghostty, baseConfig: base, surfaceTree: tree) super.init(ghostty, baseConfig: base, surfaceTree: tree)
// Setup our notifications for behaviors // Setup our notifications for behaviors
@ -50,6 +56,12 @@ class TerminalController: BaseTerminalController {
selector: #selector(onGotoTab), selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab, name: Ghostty.Notification.ghosttyGotoTab,
object: nil) object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
center.addObserver( center.addObserver(
self, self,
selector: #selector(onFrameDidChange), selector: #selector(onFrameDidChange),
@ -80,10 +92,22 @@ class TerminalController: BaseTerminalController {
//MARK: - Methods //MARK: - Methods
func configDidReload() { @objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a
// surface-specific one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
guard let window = window as? TerminalWindow else { return } guard let window = window as? TerminalWindow else { return }
window.focusFollowsMouse = ghostty.config.focusFollowsMouse window.focusFollowsMouse = config.focusFollowsMouse
syncAppearance() syncAppearance(config)
} }
/// Update the accessory view of each tab according to the keyboard /// Update the accessory view of each tab according to the keyboard
@ -144,7 +168,7 @@ class TerminalController: BaseTerminalController {
self.relabelTabs() self.relabelTabs()
} }
private func syncAppearance() { private func syncAppearance(_ config: Ghostty.Config) {
guard let window = self.window as? TerminalWindow else { return } guard let window = self.window as? TerminalWindow else { return }
// If our window is not visible, then delay this. This is possible specifically // If our window is not visible, then delay this. This is possible specifically
@ -153,19 +177,19 @@ class TerminalController: BaseTerminalController {
// APIs such as window blur have no effect unless the window is visible. // APIs such as window blur have no effect unless the window is visible.
guard window.isVisible else { guard window.isVisible else {
// Weak window so that if the window changes or is destroyed we aren't holding a ref // Weak window so that if the window changes or is destroyed we aren't holding a ref
DispatchQueue.main.async { [weak self] in self?.syncAppearance() } DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) }
return return
} }
// Set the font for the window and tab titles. // Set the font for the window and tab titles.
if let titleFontName = ghostty.config.windowTitleFontFamily { if let titleFontName = config.windowTitleFontFamily {
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
} else { } else {
window.titlebarFont = nil window.titlebarFont = nil
} }
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (ghostty.config.backgroundOpacity < 1) { if (config.backgroundOpacity < 1) {
window.isOpaque = false window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // This is weird, but we don't use ".clear" because this creates a look that
@ -179,14 +203,14 @@ class TerminalController: BaseTerminalController {
window.backgroundColor = .windowBackgroundColor window.backgroundColor = .windowBackgroundColor
} }
window.hasShadow = ghostty.config.macosWindowShadow window.hasShadow = config.macosWindowShadow
guard window.hasStyledTabs else { return } guard window.hasStyledTabs else { return }
// The titlebar is always updated. We don't need to worry about opacity // The titlebar is always updated. We don't need to worry about opacity
// because we handle it here. // because we handle it here.
let backgroundColor = OSColor(ghostty.config.backgroundColor) let backgroundColor = OSColor(config.backgroundColor)
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity) window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
if (window.isOpaque) { if (window.isOpaque) {
// Bg color is only synced if we have no transparency. This is because // Bg color is only synced if we have no transparency. This is because
@ -210,6 +234,12 @@ class TerminalController: BaseTerminalController {
override func windowDidLoad() { override func windowDidLoad() {
guard let window = window as? TerminalWindow else { return } guard let window = window as? TerminalWindow else { return }
// I copy this because we may change the source in the future but also because
// I regularly audit our codebase for "ghostty.config" access because generally
// you shouldn't use it. Its safe in this case because for a new window we should
// use whatever the latest app-level config is.
let config = ghostty.config
// Setting all three of these is required for restoration to work. // Setting all three of these is required for restoration to work.
window.isRestorable = restorable window.isRestorable = restorable
if (restorable) { if (restorable) {
@ -218,13 +248,13 @@ class TerminalController: BaseTerminalController {
} }
// If window decorations are disabled, remove our title // If window decorations are disabled, remove our title
if (!ghostty.config.windowDecorations) { window.styleMask.remove(.titled) } if (!config.windowDecorations) { window.styleMask.remove(.titled) }
// Terminals typically operate in sRGB color space and macOS defaults // Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources // to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden. // Ghostty defaults to sRGB but this can be overridden.
switch (ghostty.config.windowColorspace) { switch (config.windowColorspace) {
case "display-p3": case "display-p3":
window.colorSpace = .displayP3 window.colorSpace = .displayP3
case "srgb": case "srgb":
@ -256,30 +286,30 @@ class TerminalController: BaseTerminalController {
window.center() window.center()
// Make sure our theme is set on the window so styling is correct. // Make sure our theme is set on the window so styling is correct.
if let windowTheme = ghostty.config.windowTheme { if let windowTheme = config.windowTheme {
window.windowTheme = .init(rawValue: windowTheme) window.windowTheme = .init(rawValue: windowTheme)
} }
// Handle titlebar tabs config option. Something about what we do while setting up the // Handle titlebar tabs config option. Something about what we do while setting up the
// titlebar tabs interferes with the window restore process unless window.tabbingMode // titlebar tabs interferes with the window restore process unless window.tabbingMode
// is set to .preferred, so we set it, and switch back to automatic as soon as we can. // is set to .preferred, so we set it, and switch back to automatic as soon as we can.
if (ghostty.config.macosTitlebarStyle == "tabs") { if (config.macosTitlebarStyle == "tabs") {
window.tabbingMode = .preferred window.tabbingMode = .preferred
window.titlebarTabs = true window.titlebarTabs = true
DispatchQueue.main.async { DispatchQueue.main.async {
window.tabbingMode = .automatic window.tabbingMode = .automatic
} }
} else if (ghostty.config.macosTitlebarStyle == "transparent") { } else if (config.macosTitlebarStyle == "transparent") {
window.transparentTabs = true window.transparentTabs = true
} }
if window.hasStyledTabs { if window.hasStyledTabs {
// Set the background color of the window // Set the background color of the window
let backgroundColor = NSColor(ghostty.config.backgroundColor) let backgroundColor = NSColor(config.backgroundColor)
window.backgroundColor = backgroundColor window.backgroundColor = backgroundColor
// This makes sure our titlebar renders correctly when there is a transparent background // This makes sure our titlebar renders correctly when there is a transparent background
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity) window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
} }
// Initialize our content view to the SwiftUI root // Initialize our content view to the SwiftUI root
@ -290,7 +320,7 @@ class TerminalController: BaseTerminalController {
)) ))
// If our titlebar style is "hidden" we adjust the style appropriately // If our titlebar style is "hidden" we adjust the style appropriately
if (ghostty.config.macosTitlebarStyle == "hidden") { if (config.macosTitlebarStyle == "hidden") {
window.styleMask = [ window.styleMask = [
// We need `titled` in the mask to get the normal window frame // We need `titled` in the mask to get the normal window frame
.titled, .titled,
@ -345,10 +375,10 @@ class TerminalController: BaseTerminalController {
} }
} }
window.focusFollowsMouse = ghostty.config.focusFollowsMouse window.focusFollowsMouse = config.focusFollowsMouse
// Apply any additional appearance-related properties to the new window. // Apply any additional appearance-related properties to the new window.
syncAppearance() syncAppearance(config)
} }
// Shows the "+" button in the tab bar, responds to that click. // Shows the "+" button in the tab bar, responds to that click.
@ -464,7 +494,7 @@ class TerminalController: BaseTerminalController {
// Custom toolbar-based title used when titlebar tabs are enabled. // Custom toolbar-based title used when titlebar tabs are enabled.
if let toolbar = window.toolbar as? TerminalToolbar { if let toolbar = window.toolbar as? TerminalToolbar {
if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") { if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") {
// Updating the title text as above automatically reveals the // Updating the title text as above automatically reveals the
// native title view in macOS 15.0 and above. Since we're using // native title view in macOS 15.0 and above. Since we're using
// a custom view instead, we need to re-hide it. // a custom view instead, we need to re-hide it.
@ -593,4 +623,16 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode) toggleFullscreen(mode: fullscreenMode)
} }
private struct DerivedConfig {
let macosTitlebarStyle: String
init() {
self.macosTitlebarStyle = "system"
}
init(_ config: Ghostty.Config) {
self.macosTitlebarStyle = config.macosTitlebarStyle
}
}
} }

View File

@ -37,8 +37,12 @@ class TerminalManager {
return windows.last return windows.last
} }
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
init(_ ghostty: Ghostty.App) { init(_ ghostty: Ghostty.App) {
self.ghostty = ghostty self.ghostty = ghostty
self.derivedConfig = DerivedConfig(ghostty.config)
let center = NotificationCenter.default let center = NotificationCenter.default
center.addObserver( center.addObserver(
@ -51,6 +55,11 @@ class TerminalManager {
selector: #selector(onNewWindow), selector: #selector(onNewWindow),
name: Ghostty.Notification.ghosttyNewWindow, name: Ghostty.Notification.ghosttyNewWindow,
object: nil) object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil)
} }
deinit { deinit {
@ -70,8 +79,8 @@ class TerminalManager {
if let parent = focusedSurface?.window, if let parent = focusedSurface?.window,
parent.styleMask.contains(.fullScreen) { parent.styleMask.contains(.fullScreen) {
window.toggleFullScreen(nil) window.toggleFullScreen(nil)
} else if ghostty.config.windowFullscreen { } else if derivedConfig.windowFullscreen {
switch (ghostty.config.windowFullscreenMode) { switch (derivedConfig.windowFullscreenMode) {
case .native: case .native:
// Native has to be done immediately so that our stylemask contains // Native has to be done immediately so that our stylemask contains
// fullscreen for the logic later in this method. // fullscreen for the logic later in this method.
@ -81,7 +90,7 @@ class TerminalManager {
// If we're non-native then we have to do it on a later loop // If we're non-native then we have to do it on a later loop
// so that the content view is setup. // so that the content view is setup.
DispatchQueue.main.async { DispatchQueue.main.async {
c.toggleFullscreen(mode: self.ghostty.config.windowFullscreenMode) c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode)
} }
} }
} }
@ -159,9 +168,9 @@ class TerminalManager {
// If we have the "hidden" titlebar style we want to create new // If we have the "hidden" titlebar style we want to create new
// tabs as windows instead, so just skip adding it to the parent. // tabs as windows instead, so just skip adding it to the parent.
if (ghostty.config.macosTitlebarStyle != "hidden") { if (derivedConfig.macosTitlebarStyle != "hidden") {
// Add the window to the tab group and show it. // Add the window to the tab group and show it.
switch ghostty.config.windowNewTabPosition { switch derivedConfig.windowNewTabPosition {
case "end": case "end":
// If we already have a tab group and we want the new tab to open at the end, // If we already have a tab group and we want the new tab to open at the end,
// then we use the last window in the tab group as the parent. // then we use the last window in the tab group as the parent.
@ -325,4 +334,39 @@ class TerminalManager {
self.newTab(to: window, withBaseConfig: config) self.newTab(to: window, withBaseConfig: config)
} }
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a
// surface-specific one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
}
private struct DerivedConfig {
let windowFullscreen: Bool
let windowFullscreenMode: FullscreenMode
let macosTitlebarStyle: String
let windowNewTabPosition: String
init() {
self.windowFullscreen = false
self.windowFullscreenMode = .native
self.macosTitlebarStyle = "transparent"
self.windowNewTabPosition = ""
}
init(_ config: Ghostty.Config) {
self.windowFullscreen = config.windowFullscreen
self.windowFullscreenMode = config.windowFullscreenMode
self.macosTitlebarStyle = config.macosTitlebarStyle
self.windowNewTabPosition = config.windowNewTabPosition
}
}
} }

View File

@ -65,8 +65,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
} }
// If our configuration is "never" then we never restore the state // If our configuration is "never" then we never restore the state
// no matter what. // no matter what. Note its safe to use "ghostty.config" directly here
if (appDelegate.terminalManager.ghostty.config.windowSaveState == "never") { // 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) completionHandler(nil, nil)
return return
} }

View File

@ -3,9 +3,6 @@ import UserNotifications
import GhosttyKit import GhosttyKit
protocol GhosttyAppDelegate: AnyObject { protocol GhosttyAppDelegate: AnyObject {
/// Called when the configuration did finish reloading.
func configDidReload(_ app: Ghostty.App)
#if os(macOS) #if os(macOS)
/// Called when a callback needs access to a specific surface. This should return nil /// Called when a callback needs access to a specific surface. This should return nil
/// when the surface is no longer valid. /// when the surface is no longer valid.
@ -380,16 +377,6 @@ extension Ghostty {
let state = Unmanaged<Self>.fromOpaque(userdata!).takeUnretainedValue() let state = Unmanaged<Self>.fromOpaque(userdata!).takeUnretainedValue()
state.config = newConfig state.config = newConfig
// If we have a delegate, notify.
if let delegate = state.delegate {
delegate.configDidReload(state)
}
// Send an event out
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyDidReloadConfig,
object: nil)
return newConfig.config return newConfig.config
} }
@ -1166,11 +1153,22 @@ extension Ghostty {
_ app: ghostty_app_t, _ app: ghostty_app_t,
target: ghostty_target_s, target: ghostty_target_s,
v: ghostty_action_config_change_s) { v: ghostty_action_config_change_s) {
logger.info("config change notification")
// Clone the config so we own the memory. It'd be nicer to not have to do
// this but since we async send the config out below we have to own the lifetime.
// A future improvement might be to add reference counting to config or
// something so apprt's do not have to do this.
let config = Config(clone: v.config)
switch (target.tag) { switch (target.tag) {
case GHOSTTY_TARGET_APP: case GHOSTTY_TARGET_APP:
NotificationCenter.default.post( NotificationCenter.default.post(
name: .ghosttyConfigChange, name: .ghosttyConfigDidChange,
object: nil object: nil,
userInfo: [
SwiftUI.Notification.Name.GhosttyConfigChangeKey: config,
]
) )
return return
@ -1178,8 +1176,11 @@ extension Ghostty {
guard let surface = target.target.surface else { return } guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return } guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post( NotificationCenter.default.post(
name: .ghosttyConfigChange, name: .ghosttyConfigDidChange,
object: surfaceView object: surfaceView,
userInfo: [
SwiftUI.Notification.Name.GhosttyConfigChangeKey: config,
]
) )
default: default:

View File

@ -39,6 +39,10 @@ extension Ghostty {
} }
} }
init(clone config: ghostty_config_t) {
self.config = ghostty_config_clone(config)
}
deinit { deinit {
self.config = nil self.config = nil
} }

View File

@ -207,8 +207,8 @@ extension Ghostty {
extension Notification.Name { extension Notification.Name {
/// Configuration change. If the object is nil then it is app-wide. Otherwise its surface-specific. /// Configuration change. If the object is nil then it is app-wide. Otherwise its surface-specific.
static let ghosttyConfigChange = Notification.Name("com.mitchellh.ghostty.configChange") static let ghosttyConfigDidChange = Notification.Name("com.mitchellh.ghostty.configDidChange")
static let GhosttyConfigChangeKey = ghosttyConfigChange.rawValue static let GhosttyConfigChangeKey = ghosttyConfigDidChange.rawValue
/// Goto tab. Has tab index in the userinfo. /// Goto tab. Has tab index in the userinfo.
static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab")
@ -221,9 +221,6 @@ extension Ghostty.Notification {
/// Used to pass a configuration along when creating a new tab/window/split. /// Used to pass a configuration along when creating a new tab/window/split.
static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig" static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig"
/// Posted when the application configuration is reloaded.
static let ghosttyDidReloadConfig = Notification.Name("com.mitchellh.ghostty.didReloadConfig")
/// Posted when a new split is requested. The sending object will be the surface that had focus. The /// Posted when a new split is requested. The sending object will be the surface that had focus. The
/// userdata has one key "direction" with the direction to split to. /// userdata has one key "direction" with the direction to split to.
static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit") static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")