From 71a64a123469ad5326e8197c41ac079f0c1f4ba0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 25 Mar 2026 02:15:15 -0700 Subject: [PATCH] Fix titlebar double-click zoom handling (#2130) --- Sources/ContentView.swift | 2 + Sources/WindowDragHandleView.swift | 100 +++++++++++++++++++++++------ Sources/WorkspaceContentView.swift | 12 ++++ cmuxTests/GhosttyConfigTests.swift | 45 +++++++++++++ 4 files changed, 140 insertions(+), 19 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c204c04f..9065d597 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2489,6 +2489,7 @@ struct ContentView: View { .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) + .background(TitlebarDoubleClickMonitorView()) .background({ // The terminal area has two stacked semi-transparent layers: the Bonsplit // container chrome background plus Ghostty's own Metal-rendered background. @@ -8610,6 +8611,7 @@ struct VerticalTabsSidebar: View { // drag-to-move and double-click action (zoom/minimize). WindowDragHandleView() .frame(height: trafficLightPadding) + .background(TitlebarDoubleClickMonitorView()) } .overlay(alignment: .topLeading) { if isMinimalMode { diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 3aa5f16d..2df99769 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -84,23 +84,23 @@ private func windowDragHandleShouldResolveActiveHitCapture( /// Runs the same action macOS titlebars use for double-click: /// zoom by default, or minimize when the user preference is set. -@discardableResult -func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool { - guard let window else { return false } +enum StandardTitlebarDoubleClickAction: Equatable { + case miniaturize + case zoom + case none +} - let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] +func resolvedStandardTitlebarDoubleClickAction(globalDefaults: [String: Any]) -> StandardTitlebarDoubleClickAction { if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() { switch action { - case "minimize": - window.miniaturize(nil) - return true - case "none": - return false - case "maximize", "zoom": - window.zoom(nil) - return true + case "minimize", "miniaturize": + return .miniaturize + case "maximize", "zoom", "fill": + return .zoom + case "none", "no action": + return .none default: break } @@ -108,12 +108,29 @@ func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool { if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool, miniaturizeOnDoubleClick { - window.miniaturize(nil) - return true + return .miniaturize } - window.zoom(nil) - return true + return .zoom +} + +/// Runs the same action macOS titlebars use for double-click: +/// zoom by default, or minimize when the user preference is set. +@discardableResult +func performStandardTitlebarDoubleClick(window: NSWindow?) -> StandardTitlebarDoubleClickAction? { + guard let window else { return nil } + + let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + let action = resolvedStandardTitlebarDoubleClickAction(globalDefaults: globalDefaults) + switch action { + case .miniaturize: + window.miniaturize(nil) + case .zoom: + window.zoom(nil) + case .none: + break + } + return action } private enum WindowDragHandleAssociatedObjectKeys { @@ -410,11 +427,11 @@ struct WindowDragHandleView: NSViewRepresentable { #endif if event.clickCount >= 2 { - let handled = performStandardTitlebarDoubleClick(window: window) + let action = performStandardTitlebarDoubleClick(window: window) #if DEBUG - dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)") + dlog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))") #endif - if handled { + if action != nil { return } } @@ -440,3 +457,48 @@ struct WindowDragHandleView: NSViewRepresentable { } } } + +/// Local monitor that guarantees double-clicks in custom titlebar surfaces trigger +/// the standard macOS titlebar action even when the visible strip is hosted by +/// higher-level SwiftUI/AppKit container views. +struct TitlebarDoubleClickMonitorView: NSViewRepresentable { + final class Coordinator { + weak var view: NSView? + var monitor: Any? + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + + context.coordinator.view = view + + let coordinator = context.coordinator + coordinator.monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak coordinator] event in + guard event.clickCount >= 2 else { return event } + guard let coordinator, let view = coordinator.view, let window = view.window else { return event } + guard event.window === window else { return event } + + let point = view.convert(event.locationInWindow, from: nil) + guard view.bounds.contains(point) else { return event } + + let action = performStandardTitlebarDoubleClick(window: window) + return action == nil ? event : nil + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.view = nsView + } +} diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index ce51c046..9638443a 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -44,6 +44,12 @@ struct TmuxOverlayExperimentSettings { } } +private enum WorkspaceTitlebarInteractionMetrics { + // Keep in sync with Bonsplit's tab bar height so the monitor only covers + // the minimal-mode titlebar strip. + static let minimalModeTopStripHeight: CGFloat = 30 +} + struct TmuxPaneLayoutPane: Codable, Equatable, Sendable { let paneId: String let left: Int @@ -373,6 +379,12 @@ struct WorkspaceContentView: View { if isMinimalMode { bonsplitView .ignoresSafeArea(.container, edges: .top) + .overlay(alignment: .top) { + if isWorkspaceInputActive { + TitlebarDoubleClickMonitorView() + .frame(height: WorkspaceTitlebarInteractionMetrics.minimalModeTopStripHeight) + } + } } else { bonsplitView } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index c89cf8c2..d7820ce3 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1258,6 +1258,51 @@ final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase { } } +final class TitlebarDoubleClickPreferenceTests: XCTestCase { + func testResolvesZoomForFillPreference() { + XCTAssertEqual( + resolvedStandardTitlebarDoubleClickAction(globalDefaults: [ + "AppleActionOnDoubleClick": "Fill", + ]), + .zoom + ) + } + + func testResolvesMiniaturizeForExplicitMinimizePreference() { + XCTAssertEqual( + resolvedStandardTitlebarDoubleClickAction(globalDefaults: [ + "AppleActionOnDoubleClick": "Minimize", + ]), + .miniaturize + ) + } + + func testResolvesNoneForNoActionPreference() { + XCTAssertEqual( + resolvedStandardTitlebarDoubleClickAction(globalDefaults: [ + "AppleActionOnDoubleClick": "No Action", + ]), + .none + ) + } + + func testFallsBackToLegacyMiniaturizePreference() { + XCTAssertEqual( + resolvedStandardTitlebarDoubleClickAction(globalDefaults: [ + "AppleMiniaturizeOnDoubleClick": true, + ]), + .miniaturize + ) + } + + func testDefaultsToZoomWhenPreferenceIsMissing() { + XCTAssertEqual( + resolvedStandardTitlebarDoubleClickAction(globalDefaults: [:]), + .zoom + ) + } +} + final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { func testSupportsMultiplePendingCallsResolvedOutOfOrder() { let registry = WorkspaceRemoteDaemonPendingCallRegistry()