diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index e5ea21bc..d64cc7b1 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -23,9 +23,70 @@ final class WindowTerminalHostView: NSView { override var isOpaque: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { + if shouldPassThroughToSplitDivider(at: point) { + return nil + } let hitView = super.hitTest(point) return hitView === self ? nil : hitView } + + 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 static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { + guard !view.isHidden else { return false } + + if let splitView = view as? NSSplitView { + let pointInSplit = splitView.convert(windowPoint, from: nil) + if splitView.bounds.contains(pointInSplit) { + // Keep divider interactions reliable even when portal-hosted terminal frames + // temporarily overlap divider edges during rapid layout churn. + let expansion: CGFloat = 5 + 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 expandedDividerRect = dividerRect.insetBy(dx: -expansion, dy: -expansion) + if expandedDividerRect.contains(pointInSplit) { + return true + } + } + } + } + + for subview in view.subviews.reversed() { + if containsSplitDivider(at: windowPoint, in: subview) { + return true + } + } + + return false + } } @MainActor @@ -35,6 +96,7 @@ final class WindowTerminalPortal: NSObject { private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] + private var hasDeferredFullSyncScheduled = false private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -226,8 +288,37 @@ final class WindowTerminalPortal: NSObject { func synchronizeHostedViewForAnchor(_ anchorView: NSView) { pruneDeadEntries() - guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return } - synchronizeHostedView(withId: hostedId) + let anchorId = ObjectIdentifier(anchorView) + let primaryHostedId = hostedByAnchorId[anchorId] + if let primaryHostedId { + synchronizeHostedView(withId: primaryHostedId) + } + + // Failsafe: during aggressive divider drags/structural churn, one anchor can miss a + // geometry callback while another fires. Reconcile all mapped hosted views so no stale + // frame remains "stuck" onscreen until the next interaction. + synchronizeAllHostedViews(excluding: primaryHostedId) + scheduleDeferredFullSynchronizeAll() + } + + private func scheduleDeferredFullSynchronizeAll() { + guard !hasDeferredFullSyncScheduled else { return } + hasDeferredFullSyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasDeferredFullSyncScheduled = false + self.synchronizeAllHostedViews(excluding: nil) + } + } + + private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { + guard ensureInstalled() else { return } + pruneDeadEntries() + let hostedIds = Array(entriesByHostedId.keys) + for hostedId in hostedIds { + if hostedId == hostedIdToSkip { continue } + synchronizeHostedView(withId: hostedId) + } } private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { @@ -261,12 +352,20 @@ final class WindowTerminalPortal: NSObject { let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) let frameInHost = hostView.convert(frameInWindow, from: nil) + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 + let outsideHostBounds = !frameInHost.intersects(hostView.bounds) let shouldHide = !entry.visibleInUI || anchorHidden || - tinyFrame + tinyFrame || + !hasFiniteFrame || + outsideHostBounds let oldFrame = hostedView.frame #if DEBUG @@ -301,7 +400,8 @@ final class WindowTerminalPortal: NSObject { dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + - "tiny=\(tinyFrame ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" ) #endif hostedView.isHidden = shouldHide @@ -316,6 +416,10 @@ final class WindowTerminalPortal: NSObject { if anchor.window !== currentWindow || anchor.superview == nil { return hostedId } + if let reference = installedReferenceView, + !anchor.isDescendant(of: reference) { + return hostedId + } return nil } diff --git a/vendor/bonsplit b/vendor/bonsplit index 2bd60ba4..748d9c0f 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 2bd60ba40dc3350bd3c774b5f2de9f9b9c1b39fb +Subproject commit 748d9c0fe12edebd5448b946ce2c23d7549cd073