From 4b36b387be7ea99ac0c8f7e661d78635caca3d0c Mon Sep 17 00:00:00 2001 From: lsoengas Date: Fri, 10 Jul 2026 12:27:06 -0300 Subject: [PATCH] fix: allow dragging the window from the sidebar's empty area The main window can only be moved by grabbing the strip right next to the traffic lights. Clicking the empty space in the sidebar below the tab list now moves the window too, using the same WindowDragHandleView the titlebar already uses, mounted as a frontmost overlay so it actually receives the clicks. Double-clicking that area still creates a new workspace: the drag handle now owns the full double-click sequence via a new onDoubleClick callback instead of racing a sibling SwiftUI tap gesture for it. Existing call sites keep their default zoom/minimize behavior. --- Sources/SidebarVisuals.swift | 35 ++++++++++++++++++----- Sources/WindowDragHandleView.swift | 46 ++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index 7ce7bd54a65..877b6a9187e 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -573,13 +573,34 @@ struct SidebarEmptyArea: View { Color.clear .contentShape(Rectangle()) .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(count: 2) { - tabManager.addWorkspace(placementOverride: .end) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - selection = .tabs + // Drag view must be an .overlay (frontmost), not .background: as a background, + // the Color.clear + .contentShape content above it is the real hit-test target, + // so AppKit never even asks the background NSView's hitTest — that's why the + // titlebar.dragHandle.* logs never fired here. Once frontmost, this view's own + // hitTest bails out for non-mouseDown event types, so SwiftUI's onDrop target + // underneath is unaffected. + // + // Double-click is handled by `onDoubleClick` below rather than a sibling + // `.onTapGesture(count: 2)`: once this view is frontmost, it claims the hit-test + // for the *first* click of any double-click, so a sibling gesture recognizer on + // the content beneath never observes that first click and can't reliably count + // to two. Owning the whole click sequence here (mirroring Bonsplit's + // TabBarDragZoneView.onDoubleClick pattern) avoids that race entirely. + .overlay { + WindowDragHandleView( + handlesDoubleClick: false, + onDoubleClick: { + #if DEBUG + dlog("sidebar.dragHandle.doubleClick action=addWorkspace") + #endif + tabManager.addWorkspace(placementOverride: .end) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + selection = .tabs + } + ) } .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( targetTabId: nil, diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index c9e49ae137b..30b38aef7c6 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -309,15 +309,36 @@ 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 { + /// When `false` (and `onDoubleClick` is `nil`), double-clicks are left untouched + /// (no titlebar zoom/minimize, no drag capture) so an underlying SwiftUI gesture + /// can still fire. Defaults to `true` to preserve existing callers. + var handlesDoubleClick: Bool = true + + /// When set, this view owns double-click handling itself and invokes this closure + /// instead of the standard titlebar zoom/minimize action or the passthrough behavior. + /// Use this (rather than relying on a sibling SwiftUI `.onTapGesture(count: 2)`) when + /// this drag view is mounted in front of the gesture's content: a sibling gesture + /// recognizer never sees the first click of a double-click once this view has + /// claimed the hit-test for it, so passthrough-based double-click detection is + /// unreliable when this view is frontmost (see sidebar empty-area drag fix). + var onDoubleClick: (() -> Void)? + func makeNSView(context: Context) -> NSView { - DraggableView() + let view = DraggableView() + view.handlesDoubleClick = handlesDoubleClick + view.onDoubleClick = onDoubleClick + return view } func updateNSView(_ nsView: NSView, context: Context) { - // No-op + (nsView as? DraggableView)?.handlesDoubleClick = handlesDoubleClick + (nsView as? DraggableView)?.onDoubleClick = onDoubleClick } private final class DraggableView: NSView { + var handlesDoubleClick = true + var onDoubleClick: (() -> Void)? + override var mouseDownCanMoveWindow: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { @@ -329,6 +350,13 @@ struct WindowDragHandleView: NSViewRepresentable { guard currentEvent?.type == .leftMouseDown else { return nil } + // Let double-clicks pass through to whatever is underneath when this + // instance doesn't own double-click handling in any form (no zoom, no + // custom action). When `onDoubleClick` is set, this view owns the double + // click itself (see mouseDown) and must keep capturing the hit here too. + if !handlesDoubleClick, onDoubleClick == nil, (currentEvent?.clickCount ?? 1) >= 2 { + return nil + } let shouldCapture = windowDragHandleShouldCaptureHit( point, in: self, @@ -353,6 +381,20 @@ struct WindowDragHandleView: NSViewRepresentable { #endif if event.clickCount >= 2 { + if let onDoubleClick { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick action=custom") + #endif + onDoubleClick() + return + } + guard handlesDoubleClick else { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick skipped=handlesDoubleClickFalse") + #endif + super.mouseDown(with: event) + return + } let action = performStandardTitlebarDoubleClick(window: window) #if DEBUG dlog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))")