diff --git a/cmuxTests/PortalTabDragRoutingTests.swift b/cmuxTests/PortalTabDragRoutingTests.swift index 97f49bb54124..3ae13c40c612 100644 --- a/cmuxTests/PortalTabDragRoutingTests.swift +++ b/cmuxTests/PortalTabDragRoutingTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import SwiftUI @testable import Bonsplit #if canImport(cmux_DEV) @@ -22,6 +23,32 @@ final class PortalTabDragRoutingTests: XCTestCase { } } + func testCompactPaneTabChromeStaysBelowDragHitMinimum() throws { + let appearance = BonsplitConfiguration.Appearance( + tabMinWidth: 140, + tabMaxWidth: 220, + splitButtons: [] + ) + let measuredWidth = try XCTUnwrap( + renderedSelectedPaneTabIndicatorWidth( + title: "~", + icon: "terminal.fill", + appearance: appearance + ) + ) + + XCTAssertGreaterThan( + measuredWidth, + 40, + "The regression measurement must prove the selected tab indicator actually rendered" + ) + XCTAssertLessThanOrEqual( + measuredWidth, + 80, + "Short pane-tab visible chrome should stay compact; drag affordance must come from hit testing, not a wider rendered tab" + ) + } + private func makeHostedTerminalView(frame: NSRect) -> GhosttySurfaceScrollView { let surfaceView = GhosttyNSView(frame: frame) let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) @@ -30,6 +57,105 @@ final class PortalTabDragRoutingTests: XCTestCase { return hostedView } + private func renderedSelectedPaneTabIndicatorWidth( + title: String, + icon: String?, + appearance: BonsplitConfiguration.Appearance + ) -> CGFloat? { + let controller = BonsplitController(configuration: BonsplitConfiguration(appearance: appearance)) + guard let pane = controller.internalController.rootNode.allPanes.first else { return nil } + let tab = TabItem(title: title, icon: icon) + pane.tabs = [tab] + pane.selectedTabId = tab.id + + let size = NSSize(width: 180, height: appearance.tabBarHeight) + let hostingView = NSHostingView( + rootView: TabBarView(pane: pane, isFocused: true, showSplitButtons: false) + .environment(controller) + .environment(controller.internalController) + ) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isOpaque = false + window.backgroundColor = .clear + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { return nil } + + contentView.wantsLayer = true + contentView.layer?.backgroundColor = NSColor.clear.cgColor + hostingView.frame = NSRect(origin: .zero, size: size) + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + + window.makeKeyAndOrderFront(nil) + let sampleRect = NSRect(x: 0, y: 0, width: size.width, height: 4) + return waitForHighSaturationWidth( + in: hostingView, + sampleRect: sampleRect + ) + } + + private func waitForHighSaturationWidth( + in view: NSView, + sampleRect: NSRect, + timeout: TimeInterval = 1.0 + ) -> CGFloat? { + let deadline = Date().addingTimeInterval(timeout) + repeat { + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + if let width = highSaturationWidth(in: view, sampleRect: sampleRect) { + return width + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } while Date() < deadline + return highSaturationWidth(in: view, sampleRect: sampleRect) + } + + private func highSaturationWidth(in view: NSView, sampleRect: NSRect) -> CGFloat? { + let integralBounds = view.bounds.integral + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil } + bitmap.size = integralBounds.size + view.cacheDisplay(in: integralBounds, to: bitmap) + + let scaleX = CGFloat(bitmap.pixelsWide) / max(1, integralBounds.width) + let scaleY = CGFloat(bitmap.pixelsHigh) / max(1, integralBounds.height) + let minX = max(0, Int(floor(sampleRect.minX * scaleX))) + let maxX = min(bitmap.pixelsWide, Int(ceil(sampleRect.maxX * scaleX))) + let minY = max(0, Int(floor(sampleRect.minY * scaleY))) + let maxY = min(bitmap.pixelsHigh, Int(ceil(sampleRect.maxY * scaleY))) + + var activeColumnCount = 0 + for x in minX.. 0.05 else { continue } + let alpha = min(max(rgb.alphaComponent, 0), 1) + let red = rgb.redComponent * alpha + let green = rgb.greenComponent * alpha + let blue = rgb.blueComponent * alpha + let high = max(red, green, blue) + guard high > 0.01 else { continue } + let low = min(red, green, blue) + if (high - low) / high > 0.4 { + hasIndicatorPixel = true + break + } + } + if hasIndicatorPixel { + activeColumnCount += 1 + } + } + guard activeColumnCount > 0 else { return nil } + return CGFloat(activeColumnCount) / scaleX + } + private struct TabStripPassThroughFixture { let host: WindowTerminalHostView let pointInHost: NSPoint diff --git a/vendor/bonsplit b/vendor/bonsplit index f7a39b2b3037..a80d5af7b989 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit f7a39b2b3037822b42c2cd149072279afbbaaadd +Subproject commit a80d5af7b98951cfc77fd3d3259cb4a93e2f921c