Skip to content
35 changes: 20 additions & 15 deletions Sources/RightSidebarPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,27 +219,32 @@ struct RightSidebarPanelView: View {
private var modeBar: some View {
let _ = keyboardShortcutSettingsObserver.revision
let showsModeShortcutHints = alwaysShowShortcutHints || modeShortcutHintMonitor.isModifierPressed
return HStack(spacing: 4) {
ForEach(availableModes, id: \.rawValue) { mode in
ModeBarButton(
mode: mode,
isSelected: fileExplorerState.mode == mode,
badgeCount: mode == .feed ? feedPendingCount : 0,
shortcutHint: KeyboardShortcutSettings.shortcut(for: mode.shortcutAction),
showsShortcutHint: showsModeShortcutHints
) {
if AppDelegate.shared?.focusRightSidebarInActiveMainWindow(
return ZStack {
WindowDragHandleView()

HStack(spacing: 4) {
ForEach(availableModes, id: \.rawValue) { mode in
ModeBarButton(
mode: mode,
focusFirstItem: true,
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
) != true {
selectMode(mode)
isSelected: fileExplorerState.mode == mode,
badgeCount: mode == .feed ? feedPendingCount : 0,
shortcutHint: KeyboardShortcutSettings.shortcut(for: mode.shortcutAction),
showsShortcutHint: showsModeShortcutHints
) {
if AppDelegate.shared?.focusRightSidebarInActiveMainWindow(
mode: mode,
focusFirstItem: true,
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
) != true {
selectMode(mode)
}
}
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
}
.rightSidebarChromeBar(leadingPadding: 4, trailingPadding: 6, height: titlebarHeight)
.background(TitlebarDoubleClickMonitorView())
.background(MinimalModeTitlebarControlHitRegionView())
.accessibilityElement(children: .contain)
.accessibilityIdentifier("RightSidebarModeBar")
Expand Down
62 changes: 61 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 All @@ -1184,6 +1241,9 @@ struct TitlebarDoubleClickMonitorView: NSViewRepresentable {
window: window,
behavior: coordinator.doubleClickBehavior
)
#if DEBUG
cmuxDebugLog("titlebar.monitor.doubleClick result=\(String(describing: result))")
#endif
return result.consumesEvent ? nil : event
}

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