Merge pull request #2762 from ghostty-org/config-update

apprt config change notification, macOS transparent titlebar works with theme change
This commit is contained in:
Mitchell Hashimoto
2024-11-21 14:28:14 -08:00
committed by GitHub
21 changed files with 729 additions and 207 deletions

View File

@ -532,6 +532,11 @@ typedef struct {
uint8_t b; uint8_t b;
} ghostty_action_color_change_s; } ghostty_action_color_change_s;
// apprt.action.ConfigChange
typedef struct {
ghostty_config_t config;
} ghostty_action_config_change_s;
// apprt.Action.Key // apprt.Action.Key
typedef enum { typedef enum {
GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_WINDOW,
@ -568,6 +573,7 @@ typedef enum {
GHOSTTY_ACTION_KEY_SEQUENCE, GHOSTTY_ACTION_KEY_SEQUENCE,
GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_COLOR_CHANGE,
GHOSTTY_ACTION_CONFIG_CHANGE_CONDITIONAL_STATE, GHOSTTY_ACTION_CONFIG_CHANGE_CONDITIONAL_STATE,
GHOSTTY_ACTION_CONFIG_CHANGE,
} ghostty_action_tag_e; } ghostty_action_tag_e;
typedef union { typedef union {
@ -592,6 +598,7 @@ typedef union {
ghostty_action_secure_input_e secure_input; ghostty_action_secure_input_e secure_input;
ghostty_action_key_sequence_s key_sequence; ghostty_action_key_sequence_s key_sequence;
ghostty_action_color_change_s color_change; ghostty_action_color_change_s color_change;
ghostty_action_config_change_s config_change;
} ghostty_action_u; } ghostty_action_u;
typedef struct { typedef struct {
@ -639,6 +646,7 @@ ghostty_info_s ghostty_info(void);
ghostty_config_t ghostty_config_new(); ghostty_config_t ghostty_config_new();
void ghostty_config_free(ghostty_config_t); 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_cli_args(ghostty_config_t);
void ghostty_config_load_default_files(ghostty_config_t); void ghostty_config_load_default_files(ghostty_config_t);
void ghostty_config_load_recursive_files(ghostty_config_t); void ghostty_config_load_recursive_files(ghostty_config_t);

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import Cocoa import Cocoa
import SwiftUI import SwiftUI
import Combine
import GhosttyKit import GhosttyKit
/// A classic, tabbed terminal experience. /// A classic, tabbed terminal experience.
@ -20,6 +21,12 @@ class TerminalController: BaseTerminalController {
/// For example, terminals executing custom scripts are not restorable. /// For example, terminals executing custom scripts are not restorable.
private var restorable: Bool = true private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
/// The notification cancellable for focused surface property changes.
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
init(_ ghostty: Ghostty.App, init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil withSurfaceTree tree: Ghostty.SplitNode? = nil
@ -31,6 +38,9 @@ class TerminalController: BaseTerminalController {
// restoration. // restoration.
self.restorable = (base?.command ?? "") == "" self.restorable = (base?.command ?? "") == ""
// Setup our initial derived config based on the current app config
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(ghostty, baseConfig: base, surfaceTree: tree) super.init(ghostty, baseConfig: base, surfaceTree: tree)
// Setup our notifications for behaviors // Setup our notifications for behaviors
@ -50,6 +60,12 @@ class TerminalController: BaseTerminalController {
selector: #selector(onGotoTab), selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab, name: Ghostty.Notification.ghosttyGotoTab,
object: nil) object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
center.addObserver( center.addObserver(
self, self,
selector: #selector(onFrameDidChange), selector: #selector(onFrameDidChange),
@ -80,10 +96,38 @@ class TerminalController: BaseTerminalController {
//MARK: - Methods //MARK: - Methods
func configDidReload() { @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 } guard let window = window as? TerminalWindow else { return }
window.focusFollowsMouse = ghostty.config.focusFollowsMouse window.focusFollowsMouse = config.focusFollowsMouse
syncAppearance()
// 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 /// Update the accessory view of each tab according to the keyboard
@ -144,28 +188,23 @@ class TerminalController: BaseTerminalController {
self.relabelTabs() self.relabelTabs()
} }
private func syncAppearance() { private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
guard let window = self.window as? TerminalWindow else { return } guard let window = self.window as? TerminalWindow else { return }
// If our window is not visible, then delay this. This is possible specifically // If our window is not visible, then we do nothing. Some things such as blurring
// during state restoration but probably in other scenarios as well. To delay, // have no effect if the window is not visible. Ultimately, we'll have this called
// we just loop directly on the dispatch queue. We have to delay because some // at some point when a surface becomes focused.
// APIs such as window blur have no effect unless the window is visible. guard window.isVisible else { return }
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
}
// Set the font for the window and tab titles. // Set the font for the window and tab titles.
if let titleFontName = ghostty.config.windowTitleFontFamily { if let titleFontName = surfaceConfig.windowTitleFontFamily {
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
} else { } else {
window.titlebarFont = nil window.titlebarFont = nil
} }
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (ghostty.config.backgroundOpacity < 1) { if (surfaceConfig.backgroundOpacity < 1) {
window.isOpaque = false window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // This is weird, but we don't use ".clear" because this creates a look that
@ -179,14 +218,26 @@ class TerminalController: BaseTerminalController {
window.backgroundColor = .windowBackgroundColor window.backgroundColor = .windowBackgroundColor
} }
window.hasShadow = ghostty.config.macosWindowShadow window.hasShadow = surfaceConfig.macosWindowShadow
guard window.hasStyledTabs else { return } guard window.hasStyledTabs else { return }
// The titlebar is always updated. We don't need to worry about opacity // Our background color depends on if our focused surface borders the top or not.
// because we handle it here. // If it does, we match the focused surface. If it doesn't, we use the app
let backgroundColor = OSColor(ghostty.config.backgroundColor) // configuration.
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity) 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) { if (window.isOpaque) {
// Bg color is only synced if we have no transparency. This is because // Bg color is only synced if we have no transparency. This is because
@ -210,6 +261,12 @@ class TerminalController: BaseTerminalController {
override func windowDidLoad() { override func windowDidLoad() {
guard let window = window as? TerminalWindow else { return } guard let window = window as? TerminalWindow else { return }
// I copy this because we may change the source in the future but also because
// I regularly audit our codebase for "ghostty.config" access because generally
// you shouldn't use it. Its safe in this case because for a new window we should
// use whatever the latest app-level config is.
let config = ghostty.config
// Setting all three of these is required for restoration to work. // Setting all three of these is required for restoration to work.
window.isRestorable = restorable window.isRestorable = restorable
if (restorable) { if (restorable) {
@ -218,13 +275,13 @@ class TerminalController: BaseTerminalController {
} }
// If window decorations are disabled, remove our title // If window decorations are disabled, remove our title
if (!ghostty.config.windowDecorations) { window.styleMask.remove(.titled) } if (!config.windowDecorations) { window.styleMask.remove(.titled) }
// Terminals typically operate in sRGB color space and macOS defaults // Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources // to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden. // Ghostty defaults to sRGB but this can be overridden.
switch (ghostty.config.windowColorspace) { switch (config.windowColorspace) {
case "display-p3": case "display-p3":
window.colorSpace = .displayP3 window.colorSpace = .displayP3
case "srgb": case "srgb":
@ -256,30 +313,30 @@ class TerminalController: BaseTerminalController {
window.center() window.center()
// Make sure our theme is set on the window so styling is correct. // Make sure our theme is set on the window so styling is correct.
if let windowTheme = ghostty.config.windowTheme { if let windowTheme = config.windowTheme {
window.windowTheme = .init(rawValue: windowTheme) window.windowTheme = .init(rawValue: windowTheme)
} }
// Handle titlebar tabs config option. Something about what we do while setting up the // Handle titlebar tabs config option. Something about what we do while setting up the
// titlebar tabs interferes with the window restore process unless window.tabbingMode // titlebar tabs interferes with the window restore process unless window.tabbingMode
// is set to .preferred, so we set it, and switch back to automatic as soon as we can. // is set to .preferred, so we set it, and switch back to automatic as soon as we can.
if (ghostty.config.macosTitlebarStyle == "tabs") { if (config.macosTitlebarStyle == "tabs") {
window.tabbingMode = .preferred window.tabbingMode = .preferred
window.titlebarTabs = true window.titlebarTabs = true
DispatchQueue.main.async { DispatchQueue.main.async {
window.tabbingMode = .automatic window.tabbingMode = .automatic
} }
} else if (ghostty.config.macosTitlebarStyle == "transparent") { } else if (config.macosTitlebarStyle == "transparent") {
window.transparentTabs = true window.transparentTabs = true
} }
if window.hasStyledTabs { if window.hasStyledTabs {
// Set the background color of the window // Set the background color of the window
let backgroundColor = NSColor(ghostty.config.backgroundColor) let backgroundColor = NSColor(config.backgroundColor)
window.backgroundColor = backgroundColor window.backgroundColor = backgroundColor
// This makes sure our titlebar renders correctly when there is a transparent background // This makes sure our titlebar renders correctly when there is a transparent background
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity) window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
} }
// Initialize our content view to the SwiftUI root // Initialize our content view to the SwiftUI root
@ -290,7 +347,7 @@ class TerminalController: BaseTerminalController {
)) ))
// If our titlebar style is "hidden" we adjust the style appropriately // If our titlebar style is "hidden" we adjust the style appropriately
if (ghostty.config.macosTitlebarStyle == "hidden") { if (config.macosTitlebarStyle == "hidden") {
window.styleMask = [ window.styleMask = [
// We need `titled` in the mask to get the normal window frame // We need `titled` in the mask to get the normal window frame
.titled, .titled,
@ -345,10 +402,12 @@ class TerminalController: BaseTerminalController {
} }
} }
window.focusFollowsMouse = ghostty.config.focusFollowsMouse window.focusFollowsMouse = config.focusFollowsMouse
// Apply any additional appearance-related properties to the new window. // Apply any additional appearance-related properties to the new window. We
syncAppearance() // 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. // 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. // Custom toolbar-based title used when titlebar tabs are enabled.
if let toolbar = window.toolbar as? TerminalToolbar { if let toolbar = window.toolbar as? TerminalToolbar {
if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") { if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") {
// Updating the title text as above automatically reveals the // Updating the title text as above automatically reveals the
// native title view in macOS 15.0 and above. Since we're using // native title view in macOS 15.0 and above. Since we're using
// a custom view instead, we need to re-hide it. // a custom view instead, we need to re-hide it.
@ -485,6 +544,36 @@ class TerminalController: BaseTerminalController {
window.surfaceIsZoomed = to 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 //MARK: - Notifications
@objc private func onMoveTab(notification: SwiftUI.Notification) { @objc private func onMoveTab(notification: SwiftUI.Notification) {
@ -593,4 +682,19 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode) 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
}
}
} }

View File

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

View File

@ -65,8 +65,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
} }
// If our configuration is "never" then we never restore the state // If our configuration is "never" then we never restore the state
// no matter what. // no matter what. Note its safe to use "ghostty.config" directly here
if (appDelegate.terminalManager.ghostty.config.windowSaveState == "never") { // because window restoration is only ever invoked on app start so we
// don't have to deal with config reloads.
if (appDelegate.ghostty.config.windowSaveState == "never") {
completionHandler(nil, nil) completionHandler(nil, nil)
return return
} }

View File

@ -1,3 +1,4 @@
import SwiftUI
import GhosttyKit import GhosttyKit
extension Ghostty { extension Ghostty {
@ -5,6 +6,33 @@ extension Ghostty {
} }
extension Ghostty.Action { 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 { struct MoveTab {
let amount: Int let amount: Int

View File

@ -3,9 +3,6 @@ import UserNotifications
import GhosttyKit import GhosttyKit
protocol GhosttyAppDelegate: AnyObject { protocol GhosttyAppDelegate: AnyObject {
/// Called when the configuration did finish reloading.
func configDidReload(_ app: Ghostty.App)
#if os(macOS) #if os(macOS)
/// Called when a callback needs access to a specific surface. This should return nil /// Called when a callback needs access to a specific surface. This should return nil
/// when the surface is no longer valid. /// when the surface is no longer valid.
@ -380,16 +377,6 @@ extension Ghostty {
let state = Unmanaged<Self>.fromOpaque(userdata!).takeUnretainedValue() let state = Unmanaged<Self>.fromOpaque(userdata!).takeUnretainedValue()
state.config = newConfig state.config = newConfig
// If we have a delegate, notify.
if let delegate = state.delegate {
delegate.configDidReload(state)
}
// Send an event out
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyDidReloadConfig,
object: nil)
return newConfig.config return newConfig.config
} }
@ -524,8 +511,12 @@ extension Ghostty {
case GHOSTTY_ACTION_KEY_SEQUENCE: case GHOSTTY_ACTION_KEY_SEQUENCE:
keySequence(app, target: target, v: action.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: case GHOSTTY_ACTION_COLOR_CHANGE:
fallthrough colorChange(app, target: target, change: action.action.color_change)
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: 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 // MARK: User Notifications
/// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user /// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user

View File

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

View File

@ -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 /// 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 /// top-left-most view. This is used when creating a split or closing a split to find the
/// next view to send focus to. /// 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 // MARK: - Sequence
func makeIterator() -> IndexingIterator<[Leaf]> { func makeIterator() -> IndexingIterator<[Leaf]> {

View File

@ -206,6 +206,14 @@ extension Ghostty {
// MARK: Surface Notification // MARK: Surface Notification
extension Notification.Name { 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. /// Goto tab. Has tab index in the userinfo.
static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab")
static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue 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. /// Used to pass a configuration along when creating a new tab/window/split.
static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig" static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig"
/// Posted when the application configuration is reloaded.
static let ghosttyDidReloadConfig = Notification.Name("com.mitchellh.ghostty.didReloadConfig")
/// Posted when a new split is requested. The sending object will be the surface that had focus. The /// Posted when a new split is requested. The sending object will be the surface that had focus. The
/// userdata has one key "direction" with the direction to split to. /// userdata has one key "direction" with the direction to split to.
static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit") static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")

View File

@ -48,6 +48,13 @@ extension Ghostty {
// Whether the pointer should be visible or not // Whether the pointer should be visible or not
@Published private(set) var pointerStyle: BackportPointerStyle = .default @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 // An initial size to request for a window. This will only affect
// then the view is moved to a new window. // then the view is moved to a new window.
var initialSize: NSSize? = nil var initialSize: NSSize? = nil
@ -114,6 +121,13 @@ extension Ghostty {
self.markedText = NSMutableAttributedString() self.markedText = NSMutableAttributedString()
self.uuid = uuid ?? .init() 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 // 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 // is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING. // can do SOMETHING.
@ -137,6 +151,16 @@ extension Ghostty {
selector: #selector(ghosttyDidEndKeySequence), selector: #selector(ghosttyDidEndKeySequence),
name: Ghostty.Notification.didEndKeySequence, name: Ghostty.Notification.didEndKeySequence,
object: self) object: self)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyColorDidChange(_:)),
name: .ghosttyColorDidChange,
object: self)
center.addObserver( center.addObserver(
self, self,
selector: #selector(windowDidChangeScreen), selector: #selector(windowDidChangeScreen),
@ -333,6 +357,31 @@ extension Ghostty {
keySequence = [] 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) { @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return } guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return } guard let object = notification.object as? NSWindow, window == object else { return }
@ -1025,6 +1074,27 @@ extension Ghostty {
Ghostty.moveFocus(to: self) 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
}
}
} }
} }

View File

@ -21,6 +21,14 @@ extension OSColor {
return (0.299 * r) + (0.587 * g) + (0.114 * b) 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 { func darken(by amount: CGFloat) -> OSColor {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
self.getHue(&h, saturation: &s, brightness: &b, alpha: &a) self.getHue(&h, saturation: &s, brightness: &b, alpha: &a)

View File

@ -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 /// Update the configuration associated with the app. This can only be
/// called from the main thread. The caller owns the config memory. The /// called from the main thread. The caller owns the config memory. The
/// memory can be freed immediately when this returns. /// 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. // Go through and update all of the surface configurations.
for (self.surfaces.items) |surface| { for (self.surfaces.items) |surface| {
try surface.core_surface.handleMessage(.{ .change_config = config }); 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 /// 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", .{}); log.debug("reloading configuration", .{});
if (try rt_app.reloadConfig()) |new| { if (try rt_app.reloadConfig()) |new| {
log.debug("new configuration received, applying", .{}); log.debug("new configuration received, applying", .{});
try self.updateConfig(new); try self.updateConfig(rt_app, new);
} }
} }

View File

@ -1127,6 +1127,13 @@ pub fn updateConfig(
self.queueRender() catch |err| { self.queueRender() catch |err| {
log.warn("failed to notify renderer of config change err={}", .{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. /// Returns true if the terminal has a selection.

View File

@ -1,6 +1,7 @@
const std = @import("std"); const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig");
const input = @import("../input.zig"); const input = @import("../input.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig"); const terminal = @import("../terminal/main.zig");
@ -200,6 +201,20 @@ pub const Action = union(Key) {
/// on the app or surface. /// on the app or surface.
config_change_conditional_state, 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 /// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) { pub const Key = enum(c_int) {
new_window, new_window,
@ -236,6 +251,7 @@ pub const Action = union(Key) {
key_sequence, key_sequence,
color_change, color_change,
config_change_conditional_state, config_change_conditional_state,
config_change,
}; };
/// Sync with: ghostty_action_u /// Sync with: ghostty_action_u
@ -497,3 +513,18 @@ pub const ColorKind = enum(c_int) {
// 0+ values indicate a palette index // 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,
};
}
};

View File

@ -226,6 +226,7 @@ pub const App = struct {
.color_change, .color_change,
.pwd, .pwd,
.config_change_conditional_state, .config_change_conditional_state,
.config_change,
=> log.info("unimplemented action={}", .{action}), => log.info("unimplemented action={}", .{action}),
} }
} }

View File

@ -488,6 +488,7 @@ pub fn performAction(
.render_inspector, .render_inspector,
.renderer_health, .renderer_health,
.color_change, .color_change,
.config_change,
=> log.warn("unimplemented action={}", .{action}), => log.warn("unimplemented action={}", .{action}),
} }
} }

View File

@ -19,6 +19,7 @@ export fn ghostty_config_new() ?*Config {
result.* = Config.default(global.alloc) catch |err| { result.* = Config.default(global.alloc) catch |err| {
log.err("error creating config err={}", .{err}); log.err("error creating config err={}", .{err});
global.alloc.destroy(result);
return null; 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. /// Load the configuration from the CLI args.
export fn ghostty_config_load_cli_args(self: *Config) void { export fn ghostty_config_load_cli_args(self: *Config) void {
self.loadCliArgs(global.alloc) catch |err| { self.loadCliArgs(global.alloc) catch |err| {

View File

@ -365,7 +365,6 @@ const c = @cImport({
/// be fixed in a future update: /// be fixed in a future update:
/// ///
/// - macOS: titlebar tabs style is not updated when switching themes. /// - macOS: titlebar tabs style is not updated when switching themes.
/// - macOS: native titlebar style is not supported.
/// ///
theme: ?Theme = null, theme: ?Theme = null,
@ -1519,6 +1518,13 @@ keybind: Keybinds = .{},
/// This makes a more seamless window appearance but looks a little less /// 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. /// 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 /// The "tabs" style is a completely custom titlebar that integrates the
/// tab bar into the titlebar. This titlebar always matches the background /// tab bar into the titlebar. This titlebar always matches the background
/// color of the terminal. There are some limitations to this style: /// 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 /// but its one I think is the most aesthetically pleasing and works in
/// most cases. /// 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. /// Changing this option at runtime only applies to new windows.
@"macos-titlebar-style": MacTitlebarStyle = .transparent, @"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 // This setting doesn't make sense with different light/dark themes
// because it'll force the theme based on the Ghostty theme. // because it'll force the theme based on the Ghostty theme.
if (self.@"window-theme" == .auto) self.@"window-theme" = .system; 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;
}
} }
} }