Fix cmux border resize icon disappearing (#284)

* Keep split dividers visible in tiny panes

* Fix collapsed split border resize hit-testing

* Stabilize sidebar and split divider resize routing

* Fix split divider resize cursor routing regressions
This commit is contained in:
Austin Wang 2026-02-21 17:57:00 -08:00 committed by GitHub
parent 740b4b11e5
commit 78fe5a9b04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 561 additions and 31 deletions

View file

@ -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: &regions)
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
}
private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool {
guard !view.isHidden else { return false }
guard let nextKind = splitDividerCursorKind(at: point) else {
clearActiveDividerCursor()
return
}
activeDividerCursorKind = nextKind
nextKind.cursor.set()
}
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..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 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 {

View file

@ -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)
}

View file

@ -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: &regions)
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
}
private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool {
guard !view.isHidden else { return false }
guard let nextKind = splitDividerCursorKind(at: point) else {
clearActiveDividerCursor()
return
}
activeDividerCursorKind = nextKind
nextKind.cursor.set()
}
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..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 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: &dividerSegments)
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

View file

@ -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

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 6e50afe6d65b933c7bf2266544b69dad182daa73
Subproject commit cf929c887af79ea8b881e39da5b8c4ee1d6b9009