Skip to content
1 change: 1 addition & 0 deletions Sources/RightSidebarPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ struct RightSidebarPanelView: View {
Spacer(minLength: 0)
}
.rightSidebarChromeBar(leadingPadding: 4, trailingPadding: 6, height: titlebarHeight)
.titlebarDoubleClickRegion()
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
.background(MinimalModeTitlebarControlHitRegionView())
.accessibilityElement(children: .contain)
.accessibilityIdentifier("RightSidebarModeBar")
Expand Down
77 changes: 76 additions & 1 deletion Sources/WindowDragHandleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,8 @@ func windowDragHandleShouldCaptureHit(
/// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content
/// (e.g. sidebar tab reordering) don't move the whole window.
struct WindowDragHandleView: NSViewRepresentable {
static let viewIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarDragHandle")

var doubleClickBehavior: TitlebarDoubleClickBehavior = .standardAction

func makeNSView(context: Context) -> NSView {
Expand All @@ -1042,11 +1044,13 @@ struct WindowDragHandleView: NSViewRepresentable {
init(doubleClickBehavior: TitlebarDoubleClickBehavior) {
self.doubleClickBehavior = doubleClickBehavior
super.init(frame: .zero)
identifier = WindowDragHandleView.viewIdentifier
}

required init?(coder: NSCoder) {
self.doubleClickBehavior = .standardAction
super.init(coder: coder)
identifier = WindowDragHandleView.viewIdentifier
}

override var mouseDownCanMoveWindow: Bool { false }
Expand Down Expand Up @@ -1118,6 +1122,56 @@ struct WindowDragHandleView: NSViewRepresentable {
}
}

private func titlebarDoubleClickMonitorHasCapturingDragHandle(
in rootView: NSView,
window: NSWindow,
locationInWindow: NSPoint
) -> Bool {
if rootView.identifier == WindowDragHandleView.viewIdentifier {
let localPoint = rootView.convert(locationInWindow, from: nil)
if rootView.bounds.contains(localPoint),
windowDragHandleShouldCaptureHit(
localPoint,
in: rootView,
eventType: .leftMouseDown,
eventWindow: window
) {
return true
}
}

for subview in rootView.subviews {
if titlebarDoubleClickMonitorHasCapturingDragHandle(
in: subview,
window: window,
locationInWindow: locationInWindow
) {
return true
}
}

return false
}

private func titlebarDoubleClickMonitorShouldDeferToRegisteredControl(
window: NSWindow,
locationInWindow: NSPoint
) -> Bool {
guard isMinimalModeTitlebarControlHit(window: window, locationInWindow: locationInWindow) else {
return false
}

guard let contentView = window.contentView else {
return true
}

return !titlebarDoubleClickMonitorHasCapturingDragHandle(
in: contentView,
window: window,
locationInWindow: locationInWindow
)
}

/// 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.
Expand Down Expand Up @@ -1157,7 +1211,10 @@ struct TitlebarDoubleClickMonitorView: NSViewRepresentable {
coordinator.lastClick = nil
return event
}
guard !isMinimalModeTitlebarControlHit(window: window, locationInWindow: event.locationInWindow) else {
guard !titlebarDoubleClickMonitorShouldDeferToRegisteredControl(
window: window,
locationInWindow: event.locationInWindow
) else {
coordinator.lastClick = nil
return event
}
Expand Down Expand Up @@ -1196,6 +1253,24 @@ struct TitlebarDoubleClickMonitorView: NSViewRepresentable {
}
}

struct TitlebarDoubleClickRegionModifier: ViewModifier {
var doubleClickBehavior: TitlebarDoubleClickBehavior = .standardAction

func body(content: Content) -> some View {
content
.background(WindowDragHandleView(doubleClickBehavior: doubleClickBehavior))
.background(TitlebarDoubleClickMonitorView(doubleClickBehavior: doubleClickBehavior))
}
}

extension View {
func titlebarDoubleClickRegion(
doubleClickBehavior: TitlebarDoubleClickBehavior = .standardAction
) -> some View {
modifier(TitlebarDoubleClickRegionModifier(doubleClickBehavior: doubleClickBehavior))
}
}

func shouldHandleMinimalModeTitlebarDoubleClick(
isEnabled: Bool,
clickCount: Int,
Expand Down
140 changes: 140 additions & 0 deletions cmuxTests/WindowAndDragTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,19 @@ final class WindowDragHandleHitTests: XCTestCase {
}
}

private final class RecordingTitlebarActionWindow: NSWindow {
var zoomCallCount = 0
var miniaturizeCallCount = 0

override func zoom(_ sender: Any?) {
zoomCallCount += 1
}

override func miniaturize(_ sender: Any?) {
miniaturizeCallCount += 1
}
}

/// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit,
/// simulating the crash path where sibling.hitTest triggers a SwiftUI layout
/// pass that calls back into the drag handle's hit resolution.
Expand All @@ -641,6 +654,55 @@ final class WindowDragHandleHitTests: XCTestCase {
}
}

private static func firstSubview(
in view: NSView,
matching predicate: (NSView) -> Bool
) -> NSView? {
if predicate(view) {
return view
}

for subview in view.subviews {
if let match = firstSubview(in: subview, matching: predicate) {
return match
}
}

return nil
}

private static func firstCapturableTitlebarPoint(
in dragHandle: NSView,
window: NSWindow
) -> NSPoint? {
let bounds = dragHandle.bounds.insetBy(dx: 4, dy: 4)
guard bounds.width > 0, bounds.height > 0 else { return nil }

let yCandidates = [
bounds.midY,
bounds.minY + bounds.height * 0.25,
bounds.minY + bounds.height * 0.75
]

for y in yCandidates {
var x = bounds.maxX
while x >= bounds.minX {
let point = NSPoint(x: x, y: y)
if windowDragHandleShouldCaptureHit(
point,
in: dragHandle,
eventType: .leftMouseDown,
eventWindow: window
) {
return point
}
x -= 4
}
}

return nil
}

func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
Expand Down Expand Up @@ -1359,6 +1421,84 @@ final class WindowDragHandleHitTests: XCTestCase {
"Reentrant same-window top-hit resolution should not trigger exclusivity crashes"
)
}

func testRightSidebarModeBarEmptySpaceDoubleClickPerformsTitlebarAction() {
_ = NSApplication.shared

let previousGlobalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
var testGlobalDefaults = previousGlobalDefaults ?? [:]
testGlobalDefaults["AppleActionOnDoubleClick"] = "Fill"
testGlobalDefaults["AppleMiniaturizeOnDoubleClick"] = false
UserDefaults.standard.setPersistentDomain(testGlobalDefaults, forName: UserDefaults.globalDomain)
defer {
if let previousGlobalDefaults {
UserDefaults.standard.setPersistentDomain(previousGlobalDefaults, forName: UserDefaults.globalDomain)
} else {
UserDefaults.standard.removePersistentDomain(forName: UserDefaults.globalDomain)
}
}

let window = RecordingTitlebarActionWindow(
contentRect: NSRect(x: 0, y: 0, width: 720, height: 260),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }

let rootView = RightSidebarPanelView(
fileExplorerStore: FileExplorerStore(),
fileExplorerState: FileExplorerState(),
sessionIndexStore: SessionIndexStore(),
titlebarHeight: 36,
workspaceId: nil,
onResumeSession: nil,
onOpenFilePreview: { _ in }
)
let hostingView = NSHostingView(rootView: rootView)
hostingView.frame = window.contentRect(forFrameRect: window.frame)
window.contentView = hostingView
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
hostingView.layoutSubtreeIfNeeded()

guard let dragHandle = Self.firstSubview(
in: hostingView,
matching: { $0.identifier == WindowDragHandleView.viewIdentifier }
) else {
XCTFail("Expected right-sidebar mode bar to install a titlebar drag handle")
return
}

guard let emptyModeBarLocalPoint = Self.firstCapturableTitlebarPoint(
in: dragHandle,
window: window
) else {
XCTFail("Expected right-sidebar mode bar to expose at least one empty titlebar point")
return
}

let emptyModeBarPoint = dragHandle.convert(emptyModeBarLocalPoint, to: nil)
guard let event = NSEvent.mouseEvent(
with: .leftMouseDown,
location: emptyModeBarPoint,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 1,
clickCount: 2,
pressure: 1.0
) else {
XCTFail("Expected to create right-sidebar mode-bar double-click event")
return
}

NSApp.sendEvent(event)

XCTAssertEqual(window.zoomCallCount, 1)
XCTAssertEqual(window.miniaturizeCallCount, 0)
}
}

#if DEBUG
Expand Down
Loading