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
pull/12166/head
MKY508 2026-04-07 23:10:09 +08:00
parent 0043e665f5
commit 6f2e3be66f
5 changed files with 121 additions and 21 deletions

View File

@ -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()

View File

@ -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<Int8>?
let key = "macos-titlebar-proxy-icon"

View File

@ -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

View File

@ -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)

View File

@ -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