From 6f2e3be66f3cde2077d51ec7194847298842cc64 Mon Sep 17 00:00:00 2001 From: MKY508 Date: Tue, 7 Apr 2026 23:10:09 +0800 Subject: [PATCH] macos: add proxy icon hover mode and disabled state Extend macos-titlebar-proxy-icon from two values (visible/hidden) to three (visible/hidden/disabled). hidden now means show-on-hover using NSTrackingArea on the titlebar, matching standard macOS behavior. disabled completely removes the proxy icon. Default changes from visible to hidden. Config changes apply immediately to all open windows via GhosttyConfigDidChange notification. Closes #5919 --- .../Terminal/BaseTerminalController.swift | 104 ++++++++++++++++-- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- macos/Sources/Ghostty/GhosttyPackage.swift | 5 +- macos/Tests/Ghostty/ConfigTests.swift | 15 +++ src/config/Config.zig | 16 ++- 5 files changed, 121 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5d9d5d52701..aba75b934b4 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -80,6 +80,15 @@ class BaseTerminalController: NSWindowController, /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig + /// The current pwd for the focused surface. We cache this separately from representedURL + /// so config reload can restore the proxy icon after disabled mode clears the window state. + private var currentPwdURL: URL? + + /// Hover state and tracking used for the "hidden" proxy icon mode. + private var isHoveringProxyIconTitlebar: Bool = false + private weak var proxyIconTrackingView: NSView? + private var proxyIconTrackingArea: NSTrackingArea? + /// Track whether background is forced opaque (true) or using config transparency (false) var isBackgroundOpaque: Bool = false @@ -224,6 +233,9 @@ class BaseTerminalController: NSWindowController, deinit { NotificationCenter.default.removeObserver(self) undoManager?.removeAllActions(withTarget: self) + if let trackingArea = proxyIconTrackingArea, let trackingView = proxyIconTrackingView { + trackingView.removeTrackingArea(trackingArea) + } if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } @@ -567,6 +579,9 @@ class BaseTerminalController: NSWindowController, // Update our derived config self.derivedConfig = DerivedConfig(config) + + // Immediately refresh proxy icon behavior on all open windows. + refreshProxyIcon() } @objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) { @@ -853,14 +868,8 @@ class BaseTerminalController: NSWindowController, } func pwdDidChange(to: URL?) { - guard let window else { return } - - if derivedConfig.macosTitlebarProxyIcon == .visible { - // Use the 'to' URL directly - window.representedURL = to - } else { - window.representedURL = nil - } + currentPwdURL = to + refreshProxyIcon() } func cellSizeDidChange(to: NSSize) { @@ -1155,6 +1164,8 @@ class BaseTerminalController: NSWindowController, // Set our update overlay state updateOverlayIsVisible = defaultUpdateOverlayVisibility() + + refreshProxyIcon() } func defaultUpdateOverlayVisibility() -> Bool { @@ -1243,6 +1254,8 @@ class BaseTerminalController: NSWindowController, DispatchQueue.main.async { self.syncFocusToSurfaceTree() } + + refreshProxyIcon() } func windowDidResignKey(_ notification: Notification) { @@ -1262,10 +1275,26 @@ class BaseTerminalController: NSWindowController, func windowDidResize(_ notification: Notification) { windowFrameDidChange() + refreshProxyIcon() } func windowDidMove(_ notification: Notification) { windowFrameDidChange() + refreshProxyIcon() + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + guard derivedConfig.macosTitlebarProxyIcon == .hidden else { return } + isHoveringProxyIconTitlebar = true + refreshProxyIcon() + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + guard derivedConfig.macosTitlebarProxyIcon == .hidden else { return } + isHoveringProxyIconTitlebar = false + refreshProxyIcon() } func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { @@ -1427,6 +1456,63 @@ class BaseTerminalController: NSWindowController, ghostty.resetTerminal(surface: surface) } + private func refreshProxyIcon() { + guard let window else { return } + + refreshProxyIconTrackingArea() + + let pwdURL = currentPwdURL ?? window.representedURL + + switch derivedConfig.macosTitlebarProxyIcon { + case .visible: + window.representedURL = pwdURL + window.standardWindowButton(.documentIconButton)?.isHidden = (pwdURL == nil) + + case .hidden: + window.representedURL = pwdURL + window.standardWindowButton(.documentIconButton)?.isHidden = + !isHoveringProxyIconTitlebar || pwdURL == nil + + case .disabled: + window.standardWindowButton(.documentIconButton)?.isHidden = true + window.representedURL = nil + } + } + + private func refreshProxyIconTrackingArea() { + if let trackingArea = proxyIconTrackingArea, let trackingView = proxyIconTrackingView { + trackingView.removeTrackingArea(trackingArea) + proxyIconTrackingArea = nil + proxyIconTrackingView = nil + } + + guard derivedConfig.macosTitlebarProxyIcon == .hidden else { + isHoveringProxyIconTitlebar = false + return + } + + guard let terminalWindow = window as? TerminalWindow, + let titlebarContainer = terminalWindow.titlebarContainer else { + isHoveringProxyIconTitlebar = false + return + } + + let trackingArea = NSTrackingArea( + rect: .zero, + options: [.activeAlways, .inVisibleRect, .mouseEnteredAndExited], + owner: self, + userInfo: nil) + titlebarContainer.addTrackingArea(trackingArea) + + proxyIconTrackingArea = trackingArea + proxyIconTrackingView = titlebarContainer + + let mouseLocation = titlebarContainer.convert( + window?.mouseLocationOutsideOfEventStream ?? .zero, + from: nil) + isHoveringProxyIconTitlebar = titlebarContainer.bounds.contains(mouseLocation) + } + private struct DerivedConfig { let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool @@ -1434,7 +1520,7 @@ class BaseTerminalController: NSWindowController, let splitPreserveZoom: Ghostty.Config.SplitPreserveZoom init() { - self.macosTitlebarProxyIcon = .visible + self.macosTitlebarProxyIcon = .hidden self.windowStepResize = false self.focusFollowsMouse = false self.splitPreserveZoom = .init() diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 743ebfa2fe1..73c5ba70a3c 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -369,7 +369,7 @@ extension Ghostty { } var macosTitlebarProxyIcon: MacOSTitlebarProxyIcon { - let defaultValue = MacOSTitlebarProxyIcon.visible + let defaultValue = MacOSTitlebarProxyIcon.hidden guard let config = self.config else { return defaultValue } var v: UnsafePointer? let key = "macos-titlebar-proxy-icon" diff --git a/macos/Sources/Ghostty/GhosttyPackage.swift b/macos/Sources/Ghostty/GhosttyPackage.swift index 03211862fba..25a59ceba19 100644 --- a/macos/Sources/Ghostty/GhosttyPackage.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -316,8 +316,9 @@ extension Ghostty { /// Enum for the macos-titlebar-proxy-icon config option enum MacOSTitlebarProxyIcon: String { - case visible - case hidden + case visible = "visible" + case hidden = "hidden" + case disabled = "disabled" } /// Enum for auto-update-channel config option diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift index a4b8472accd..1ff6fbee453 100644 --- a/macos/Tests/Ghostty/ConfigTests.swift +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -106,6 +106,21 @@ struct ConfigTests { #expect(config.macosTitlebarStyle == expected) } + @Test func macosTitlebarProxyIconDefaultsToHidden() throws { + let config = try TemporaryConfig("") + #expect(config.macosTitlebarProxyIcon == .hidden) + } + + @Test(arguments: [ + ("visible", Ghostty.Config.MacOSTitlebarProxyIcon.visible), + ("hidden", Ghostty.Config.MacOSTitlebarProxyIcon.hidden), + ("disabled", Ghostty.Config.MacOSTitlebarProxyIcon.disabled), + ]) + func macosTitlebarProxyIconValues(raw: String, expected: Ghostty.Config.MacOSTitlebarProxyIcon) throws { + let config = try TemporaryConfig("macos-titlebar-proxy-icon = \(raw)") + #expect(config.macosTitlebarProxyIcon == expected) + } + @Test func resizeOverlayDefaultsToAfterFirst() throws { let config = try TemporaryConfig("") #expect(config.resizeOverlay == .after_first) diff --git a/src/config/Config.zig b/src/config/Config.zig index 13f78eea686..de4e0d9dd40 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3247,17 +3247,14 @@ keybind: Keybinds = .{}, /// /// Valid values are: /// -/// * `visible` - Show the proxy icon. -/// * `hidden` - Hide the proxy icon. -/// -/// The default value is `visible`. +/// Proxy icon visibility mode for the titlebar. Valid values: +/// - `visible` - always show the proxy icon +/// - `hidden` - show proxy icon on hover (default, modern macOS behavior) +/// - `disabled` - never show the proxy icon (drag-drop and context menu also disabled) /// /// This setting can be changed at runtime and will affect all currently -/// open windows but only after their working directory changes again. -/// Therefore, to make this work after changing the setting, you must -/// usually `cd` to a different directory, open a different file in an -/// editor, etc. -@"macos-titlebar-proxy-icon": MacTitlebarProxyIcon = .visible, +/// open windows immediately. +@"macos-titlebar-proxy-icon": MacTitlebarProxyIcon = .hidden, /// Controls the windowing behavior when dropping a file or folder /// onto the Ghostty icon in the macOS dock. @@ -8955,6 +8952,7 @@ pub const MacTitlebarStyle = enum { pub const MacTitlebarProxyIcon = enum { visible, hidden, + disabled, }; /// See macos-hidden