Fix titlebar double-click zoom handling (#2130)
This commit is contained in:
parent
0ea16b12c2
commit
71a64a1234
4 changed files with 140 additions and 19 deletions
|
|
@ -2489,6 +2489,7 @@ struct ContentView: View {
|
||||||
.frame(height: titlebarPadding)
|
.frame(height: titlebarPadding)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.background(TitlebarDoubleClickMonitorView())
|
||||||
.background({
|
.background({
|
||||||
// The terminal area has two stacked semi-transparent layers: the Bonsplit
|
// The terminal area has two stacked semi-transparent layers: the Bonsplit
|
||||||
// container chrome background plus Ghostty's own Metal-rendered background.
|
// 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).
|
// drag-to-move and double-click action (zoom/minimize).
|
||||||
WindowDragHandleView()
|
WindowDragHandleView()
|
||||||
.frame(height: trafficLightPadding)
|
.frame(height: trafficLightPadding)
|
||||||
|
.background(TitlebarDoubleClickMonitorView())
|
||||||
}
|
}
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if isMinimalMode {
|
if isMinimalMode {
|
||||||
|
|
|
||||||
|
|
@ -84,23 +84,23 @@ private func windowDragHandleShouldResolveActiveHitCapture(
|
||||||
|
|
||||||
/// Runs the same action macOS titlebars use for double-click:
|
/// Runs the same action macOS titlebars use for double-click:
|
||||||
/// zoom by default, or minimize when the user preference is set.
|
/// zoom by default, or minimize when the user preference is set.
|
||||||
@discardableResult
|
enum StandardTitlebarDoubleClickAction: Equatable {
|
||||||
func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
|
case miniaturize
|
||||||
guard let window else { return false }
|
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)?
|
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.lowercased() {
|
.lowercased() {
|
||||||
switch action {
|
switch action {
|
||||||
case "minimize":
|
case "minimize", "miniaturize":
|
||||||
window.miniaturize(nil)
|
return .miniaturize
|
||||||
return true
|
case "maximize", "zoom", "fill":
|
||||||
case "none":
|
return .zoom
|
||||||
return false
|
case "none", "no action":
|
||||||
case "maximize", "zoom":
|
return .none
|
||||||
window.zoom(nil)
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -108,12 +108,29 @@ func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
|
||||||
|
|
||||||
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
|
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
|
||||||
miniaturizeOnDoubleClick {
|
miniaturizeOnDoubleClick {
|
||||||
window.miniaturize(nil)
|
return .miniaturize
|
||||||
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)
|
window.zoom(nil)
|
||||||
return true
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return action
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum WindowDragHandleAssociatedObjectKeys {
|
private enum WindowDragHandleAssociatedObjectKeys {
|
||||||
|
|
@ -410,11 +427,11 @@ struct WindowDragHandleView: NSViewRepresentable {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if event.clickCount >= 2 {
|
if event.clickCount >= 2 {
|
||||||
let handled = performStandardTitlebarDoubleClick(window: window)
|
let action = performStandardTitlebarDoubleClick(window: window)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
|
dlog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))")
|
||||||
#endif
|
#endif
|
||||||
if handled {
|
if action != nil {
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
struct TmuxPaneLayoutPane: Codable, Equatable, Sendable {
|
||||||
let paneId: String
|
let paneId: String
|
||||||
let left: Int
|
let left: Int
|
||||||
|
|
@ -373,6 +379,12 @@ struct WorkspaceContentView: View {
|
||||||
if isMinimalMode {
|
if isMinimalMode {
|
||||||
bonsplitView
|
bonsplitView
|
||||||
.ignoresSafeArea(.container, edges: .top)
|
.ignoresSafeArea(.container, edges: .top)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if isWorkspaceInputActive {
|
||||||
|
TitlebarDoubleClickMonitorView()
|
||||||
|
.frame(height: WorkspaceTitlebarInteractionMetrics.minimalModeTopStripHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
bonsplitView
|
bonsplitView
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase {
|
||||||
func testSupportsMultiplePendingCallsResolvedOutOfOrder() {
|
func testSupportsMultiplePendingCallsResolvedOutOfOrder() {
|
||||||
let registry = WorkspaceRemoteDaemonPendingCallRegistry()
|
let registry = WorkspaceRemoteDaemonPendingCallRegistry()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue