From fadfb08efef52b23ceac598839c98fdf51d2cf1c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Nov 2024 15:08:47 -0800 Subject: [PATCH 1/8] apprt: add `config_change` action --- include/ghostty.h | 7 +++++++ src/App.zig | 11 +++++++++-- src/Surface.zig | 7 +++++++ src/apprt/action.zig | 31 +++++++++++++++++++++++++++++++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 1 + 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index d0426e995..5186ad783 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 { 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}), } } From 3afb9065f03f662a3f158b35add3eeffacfbfa23 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Nov 2024 15:22:05 -0800 Subject: [PATCH 2/8] macos: listen for config change and post a notification --- macos/Sources/Ghostty/Ghostty.App.swift | 28 +++++++++++++++++++++++++ macos/Sources/Ghostty/Package.swift | 4 ++++ 2 files changed, 32 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 489493ad3..cc365ce4f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -524,6 +524,9 @@ 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 case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: @@ -1159,6 +1162,31 @@ extension Ghostty { } } + private static func configChange( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_config_change_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + NotificationCenter.default.post( + name: .ghosttyConfigChange, + object: nil + ) + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyConfigChange, + object: surfaceView + ) + + 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/Package.swift b/macos/Sources/Ghostty/Package.swift index e4ab91a5f..b926edab5 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -206,6 +206,10 @@ 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 ghosttyConfigChange = Notification.Name("com.mitchellh.ghostty.configChange") + static let GhosttyConfigChangeKey = ghosttyConfigChange.rawValue + /// Goto tab. Has tab index in the userinfo. static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue From 037d4364e596cc42aa487d88d08364b5d78a4456 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Nov 2024 15:28:10 -0800 Subject: [PATCH 3/8] config: C API ghostty_config_clone --- include/ghostty.h | 1 + src/config/CAPI.zig | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 5186ad783..813f81df2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -646,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/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| { From 35fcb1a29b192454b08a53b47dfb7d7955e463f6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Nov 2024 15:28:10 -0800 Subject: [PATCH 4/8] macos: change config access to evented, derived config like libghostty Previously, we would access the `ghostty.config` object from anywhere. The issue with this is that memory lifetime access to the underlying `ghostty_config_t` was messy. It was easy when the apprt owned every reference but since automatic theme changes were implemented, this isn't always true anymore. To fix this, we move to the same pattern we use internally in the core of ghostty: whenever the config changes, we handle an event, derive our desired values out of the config (copy them), and then let the caller free the config if they want to. This way, we can be sure that any information we need from the config is always owned by us. --- macos/Sources/App/macOS/AppDelegate.swift | 281 ++++++++++-------- .../QuickTerminalController.swift | 55 +++- .../Terminal/BaseTerminalController.swift | 42 ++- .../Terminal/TerminalController.swift | 84 ++++-- .../Features/Terminal/TerminalManager.swift | 54 +++- .../Terminal/TerminalRestorable.swift | 6 +- macos/Sources/Ghostty/Ghostty.App.swift | 61 ++-- macos/Sources/Ghostty/Ghostty.Config.swift | 4 + macos/Sources/Ghostty/Package.swift | 7 +- 9 files changed, 395 insertions(+), 199 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8179e1950..faafa7205 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -69,6 +69,9 @@ class AppDelegate: NSObject, /// seconds since the process was launched. private var applicationLaunchTime: TimeInterval = 0 + /// This is the current configuration from the Ghostty configuration that we need. + private var derivedConfig: DerivedConfig = DerivedConfig() + /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() @@ -138,7 +141,7 @@ class AppDelegate: NSObject, menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) // Initial config loading - configDidReload(ghostty) + ghosttyConfigDidChange(config: ghostty.config) // Start our update checker. updaterController.startUpdater() @@ -162,6 +165,12 @@ class AppDelegate: NSObject, name: .quickTerminalDidChangeVisibility, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) // Configure user notifications let actions = [ @@ -188,13 +197,13 @@ class AppDelegate: NSObject, // is possible to have other windows in a few scenarios: // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && ghostty.config.initialWindow { + if terminalManager.windows.count == 0 && derivedConfig.initialWindow { terminalManager.newWindow() } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return ghostty.config.shouldQuitAfterLastWindowClosed + return derivedConfig.shouldQuitAfterLastWindowClosed } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -300,52 +309,52 @@ class AppDelegate: NSObject, } /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. - private func syncMenuShortcuts() { + private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } - syncMenuShortcut(action: "open_config", menuItem: self.menuOpenConfig) - syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig) - syncMenuShortcut(action: "quit", menuItem: self.menuQuit) + syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) + syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) + syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) - syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow) - syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab) - syncMenuShortcut(action: "close_surface", menuItem: self.menuClose) - syncMenuShortcut(action: "close_window", menuItem: self.menuCloseWindow) - syncMenuShortcut(action: "close_all_windows", menuItem: self.menuCloseAllWindows) - syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitRight) - syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitDown) + syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) + syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) + syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) + syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) + syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) - syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy) - syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste) - syncMenuShortcut(action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) + syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) - syncMenuShortcut(action: "toggle_split_zoom", menuItem: self.menuZoomSplit) - syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit) - syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) - syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft) - syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight) - syncMenuShortcut(action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) - syncMenuShortcut(action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) - syncMenuShortcut(action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) - syncMenuShortcut(action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) - syncMenuShortcut(action: "equalize_splits", menuItem: self.menuEqualizeSplits) + syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) + syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) + syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) + syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) + syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) + syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) + syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) + syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) + syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) + syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) - syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) - syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) - syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) - syncMenuShortcut(action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) - syncMenuShortcut(action: "toggle_visibility", menuItem: self.menuToggleVisibility) - syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) + syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) + syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) + syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) + syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) + syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) - syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) + syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput) // This menu item is NOT synced with the configuration because it disables macOS // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue // to work but it won't be reflected in the menu item. // - // syncMenuShortcut(action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) + // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) // Dock menu reloadDockMenu() @@ -353,9 +362,9 @@ class AppDelegate: NSObject, /// Syncs a single menu shortcut for the given action. The action string is the same /// action string used for the Ghostty configuration. - private func syncMenuShortcut(action: String, menuItem: NSMenuItem?) { + private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { guard let menu = menuItem else { return } - guard let equiv = ghostty.config.keyEquivalent(for: action) else { + guard let equiv = config.keyEquivalent(for: action) else { // No shortcut, clear the menu item menu.keyEquivalent = "" menu.keyEquivalentModifierMask = [] @@ -422,6 +431,98 @@ class AppDelegate: NSObject, self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off } } + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + // We only care if the configuration is a global configuration, not a surface one. + guard notification.object == nil else { return } + + // Get our managed configuration object out + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + + ghosttyConfigDidChange(config: config) + } + + private func ghosttyConfigDidChange(config: Ghostty.Config) { + // Update the config we need to store + self.derivedConfig = DerivedConfig(config) + + // Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows + // configuration. This is the only way to carefully control whether macOS invokes the + // state restoration system. + switch (config.windowSaveState) { + case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") + case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") + case "default": fallthrough + default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") + } + + // Sync our auto-update settings + updaterController.updater.automaticallyChecksForUpdates = + config.autoUpdate == .check || config.autoUpdate == .download + updaterController.updater.automaticallyDownloadsUpdates = + config.autoUpdate == .download + + // Config could change keybindings, so update everything that depends on that + syncMenuShortcuts(config) + terminalManager.relabelAllTabs() + + // Config could change window appearance. We wrap this in an async queue because when + // this is called as part of application launch it can deadlock with an internal + // AppKit mutex on the appearance. + DispatchQueue.main.async { self.syncAppearance(config: config) } + + // If we have configuration errors, we need to show them. + let c = ConfigurationErrorsController.sharedInstance + c.errors = config.errors + if (c.errors.count > 0) { + if (c.window == nil || !c.window!.isVisible) { + c.showWindow(self) + } + } + + // We need to handle our global event tap depending on if there are global + // events that we care about in Ghostty. + if (ghostty_app_has_global_keybinds(ghostty.app!)) { + if (timeSinceLaunch > 5) { + // If the process has been running for awhile we enable right away + // because no windows are likely to pop up. + GlobalEventTap.shared.enable() + } else { + // If the process just started, we wait a couple seconds to allow + // the initial windows and so on to load so our permissions dialog + // doesn't get buried. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + GlobalEventTap.shared.enable() + } + } + } else { + GlobalEventTap.shared.disable() + } + } + + /// Sync the appearance of our app with the theme specified in the config. + private func syncAppearance(config: Ghostty.Config) { + guard let theme = config.windowTheme else { return } + switch (theme) { + case "dark": + let appearance = NSAppearance(named: .darkAqua) + NSApplication.shared.appearance = appearance + + case "light": + let appearance = NSAppearance(named: .aqua) + NSApplication.shared.appearance = appearance + + case "auto": + let color = OSColor(config.backgroundColor) + let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua) + NSApplication.shared.appearance = appearance + + default: + NSApplication.shared.appearance = nil + } + } + //MARK: - Restorable State /// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways. @@ -470,88 +571,6 @@ class AppDelegate: NSObject, return nil } - func configDidReload(_ state: Ghostty.App) { - // Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows - // configuration. This is the only way to carefully control whether macOS invokes the - // state restoration system. - switch (ghostty.config.windowSaveState) { - case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") - case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") - case "default": fallthrough - default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") - } - - // Sync our auto-update settings - updaterController.updater.automaticallyChecksForUpdates = - ghostty.config.autoUpdate == .check || ghostty.config.autoUpdate == .download - updaterController.updater.automaticallyDownloadsUpdates = - ghostty.config.autoUpdate == .download - - // Config could change keybindings, so update everything that depends on that - syncMenuShortcuts() - terminalManager.relabelAllTabs() - - // Config could change window appearance. We wrap this in an async queue because when - // this is called as part of application launch it can deadlock with an internal - // AppKit mutex on the appearance. - DispatchQueue.main.async { self.syncAppearance() } - - // Update all of our windows - terminalManager.windows.forEach { window in - window.controller.configDidReload() - } - - // If we have configuration errors, we need to show them. - let c = ConfigurationErrorsController.sharedInstance - c.errors = state.config.errors - if (c.errors.count > 0) { - if (c.window == nil || !c.window!.isVisible) { - c.showWindow(self) - } - } - - // We need to handle our global event tap depending on if there are global - // events that we care about in Ghostty. - if (ghostty_app_has_global_keybinds(ghostty.app!)) { - if (timeSinceLaunch > 5) { - // If the process has been running for awhile we enable right away - // because no windows are likely to pop up. - GlobalEventTap.shared.enable() - } else { - // If the process just started, we wait a couple seconds to allow - // the initial windows and so on to load so our permissions dialog - // doesn't get buried. - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { - GlobalEventTap.shared.enable() - } - } - } else { - GlobalEventTap.shared.disable() - } - } - - /// Sync the appearance of our app with the theme specified in the config. - private func syncAppearance() { - guard let theme = ghostty.config.windowTheme else { return } - switch (theme) { - case "dark": - let appearance = NSAppearance(named: .darkAqua) - NSApplication.shared.appearance = appearance - - case "light": - let appearance = NSAppearance(named: .aqua) - NSApplication.shared.appearance = appearance - - case "auto": - let color = OSColor(ghostty.config.backgroundColor) - let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua) - NSApplication.shared.appearance = appearance - - default: - NSApplication.shared.appearance = nil - } - } - //MARK: - Dock Menu private func reloadDockMenu() { @@ -629,7 +648,7 @@ class AppDelegate: NSObject, if quickController == nil { quickController = QuickTerminalController( ghostty, - position: ghostty.config.quickTerminalPosition + position: derivedConfig.quickTerminalPosition ) } @@ -655,4 +674,22 @@ class AppDelegate: NSObject, isVisible.toggle() } + + private struct DerivedConfig { + let initialWindow: Bool + let shouldQuitAfterLastWindowClosed: Bool + let quickTerminalPosition: QuickTerminalPosition + + init() { + self.initialWindow = true + self.shouldQuitAfterLastWindowClosed = false + self.quickTerminalPosition = .top + } + + init(_ config: Ghostty.Config) { + self.initialWindow = config.initialWindow + self.shouldQuitAfterLastWindowClosed = config.shouldQuitAfterLastWindowClosed + self.quickTerminalPosition = config.quickTerminalPosition + } + } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index bdd427be0..18549eea1 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -18,12 +18,16 @@ class QuickTerminalController: BaseTerminalController { /// application to the front. private var previousApp: NSRunningApplication? = nil + /// The configuration derived from the Ghostty config so we don't need to rely on references. + private var derivedConfig: DerivedConfig + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: Ghostty.SplitNode? = nil ) { self.position = position + self.derivedConfig = DerivedConfig(ghostty.config) super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors @@ -35,8 +39,8 @@ class QuickTerminalController: BaseTerminalController { object: nil) center.addObserver( self, - selector: #selector(ghosttyDidReloadConfig), - name: Ghostty.Notification.ghosttyDidReloadConfig, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, object: nil) } @@ -64,7 +68,7 @@ class QuickTerminalController: BaseTerminalController { window.isRestorable = false // Setup our configured appearance that we support. - syncAppearance() + syncAppearance(ghostty.config) // Setup our initial size based on our configured position position.setLoaded(window) @@ -186,7 +190,7 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { - guard let screen = ghostty.config.quickTerminalScreen.screen else { return } + guard let screen = derivedConfig.quickTerminalScreen.screen else { return } // Move our window off screen to the top position.setInitial(in: window, on: screen) @@ -197,7 +201,7 @@ class QuickTerminalController: BaseTerminalController { // Run the animation that moves our window into the proper place and makes // it visible. NSAnimationContext.runAnimationGroup({ context in - context.duration = ghostty.config.quickTerminalAnimationDuration + context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) position.setFinal(in: window.animator(), on: screen) }, completionHandler: { @@ -287,7 +291,7 @@ class QuickTerminalController: BaseTerminalController { } NSAnimationContext.runAnimationGroup({ context in - context.duration = ghostty.config.quickTerminalAnimationDuration + context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) position.setInitial(in: window.animator(), on: screen) }, completionHandler: { @@ -297,7 +301,7 @@ class QuickTerminalController: BaseTerminalController { }) } - private func syncAppearance() { + private func syncAppearance(_ config: Ghostty.Config) { guard let window else { return } // If our window is not visible, then delay this. This is possible specifically @@ -306,7 +310,7 @@ class QuickTerminalController: BaseTerminalController { // APIs such as window blur have no effect unless the window is visible. guard window.isVisible else { // Weak window so that if the window changes or is destroyed we aren't holding a ref - DispatchQueue.main.async { [weak self] in self?.syncAppearance() } + DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) } return } @@ -314,7 +318,7 @@ class QuickTerminalController: BaseTerminalController { // to "native" which is typically P3. There is a lot more resources // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // Ghostty defaults to sRGB but this can be overridden. - switch (ghostty.config.windowColorspace) { + switch (config.windowColorspace) { case "display-p3": window.colorSpace = .displayP3 case "srgb": @@ -324,7 +328,7 @@ class QuickTerminalController: BaseTerminalController { } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (ghostty.config.backgroundOpacity < 1) { + if (config.backgroundOpacity < 1) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -371,8 +375,35 @@ class QuickTerminalController: BaseTerminalController { toggleFullscreen(mode: .nonNative) } - @objc private func ghosttyDidReloadConfig(notification: SwiftUI.Notification) { - syncAppearance() + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + // We only care if the configuration is a global configuration, not a + // surface-specific one. + guard notification.object == nil else { return } + + // Get our managed configuration object out + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + + // Update our derived config + self.derivedConfig = DerivedConfig(config) + + syncAppearance(config) + } + + private struct DerivedConfig { + let quickTerminalScreen: QuickTerminalScreen + let quickTerminalAnimationDuration: Double + + init() { + self.quickTerminalScreen = .main + self.quickTerminalAnimationDuration = 0.2 + } + + init(_ config: Ghostty.Config) { + self.quickTerminalScreen = config.quickTerminalScreen + self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration + } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 000d72418..721248013 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -60,6 +60,9 @@ class BaseTerminalController: NSWindowController, /// The previous frame information from the window private var savedFrame: SavedFrame? = nil + /// The configuration derived from the Ghostty config so we don't need to rely on references. + private var derivedConfig: DerivedConfig + struct SavedFrame { let window: NSRect let screen: NSRect @@ -74,6 +77,7 @@ class BaseTerminalController: NSWindowController, surfaceTree tree: Ghostty.SplitNode? = nil ) { self.ghostty = ghostty + self.derivedConfig = DerivedConfig(ghostty.config) super.init(window: nil) @@ -93,6 +97,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(didChangeScreenParametersNotification), name: NSApplication.didChangeScreenParametersNotification, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyConfigDidChangeBase(_:)), + name: .ghosttyConfigDidChange, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -191,6 +200,20 @@ class BaseTerminalController: NSWindowController, window.setFrame(newFrame, display: true) } + @objc private func ghosttyConfigDidChangeBase(_ notification: Notification) { + // We only care if the configuration is a global configuration, not a + // surface-specific one. + guard notification.object == nil else { return } + + // Get our managed configuration object out + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + + // Update our derived config + self.derivedConfig = DerivedConfig(config) + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { @@ -245,7 +268,7 @@ class BaseTerminalController: NSWindowController, func pwdDidChange(to: URL?) { guard let window else { return } - if ghostty.config.macosTitlebarProxyIcon == .visible { + if derivedConfig.macosTitlebarProxyIcon == .visible { // Use the 'to' URL directly window.representedURL = to } else { @@ -255,7 +278,7 @@ class BaseTerminalController: NSWindowController, func cellSizeDidChange(to: NSSize) { - guard ghostty.config.windowStepResize else { return } + guard derivedConfig.windowStepResize else { return } self.window?.contentResizeIncrements = to } @@ -563,4 +586,19 @@ class BaseTerminalController: NSWindowController, guard let surface = focusedSurface?.surface else { return } ghostty.resetTerminal(surface: surface) } + + private struct DerivedConfig { + let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon + let windowStepResize: Bool + + init() { + self.macosTitlebarProxyIcon = .visible + self.windowStepResize = false + } + + init(_ config: Ghostty.Config) { + self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon + self.windowStepResize = config.windowStepResize + } + } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f31740105..09b758a1e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -20,6 +20,9 @@ class TerminalController: BaseTerminalController { /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true + /// The configuration derived from the Ghostty config so we don't need to rely on references. + private var derivedConfig: DerivedConfig + init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: Ghostty.SplitNode? = nil @@ -31,6 +34,9 @@ class TerminalController: BaseTerminalController { // restoration. self.restorable = (base?.command ?? "") == "" + // Setup our initial derived config based on the current app config + self.derivedConfig = DerivedConfig(ghostty.config) + super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors @@ -50,6 +56,12 @@ class TerminalController: BaseTerminalController { selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) center.addObserver( self, selector: #selector(onFrameDidChange), @@ -80,10 +92,22 @@ class TerminalController: BaseTerminalController { //MARK: - Methods - func configDidReload() { + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + // We only care if the configuration is a global configuration, not a + // surface-specific one. + guard notification.object == nil else { return } + + // Get our managed configuration object out + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + + // Update our derived config + self.derivedConfig = DerivedConfig(config) + guard let window = window as? TerminalWindow else { return } - window.focusFollowsMouse = ghostty.config.focusFollowsMouse - syncAppearance() + window.focusFollowsMouse = config.focusFollowsMouse + syncAppearance(config) } /// Update the accessory view of each tab according to the keyboard @@ -144,7 +168,7 @@ class TerminalController: BaseTerminalController { self.relabelTabs() } - private func syncAppearance() { + private func syncAppearance(_ config: Ghostty.Config) { guard let window = self.window as? TerminalWindow else { return } // If our window is not visible, then delay this. This is possible specifically @@ -153,19 +177,19 @@ class TerminalController: BaseTerminalController { // APIs such as window blur have no effect unless the window is visible. guard window.isVisible else { // Weak window so that if the window changes or is destroyed we aren't holding a ref - DispatchQueue.main.async { [weak self] in self?.syncAppearance() } + DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) } return } // Set the font for the window and tab titles. - if let titleFontName = ghostty.config.windowTitleFontFamily { + if let titleFontName = config.windowTitleFontFamily { window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) } else { window.titlebarFont = nil } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (ghostty.config.backgroundOpacity < 1) { + if (config.backgroundOpacity < 1) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -179,14 +203,14 @@ class TerminalController: BaseTerminalController { window.backgroundColor = .windowBackgroundColor } - window.hasShadow = ghostty.config.macosWindowShadow + window.hasShadow = config.macosWindowShadow guard window.hasStyledTabs else { return } // The titlebar is always updated. We don't need to worry about opacity // because we handle it here. - let backgroundColor = OSColor(ghostty.config.backgroundColor) - window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity) + let backgroundColor = OSColor(config.backgroundColor) + window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) if (window.isOpaque) { // Bg color is only synced if we have no transparency. This is because @@ -210,6 +234,12 @@ class TerminalController: BaseTerminalController { override func windowDidLoad() { guard let window = window as? TerminalWindow else { return } + // I copy this because we may change the source in the future but also because + // I regularly audit our codebase for "ghostty.config" access because generally + // you shouldn't use it. Its safe in this case because for a new window we should + // use whatever the latest app-level config is. + let config = ghostty.config + // Setting all three of these is required for restoration to work. window.isRestorable = restorable if (restorable) { @@ -218,13 +248,13 @@ class TerminalController: BaseTerminalController { } // If window decorations are disabled, remove our title - if (!ghostty.config.windowDecorations) { window.styleMask.remove(.titled) } + if (!config.windowDecorations) { window.styleMask.remove(.titled) } // Terminals typically operate in sRGB color space and macOS defaults // to "native" which is typically P3. There is a lot more resources // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // Ghostty defaults to sRGB but this can be overridden. - switch (ghostty.config.windowColorspace) { + switch (config.windowColorspace) { case "display-p3": window.colorSpace = .displayP3 case "srgb": @@ -256,30 +286,30 @@ class TerminalController: BaseTerminalController { window.center() // Make sure our theme is set on the window so styling is correct. - if let windowTheme = ghostty.config.windowTheme { + if let windowTheme = config.windowTheme { window.windowTheme = .init(rawValue: windowTheme) } // Handle titlebar tabs config option. Something about what we do while setting up the // titlebar tabs interferes with the window restore process unless window.tabbingMode // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - if (ghostty.config.macosTitlebarStyle == "tabs") { + if (config.macosTitlebarStyle == "tabs") { window.tabbingMode = .preferred window.titlebarTabs = true DispatchQueue.main.async { window.tabbingMode = .automatic } - } else if (ghostty.config.macosTitlebarStyle == "transparent") { + } else if (config.macosTitlebarStyle == "transparent") { window.transparentTabs = true } if window.hasStyledTabs { // Set the background color of the window - let backgroundColor = NSColor(ghostty.config.backgroundColor) + let backgroundColor = NSColor(config.backgroundColor) window.backgroundColor = backgroundColor // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity) + window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) } // Initialize our content view to the SwiftUI root @@ -290,7 +320,7 @@ class TerminalController: BaseTerminalController { )) // If our titlebar style is "hidden" we adjust the style appropriately - if (ghostty.config.macosTitlebarStyle == "hidden") { + if (config.macosTitlebarStyle == "hidden") { window.styleMask = [ // We need `titled` in the mask to get the normal window frame .titled, @@ -345,10 +375,10 @@ class TerminalController: BaseTerminalController { } } - window.focusFollowsMouse = ghostty.config.focusFollowsMouse + window.focusFollowsMouse = config.focusFollowsMouse // Apply any additional appearance-related properties to the new window. - syncAppearance() + syncAppearance(config) } // Shows the "+" button in the tab bar, responds to that click. @@ -464,7 +494,7 @@ class TerminalController: BaseTerminalController { // Custom toolbar-based title used when titlebar tabs are enabled. if let toolbar = window.toolbar as? TerminalToolbar { - if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") { + if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") { // Updating the title text as above automatically reveals the // native title view in macOS 15.0 and above. Since we're using // a custom view instead, we need to re-hide it. @@ -593,4 +623,16 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } + + private struct DerivedConfig { + let macosTitlebarStyle: String + + init() { + self.macosTitlebarStyle = "system" + } + + init(_ config: Ghostty.Config) { + self.macosTitlebarStyle = config.macosTitlebarStyle + } + } } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 0766f33b0..42e35b90e 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -37,8 +37,12 @@ class TerminalManager { return windows.last } + /// The configuration derived from the Ghostty config so we don't need to rely on references. + private var derivedConfig: DerivedConfig + init(_ ghostty: Ghostty.App) { self.ghostty = ghostty + self.derivedConfig = DerivedConfig(ghostty.config) let center = NotificationCenter.default center.addObserver( @@ -51,6 +55,11 @@ class TerminalManager { selector: #selector(onNewWindow), name: Ghostty.Notification.ghosttyNewWindow, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil) } deinit { @@ -70,8 +79,8 @@ class TerminalManager { if let parent = focusedSurface?.window, parent.styleMask.contains(.fullScreen) { window.toggleFullScreen(nil) - } else if ghostty.config.windowFullscreen { - switch (ghostty.config.windowFullscreenMode) { + } else if derivedConfig.windowFullscreen { + switch (derivedConfig.windowFullscreenMode) { case .native: // Native has to be done immediately so that our stylemask contains // fullscreen for the logic later in this method. @@ -81,7 +90,7 @@ class TerminalManager { // If we're non-native then we have to do it on a later loop // so that the content view is setup. DispatchQueue.main.async { - c.toggleFullscreen(mode: self.ghostty.config.windowFullscreenMode) + c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode) } } } @@ -159,9 +168,9 @@ class TerminalManager { // If we have the "hidden" titlebar style we want to create new // tabs as windows instead, so just skip adding it to the parent. - if (ghostty.config.macosTitlebarStyle != "hidden") { + if (derivedConfig.macosTitlebarStyle != "hidden") { // Add the window to the tab group and show it. - switch ghostty.config.windowNewTabPosition { + switch derivedConfig.windowNewTabPosition { case "end": // If we already have a tab group and we want the new tab to open at the end, // then we use the last window in the tab group as the parent. @@ -325,4 +334,39 @@ class TerminalManager { self.newTab(to: window, withBaseConfig: config) } + + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + // We only care if the configuration is a global configuration, not a + // surface-specific one. + guard notification.object == nil else { return } + + // Get our managed configuration object out + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + + // Update our derived config + self.derivedConfig = DerivedConfig(config) + } + + private struct DerivedConfig { + let windowFullscreen: Bool + let windowFullscreenMode: FullscreenMode + let macosTitlebarStyle: String + let windowNewTabPosition: String + + init() { + self.windowFullscreen = false + self.windowFullscreenMode = .native + self.macosTitlebarStyle = "transparent" + self.windowNewTabPosition = "" + } + + init(_ config: Ghostty.Config) { + self.windowFullscreen = config.windowFullscreen + self.windowFullscreenMode = config.windowFullscreenMode + self.macosTitlebarStyle = config.macosTitlebarStyle + self.windowNewTabPosition = config.windowNewTabPosition + } + } } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 1d1ae82d0..b9d9b0ac0 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -65,8 +65,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } // If our configuration is "never" then we never restore the state - // no matter what. - if (appDelegate.terminalManager.ghostty.config.windowSaveState == "never") { + // no matter what. Note its safe to use "ghostty.config" directly here + // because window restoration is only ever invoked on app start so we + // don't have to deal with config reloads. + if (appDelegate.ghostty.config.windowSaveState == "never") { completionHandler(nil, nil) return } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index cc365ce4f..2ec62a426 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -3,9 +3,6 @@ import UserNotifications import GhosttyKit protocol GhosttyAppDelegate: AnyObject { - /// Called when the configuration did finish reloading. - func configDidReload(_ app: Ghostty.App) - #if os(macOS) /// Called when a callback needs access to a specific surface. This should return nil /// when the surface is no longer valid. @@ -380,16 +377,6 @@ extension Ghostty { let state = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() state.config = newConfig - // If we have a delegate, notify. - if let delegate = state.delegate { - delegate.configDidReload(state) - } - - // Send an event out - NotificationCenter.default.post( - name: Ghostty.Notification.ghosttyDidReloadConfig, - object: nil) - return newConfig.config } @@ -1166,26 +1153,40 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_config_change_s) { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - NotificationCenter.default.post( - name: .ghosttyConfigChange, - object: nil - ) - return + logger.info("config change notification") - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return } - guard let surfaceView = self.surfaceView(from: surface) else { return } - NotificationCenter.default.post( - name: .ghosttyConfigChange, - object: surfaceView - ) + // Clone the config so we own the memory. It'd be nicer to not have to do + // this but since we async send the config out below we have to own the lifetime. + // A future improvement might be to add reference counting to config or + // something so apprt's do not have to do this. + let config = Config(clone: v.config) - default: - assertionFailure() + switch (target.tag) { + case GHOSTTY_TARGET_APP: + NotificationCenter.default.post( + name: .ghosttyConfigDidChange, + object: nil, + userInfo: [ + SwiftUI.Notification.Name.GhosttyConfigChangeKey: config, + ] + ) + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyConfigDidChange, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.GhosttyConfigChangeKey: config, + ] + ) + + default: + assertionFailure() + } } - } // MARK: User Notifications diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b8c7d2594..3a58455d9 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -39,6 +39,10 @@ extension Ghostty { } } + init(clone config: ghostty_config_t) { + self.config = ghostty_config_clone(config) + } + deinit { self.config = nil } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b926edab5..074ce4743 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -207,8 +207,8 @@ extension Ghostty { extension Notification.Name { /// Configuration change. If the object is nil then it is app-wide. Otherwise its surface-specific. - static let ghosttyConfigChange = Notification.Name("com.mitchellh.ghostty.configChange") - static let GhosttyConfigChangeKey = ghosttyConfigChange.rawValue + static let ghosttyConfigDidChange = Notification.Name("com.mitchellh.ghostty.configDidChange") + static let GhosttyConfigChangeKey = ghosttyConfigDidChange.rawValue /// Goto tab. Has tab index in the userinfo. static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") @@ -221,9 +221,6 @@ extension Ghostty.Notification { /// Used to pass a configuration along when creating a new tab/window/split. static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig" - /// Posted when the application configuration is reloaded. - static let ghosttyDidReloadConfig = Notification.Name("com.mitchellh.ghostty.didReloadConfig") - /// Posted when a new split is requested. The sending object will be the surface that had focus. The /// userdata has one key "direction" with the direction to split to. static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit") From f722e30bf5f5ffca027dfd17f0c68ab3fe8857e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Nov 2024 12:12:16 -0800 Subject: [PATCH 5/8] macos: terminal controller reacts to surface config changes --- .../Terminal/TerminalController.swift | 63 +++++++++++++------ .../Sources/Ghostty/SurfaceView_AppKit.swift | 46 ++++++++++++++ src/config/Config.zig | 7 --- 3 files changed, 91 insertions(+), 25 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 09b758a1e..e997868f7 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -93,21 +93,37 @@ class TerminalController: BaseTerminalController { //MARK: - Methods @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) + // 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 - syncAppearance(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 @@ -168,7 +184,7 @@ class TerminalController: BaseTerminalController { self.relabelTabs() } - private func syncAppearance(_ config: Ghostty.Config) { + 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 @@ -177,19 +193,19 @@ class TerminalController: BaseTerminalController { // APIs such as window blur have no effect unless the window is visible. guard window.isVisible else { // Weak window so that if the window changes or is destroyed we aren't holding a ref - DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) } + DispatchQueue.main.async { [weak self] in self?.syncAppearance(surfaceConfig) } return } // Set the font for the window and tab titles. - if let titleFontName = 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 (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 @@ -203,14 +219,14 @@ class TerminalController: BaseTerminalController { window.backgroundColor = .windowBackgroundColor } - window.hasShadow = 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(config.backgroundColor) - window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) + let backgroundColor = OSColor(surfaceConfig.backgroundColor) + window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity) if (window.isOpaque) { // Bg color is only synced if we have no transparency. This is because @@ -377,8 +393,10 @@ class TerminalController: BaseTerminalController { window.focusFollowsMouse = config.focusFollowsMouse - // Apply any additional appearance-related properties to the new window. - syncAppearance(config) + // 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. @@ -515,6 +533,15 @@ class TerminalController: BaseTerminalController { window.surfaceIsZoomed = to } + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { + super.focusedSurfaceDidChange(to: to) + + // When our focus changes, we update our window appearance based on the + // currently focused surface. + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + } + //MARK: - Notifications @objc private func onMoveTab(notification: SwiftUI.Notification) { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c678ca79d..8f281df54 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -48,6 +48,9 @@ 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 + // 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 +117,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 +147,11 @@ 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(windowDidChangeScreen), @@ -333,6 +348,16 @@ 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 windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } @@ -1025,6 +1050,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/src/config/Config.zig b/src/config/Config.zig index e68ad3da8..a22779ac1 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, @@ -2756,12 +2755,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; - } } } From 7fb86a3c9cf86127be57ef0775626b2ffa0d9be0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Nov 2024 13:36:49 -0800 Subject: [PATCH 6/8] macos: listen for color change property to update window appearance --- .../Terminal/TerminalController.swift | 27 ++++++++++++++++- macos/Sources/Ghostty/Ghostty.Action.swift | 28 ++++++++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 29 ++++++++++++++++++- macos/Sources/Ghostty/Package.swift | 4 +++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index e997868f7..e5f3bc298 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. @@ -23,6 +24,9 @@ class TerminalController: BaseTerminalController { /// 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 @@ -225,7 +229,7 @@ class TerminalController: BaseTerminalController { // The titlebar is always updated. We don't need to worry about opacity // because we handle it here. - let backgroundColor = OSColor(surfaceConfig.backgroundColor) + let backgroundColor = OSColor(focusedSurface?.backgroundColor ?? surfaceConfig.backgroundColor) window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity) if (window.isOpaque) { @@ -536,10 +540,31 @@ class TerminalController: BaseTerminalController { 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 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 2ec62a426..b9bebe542 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -515,7 +515,8 @@ extension Ghostty { 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: @@ -1188,6 +1189,32 @@ extension Ghostty { } } + 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/Package.swift b/macos/Sources/Ghostty/Package.swift index 074ce4743..a4d1914e0 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -210,6 +210,10 @@ extension Notification.Name { 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 diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8f281df54..7e861a229 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -51,6 +51,10 @@ extension Ghostty { /// 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 @@ -152,6 +156,11 @@ extension Ghostty { selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: self) + center.addObserver( + self, + selector: #selector(ghosttyColorDidChange(_:)), + name: .ghosttyColorDidChange, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -358,6 +367,21 @@ extension Ghostty { 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 } From 36a57826a6633d0cfa82cd5db902a5635f6884ff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Nov 2024 14:07:21 -0800 Subject: [PATCH 7/8] macos: only color the titlebar of surfaces that border the top --- .../Terminal/TerminalController.swift | 21 ++++++++++++-- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 28 +++++++++++++++++++ src/config/Config.zig | 12 ++++---- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index e5f3bc298..75bb0e3bd 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -227,9 +227,21 @@ class TerminalController: BaseTerminalController { 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(focusedSurface?.backgroundColor ?? surfaceConfig.backgroundColor) + // 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 ?? derivedConfig.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) { @@ -677,13 +689,16 @@ class TerminalController: BaseTerminalController { } 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/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/src/config/Config.zig b/src/config/Config.zig index a22779ac1..1e67a7e12 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1518,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: @@ -1536,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, From 1aa77837eba4667fc8ea68018e1dff7dd3036b62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Nov 2024 14:20:09 -0800 Subject: [PATCH 8/8] macos: use correct title background if top surface --- .../Features/Terminal/TerminalController.swift | 15 +++++---------- macos/Sources/Helpers/OSColor+Extension.swift | 8 ++++++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 75bb0e3bd..81c74987b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -191,15 +191,10 @@ class TerminalController: BaseTerminalController { 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(surfaceConfig) } - 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 = surfaceConfig.windowTitleFontFamily { @@ -233,7 +228,7 @@ class TerminalController: BaseTerminalController { let backgroundColor: OSColor if let surfaceTree { if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - backgroundColor = OSColor(focusedSurface.backgroundColor ?? derivedConfig.backgroundColor) + 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. 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)