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