Skip to content

Commit 71a64a1

Browse files
authored
Fix titlebar double-click zoom handling (#2130)
1 parent 0ea16b1 commit 71a64a1

4 files changed

Lines changed: 140 additions & 19 deletions

File tree

Sources/ContentView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2489,6 +2489,7 @@ struct ContentView: View {
24892489
.frame(height: titlebarPadding)
24902490
.frame(maxWidth: .infinity)
24912491
.contentShape(Rectangle())
2492+
.background(TitlebarDoubleClickMonitorView())
24922493
.background({
24932494
// The terminal area has two stacked semi-transparent layers: the Bonsplit
24942495
// container chrome background plus Ghostty's own Metal-rendered background.
@@ -8610,6 +8611,7 @@ struct VerticalTabsSidebar: View {
86108611
// drag-to-move and double-click action (zoom/minimize).
86118612
WindowDragHandleView()
86128613
.frame(height: trafficLightPadding)
8614+
.background(TitlebarDoubleClickMonitorView())
86138615
}
86148616
.overlay(alignment: .topLeading) {
86158617
if isMinimalMode {

Sources/WindowDragHandleView.swift

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -84,36 +84,53 @@ private func windowDragHandleShouldResolveActiveHitCapture(
8484

8585
/// Runs the same action macOS titlebars use for double-click:
8686
/// zoom by default, or minimize when the user preference is set.
87-
@discardableResult
88-
func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
89-
guard let window else { return false }
87+
enum StandardTitlebarDoubleClickAction: Equatable {
88+
case miniaturize
89+
case zoom
90+
case none
91+
}
9092

91-
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
93+
func resolvedStandardTitlebarDoubleClickAction(globalDefaults: [String: Any]) -> StandardTitlebarDoubleClickAction {
9294
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
9395
.trimmingCharacters(in: .whitespacesAndNewlines)
9496
.lowercased() {
9597
switch action {
96-
case "minimize":
97-
window.miniaturize(nil)
98-
return true
99-
case "none":
100-
return false
101-
case "maximize", "zoom":
102-
window.zoom(nil)
103-
return true
98+
case "minimize", "miniaturize":
99+
return .miniaturize
100+
case "maximize", "zoom", "fill":
101+
return .zoom
102+
case "none", "no action":
103+
return .none
104104
default:
105105
break
106106
}
107107
}
108108

109109
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
110110
miniaturizeOnDoubleClick {
111-
window.miniaturize(nil)
112-
return true
111+
return .miniaturize
113112
}
114113

115-
window.zoom(nil)
116-
return true
114+
return .zoom
115+
}
116+
117+
/// Runs the same action macOS titlebars use for double-click:
118+
/// zoom by default, or minimize when the user preference is set.
119+
@discardableResult
120+
func performStandardTitlebarDoubleClick(window: NSWindow?) -> StandardTitlebarDoubleClickAction? {
121+
guard let window else { return nil }
122+
123+
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
124+
let action = resolvedStandardTitlebarDoubleClickAction(globalDefaults: globalDefaults)
125+
switch action {
126+
case .miniaturize:
127+
window.miniaturize(nil)
128+
case .zoom:
129+
window.zoom(nil)
130+
case .none:
131+
break
132+
}
133+
return action
117134
}
118135

119136
private enum WindowDragHandleAssociatedObjectKeys {
@@ -410,11 +427,11 @@ struct WindowDragHandleView: NSViewRepresentable {
410427
#endif
411428

412429
if event.clickCount >= 2 {
413-
let handled = performStandardTitlebarDoubleClick(window: window)
430+
let action = performStandardTitlebarDoubleClick(window: window)
414431
#if DEBUG
415-
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
432+
dlog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))")
416433
#endif
417-
if handled {
434+
if action != nil {
418435
return
419436
}
420437
}
@@ -440,3 +457,48 @@ struct WindowDragHandleView: NSViewRepresentable {
440457
}
441458
}
442459
}
460+
461+
/// Local monitor that guarantees double-clicks in custom titlebar surfaces trigger
462+
/// the standard macOS titlebar action even when the visible strip is hosted by
463+
/// higher-level SwiftUI/AppKit container views.
464+
struct TitlebarDoubleClickMonitorView: NSViewRepresentable {
465+
final class Coordinator {
466+
weak var view: NSView?
467+
var monitor: Any?
468+
469+
deinit {
470+
if let monitor {
471+
NSEvent.removeMonitor(monitor)
472+
}
473+
}
474+
}
475+
476+
func makeCoordinator() -> Coordinator { Coordinator() }
477+
478+
func makeNSView(context: Context) -> NSView {
479+
let view = NSView(frame: .zero)
480+
view.wantsLayer = true
481+
view.layer?.backgroundColor = NSColor.clear.cgColor
482+
483+
context.coordinator.view = view
484+
485+
let coordinator = context.coordinator
486+
coordinator.monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak coordinator] event in
487+
guard event.clickCount >= 2 else { return event }
488+
guard let coordinator, let view = coordinator.view, let window = view.window else { return event }
489+
guard event.window === window else { return event }
490+
491+
let point = view.convert(event.locationInWindow, from: nil)
492+
guard view.bounds.contains(point) else { return event }
493+
494+
let action = performStandardTitlebarDoubleClick(window: window)
495+
return action == nil ? event : nil
496+
}
497+
498+
return view
499+
}
500+
501+
func updateNSView(_ nsView: NSView, context: Context) {
502+
context.coordinator.view = nsView
503+
}
504+
}

Sources/WorkspaceContentView.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ struct TmuxOverlayExperimentSettings {
4444
}
4545
}
4646

47+
private enum WorkspaceTitlebarInteractionMetrics {
48+
// Keep in sync with Bonsplit's tab bar height so the monitor only covers
49+
// the minimal-mode titlebar strip.
50+
static let minimalModeTopStripHeight: CGFloat = 30
51+
}
52+
4753
struct TmuxPaneLayoutPane: Codable, Equatable, Sendable {
4854
let paneId: String
4955
let left: Int
@@ -373,6 +379,12 @@ struct WorkspaceContentView: View {
373379
if isMinimalMode {
374380
bonsplitView
375381
.ignoresSafeArea(.container, edges: .top)
382+
.overlay(alignment: .top) {
383+
if isWorkspaceInputActive {
384+
TitlebarDoubleClickMonitorView()
385+
.frame(height: WorkspaceTitlebarInteractionMetrics.minimalModeTopStripHeight)
386+
}
387+
}
376388
} else {
377389
bonsplitView
378390
}

cmuxTests/GhosttyConfigTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,6 +1258,51 @@ final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase {
12581258
}
12591259
}
12601260

1261+
final class TitlebarDoubleClickPreferenceTests: XCTestCase {
1262+
func testResolvesZoomForFillPreference() {
1263+
XCTAssertEqual(
1264+
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
1265+
"AppleActionOnDoubleClick": "Fill",
1266+
]),
1267+
.zoom
1268+
)
1269+
}
1270+
1271+
func testResolvesMiniaturizeForExplicitMinimizePreference() {
1272+
XCTAssertEqual(
1273+
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
1274+
"AppleActionOnDoubleClick": "Minimize",
1275+
]),
1276+
.miniaturize
1277+
)
1278+
}
1279+
1280+
func testResolvesNoneForNoActionPreference() {
1281+
XCTAssertEqual(
1282+
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
1283+
"AppleActionOnDoubleClick": "No Action",
1284+
]),
1285+
.none
1286+
)
1287+
}
1288+
1289+
func testFallsBackToLegacyMiniaturizePreference() {
1290+
XCTAssertEqual(
1291+
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
1292+
"AppleMiniaturizeOnDoubleClick": true,
1293+
]),
1294+
.miniaturize
1295+
)
1296+
}
1297+
1298+
func testDefaultsToZoomWhenPreferenceIsMissing() {
1299+
XCTAssertEqual(
1300+
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [:]),
1301+
.zoom
1302+
)
1303+
}
1304+
}
1305+
12611306
final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase {
12621307
func testSupportsMultiplePendingCallsResolvedOutOfOrder() {
12631308
let registry = WorkspaceRemoteDaemonPendingCallRegistry()

0 commit comments

Comments
 (0)