From 0533279f21b9b12b198aa53d0e0e785a223594d7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 22 Apr 2026 14:33:20 -0700 Subject: [PATCH 1/2] Add regression tests for minimal tab strip drag --- cmuxTests/BrowserPanelTests.swift | 57 +++++++++++++++++++++++++ cmuxTests/TerminalAndGhosttyTests.swift | 57 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index abe4cb26bd67..0f8d5929c5f0 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -399,6 +399,12 @@ final class WindowBrowserHostViewTests: XCTestCase { } } + private final class FakeTabBarBackgroundNSView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + private final class PrimaryPageProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil @@ -457,6 +463,57 @@ final class WindowBrowserHostViewTests: XCTestCase { return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit)) } + func testHostViewPassesThroughUnderlyingTabStripBelowTitlebarBand() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let tabStripHeight: CGFloat = 44 + let tabStrip = FakeTabBarBackgroundNSView( + frame: NSRect( + x: 0, + y: contentView.bounds.maxY - tabStripHeight, + width: contentView.bounds.width, + height: tabStripHeight + ) + ) + tabStrip.autoresizingMask = [.width, .minYMargin] + contentView.addSubview(tabStrip) + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let titlebarBandHeight = max(28, min(72, window.frame.height - window.contentLayoutRect.height)) + let pointInContent = NSPoint( + x: contentView.bounds.midX, + y: contentView.bounds.maxY - titlebarBandHeight - 8 + ) + let pointInWindow = contentView.convert(pointInContent, to: nil) + let pointInHost = host.convert(pointInWindow, from: nil) + + XCTAssertNil( + host.hitTest(pointInHost), + "Browser portal should defer to the minimal tab strip even just below the native titlebar interaction band" + ) + } + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 7a087401eec4..cd2feb250b36 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2075,6 +2075,12 @@ final class WindowTerminalHostViewTests: XCTestCase { } } + private final class FakeTabBarBackgroundNSView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} private func makeHostedTerminalView(frame: NSRect) -> GhosttySurfaceScrollView { @@ -2105,6 +2111,57 @@ final class WindowTerminalHostViewTests: XCTestCase { ) } + func testHostViewPassesThroughUnderlyingTabStripBelowTitlebarBand() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let tabStripHeight: CGFloat = 44 + let tabStrip = FakeTabBarBackgroundNSView( + frame: NSRect( + x: 0, + y: contentView.bounds.maxY - tabStripHeight, + width: contentView.bounds.width, + height: tabStripHeight + ) + ) + tabStrip.autoresizingMask = [.width, .minYMargin] + contentView.addSubview(tabStrip) + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowTerminalHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let titlebarBandHeight = max(28, min(72, window.frame.height - window.contentLayoutRect.height)) + let pointInContent = NSPoint( + x: contentView.bounds.midX, + y: contentView.bounds.maxY - titlebarBandHeight - 8 + ) + let pointInWindow = contentView.convert(pointInContent, to: nil) + let pointInHost = host.convert(pointInWindow, from: nil) + + XCTAssertNil( + host.hitTest(pointInHost), + "Terminal portal should defer to the minimal tab strip even just below the native titlebar interaction band" + ) + } + func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() { let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) From bd54dad85dc7af1be380e0f868544c01dfdfb9fe Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 22 Apr 2026 14:33:31 -0700 Subject: [PATCH 2/2] Pass portal hits through minimal tab strip --- Sources/BrowserWindowPortal.swift | 122 ++++++++++++++++++++++++++++- Sources/TerminalWindowPortal.swift | 36 +++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 19a9d7dee0b1..6aa55ee6f2c3 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -10,6 +10,100 @@ private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0 private var cmuxBrowserPortalNeedsRenderingStateReattachKey: UInt8 = 0 private var cmuxWindowInteractiveSplitDividerDragKey: UInt8 = 0 +/// Shared helpers for portal hosts that must defer to the minimal-mode +/// Bonsplit tab strip rendered underneath them. +enum BonsplitTabBarPassThrough { + static func isPassThroughPointerEvent(_ eventType: NSEvent.EventType?) -> Bool { + switch eventType { + case .leftMouseDown, .leftMouseUp, + .rightMouseDown, .rightMouseUp, + .otherMouseDown, .otherMouseUp, + .mouseMoved, .mouseEntered, + .mouseExited, .cursorUpdate: + return true + default: + return false + } + } + + static func titlebarInteractionBandMinY(in window: NSWindow) -> CGFloat { + let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height + let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight)) + return window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5 + } + + static func shouldPassThroughToPaneTabBar( + windowPoint: NSPoint, + below portalHost: NSView + ) -> (result: Bool, registryHit: Bool) { + let registryHit = portalHost.window.map { + BonsplitTabBarHitRegionRegistry.containsWindowPoint(windowPoint, in: $0) + } ?? false + if registryHit { + return (true, true) + } + let fallbackHit = hasUnderlyingBonsplitTabBarBackground( + at: windowPoint, + below: portalHost + ) + return (fallbackHit, false) + } + + static func passThroughDecision( + at point: NSPoint, + in portalHost: NSView, + eventType: NSEvent.EventType? + ) -> (windowPoint: NSPoint, result: Bool, registryHit: Bool)? { + guard isPassThroughPointerEvent(eventType) else { return nil } + let windowPoint = portalHost.convert(point, to: nil) + let decision = shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: portalHost) + return (windowPoint, decision.result, decision.registryHit) + } + + static func hasBonsplitTabBarBackground(at windowPoint: NSPoint, in view: NSView) -> Bool { + guard !view.isHidden, view.alphaValue > 0 else { return false } + + // NSView subviews are not clipped to parent bounds by default, and the + // minimal tab strip can render outside its immediate container. + let className = NSStringFromClass(type(of: view)) + if className.contains("TabBarBackgroundNSView") { + let pointInView = view.convert(windowPoint, from: nil) + if view.bounds.contains(pointInView) { + return true + } + } + + for subview in view.subviews.reversed() { + if hasBonsplitTabBarBackground(at: windowPoint, in: subview) { + return true + } + } + return false + } + + static func hasUnderlyingBonsplitTabBarBackground( + at windowPoint: NSPoint, + below portalHost: NSView + ) -> Bool { + if let container = portalHost.superview, + let hostIndex = container.subviews.firstIndex(of: portalHost) { + for sibling in container.subviews[.. 0 else { continue } + if hasBonsplitTabBarBackground(at: windowPoint, in: sibling) { + return true + } + } + return false + } + + guard let window = portalHost.window, + let rootView = window.contentView else { + return false + } + return hasBonsplitTabBarBackground(at: windowPoint, in: rootView) + } +} + #if DEBUG private func browserPortalDebugToken(_ view: NSView?) -> String { guard let view else { return "nil" } @@ -493,6 +587,9 @@ final class WindowBrowserHostView: NSView { #endif return nil } + if shouldPassThroughToPaneTabBar(at: point, eventType: NSApp.currentEvent?.type) { + return nil + } if sidebarPassThrough { #if DEBUG debugLogPointerRouting( @@ -689,10 +786,27 @@ final class WindowBrowserHostView: NSView { // hits that land in native titlebar space or the custom titlebar strip // we reserve directly under it for window drag/double-click behaviors. let windowPoint = convert(point, to: nil) - let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height - let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight)) - let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5 - return windowPoint.y >= interactionBandMinY + return windowPoint.y >= BonsplitTabBarPassThrough.titlebarInteractionBandMinY(in: window) + } + + private func shouldPassThroughToPaneTabBar( + at point: NSPoint, + eventType: NSEvent.EventType? + ) -> Bool { + guard let decision = BonsplitTabBarPassThrough.passThroughDecision( + at: point, + in: self, + eventType: eventType + ) else { return false } +#if DEBUG + if eventType == .leftMouseDown { + dlog( + "portal.brwsr.passThroughTabBar wp=\(Int(decision.windowPoint.x)),\(Int(decision.windowPoint.y)) " + + "registry=\(decision.registryHit ? 1 : 0) result=\(decision.result ? 1 : 0)" + ) + } +#endif + return decision.result } private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2bdc2398fd0e..65984467eecf 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -146,6 +146,16 @@ final class WindowTerminalHostView: NSView { } if isPointerEvent { + if shouldPassThroughToTitlebar(at: point) { + clearActiveDividerCursor(restoreArrow: false) + return nil + } + + if shouldPassThroughToPaneTabBar(at: point, eventType: currentEvent?.type) { + clearActiveDividerCursor(restoreArrow: false) + return nil + } + if shouldPassThroughToSidebarResizer(at: point) { clearActiveDividerCursor(restoreArrow: false) return nil @@ -199,6 +209,32 @@ final class WindowTerminalHostView: NSView { return hitView === self ? nil : hitView } + private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool { + guard let window else { return false } + let windowPoint = convert(point, to: nil) + return windowPoint.y >= BonsplitTabBarPassThrough.titlebarInteractionBandMinY(in: window) + } + + private func shouldPassThroughToPaneTabBar( + at point: NSPoint, + eventType: NSEvent.EventType? + ) -> Bool { + guard let decision = BonsplitTabBarPassThrough.passThroughDecision( + at: point, + in: self, + eventType: eventType + ) else { return false } +#if DEBUG + if eventType == .leftMouseDown { + dlog( + "portal.term.passThroughTabBar wp=\(Int(decision.windowPoint.x)),\(Int(decision.windowPoint.y)) " + + "registry=\(decision.registryHit ? 1 : 0) result=\(decision.result ? 1 : 0)" + ) + } +#endif + return decision.result + } + private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { // The sidebar resizer handle is implemented in SwiftUI. When terminals // are portal-hosted, this AppKit host can otherwise sit above the handle