diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 86fead61..eea7e62d 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -21,10 +21,102 @@ private func browserPortalDebugFrame(_ rect: NSRect) -> String { #endif final class WindowBrowserHostView: NSView { + private struct DividerRegion { + let rectInWindow: NSRect + let isVertical: Bool + } + + private enum DividerCursorKind: Equatable { + case vertical + case horizontal + + var cursor: NSCursor { + switch self { + case .vertical: return .resizeLeftRight + case .horizontal: return .resizeUpDown + } + } + } + override var isOpaque: Bool { false } + private static let sidebarLeadingEdgeEpsilon: CGFloat = 1 + private static let minimumVisibleLeadingContentWidth: CGFloat = 24 private var cachedSidebarDividerX: CGFloat? + private var sidebarDividerMissCount = 0 + private var trackingArea: NSTrackingArea? + private var activeDividerCursorKind: DividerCursorKind? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + clearActiveDividerCursor() + } + window?.invalidateCursorRects(for: self) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + window?.invalidateCursorRects(for: self) + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + window?.invalidateCursorRects(for: self) + } + + override func resetCursorRects() { + super.resetCursorRects() + guard let window, let rootView = window.contentView else { return } + var regions: [DividerRegion] = [] + Self.collectSplitDividerRegions(in: rootView, into: ®ions) + let expansion: CGFloat = 4 + for region in regions { + var rectInHost = convert(region.rectInWindow, from: nil) + rectInHost = rectInHost.insetBy( + dx: region.isVertical ? -expansion : 0, + dy: region.isVertical ? 0 : -expansion + ) + let clipped = rectInHost.intersection(bounds) + guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue } + addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown) + } + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [ + .inVisibleRect, + .activeAlways, + .cursorUpdate, + .mouseMoved, + .mouseEnteredAndExited, + .enabledDuringMouseDrag, + ] + let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + override func cursorUpdate(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseExited(with event: NSEvent) { + clearActiveDividerCursor() + } override func hitTest(_ point: NSPoint) -> NSView? { + updateDividerCursor(at: point) + if shouldPassThroughToSidebarResizer(at: point) { return nil } @@ -41,13 +133,40 @@ final class WindowBrowserHostView: NSView { let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } .filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 } + // If content is flush to the leading edge, sidebar is effectively hidden. + // In that state, treating any internal split edge as a sidebar divider + // steals split-divider cursor/drag behavior. + let hasLeadingContent = visibleSlots.contains { + $0.frame.minX <= Self.sidebarLeadingEdgeEpsilon + && $0.frame.maxX > Self.minimumVisibleLeadingContentWidth + } + if hasLeadingContent { + if cachedSidebarDividerX != nil { + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 2 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } + } + return false + } + // Ignore transient 0-origin slots during layout churn and preserve the last // known-good divider edge. let dividerCandidates = visibleSlots .map(\.frame.minX) - .filter { $0 > 1 } + .filter { $0 > Self.sidebarLeadingEdgeEpsilon } if let leftMostEdge = dividerCandidates.min() { cachedSidebarDividerX = leftMostEdge + sidebarDividerMissCount = 0 + } else if cachedSidebarDividerX != nil { + // Keep cache briefly for layout churn, but clear if we miss repeatedly + // so stale divider positions don't steal pointer routing. + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 4 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } } guard let dividerX = cachedSidebarDividerX else { @@ -59,15 +178,39 @@ final class WindowBrowserHostView: NSView { return point.x >= regionMinX && point.x <= regionMaxX } - private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { - guard let window else { return false } - let windowPoint = convert(point, to: nil) - guard let rootView = window.contentView else { return false } - return Self.containsSplitDivider(at: windowPoint, in: rootView) + private func updateDividerCursor(at point: NSPoint) { + if shouldPassThroughToSidebarResizer(at: point) { + clearActiveDividerCursor() + return + } + + guard let nextKind = splitDividerCursorKind(at: point) else { + clearActiveDividerCursor() + return + } + activeDividerCursorKind = nextKind + nextKind.cursor.set() } - private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { - guard !view.isHidden else { return false } + private func clearActiveDividerCursor() { + guard activeDividerCursorKind != nil else { return } + window?.invalidateCursorRects(for: self) + activeDividerCursorKind = nil + } + + private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? { + guard let window else { return nil } + let windowPoint = convert(point, to: nil) + guard let rootView = window.contentView else { return nil } + return Self.dividerCursorKind(at: windowPoint, in: rootView) + } + + private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + splitDividerCursorKind(at: point) != nil + } + + private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { + guard !view.isHidden else { return nil } if let splitView = view as? NSSplitView { let pointInSplit = splitView.convert(windowPoint, from: nil) @@ -80,7 +223,10 @@ final class WindowBrowserHostView: NSView { let thickness = splitView.dividerThickness let dividerRect: NSRect if splitView.isVertical { - guard first.width > 1, second.width > 1 else { continue } + // Keep divider hit-testing active even when one side is nearly collapsed, + // so users can drag the divider back out from the border. + // But ignore transient states where both panes are effectively 0-width. + guard first.width > 1 || second.width > 1 else { continue } let x = max(0, first.maxX) dividerRect = NSRect( x: x, @@ -89,7 +235,8 @@ final class WindowBrowserHostView: NSView { height: splitView.bounds.height ) } else { - guard first.height > 1, second.height > 1 else { continue } + // Same behavior for horizontal splits with a near-zero-height pane. + guard first.height > 1 || second.height > 1 else { continue } let y = max(0, first.maxY) dividerRect = NSRect( x: 0, @@ -100,20 +247,56 @@ final class WindowBrowserHostView: NSView { } let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion) if expanded.contains(pointInSplit) { - return true + return splitView.isVertical ? .vertical : .horizontal } } } } for subview in view.subviews.reversed() { - if containsSplitDivider(at: windowPoint, in: subview) { - return true + if let kind = dividerCursorKind(at: windowPoint, in: subview) { + return kind } } - return false + return nil } + + private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) { + guard !view.isHidden else { return } + + if let splitView = view as? NSSplitView { + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + for dividerIndex in 0.. 1 || second.width > 1 else { continue } + let x = max(0, first.maxX) + dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height) + } else { + guard first.height > 1 || second.height > 1 else { continue } + let y = max(0, first.maxY) + dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness) + } + let dividerRectInWindow = splitView.convert(dividerRect, to: nil) + guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue } + result.append( + DividerRegion( + rectInWindow: dividerRectInWindow, + isVertical: splitView.isVertical + ) + ) + } + } + + for subview in view.subviews { + collectSplitDividerRegions(in: subview, into: &result) + } + } + } final class WindowBrowserSlotView: NSView { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 02dc2311..4ad00b12 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -824,6 +824,7 @@ struct ContentView: View { @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? @State private var sidebarResizerPointerMonitor: Any? @State private var isResizerBandActive = false + @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? private static let fixedSidebarResizeCursor = NSCursor( @@ -846,6 +847,7 @@ struct ContentView: View { private func activateSidebarResizerCursor() { sidebarResizerCursorReleaseWorkItem?.cancel() sidebarResizerCursorReleaseWorkItem = nil + isSidebarResizerCursorActive = true Self.fixedSidebarResizeCursor.set() } @@ -854,6 +856,8 @@ struct ContentView: View { let shouldKeepCursor = !force && (isResizerDragging || isResizerBandActive || !hoveredResizerHandles.isEmpty || isLeftMouseButtonDown) guard !shouldKeepCursor else { return } + guard isSidebarResizerCursorActive else { return } + isSidebarResizerCursorActive = false NSCursor.arrow.set() } @@ -974,6 +978,7 @@ struct ContentView: View { sidebarResizerPointerMonitor = nil } isResizerBandActive = false + isSidebarResizerCursorActive = false stopSidebarResizerCursorStabilizer() scheduleSidebarResizerCursorRelease(force: true) } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index a4d37f7c..77cf72f5 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -20,13 +20,105 @@ private func portalDebugFrame(_ rect: NSRect) -> String { #endif final class WindowTerminalHostView: NSView { + private struct DividerRegion { + let rectInWindow: NSRect + let isVertical: Bool + } + + private enum DividerCursorKind: Equatable { + case vertical + case horizontal + + var cursor: NSCursor { + switch self { + case .vertical: return .resizeLeftRight + case .horizontal: return .resizeUpDown + } + } + } + override var isOpaque: Bool { false } + private static let sidebarLeadingEdgeEpsilon: CGFloat = 1 + private static let minimumVisibleLeadingContentWidth: CGFloat = 24 private var cachedSidebarDividerX: CGFloat? + private var sidebarDividerMissCount = 0 + private var trackingArea: NSTrackingArea? + private var activeDividerCursorKind: DividerCursorKind? #if DEBUG private var lastDragRouteSignature: String? #endif + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + clearActiveDividerCursor() + } + window?.invalidateCursorRects(for: self) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + window?.invalidateCursorRects(for: self) + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + window?.invalidateCursorRects(for: self) + } + + override func resetCursorRects() { + super.resetCursorRects() + guard let window, let rootView = window.contentView else { return } + var regions: [DividerRegion] = [] + Self.collectSplitDividerRegions(in: rootView, into: ®ions) + let expansion: CGFloat = 4 + for region in regions { + var rectInHost = convert(region.rectInWindow, from: nil) + rectInHost = rectInHost.insetBy( + dx: region.isVertical ? -expansion : 0, + dy: region.isVertical ? 0 : -expansion + ) + let clipped = rectInHost.intersection(bounds) + guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue } + addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown) + } + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [ + .inVisibleRect, + .activeAlways, + .cursorUpdate, + .mouseMoved, + .mouseEnteredAndExited, + .enabledDuringMouseDrag, + ] + let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + override func cursorUpdate(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseExited(with event: NSEvent) { + clearActiveDividerCursor() + } + override func hitTest(_ point: NSPoint) -> NSView? { + updateDividerCursor(at: point) + if shouldPassThroughToSidebarResizer(at: point) { return nil } @@ -72,14 +164,41 @@ final class WindowTerminalHostView: NSView { let visibleHostedViews = subviews.compactMap { $0 as? GhosttySurfaceScrollView } .filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 } + // If content is flush to the leading edge, sidebar is effectively hidden. + // In that state, treating any internal split edge as a sidebar divider + // steals split-divider cursor/drag behavior. + let hasLeadingContent = visibleHostedViews.contains { + $0.frame.minX <= Self.sidebarLeadingEdgeEpsilon + && $0.frame.maxX > Self.minimumVisibleLeadingContentWidth + } + if hasLeadingContent { + if cachedSidebarDividerX != nil { + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 2 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } + } + return false + } + // Ignore transient 0-origin hosts while layouts churn (e.g. workspace // creation/switching). They can temporarily report minX=0 and would // otherwise clear divider pass-through, causing hover flicker. let dividerCandidates = visibleHostedViews .map(\.frame.minX) - .filter { $0 > 1 } + .filter { $0 > Self.sidebarLeadingEdgeEpsilon } if let leftMostEdge = dividerCandidates.min() { cachedSidebarDividerX = leftMostEdge + sidebarDividerMissCount = 0 + } else if cachedSidebarDividerX != nil { + // Keep cache briefly for layout churn, but clear if we miss repeatedly + // so stale divider positions don't steal pointer routing. + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 4 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } } guard let dividerX = cachedSidebarDividerX else { @@ -91,15 +210,39 @@ final class WindowTerminalHostView: NSView { return point.x >= regionMinX && point.x <= regionMaxX } - private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { - guard let window else { return false } - let windowPoint = convert(point, to: nil) - guard let rootView = window.contentView else { return false } - return Self.containsSplitDivider(at: windowPoint, in: rootView) + private func updateDividerCursor(at point: NSPoint) { + if shouldPassThroughToSidebarResizer(at: point) { + clearActiveDividerCursor() + return + } + + guard let nextKind = splitDividerCursorKind(at: point) else { + clearActiveDividerCursor() + return + } + activeDividerCursorKind = nextKind + nextKind.cursor.set() } - private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { - guard !view.isHidden else { return false } + private func clearActiveDividerCursor() { + guard activeDividerCursorKind != nil else { return } + window?.invalidateCursorRects(for: self) + activeDividerCursorKind = nil + } + + private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? { + guard let window else { return nil } + let windowPoint = convert(point, to: nil) + guard let rootView = window.contentView else { return nil } + return Self.dividerCursorKind(at: windowPoint, in: rootView) + } + + private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + splitDividerCursorKind(at: point) != nil + } + + private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { + guard !view.isHidden else { return nil } if let splitView = view as? NSSplitView { let pointInSplit = splitView.convert(windowPoint, from: nil) @@ -114,7 +257,10 @@ final class WindowTerminalHostView: NSView { let thickness = splitView.dividerThickness let dividerRect: NSRect if splitView.isVertical { - guard first.width > 1, second.width > 1 else { continue } + // Keep divider hit-testing active even when one side is nearly collapsed, + // so users can drag the divider back out from the border. + // But ignore transient states where both panes are effectively 0-width. + guard first.width > 1 || second.width > 1 else { continue } let x = max(0, first.maxX) dividerRect = NSRect( x: x, @@ -123,7 +269,8 @@ final class WindowTerminalHostView: NSView { height: splitView.bounds.height ) } else { - guard first.height > 1, second.height > 1 else { continue } + // Same behavior for horizontal splits with a near-zero-height pane. + guard first.height > 1 || second.height > 1 else { continue } let y = max(0, first.maxY) dividerRect = NSRect( x: 0, @@ -134,19 +281,54 @@ final class WindowTerminalHostView: NSView { } let expandedDividerRect = dividerRect.insetBy(dx: -expansion, dy: -expansion) if expandedDividerRect.contains(pointInSplit) { - return true + return splitView.isVertical ? .vertical : .horizontal } } } } for subview in view.subviews.reversed() { - if containsSplitDivider(at: windowPoint, in: subview) { - return true + if let kind = dividerCursorKind(at: windowPoint, in: subview) { + return kind } } - return false + return nil + } + + private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) { + guard !view.isHidden else { return } + + if let splitView = view as? NSSplitView { + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + for dividerIndex in 0.. 1 || second.width > 1 else { continue } + let x = max(0, first.maxX) + dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height) + } else { + guard first.height > 1 || second.height > 1 else { continue } + let y = max(0, first.maxY) + dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness) + } + let dividerRectInWindow = splitView.convert(dividerRect, to: nil) + guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue } + result.append( + DividerRegion( + rectInWindow: dividerRectInWindow, + isVertical: splitView.isVertical + ) + ) + } + } + + for subview in view.subviews { + collectSplitDividerRegions(in: subview, into: &result) + } } #if DEBUG @@ -213,6 +395,7 @@ private final class SplitDividerOverlayView: NSView { private struct DividerSegment { let rect: NSRect let color: NSColor + let isVertical: Bool } override var isOpaque: Bool { false } @@ -227,13 +410,16 @@ private final class SplitDividerOverlayView: NSView { var dividerSegments: [DividerSegment] = [] collectDividerSegments(in: rootView, into: ÷rSegments) guard !dividerSegments.isEmpty else { return } + let hostedFrames = hostedFramesLikelyToOccludeDividers() + let visibleSegments = dividerSegments.filter { shouldRenderOverlay(for: $0, hostedFrames: hostedFrames) } + guard !visibleSegments.isEmpty else { return } NSGraphicsContext.saveGraphicsState() defer { NSGraphicsContext.restoreGraphicsState() } // Keep separators visible above portal-hosted surfaces while matching each split view's // native divider color (avoids visible color shifts at tiny pane sizes). - for segment in dividerSegments where segment.rect.intersects(dirtyRect) { + for segment in visibleSegments where segment.rect.intersects(dirtyRect) { segment.color.setFill() let rect = segment.rect let pixelAligned = NSRect( @@ -275,7 +461,13 @@ private final class SplitDividerOverlayView: NSView { let dividerRectInWindow = splitView.convert(dividerRectInSplit, to: nil) let dividerRectInOverlay = convert(dividerRectInWindow, from: nil) if dividerRectInOverlay.intersects(bounds) { - result.append(DividerSegment(rect: dividerRectInOverlay, color: dividerColor)) + result.append( + DividerSegment( + rect: dividerRectInOverlay, + color: dividerColor, + isVertical: splitView.isVertical + ) + ) } } } @@ -285,6 +477,37 @@ private final class SplitDividerOverlayView: NSView { } } + private func hostedFramesLikelyToOccludeDividers() -> [NSRect] { + guard let hostView = superview else { return [] } + return hostView.subviews.compactMap { subview -> NSRect? in + guard let hosted = subview as? GhosttySurfaceScrollView else { return nil } + guard !hosted.isHidden, hosted.window != nil else { return nil } + return hosted.frame + } + } + + private func shouldRenderOverlay(for segment: DividerSegment, hostedFrames: [NSRect]) -> Bool { + // Draw only when a hosted surface actually intrudes across the divider centerline. + // This preserves tiny-pane visibility fixes without darkening regular dividers. + let axisEpsilon: CGFloat = 0.01 + let axis = segment.isVertical ? segment.rect.midX : segment.rect.midY + let extentRect = segment.rect.insetBy( + dx: segment.isVertical ? 0 : -1, + dy: segment.isVertical ? -1 : 0 + ) + + for frame in hostedFrames where frame.intersects(extentRect) { + if segment.isVertical { + if frame.minX < axis - axisEpsilon && frame.maxX > axis + axisEpsilon { + return true + } + } else if frame.minY < axis - axisEpsilon && frame.maxY > axis + axisEpsilon { + return true + } + } + return false + } + private func overlayDividerColor(for splitView: NSSplitView) -> NSColor { let divider = splitView.dividerColor.usingColorSpace(.deviceRGB) ?? splitView.dividerColor let alpha = divider.alphaComponent diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 562e637e..7514d1e6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2909,6 +2909,8 @@ final class WindowTerminalHostViewTests: XCTestCase { } } + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() { let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) @@ -2923,6 +2925,123 @@ final class WindowTerminalHostViewTests: XCTestCase { XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child) XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100))) } + + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + splitView.dividerStyle = .thin + let splitDelegate = BonsplitMockSplitDelegate() + splitView.delegate = splitDelegate + let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) + let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) + splitView.addSubview(first) + splitView.addSubview(second) + contentView.addSubview(splitView) + splitView.setPosition(1, ofDividerAt: 0) + splitView.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let host = WindowTerminalHostView(frame: contentView.bounds) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + contentView.addSubview(host) + + let dividerPointInSplit = NSPoint( + x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), + y: splitView.bounds.midY + ) + let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) + XCTAssertNil( + host.hitTest(dividerPointInHost), + "Host view must pass through divider hits even when one pane is nearly collapsed" + ) + + let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) + let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) + let contentPointInHost = host.convert(contentPointInWindow, from: nil) + XCTAssertTrue(host.hitTest(contentPointInHost) === child) + } +} + +@MainActor +final class WindowBrowserHostViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + splitView.dividerStyle = .thin + let splitDelegate = BonsplitMockSplitDelegate() + splitView.delegate = splitDelegate + let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) + let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) + splitView.addSubview(first) + splitView.addSubview(second) + contentView.addSubview(splitView) + splitView.setPosition(1, ofDividerAt: 0) + splitView.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let host = WindowBrowserHostView(frame: contentView.bounds) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + contentView.addSubview(host) + + let dividerPointInSplit = NSPoint( + x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), + y: splitView.bounds.midY + ) + let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) + XCTAssertNil( + host.hitTest(dividerPointInHost), + "Browser host must pass through divider hits even when one pane is nearly collapsed" + ) + + let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) + let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) + let contentPointInHost = host.convert(contentPointInWindow, from: nil) + XCTAssertTrue(host.hitTest(contentPointInHost) === child) + } } @MainActor diff --git a/vendor/bonsplit b/vendor/bonsplit index 6e50afe6..cf929c88 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 6e50afe6d65b933c7bf2266544b69dad182daa73 +Subproject commit cf929c887af79ea8b881e39da5b8c4ee1d6b9009