From 35fcb1a29b192454b08a53b47dfb7d7955e463f6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Nov 2024 15:28:10 -0800 Subject: [PATCH] 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. --- macos/Sources/App/macOS/AppDelegate.swift | 281 ++++++++++-------- .../QuickTerminalController.swift | 55 +++- .../Terminal/BaseTerminalController.swift | 42 ++- .../Terminal/TerminalController.swift | 84 ++++-- .../Features/Terminal/TerminalManager.swift | 54 +++- .../Terminal/TerminalRestorable.swift | 6 +- macos/Sources/Ghostty/Ghostty.App.swift | 61 ++-- macos/Sources/Ghostty/Ghostty.Config.swift | 4 + macos/Sources/Ghostty/Package.swift | 7 +- 9 files changed, 395 insertions(+), 199 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8179e1950..faafa7205 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -69,6 +69,9 @@ class AppDelegate: NSObject, /// seconds since the process was launched. 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. let ghostty: Ghostty.App = Ghostty.App() @@ -138,7 +141,7 @@ class AppDelegate: NSObject, menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) // Initial config loading - configDidReload(ghostty) + ghosttyConfigDidChange(config: ghostty.config) // Start our update checker. updaterController.startUpdater() @@ -162,6 +165,12 @@ class AppDelegate: NSObject, name: .quickTerminalDidChangeVisibility, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) // Configure user notifications let actions = [ @@ -188,13 +197,13 @@ class AppDelegate: NSObject, // 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 restoring from persisted state - if terminalManager.windows.count == 0 && ghostty.config.initialWindow { + if terminalManager.windows.count == 0 && derivedConfig.initialWindow { terminalManager.newWindow() } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return ghostty.config.shouldQuitAfterLastWindowClosed + return derivedConfig.shouldQuitAfterLastWindowClosed } 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. - private func syncMenuShortcuts() { + private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } - syncMenuShortcut(action: "open_config", menuItem: self.menuOpenConfig) - syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig) - syncMenuShortcut(action: "quit", menuItem: self.menuQuit) + syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) + syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) + syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) - syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow) - syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab) - syncMenuShortcut(action: "close_surface", menuItem: self.menuClose) - syncMenuShortcut(action: "close_window", menuItem: self.menuCloseWindow) - syncMenuShortcut(action: "close_all_windows", menuItem: self.menuCloseAllWindows) - syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitRight) - syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitDown) + syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) + syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) + syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) + syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) + syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) - syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy) - syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste) - syncMenuShortcut(action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) + syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) - syncMenuShortcut(action: "toggle_split_zoom", menuItem: self.menuZoomSplit) - syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit) - syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) - syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft) - syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight) - syncMenuShortcut(action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) - syncMenuShortcut(action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) - syncMenuShortcut(action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) - syncMenuShortcut(action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) - syncMenuShortcut(action: "equalize_splits", menuItem: self.menuEqualizeSplits) + syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) + syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) + syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) + syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) + syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) + syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) + syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) + syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) + syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) + syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) - syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) - syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) - syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) - syncMenuShortcut(action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) - syncMenuShortcut(action: "toggle_visibility", menuItem: self.menuToggleVisibility) - syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) + syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) + syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) + syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) + syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) + 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 // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue // 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 reloadDockMenu() @@ -353,9 +362,9 @@ class AppDelegate: NSObject, /// Syncs a single menu shortcut for the given action. The action string is the same /// 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 equiv = ghostty.config.keyEquivalent(for: action) else { + guard let equiv = config.keyEquivalent(for: action) else { // No shortcut, clear the menu item menu.keyEquivalent = "" menu.keyEquivalentModifierMask = [] @@ -422,6 +431,98 @@ class AppDelegate: NSObject, 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 /// 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 } - 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 private func reloadDockMenu() { @@ -629,7 +648,7 @@ class AppDelegate: NSObject, if quickController == nil { quickController = QuickTerminalController( ghostty, - position: ghostty.config.quickTerminalPosition + position: derivedConfig.quickTerminalPosition ) } @@ -655,4 +674,22 @@ class AppDelegate: NSObject, 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 + } + } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index bdd427be0..18549eea1 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -18,12 +18,16 @@ class QuickTerminalController: BaseTerminalController { /// application to the front. 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, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: Ghostty.SplitNode? = nil ) { self.position = position + self.derivedConfig = DerivedConfig(ghostty.config) super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors @@ -35,8 +39,8 @@ class QuickTerminalController: BaseTerminalController { object: nil) center.addObserver( self, - selector: #selector(ghosttyDidReloadConfig), - name: Ghostty.Notification.ghosttyDidReloadConfig, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, object: nil) } @@ -64,7 +68,7 @@ class QuickTerminalController: BaseTerminalController { window.isRestorable = false // Setup our configured appearance that we support. - syncAppearance() + syncAppearance(ghostty.config) // Setup our initial size based on our configured position position.setLoaded(window) @@ -186,7 +190,7 @@ class QuickTerminalController: BaseTerminalController { } 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 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 // it visible. NSAnimationContext.runAnimationGroup({ context in - context.duration = ghostty.config.quickTerminalAnimationDuration + context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) position.setFinal(in: window.animator(), on: screen) }, completionHandler: { @@ -287,7 +291,7 @@ class QuickTerminalController: BaseTerminalController { } NSAnimationContext.runAnimationGroup({ context in - context.duration = ghostty.config.quickTerminalAnimationDuration + context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) position.setInitial(in: window.animator(), on: screen) }, completionHandler: { @@ -297,7 +301,7 @@ class QuickTerminalController: BaseTerminalController { }) } - private func syncAppearance() { + private func syncAppearance(_ config: Ghostty.Config) { guard let window else { return } // 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. guard window.isVisible else { // 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 } @@ -314,7 +318,7 @@ class QuickTerminalController: BaseTerminalController { // to "native" which is typically P3. There is a lot more resources // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // Ghostty defaults to sRGB but this can be overridden. - switch (ghostty.config.windowColorspace) { + switch (config.windowColorspace) { case "display-p3": window.colorSpace = .displayP3 case "srgb": @@ -324,7 +328,7 @@ class QuickTerminalController: BaseTerminalController { } // 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 // 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) } - @objc private func ghosttyDidReloadConfig(notification: SwiftUI.Notification) { - syncAppearance() + @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) + + 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 + } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 000d72418..721248013 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -60,6 +60,9 @@ class BaseTerminalController: NSWindowController, /// The previous frame information from the window 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 { let window: NSRect let screen: NSRect @@ -74,6 +77,7 @@ class BaseTerminalController: NSWindowController, surfaceTree tree: Ghostty.SplitNode? = nil ) { self.ghostty = ghostty + self.derivedConfig = DerivedConfig(ghostty.config) super.init(window: nil) @@ -93,6 +97,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(didChangeScreenParametersNotification), name: NSApplication.didChangeScreenParametersNotification, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyConfigDidChangeBase(_:)), + name: .ghosttyConfigDidChange, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -191,6 +200,20 @@ class BaseTerminalController: NSWindowController, 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 private func localEventHandler(_ event: NSEvent) -> NSEvent? { @@ -245,7 +268,7 @@ class BaseTerminalController: NSWindowController, func pwdDidChange(to: URL?) { guard let window else { return } - if ghostty.config.macosTitlebarProxyIcon == .visible { + if derivedConfig.macosTitlebarProxyIcon == .visible { // Use the 'to' URL directly window.representedURL = to } else { @@ -255,7 +278,7 @@ class BaseTerminalController: NSWindowController, func cellSizeDidChange(to: NSSize) { - guard ghostty.config.windowStepResize else { return } + guard derivedConfig.windowStepResize else { return } self.window?.contentResizeIncrements = to } @@ -563,4 +586,19 @@ class BaseTerminalController: NSWindowController, guard let surface = focusedSurface?.surface else { return } 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 + } + } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f31740105..09b758a1e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -20,6 +20,9 @@ class TerminalController: BaseTerminalController { /// For example, terminals executing custom scripts are not restorable. 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, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: Ghostty.SplitNode? = nil @@ -31,6 +34,9 @@ class TerminalController: BaseTerminalController { // restoration. 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) // Setup our notifications for behaviors @@ -50,6 +56,12 @@ class TerminalController: BaseTerminalController { selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) center.addObserver( self, selector: #selector(onFrameDidChange), @@ -80,10 +92,22 @@ class TerminalController: BaseTerminalController { //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 } - window.focusFollowsMouse = ghostty.config.focusFollowsMouse - syncAppearance() + window.focusFollowsMouse = config.focusFollowsMouse + syncAppearance(config) } /// Update the accessory view of each tab according to the keyboard @@ -144,7 +168,7 @@ class TerminalController: BaseTerminalController { self.relabelTabs() } - private func syncAppearance() { + private func syncAppearance(_ config: Ghostty.Config) { guard let window = self.window as? TerminalWindow else { return } // 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. guard window.isVisible else { // 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 } // 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) } else { window.titlebarFont = nil } // 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 // 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.hasShadow = ghostty.config.macosWindowShadow + window.hasShadow = config.macosWindowShadow guard window.hasStyledTabs else { return } // The titlebar is always updated. We don't need to worry about opacity // because we handle it here. - let backgroundColor = OSColor(ghostty.config.backgroundColor) - window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity) + let backgroundColor = OSColor(config.backgroundColor) + window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) if (window.isOpaque) { // Bg color is only synced if we have no transparency. This is because @@ -210,6 +234,12 @@ class TerminalController: BaseTerminalController { override func windowDidLoad() { 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. window.isRestorable = restorable if (restorable) { @@ -218,13 +248,13 @@ class TerminalController: BaseTerminalController { } // 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 // to "native" which is typically P3. There is a lot more resources // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // Ghostty defaults to sRGB but this can be overridden. - switch (ghostty.config.windowColorspace) { + switch (config.windowColorspace) { case "display-p3": window.colorSpace = .displayP3 case "srgb": @@ -256,30 +286,30 @@ class TerminalController: BaseTerminalController { window.center() // 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) } // 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 // 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.titlebarTabs = true DispatchQueue.main.async { window.tabbingMode = .automatic } - } else if (ghostty.config.macosTitlebarStyle == "transparent") { + } else if (config.macosTitlebarStyle == "transparent") { window.transparentTabs = true } if window.hasStyledTabs { // Set the background color of the window - let backgroundColor = NSColor(ghostty.config.backgroundColor) + let backgroundColor = NSColor(config.backgroundColor) window.backgroundColor = backgroundColor // 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 @@ -290,7 +320,7 @@ class TerminalController: BaseTerminalController { )) // If our titlebar style is "hidden" we adjust the style appropriately - if (ghostty.config.macosTitlebarStyle == "hidden") { + if (config.macosTitlebarStyle == "hidden") { window.styleMask = [ // We need `titled` in the mask to get the normal window frame .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. - syncAppearance() + syncAppearance(config) } // 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. 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 // native title view in macOS 15.0 and above. Since we're using // a custom view instead, we need to re-hide it. @@ -593,4 +623,16 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } + + private struct DerivedConfig { + let macosTitlebarStyle: String + + init() { + self.macosTitlebarStyle = "system" + } + + init(_ config: Ghostty.Config) { + self.macosTitlebarStyle = config.macosTitlebarStyle + } + } } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 0766f33b0..42e35b90e 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -37,8 +37,12 @@ class TerminalManager { 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) { self.ghostty = ghostty + self.derivedConfig = DerivedConfig(ghostty.config) let center = NotificationCenter.default center.addObserver( @@ -51,6 +55,11 @@ class TerminalManager { selector: #selector(onNewWindow), name: Ghostty.Notification.ghosttyNewWindow, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil) } deinit { @@ -70,8 +79,8 @@ class TerminalManager { if let parent = focusedSurface?.window, parent.styleMask.contains(.fullScreen) { window.toggleFullScreen(nil) - } else if ghostty.config.windowFullscreen { - switch (ghostty.config.windowFullscreenMode) { + } else if derivedConfig.windowFullscreen { + switch (derivedConfig.windowFullscreenMode) { case .native: // Native has to be done immediately so that our stylemask contains // 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 // so that the content view is setup. 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 // 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. - switch ghostty.config.windowNewTabPosition { + switch derivedConfig.windowNewTabPosition { case "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. @@ -325,4 +334,39 @@ class TerminalManager { 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 + } + } } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 1d1ae82d0..b9d9b0ac0 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -65,8 +65,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } // If our configuration is "never" then we never restore the state - // no matter what. - if (appDelegate.terminalManager.ghostty.config.windowSaveState == "never") { + // 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 } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index cc365ce4f..2ec62a426 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -3,9 +3,6 @@ import UserNotifications import GhosttyKit protocol GhosttyAppDelegate: AnyObject { - /// Called when the configuration did finish reloading. - func configDidReload(_ app: Ghostty.App) - #if os(macOS) /// Called when a callback needs access to a specific surface. This should return nil /// when the surface is no longer valid. @@ -380,16 +377,6 @@ extension Ghostty { let state = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() 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 } @@ -1166,26 +1153,40 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_config_change_s) { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - NotificationCenter.default.post( - name: .ghosttyConfigChange, - object: nil - ) - return + logger.info("config change notification") - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return } - guard let surfaceView = self.surfaceView(from: surface) else { return } - NotificationCenter.default.post( - name: .ghosttyConfigChange, - object: surfaceView - ) + // 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) - default: - assertionFailure() + switch (target.tag) { + case GHOSTTY_TARGET_APP: + NotificationCenter.default.post( + name: .ghosttyConfigDidChange, + object: nil, + userInfo: [ + SwiftUI.Notification.Name.GhosttyConfigChangeKey: config, + ] + ) + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyConfigDidChange, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.GhosttyConfigChangeKey: config, + ] + ) + + default: + assertionFailure() + } } - } // MARK: User Notifications diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b8c7d2594..3a58455d9 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -39,6 +39,10 @@ extension Ghostty { } } + init(clone config: ghostty_config_t) { + self.config = ghostty_config_clone(config) + } + deinit { self.config = nil } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b926edab5..074ce4743 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -207,8 +207,8 @@ extension Ghostty { extension Notification.Name { /// 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 GhosttyConfigChangeKey = ghosttyConfigChange.rawValue + static let ghosttyConfigDidChange = Notification.Name("com.mitchellh.ghostty.configDidChange") + static let GhosttyConfigChangeKey = ghosttyConfigDidChange.rawValue /// Goto tab. Has tab index in the userinfo. 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. 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 /// userdata has one key "direction" with the direction to split to. static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")