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")