diff --git a/include/ghostty.h b/include/ghostty.h index d0426e995..813f81df2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -532,6 +532,11 @@ typedef struct { uint8_t b; } ghostty_action_color_change_s; +// apprt.action.ConfigChange +typedef struct { + ghostty_config_t config; +} ghostty_action_config_change_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_NEW_WINDOW, @@ -568,6 +573,7 @@ typedef enum { GHOSTTY_ACTION_KEY_SEQUENCE, GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_CONFIG_CHANGE_CONDITIONAL_STATE, + GHOSTTY_ACTION_CONFIG_CHANGE, } ghostty_action_tag_e; typedef union { @@ -592,6 +598,7 @@ typedef union { ghostty_action_secure_input_e secure_input; ghostty_action_key_sequence_s key_sequence; ghostty_action_color_change_s color_change; + ghostty_action_config_change_s config_change; } ghostty_action_u; typedef struct { @@ -639,6 +646,7 @@ ghostty_info_s ghostty_info(void); ghostty_config_t ghostty_config_new(); void ghostty_config_free(ghostty_config_t); +ghostty_config_t ghostty_config_clone(ghostty_config_t); void ghostty_config_load_cli_args(ghostty_config_t); void ghostty_config_load_default_files(ghostty_config_t); void ghostty_config_load_recursive_files(ghostty_config_t); 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..81c74987b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1,6 +1,7 @@ import Foundation import Cocoa import SwiftUI +import Combine import GhosttyKit /// A classic, tabbed terminal experience. @@ -20,6 +21,12 @@ 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 + + /// The notification cancellable for focused surface property changes. + private var surfaceAppearanceCancellables: Set = [] + init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: Ghostty.SplitNode? = nil @@ -31,6 +38,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 +60,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 +96,38 @@ class TerminalController: BaseTerminalController { //MARK: - Methods - func configDidReload() { - guard let window = window as? TerminalWindow else { return } - window.focusFollowsMouse = ghostty.config.focusFollowsMouse - syncAppearance() + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + // Get our managed configuration object out + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + + // If this is an app-level config update then we update some things. + if (notification.object == nil) { + // Update our derived config + self.derivedConfig = DerivedConfig(config) + + guard let window = window as? TerminalWindow else { return } + window.focusFollowsMouse = config.focusFollowsMouse + + // If we have no surfaces in our window (is that possible?) then we update + // our window appearance based on the root config. If we have surfaces, we + // don't call this because the TODO + if surfaceTree == nil { + syncAppearance(.init(config)) + } + + return + } + + // This is a surface-level config update. If we have the surface, we + // update our appearance based on it. + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + + // We can't use surfaceView.derivedConfig because it may not be updated + // yet since it also responds to notifications. + syncAppearance(.init(config)) } /// Update the accessory view of each tab according to the keyboard @@ -144,28 +188,23 @@ class TerminalController: BaseTerminalController { self.relabelTabs() } - private func syncAppearance() { + private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { guard let window = self.window as? TerminalWindow else { return } - // If our window is not visible, then delay this. This is possible specifically - // during state restoration but probably in other scenarios as well. To delay, - // we just loop directly on the dispatch queue. We have to delay because some - // 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() } - return - } + // If our window is not visible, then we do nothing. Some things such as blurring + // have no effect if the window is not visible. Ultimately, we'll have this called + // at some point when a surface becomes focused. + guard window.isVisible else { return } // Set the font for the window and tab titles. - if let titleFontName = ghostty.config.windowTitleFontFamily { + if let titleFontName = surfaceConfig.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 (surfaceConfig.backgroundOpacity < 1) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -179,14 +218,26 @@ class TerminalController: BaseTerminalController { window.backgroundColor = .windowBackgroundColor } - window.hasShadow = ghostty.config.macosWindowShadow + window.hasShadow = surfaceConfig.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) + // Our background color depends on if our focused surface borders the top or not. + // If it does, we match the focused surface. If it doesn't, we use the app + // configuration. + let backgroundColor: OSColor + if let surfaceTree { + if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { + backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor) + } else { + // We don't have a focused surface or our surface doesn't border the + // top. We choose to match the color of the top-left most surface. + backgroundColor = OSColor(surfaceTree.topLeft().backgroundColor ?? derivedConfig.backgroundColor) + } + } else { + backgroundColor = OSColor(self.derivedConfig.backgroundColor) + } + window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity) if (window.isOpaque) { // Bg color is only synced if we have no transparency. This is because @@ -210,6 +261,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 +275,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 +313,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 +347,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 +402,12 @@ class TerminalController: BaseTerminalController { } } - window.focusFollowsMouse = ghostty.config.focusFollowsMouse + window.focusFollowsMouse = config.focusFollowsMouse - // Apply any additional appearance-related properties to the new window. - syncAppearance() + // Apply any additional appearance-related properties to the new window. We + // apply this based on the root config but change it later based on surface + // config (see focused surface change callback). + syncAppearance(.init(config)) } // Shows the "+" button in the tab bar, responds to that click. @@ -464,7 +523,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. @@ -485,6 +544,36 @@ class TerminalController: BaseTerminalController { window.surfaceIsZoomed = to } + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { + super.focusedSurfaceDidChange(to: to) + + // We always cancel our event listener + surfaceAppearanceCancellables.removeAll() + + // When our focus changes, we update our window appearance based on the + // currently focused surface. + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + + // We also want to get notified of certain changes to update our appearance. + focusedSurface.$derivedConfig + .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } + .store(in: &surfaceAppearanceCancellables) + focusedSurface.$backgroundColor + .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } + .store(in: &surfaceAppearanceCancellables) + } + + private func syncAppearanceOnPropertyChange(_ surface: Ghostty.SurfaceView?) { + guard let surface else { return } + DispatchQueue.main.async { [weak self, weak surface] in + guard let surface else { return } + guard let self else { return } + guard self.focusedSurface == surface else { return } + self.syncAppearance(surface.derivedConfig) + } + } + //MARK: - Notifications @objc private func onMoveTab(notification: SwiftUI.Notification) { @@ -593,4 +682,19 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } + + private struct DerivedConfig { + let backgroundColor: Color + let macosTitlebarStyle: String + + init() { + self.backgroundColor = Color(NSColor.windowBackgroundColor) + self.macosTitlebarStyle = "system" + } + + init(_ config: Ghostty.Config) { + self.backgroundColor = config.backgroundColor + 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.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index d9e58b28c..dfdb0bff5 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -1,3 +1,4 @@ +import SwiftUI import GhosttyKit extension Ghostty { @@ -5,6 +6,33 @@ extension Ghostty { } extension Ghostty.Action { + struct ColorChange { + let kind: Kind + let color: Color + + enum Kind { + case foreground + case background + case cursor + case palette(index: UInt8) + } + + init(c: ghostty_action_color_change_s) { + switch (c.kind) { + case GHOSTTY_ACTION_COLOR_KIND_FOREGROUND: + self.kind = .foreground + case GHOSTTY_ACTION_COLOR_KIND_BACKGROUND: + self.kind = .background + case GHOSTTY_ACTION_COLOR_KIND_CURSOR: + self.kind = .cursor + default: + self.kind = .palette(index: UInt8(c.kind.rawValue)) + } + + self.color = Color(red: Double(c.r) / 255, green: Double(c.g) / 255, blue: Double(c.b) / 255) + } + } + struct MoveTab { let amount: Int diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 489493ad3..b9bebe542 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 } @@ -524,8 +511,12 @@ extension Ghostty { case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) + case GHOSTTY_ACTION_CONFIG_CHANGE: + configChange(app, target: target, v: action.action.config_change) + case GHOSTTY_ACTION_COLOR_CHANGE: - fallthrough + colorChange(app, target: target, change: action.action.color_change) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -1159,6 +1150,71 @@ extension Ghostty { } } + private static func configChange( + _ app: ghostty_app_t, + target: ghostty_target_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) { + 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() + } + } + + private static func colorChange( + _ app: ghostty_app_t, + target: ghostty_target_s, + change: ghostty_action_color_change_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("color change does nothing with an app target") + 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: .ghosttyColorDidChange, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.GhosttyColorChangeKey: Action.ColorChange(c: change) + ] + ) + + default: + assertionFailure() + } + } + + // MARK: User Notifications /// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user 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/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index 361af1e1b..f863eeada 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -38,6 +38,16 @@ extension Ghostty { } } + func topLeft() -> SurfaceView { + switch (self) { + case .leaf(let leaf): + return leaf.surface + + case .split(let container): + return container.topLeft.topLeft() + } + } + /// Returns the view that would prefer receiving focus in this tree. This is always the /// top-left-most view. This is used when creating a split or closing a split to find the /// next view to send focus to. @@ -136,6 +146,24 @@ extension Ghostty { } } + /// Returns true if the surface borders the top. Assumes the view is in the tree. + func doesBorderTop(view: SurfaceView) -> Bool { + switch (self) { + case .leaf(let leaf): + return leaf.surface == view + + case .split(let container): + switch (container.direction) { + case .vertical: + return container.topLeft.doesBorderTop(view: view) + + case .horizontal: + return container.topLeft.doesBorderTop(view: view) || + container.bottomRight.doesBorderTop(view: view) + } + } + } + // MARK: - Sequence func makeIterator() -> IndexingIterator<[Leaf]> { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index e4ab91a5f..a4d1914e0 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -206,6 +206,14 @@ extension Ghostty { // MARK: Surface Notification extension Notification.Name { + /// Configuration change. If the object is nil then it is app-wide. Otherwise its surface-specific. + static let ghosttyConfigDidChange = Notification.Name("com.mitchellh.ghostty.configDidChange") + static let GhosttyConfigChangeKey = ghosttyConfigDidChange.rawValue + + /// Color change. Object is the surface changing. + static let ghosttyColorDidChange = Notification.Name("com.mitchellh.ghostty.ghosttyColorDidChange") + static let GhosttyColorChangeKey = ghosttyColorDidChange.rawValue + /// Goto tab. Has tab index in the userinfo. static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue @@ -217,9 +225,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") diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c678ca79d..7e861a229 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -48,6 +48,13 @@ extension Ghostty { // Whether the pointer should be visible or not @Published private(set) var pointerStyle: BackportPointerStyle = .default + /// The configuration derived from the Ghostty config so we don't need to rely on references. + @Published private(set) var derivedConfig: DerivedConfig + + /// The background color within the color palette of the surface. This is only set if it is + /// dynamically updated. Otherwise, the background color is the default background color. + @Published private(set) var backgroundColor: Color? = nil + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -114,6 +121,13 @@ extension Ghostty { self.markedText = NSMutableAttributedString() self.uuid = uuid ?? .init() + // Our initial config always is our application wide config. + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + self.derivedConfig = DerivedConfig(appDelegate.ghostty.config) + } else { + self.derivedConfig = DerivedConfig() + } + // Initialize with some default frame size. The important thing is that this // is non-zero so that our layer bounds are non-zero so that our renderer // can do SOMETHING. @@ -137,6 +151,16 @@ extension Ghostty { selector: #selector(ghosttyDidEndKeySequence), name: Ghostty.Notification.didEndKeySequence, object: self) + center.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: self) + center.addObserver( + self, + selector: #selector(ghosttyColorDidChange(_:)), + name: .ghosttyColorDidChange, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -333,6 +357,31 @@ extension Ghostty { keySequence = [] } + @objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) { + // Get our managed configuration object out + guard let config = notification.userInfo?[ + SwiftUI.Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + + // Update our derived config + self.derivedConfig = DerivedConfig(config) + } + + @objc private func ghosttyColorDidChange(_ notification: SwiftUI.Notification) { + guard let change = notification.userInfo?[ + SwiftUI.Notification.Name.GhosttyColorChangeKey + ] as? Ghostty.Action.ColorChange else { return } + + switch (change.kind) { + case .background: + self.backgroundColor = change.color + + default: + // We don't do anything for the other colors yet. + break + } + } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } @@ -1025,6 +1074,27 @@ extension Ghostty { Ghostty.moveFocus(to: self) } } + + struct DerivedConfig { + let backgroundColor: Color + let backgroundOpacity: Double + let macosWindowShadow: Bool + let windowTitleFontFamily: String? + + init() { + self.backgroundColor = Color(NSColor.windowBackgroundColor) + self.backgroundOpacity = 1 + self.macosWindowShadow = true + self.windowTitleFontFamily = nil + } + + init(_ config: Ghostty.Config) { + self.backgroundColor = config.backgroundColor + self.backgroundOpacity = config.backgroundOpacity + self.macosWindowShadow = config.macosWindowShadow + self.windowTitleFontFamily = config.windowTitleFontFamily + } + } } } diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/OSColor+Extension.swift index a6d545f2b..7cdfcc1e6 100644 --- a/macos/Sources/Helpers/OSColor+Extension.swift +++ b/macos/Sources/Helpers/OSColor+Extension.swift @@ -21,6 +21,14 @@ extension OSColor { return (0.299 * r) + (0.587 * g) + (0.114 * b) } + var hexString: String? { + guard let rgb = usingColorSpace(.deviceRGB) else { return nil } + let red = Int(rgb.redComponent * 255) + let green = Int(rgb.greenComponent * 255) + let blue = Int(rgb.blueComponent * 255) + return String(format: "#%02X%02X%02X", red, green, blue) + } + func darken(by amount: CGFloat) -> OSColor { var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 self.getHue(&h, saturation: &s, brightness: &b, alpha: &a) diff --git a/src/App.zig b/src/App.zig index 271ba2043..ebf257f04 100644 --- a/src/App.zig +++ b/src/App.zig @@ -147,11 +147,18 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool { /// Update the configuration associated with the app. This can only be /// called from the main thread. The caller owns the config memory. The /// memory can be freed immediately when this returns. -pub fn updateConfig(self: *App, config: *const Config) !void { +pub fn updateConfig(self: *App, rt_app: *apprt.App, config: *const Config) !void { // Go through and update all of the surface configurations. for (self.surfaces.items) |surface| { try surface.core_surface.handleMessage(.{ .change_config = config }); } + + // Notify the apprt that the app has changed configuration. + try rt_app.performAction( + .app, + .config_change, + .{ .config = config }, + ); } /// Add an initialized surface. This is really only for the runtime @@ -257,7 +264,7 @@ pub fn reloadConfig(self: *App, rt_app: *apprt.App) !void { log.debug("reloading configuration", .{}); if (try rt_app.reloadConfig()) |new| { log.debug("new configuration received, applying", .{}); - try self.updateConfig(new); + try self.updateConfig(rt_app, new); } } diff --git a/src/Surface.zig b/src/Surface.zig index 27a3fb5a8..ebeac4b97 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1127,6 +1127,13 @@ pub fn updateConfig( self.queueRender() catch |err| { log.warn("failed to notify renderer of config change err={}", .{err}); }; + + // Notify the window + try self.rt_app.performAction( + .{ .surface = self }, + .config_change, + .{ .config = config }, + ); } /// Returns true if the terminal has a selection. diff --git a/src/apprt/action.zig b/src/apprt/action.zig index aef6937a8..136f5fd7e 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; const apprt = @import("../apprt.zig"); +const configpkg = @import("../config.zig"); const input = @import("../input.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); @@ -200,6 +201,20 @@ pub const Action = union(Key) { /// on the app or surface. config_change_conditional_state, + /// The configuration has changed. The value is a pointer to the new + /// configuration. The pointer is only valid for the duration of the + /// action and should not be stored. + /// + /// This should be used by apprts to update any internal state that + /// depends on configuration for the given target (i.e. headerbar colors). + /// The apprt should copy any data it needs since the memory lifetime + /// is only valid for the duration of the action. + /// + /// This allows an apprt to have config-dependent state reactively + /// change without having to store the entire configuration or poll + /// for changes. + config_change: ConfigChange, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { new_window, @@ -236,6 +251,7 @@ pub const Action = union(Key) { key_sequence, color_change, config_change_conditional_state, + config_change, }; /// Sync with: ghostty_action_u @@ -497,3 +513,18 @@ pub const ColorKind = enum(c_int) { // 0+ values indicate a palette index _, }; + +pub const ConfigChange = struct { + config: *const configpkg.Config, + + // Sync with: ghostty_action_config_change_s + pub const C = extern struct { + config: *const configpkg.Config, + }; + + pub fn cval(self: ConfigChange) C { + return .{ + .config = self.config, + }; + } +}; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 3c866a1de..19be46778 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -226,6 +226,7 @@ pub const App = struct { .color_change, .pwd, .config_change_conditional_state, + .config_change, => log.info("unimplemented action={}", .{action}), } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 99148fd87..fa8a73830 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -488,6 +488,7 @@ pub fn performAction( .render_inspector, .renderer_health, .color_change, + .config_change, => log.warn("unimplemented action={}", .{action}), } } diff --git a/src/config/CAPI.zig b/src/config/CAPI.zig index bf86a0954..0b7108a59 100644 --- a/src/config/CAPI.zig +++ b/src/config/CAPI.zig @@ -19,6 +19,7 @@ export fn ghostty_config_new() ?*Config { result.* = Config.default(global.alloc) catch |err| { log.err("error creating config err={}", .{err}); + global.alloc.destroy(result); return null; }; @@ -32,6 +33,22 @@ export fn ghostty_config_free(ptr: ?*Config) void { } } +/// Deep clone the configuration. +export fn ghostty_config_clone(self: *Config) ?*Config { + const result = global.alloc.create(Config) catch |err| { + log.err("error allocating config err={}", .{err}); + return null; + }; + + result.* = self.clone(global.alloc) catch |err| { + log.err("error cloning config err={}", .{err}); + global.alloc.destroy(result); + return null; + }; + + return result; +} + /// Load the configuration from the CLI args. export fn ghostty_config_load_cli_args(self: *Config) void { self.loadCliArgs(global.alloc) catch |err| { diff --git a/src/config/Config.zig b/src/config/Config.zig index e68ad3da8..1e67a7e12 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -365,7 +365,6 @@ const c = @cImport({ /// be fixed in a future update: /// /// - macOS: titlebar tabs style is not updated when switching themes. -/// - macOS: native titlebar style is not supported. /// theme: ?Theme = null, @@ -1519,6 +1518,13 @@ keybind: Keybinds = .{}, /// This makes a more seamless window appearance but looks a little less /// typical for a macOS application and may not work well with all themes. /// +/// The "transparent" style will also update in real-time to dynamic +/// changes to the window background color, i.e. via OSC 11. To make this +/// more aesthetically pleasing, this only happens if the terminal is +/// a window, tab, or split that borders the top of the window. This +/// avoids a disjointed appearance where the titlebar color changes +/// but all the topmost terminals don't match. +/// /// The "tabs" style is a completely custom titlebar that integrates the /// tab bar into the titlebar. This titlebar always matches the background /// color of the terminal. There are some limitations to this style: @@ -1537,11 +1543,6 @@ keybind: Keybinds = .{}, /// but its one I think is the most aesthetically pleasing and works in /// most cases. /// -/// BUG: If a separate light/dark mode theme is configured with "theme", -/// then `macos-titlebar-style = transparent` will not work correctly. To -/// avoid ugly titlebars, `macos-titlebar-style` will become `native` if -/// a separate light/dark theme is configured. -/// /// Changing this option at runtime only applies to new windows. @"macos-titlebar-style": MacTitlebarStyle = .transparent, @@ -2756,12 +2757,6 @@ pub fn finalize(self: *Config) !void { // This setting doesn't make sense with different light/dark themes // because it'll force the theme based on the Ghostty theme. if (self.@"window-theme" == .auto) self.@"window-theme" = .system; - - // This is buggy with different light/dark themes and is noted - // in the documentation. - if (self.@"macos-titlebar-style" == .transparent) { - self.@"macos-titlebar-style" = .native; - } } }