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:
parent
740b4b11e5
commit
78fe5a9b04
5 changed files with 561 additions and 31 deletions
|
|
@ -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..<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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..<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: ÷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
|
||||
|
|
|
|||
|
|
@ -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
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 6e50afe6d65b933c7bf2266544b69dad182daa73
|
||||
Subproject commit cf929c887af79ea8b881e39da5b8c4ee1d6b9009
|
||||
Loading…
Add table
Add a link
Reference in a new issue