Merge pull request #995 from manaflow-ai/issue-969-browser-drag-top-drop-targets
Fix browser portal anchor churn during pane drag
This commit is contained in:
commit
0f14fc0cd1
6 changed files with 483 additions and 152 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import ObjectiveC
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
private var cmuxWindowBrowserPortalKey: UInt8 = 0
|
||||
|
|
@ -905,6 +906,14 @@ private final class BrowserDropZoneOverlayView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
struct BrowserPortalSearchOverlayConfiguration {
|
||||
let panelId: UUID
|
||||
let searchState: BrowserSearchState
|
||||
let onNext: () -> Void
|
||||
let onPrevious: () -> Void
|
||||
let onClose: () -> Void
|
||||
}
|
||||
|
||||
struct BrowserPaneDropContext: Equatable {
|
||||
let workspaceId: UUID
|
||||
let panelId: UUID
|
||||
|
|
@ -965,16 +974,23 @@ enum BrowserPaneDropAction: Equatable {
|
|||
}
|
||||
|
||||
enum BrowserPaneDropRouting {
|
||||
static func zone(for location: CGPoint, in size: CGSize) -> DropZone {
|
||||
private static let padding: CGFloat = 4
|
||||
|
||||
private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize {
|
||||
CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight))
|
||||
}
|
||||
|
||||
static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone {
|
||||
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
|
||||
let edgeRatio: CGFloat = 0.25
|
||||
let horizontalEdge = max(80, size.width * edgeRatio)
|
||||
let verticalEdge = max(80, size.height * edgeRatio)
|
||||
let horizontalEdge = max(80, fullPaneSize.width * edgeRatio)
|
||||
let verticalEdge = max(80, fullPaneSize.height * edgeRatio)
|
||||
|
||||
if location.x < horizontalEdge {
|
||||
return .left
|
||||
} else if location.x > size.width - horizontalEdge {
|
||||
} else if location.x > fullPaneSize.width - horizontalEdge {
|
||||
return .right
|
||||
} else if location.y > size.height - verticalEdge {
|
||||
} else if location.y > fullPaneSize.height - verticalEdge {
|
||||
return .top
|
||||
} else if location.y < verticalEdge {
|
||||
return .bottom
|
||||
|
|
@ -983,6 +999,47 @@ enum BrowserPaneDropRouting {
|
|||
}
|
||||
}
|
||||
|
||||
static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect {
|
||||
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
|
||||
switch zone {
|
||||
case .center:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: padding,
|
||||
width: fullPaneSize.width - padding * 2,
|
||||
height: fullPaneSize.height - padding * 2
|
||||
)
|
||||
case .left:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: padding,
|
||||
width: fullPaneSize.width / 2 - padding,
|
||||
height: fullPaneSize.height - padding * 2
|
||||
)
|
||||
case .right:
|
||||
return CGRect(
|
||||
x: fullPaneSize.width / 2,
|
||||
y: padding,
|
||||
width: fullPaneSize.width / 2 - padding,
|
||||
height: fullPaneSize.height - padding * 2
|
||||
)
|
||||
case .top:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: fullPaneSize.height / 2,
|
||||
width: fullPaneSize.width - padding * 2,
|
||||
height: fullPaneSize.height / 2 - padding
|
||||
)
|
||||
case .bottom:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: padding,
|
||||
width: fullPaneSize.width - padding * 2,
|
||||
height: fullPaneSize.height / 2 - padding
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func action(
|
||||
for transfer: BrowserPaneDragTransfer,
|
||||
target: BrowserPaneDropContext,
|
||||
|
|
@ -1102,7 +1159,11 @@ final class BrowserPaneDropTargetView: NSView {
|
|||
}
|
||||
|
||||
let location = convert(sender.draggingLocation, from: nil)
|
||||
let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size)
|
||||
let zone = BrowserPaneDropRouting.zone(
|
||||
for: location,
|
||||
in: bounds.size,
|
||||
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
|
||||
)
|
||||
guard let action = BrowserPaneDropRouting.action(
|
||||
for: transfer,
|
||||
target: dropContext,
|
||||
|
|
@ -1158,7 +1219,11 @@ final class BrowserPaneDropTargetView: NSView {
|
|||
}
|
||||
|
||||
let location = convert(sender.draggingLocation, from: nil)
|
||||
let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size)
|
||||
let zone = BrowserPaneDropRouting.zone(
|
||||
for: location,
|
||||
in: bounds.size,
|
||||
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
|
||||
)
|
||||
activeZone = zone
|
||||
slotView?.setPortalDragDropZone(zone)
|
||||
#if DEBUG
|
||||
|
|
@ -1215,11 +1280,13 @@ final class WindowBrowserSlotView: NSView {
|
|||
override var isOpaque: Bool { false }
|
||||
private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero)
|
||||
private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero)
|
||||
private var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
|
||||
private var forwardedDropZone: DropZone?
|
||||
private var portalDragDropZone: DropZone?
|
||||
private var displayedDropZone: DropZone?
|
||||
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
|
||||
private var isRefreshingInteractionLayers = false
|
||||
private var paneTopChromeHeight: CGFloat = 0
|
||||
var preferredHostedInspectorWidth: CGFloat?
|
||||
var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)?
|
||||
fileprivate var isApplyingHostedInspectorLayout = false
|
||||
|
|
@ -1240,7 +1307,6 @@ final class WindowBrowserSlotView: NSView {
|
|||
dropZoneOverlayView.layer?.cornerRadius = 8
|
||||
dropZoneOverlayView.isHidden = true
|
||||
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
|
||||
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1256,6 +1322,12 @@ final class WindowBrowserSlotView: NSView {
|
|||
onHostedInspectorLayout?(self)
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
super.viewDidMoveToSuperview()
|
||||
attachDropZoneOverlayIfNeeded()
|
||||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
func setDropZoneOverlay(zone: DropZone?) {
|
||||
forwardedDropZone = zone
|
||||
applyResolvedDropZoneOverlay()
|
||||
|
|
@ -1270,9 +1342,62 @@ final class WindowBrowserSlotView: NSView {
|
|||
paneDropTargetView.dropContext = context
|
||||
}
|
||||
|
||||
func setPaneTopChromeHeight(_ height: CGFloat) {
|
||||
let resolvedHeight = max(0, height)
|
||||
guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
|
||||
paneTopChromeHeight = resolvedHeight
|
||||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
|
||||
guard let configuration else {
|
||||
searchOverlayHostingView?.removeFromSuperview()
|
||||
searchOverlayHostingView = nil
|
||||
return
|
||||
}
|
||||
|
||||
let rootView = BrowserSearchOverlay(
|
||||
panelId: configuration.panelId,
|
||||
searchState: configuration.searchState,
|
||||
onNext: configuration.onNext,
|
||||
onPrevious: configuration.onPrevious,
|
||||
onClose: configuration.onClose
|
||||
)
|
||||
|
||||
if let overlay = searchOverlayHostingView {
|
||||
overlay.rootView = rootView
|
||||
if overlay.superview !== self {
|
||||
overlay.removeFromSuperview()
|
||||
addSubview(overlay)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let overlay = NSHostingView(rootView: rootView)
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(overlay)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
searchOverlayHostingView = overlay
|
||||
}
|
||||
|
||||
func effectivePaneTopChromeHeight() -> CGFloat {
|
||||
paneTopChromeHeight
|
||||
}
|
||||
|
||||
override func didAddSubview(_ subview: NSView) {
|
||||
super.didAddSubview(subview)
|
||||
guard subview !== paneDropTargetView, subview !== dropZoneOverlayView else { return }
|
||||
guard subview !== paneDropTargetView else { return }
|
||||
bringInteractionLayersToFrontIfNeeded()
|
||||
}
|
||||
|
||||
|
|
@ -1280,6 +1405,17 @@ final class WindowBrowserSlotView: NSView {
|
|||
portalDragDropZone ?? forwardedDropZone
|
||||
}
|
||||
|
||||
private func overlayContainerView() -> NSView {
|
||||
superview ?? self
|
||||
}
|
||||
|
||||
private func attachDropZoneOverlayIfNeeded() {
|
||||
let container = overlayContainerView()
|
||||
guard dropZoneOverlayView.superview !== container else { return }
|
||||
dropZoneOverlayView.removeFromSuperview()
|
||||
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
|
||||
private func applyResolvedDropZoneOverlay() {
|
||||
let resolvedZone = activeDropZone
|
||||
if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) {
|
||||
|
|
@ -1315,6 +1451,7 @@ final class WindowBrowserSlotView: NSView {
|
|||
}
|
||||
return
|
||||
}
|
||||
attachDropZoneOverlayIfNeeded()
|
||||
|
||||
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
|
||||
let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame)
|
||||
|
|
@ -1356,7 +1493,6 @@ final class WindowBrowserSlotView: NSView {
|
|||
|
||||
private func interactionLayerPriority(of view: NSView) -> Int {
|
||||
if view === paneDropTargetView { return 1 }
|
||||
if view === dropZoneOverlayView { return 2 }
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
@ -1368,8 +1504,11 @@ final class WindowBrowserSlotView: NSView {
|
|||
if paneDropTargetView.superview !== self {
|
||||
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
if dropZoneOverlayView.superview !== self {
|
||||
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
|
||||
let overlayContainer = overlayContainerView()
|
||||
if dropZoneOverlayView.superview !== overlayContainer {
|
||||
attachDropZoneOverlayIfNeeded()
|
||||
} else if overlayContainer.subviews.last !== dropZoneOverlayView {
|
||||
overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
|
||||
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
|
@ -1392,19 +1531,13 @@ final class WindowBrowserSlotView: NSView {
|
|||
}
|
||||
|
||||
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
|
||||
let padding: CGFloat = 4
|
||||
switch zone {
|
||||
case .center:
|
||||
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
|
||||
case .left:
|
||||
return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
||||
case .right:
|
||||
return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
||||
case .top:
|
||||
return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
|
||||
case .bottom:
|
||||
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
|
||||
}
|
||||
let localFrame = BrowserPaneDropRouting.overlayFrame(
|
||||
for: zone,
|
||||
in: size,
|
||||
topChromeHeight: paneTopChromeHeight
|
||||
)
|
||||
guard let superview else { return localFrame }
|
||||
return superview.convert(localFrame, from: self)
|
||||
}
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
|
|
@ -1435,6 +1568,9 @@ final class WindowBrowserPortal: NSObject {
|
|||
var zPriority: Int
|
||||
var dropZone: DropZone?
|
||||
var paneDropContext: BrowserPaneDropContext?
|
||||
var searchOverlay: BrowserPortalSearchOverlayConfiguration?
|
||||
var paneTopChromeHeight: CGFloat
|
||||
var transientRecoveryReason: String?
|
||||
var transientRecoveryRetriesRemaining: Int
|
||||
}
|
||||
|
||||
|
|
@ -1693,10 +1829,14 @@ final class WindowBrowserPortal: NSObject {
|
|||
private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView {
|
||||
if let existing = entry.containerView {
|
||||
existing.setPaneDropContext(entry.paneDropContext)
|
||||
existing.setSearchOverlay(entry.searchOverlay)
|
||||
existing.setPaneTopChromeHeight(entry.paneTopChromeHeight)
|
||||
return existing
|
||||
}
|
||||
let created = WindowBrowserSlotView(frame: .zero)
|
||||
created.setPaneDropContext(entry.paneDropContext)
|
||||
created.setSearchOverlay(entry.searchOverlay)
|
||||
created.setPaneTopChromeHeight(entry.paneTopChromeHeight)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.container.create web=\(browserPortalDebugToken(webView)) " +
|
||||
|
|
@ -1818,6 +1958,14 @@ final class WindowBrowserPortal: NSObject {
|
|||
entriesByWebViewId[webViewId] = entry
|
||||
}
|
||||
|
||||
func hideWebView(withId webViewId: ObjectIdentifier, source: String = "externalHide") {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
entry.visibleInUI = false
|
||||
entry.zPriority = 0
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
synchronizeWebView(withId: webViewId, source: source)
|
||||
}
|
||||
|
||||
func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
entry.dropZone = zone
|
||||
|
|
@ -1832,6 +1980,25 @@ final class WindowBrowserPortal: NSObject {
|
|||
entry.containerView?.setPaneDropContext(context)
|
||||
}
|
||||
|
||||
func updateSearchOverlay(
|
||||
forWebViewId webViewId: ObjectIdentifier,
|
||||
configuration: BrowserPortalSearchOverlayConfiguration?
|
||||
) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
entry.searchOverlay = configuration
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
entry.containerView?.setSearchOverlay(configuration)
|
||||
}
|
||||
|
||||
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
let resolvedHeight = max(0, height)
|
||||
guard abs(entry.paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
|
||||
entry.paneTopChromeHeight = resolvedHeight
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
entry.containerView?.setPaneTopChromeHeight(resolvedHeight)
|
||||
}
|
||||
|
||||
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard ensureInstalled() else { return }
|
||||
|
||||
|
|
@ -1847,6 +2014,9 @@ final class WindowBrowserPortal: NSObject {
|
|||
zPriority: 0,
|
||||
dropZone: nil,
|
||||
paneDropContext: nil,
|
||||
searchOverlay: nil,
|
||||
paneTopChromeHeight: 0,
|
||||
transientRecoveryReason: nil,
|
||||
transientRecoveryRetriesRemaining: 0
|
||||
),
|
||||
webView: webView
|
||||
|
|
@ -1880,6 +2050,9 @@ final class WindowBrowserPortal: NSObject {
|
|||
zPriority: zPriority,
|
||||
dropZone: previousEntry?.dropZone,
|
||||
paneDropContext: previousEntry?.paneDropContext,
|
||||
searchOverlay: previousEntry?.searchOverlay,
|
||||
paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0,
|
||||
transientRecoveryReason: previousEntry?.transientRecoveryReason,
|
||||
transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0
|
||||
)
|
||||
|
||||
|
|
@ -1997,7 +2170,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
|
||||
private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) {
|
||||
guard entry.transientRecoveryRetriesRemaining != 0 else { return }
|
||||
guard entry.transientRecoveryRetriesRemaining != 0 || entry.transientRecoveryReason != nil else { return }
|
||||
entry.transientRecoveryReason = nil
|
||||
entry.transientRecoveryRetriesRemaining = 0
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
}
|
||||
|
|
@ -2008,9 +2182,18 @@ final class WindowBrowserPortal: NSObject {
|
|||
webView: WKWebView,
|
||||
reason: String
|
||||
) -> Bool {
|
||||
if entry.transientRecoveryRetriesRemaining == 0 {
|
||||
if entry.transientRecoveryReason != reason {
|
||||
entry.transientRecoveryReason = reason
|
||||
entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget
|
||||
}
|
||||
#if DEBUG
|
||||
if entry.transientRecoveryRetriesRemaining <= 0 {
|
||||
dlog(
|
||||
"browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=\(reason) exhausted=1"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
guard entry.transientRecoveryRetriesRemaining > 0 else { return false }
|
||||
|
||||
entry.transientRecoveryRetriesRemaining -= 1
|
||||
|
|
@ -2045,15 +2228,24 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
return
|
||||
}
|
||||
func scheduleTransientDetachRecovery(reason: String) -> Bool {
|
||||
guard entry.visibleInUI else { return false }
|
||||
return scheduleTransientRecoveryRetryIfNeeded(
|
||||
forWebViewId: webViewId,
|
||||
entry: &entry,
|
||||
webView: webView,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
guard let anchorView = entry.anchorView, let window else {
|
||||
if entry.visibleInUI {
|
||||
_ = scheduleTransientRecoveryRetryIfNeeded(
|
||||
forWebViewId: webViewId,
|
||||
entry: &entry,
|
||||
webView: webView,
|
||||
reason: "missingAnchorOrWindow"
|
||||
)
|
||||
} else {
|
||||
if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") {
|
||||
containerView.setPaneTopChromeHeight(0)
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
containerView.isHidden = true
|
||||
return
|
||||
}
|
||||
if !entry.visibleInUI {
|
||||
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
|
||||
}
|
||||
#if DEBUG
|
||||
|
|
@ -2064,11 +2256,20 @@ final class WindowBrowserPortal: NSObject {
|
|||
)
|
||||
}
|
||||
#endif
|
||||
containerView.setPaneTopChromeHeight(0)
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
containerView.isHidden = true
|
||||
return
|
||||
}
|
||||
guard anchorView.window === window else {
|
||||
if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") {
|
||||
containerView.setPaneTopChromeHeight(0)
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
containerView.isHidden = true
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
if !containerView.isHidden {
|
||||
dlog(
|
||||
|
|
@ -2078,16 +2279,11 @@ final class WindowBrowserPortal: NSObject {
|
|||
)
|
||||
}
|
||||
#endif
|
||||
if entry.visibleInUI {
|
||||
_ = scheduleTransientRecoveryRetryIfNeeded(
|
||||
forWebViewId: webViewId,
|
||||
entry: &entry,
|
||||
webView: webView,
|
||||
reason: "anchorWindowMismatch"
|
||||
)
|
||||
} else {
|
||||
if !entry.visibleInUI {
|
||||
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
|
||||
}
|
||||
containerView.setPaneTopChromeHeight(0)
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
containerView.isHidden = true
|
||||
return
|
||||
|
|
@ -2168,6 +2364,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
} else {
|
||||
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
|
||||
}
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
containerView.isHidden = true
|
||||
if entry.visibleInUI {
|
||||
|
|
@ -2180,6 +2377,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
} else {
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
}
|
||||
containerView.setPaneTopChromeHeight(0)
|
||||
return
|
||||
}
|
||||
let oldFrame = containerView.frame
|
||||
|
|
@ -2339,6 +2537,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
#endif
|
||||
containerView.isHidden = false
|
||||
}
|
||||
containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight)
|
||||
containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay)
|
||||
containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone)
|
||||
if revealedForDisplay {
|
||||
refreshReasons.append("reveal")
|
||||
|
|
@ -2549,6 +2749,13 @@ enum BrowserWindowPortalRegistry {
|
|||
portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority)
|
||||
}
|
||||
|
||||
static func hide(webView: WKWebView, source: String = "externalHide") {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return }
|
||||
portal.hideWebView(withId: webViewId, source: source)
|
||||
}
|
||||
|
||||
static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
|
|
@ -2563,6 +2770,23 @@ enum BrowserWindowPortalRegistry {
|
|||
portal.updatePaneDropContext(forWebViewId: webViewId, context: context)
|
||||
}
|
||||
|
||||
static func updateSearchOverlay(
|
||||
for webView: WKWebView,
|
||||
configuration: BrowserPortalSearchOverlayConfiguration?
|
||||
) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return }
|
||||
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
|
||||
}
|
||||
|
||||
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return }
|
||||
portal.updatePaneTopChromeHeight(forWebViewId: webViewId, height: height)
|
||||
}
|
||||
|
||||
static func detach(webView: WKWebView) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
|
||||
|
|
|
|||
|
|
@ -2815,13 +2815,14 @@ struct ContentView: View {
|
|||
workspaceHandoffFallbackTask = nil
|
||||
let retiring = retiringWorkspaceId
|
||||
|
||||
// Hide terminal portal views for the retiring workspace BEFORE clearing
|
||||
// Hide portal-hosted views for the retiring workspace BEFORE clearing
|
||||
// retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts
|
||||
// the workspace — but dismantleNSView intentionally doesn't hide portal views
|
||||
// (to avoid blackouts during transient bonsplit dismantles). Hiding here
|
||||
// prevents stale portal-hosted terminals from covering browser panes.
|
||||
// during transient rebuilds. Hiding here prevents stale terminal/browser
|
||||
// portals from covering the newly selected workspace.
|
||||
if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) {
|
||||
workspace.hideAllTerminalPortalViews()
|
||||
workspace.hideAllBrowserPortalViews()
|
||||
}
|
||||
|
||||
retiringWorkspaceId = nil
|
||||
|
|
|
|||
|
|
@ -1244,6 +1244,15 @@ final class BrowserSearchState: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserPortalAnchorView: NSView {
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserPanel: Panel, ObservableObject {
|
||||
/// Shared process pool for cookie sharing across all browser panels
|
||||
|
|
@ -1481,6 +1490,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
}
|
||||
private var searchNeedleCancellable: AnyCancellable?
|
||||
let portalAnchorView = BrowserPortalAnchorView(frame: .zero)
|
||||
private var webViewCancellables = Set<AnyCancellable>()
|
||||
private var navigationDelegate: BrowserNavigationDelegate?
|
||||
private var uiDelegate: BrowserUIDelegate?
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ struct BrowserPanelView: View {
|
|||
@State private var focusFlashOpacity: Double = 0.0
|
||||
@State private var focusFlashAnimationGeneration: Int = 0
|
||||
@State private var omnibarPillFrame: CGRect = .zero
|
||||
@State private var addressBarHeight: CGFloat = 0
|
||||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
@State private var isBrowserThemeMenuPresented = false
|
||||
@State private var ghosttyBackgroundGeneration: Int = 0
|
||||
|
|
@ -310,21 +311,16 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
// Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit
|
||||
// container. Rendering it here can hide it behind the portal-hosted WKWebView.
|
||||
VStack(spacing: 0) {
|
||||
addressBar
|
||||
webView
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
||||
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay {
|
||||
// Keep Cmd+F usable when the browser is still in the empty new-tab
|
||||
// state (no WKWebView mounted yet). WebView-backed cases are hosted
|
||||
// in AppKit by WebViewRepresentable to avoid layering/clipping issues.
|
||||
// in AppKit by WindowBrowserPortal to avoid layering/clipping issues.
|
||||
if !panel.shouldRenderWebView, let searchState = panel.searchState {
|
||||
BrowserSearchOverlay(
|
||||
panelId: panel.id,
|
||||
|
|
@ -335,6 +331,13 @@ struct BrowserPanelView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
||||
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
|
||||
OmnibarSuggestionsView(
|
||||
|
|
@ -361,6 +364,9 @@ struct BrowserPanelView: View {
|
|||
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
|
||||
omnibarPillFrame = frame
|
||||
}
|
||||
.onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in
|
||||
addressBarHeight = height
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in
|
||||
// Only handle clicks from our own webview.
|
||||
guard let webView = note.object as? CmuxWebView else { return false }
|
||||
|
|
@ -502,6 +508,15 @@ struct BrowserPanelView: View {
|
|||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, addressBarVerticalPadding)
|
||||
.background(browserChromeBackground)
|
||||
.background {
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.preference(
|
||||
key: BrowserAddressBarHeightPreferenceKey.self,
|
||||
value: geo.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
|
||||
.zIndex(1)
|
||||
.environment(\.colorScheme, browserChromeColorScheme)
|
||||
|
|
@ -742,12 +757,21 @@ struct BrowserPanelView: View {
|
|||
if panel.shouldRenderWebView {
|
||||
WebViewRepresentable(
|
||||
panel: panel,
|
||||
browserSearchState: panel.searchState,
|
||||
shouldAttachWebView: isVisibleInUI,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority,
|
||||
paneDropZone: paneDropZone
|
||||
paneDropZone: paneDropZone,
|
||||
searchOverlay: panel.searchState.map { searchState in
|
||||
BrowserPortalSearchOverlayConfiguration(
|
||||
panelId: panel.id,
|
||||
searchState: searchState,
|
||||
onNext: { panel.findNext() },
|
||||
onPrevious: { panel.findPrevious() },
|
||||
onClose: { panel.hideFind() }
|
||||
)
|
||||
},
|
||||
paneTopChromeHeight: addressBarHeight
|
||||
)
|
||||
// Keep the host stable for normal pane churn, but force a remount when
|
||||
// BrowserPanel replaces its underlying WKWebView after process termination.
|
||||
|
|
@ -1945,6 +1969,14 @@ private struct OmnibarPillFramePreferenceKey: PreferenceKey {
|
|||
}
|
||||
}
|
||||
|
||||
private struct BrowserAddressBarHeightPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Omnibar State Machine
|
||||
|
||||
struct OmnibarState: Equatable {
|
||||
|
|
@ -3044,12 +3076,13 @@ private struct OmnibarSuggestionsView: View {
|
|||
/// NSViewRepresentable wrapper for WKWebView
|
||||
struct WebViewRepresentable: NSViewRepresentable {
|
||||
let panel: BrowserPanel
|
||||
let browserSearchState: BrowserSearchState?
|
||||
let shouldAttachWebView: Bool
|
||||
let shouldFocusWebView: Bool
|
||||
let isPanelFocused: Bool
|
||||
let portalZPriority: Int
|
||||
let paneDropZone: DropZone?
|
||||
let searchOverlay: BrowserPortalSearchOverlayConfiguration?
|
||||
let paneTopChromeHeight: CGFloat
|
||||
|
||||
final class Coordinator {
|
||||
weak var panel: BrowserPanel?
|
||||
|
|
@ -3058,7 +3091,6 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
var desiredPortalVisibleInUI: Bool = true
|
||||
var desiredPortalZPriority: Int = 0
|
||||
var lastPortalHostId: ObjectIdentifier?
|
||||
var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
|
||||
}
|
||||
|
||||
final class HostContainerView: NSView {
|
||||
|
|
@ -3771,65 +3803,16 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
host.onGeometryChanged = nil
|
||||
}
|
||||
|
||||
private static func removeSearchOverlay(from coordinator: Coordinator) {
|
||||
coordinator.searchOverlayHostingView?.removeFromSuperview()
|
||||
coordinator.searchOverlayHostingView = nil
|
||||
}
|
||||
|
||||
private static func updateSearchOverlay(
|
||||
panel: BrowserPanel,
|
||||
coordinator: Coordinator,
|
||||
containerView: NSView?
|
||||
) {
|
||||
// Layering contract: keep browser Cmd+F UI in the portal-hosted AppKit layer.
|
||||
// SwiftUI panel overlays can be covered by portal-hosted WKWebView content.
|
||||
guard let searchState = panel.searchState,
|
||||
let containerView else {
|
||||
removeSearchOverlay(from: coordinator)
|
||||
return
|
||||
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
|
||||
if anchorView.superview !== host {
|
||||
anchorView.removeFromSuperview()
|
||||
anchorView.frame = host.bounds
|
||||
anchorView.translatesAutoresizingMaskIntoConstraints = true
|
||||
anchorView.autoresizingMask = [.width, .height]
|
||||
host.addSubview(anchorView)
|
||||
} else if anchorView.frame != host.bounds {
|
||||
anchorView.frame = host.bounds
|
||||
}
|
||||
|
||||
let rootView = BrowserSearchOverlay(
|
||||
panelId: panel.id,
|
||||
searchState: searchState,
|
||||
onNext: { [weak panel] in
|
||||
panel?.findNext()
|
||||
},
|
||||
onPrevious: { [weak panel] in
|
||||
panel?.findPrevious()
|
||||
},
|
||||
onClose: { [weak panel] in
|
||||
panel?.hideFind()
|
||||
}
|
||||
)
|
||||
|
||||
if let overlay = coordinator.searchOverlayHostingView {
|
||||
overlay.rootView = rootView
|
||||
if overlay.superview !== containerView {
|
||||
overlay.removeFromSuperview()
|
||||
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
])
|
||||
} else if containerView.subviews.last !== overlay {
|
||||
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let overlay = NSHostingView(rootView: rootView)
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
])
|
||||
coordinator.searchOverlayHostingView = overlay
|
||||
}
|
||||
|
||||
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
|
||||
|
|
@ -3843,32 +3826,35 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
|
||||
let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil
|
||||
let portalAnchorView = panel.portalAnchorView
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
|
||||
guard let host, let webView, let coordinator else { return }
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
|
||||
guard let host, let webView, let coordinator, let portalAnchorView else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
guard host.window != nil else { return }
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
webView: webView,
|
||||
to: host,
|
||||
to: portalAnchorView,
|
||||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
coordinator.lastPortalHostId = ObjectIdentifier(host)
|
||||
if let panel = coordinator.panel {
|
||||
Self.updateSearchOverlay(
|
||||
panel: panel,
|
||||
coordinator: coordinator,
|
||||
containerView: webView.superview
|
||||
)
|
||||
}
|
||||
}
|
||||
host.onGeometryChanged = { [weak host, weak coordinator] in
|
||||
guard let host, let coordinator else { return }
|
||||
host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in
|
||||
guard let host, let coordinator, let portalAnchorView else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
|
||||
}
|
||||
|
||||
if !shouldAttachWebView {
|
||||
|
|
@ -3885,20 +3871,21 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
previousVisible != shouldAttachWebView ||
|
||||
previousZPriority != portalZPriority
|
||||
if shouldBindNow {
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
webView: webView,
|
||||
to: host,
|
||||
to: portalAnchorView,
|
||||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastPortalHostId = hostId
|
||||
}
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
Self.updateSearchOverlay(
|
||||
panel: panel,
|
||||
coordinator: coordinator,
|
||||
containerView: webView.superview
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: shouldAttachWebView ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
|
||||
} else {
|
||||
// Bind is deferred until host moves into a window. Keep the current
|
||||
// portal entry's desired state in sync so stale callbacks cannot keep
|
||||
|
|
@ -3908,17 +3895,21 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
Self.removeSearchOverlay(from: coordinator)
|
||||
}
|
||||
|
||||
BrowserWindowPortalRegistry.updateDropZoneOverlay(
|
||||
for: webView,
|
||||
zone: shouldAttachWebView ? paneDropZone : nil
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: shouldAttachWebView ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(
|
||||
for: webView,
|
||||
context: paneDropContext
|
||||
)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
|
|
@ -3937,7 +3928,6 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
let webView = panel.webView
|
||||
let coordinator = context.coordinator
|
||||
if let previousWebView = coordinator.webView, previousWebView !== webView {
|
||||
Self.removeSearchOverlay(from: coordinator)
|
||||
BrowserWindowPortalRegistry.detach(webView: previousWebView)
|
||||
coordinator.lastPortalHostId = nil
|
||||
}
|
||||
|
|
@ -4009,7 +3999,6 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||
coordinator.attachGeneration += 1
|
||||
clearPortalCallbacks(for: nsView)
|
||||
removeSearchOverlay(from: coordinator)
|
||||
|
||||
guard let webView = coordinator.webView else { return }
|
||||
let panel = coordinator.panel
|
||||
|
|
@ -4039,7 +4028,9 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach
|
||||
// still happens on real web view replacement and panel teardown.
|
||||
BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil)
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(for: webView, height: 0)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil)
|
||||
coordinator.lastPortalHostId = nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3280,6 +3280,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hide all browser portal views for this workspace.
|
||||
/// Called before the workspace is unmounted so a portal-hosted WKWebView
|
||||
/// cannot remain visible after this workspace stops being selected.
|
||||
func hideAllBrowserPortalViews() {
|
||||
for panel in panels.values {
|
||||
guard let browser = panel as? BrowserPanel else { continue }
|
||||
BrowserWindowPortalRegistry.hide(
|
||||
webView: browser.webView,
|
||||
source: "workspaceRetire"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Utility
|
||||
|
||||
/// Create a new terminal panel (used when replacing the last panel)
|
||||
|
|
|
|||
|
|
@ -2534,12 +2534,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
|
||||
let representable = WebViewRepresentable(
|
||||
panel: panel,
|
||||
browserSearchState: nil,
|
||||
shouldAttachWebView: true,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0,
|
||||
paneDropZone: nil
|
||||
paneDropZone: nil,
|
||||
searchOverlay: nil,
|
||||
paneTopChromeHeight: 0
|
||||
)
|
||||
let coordinator = representable.makeCoordinator()
|
||||
coordinator.webView = panel.webView
|
||||
|
|
@ -2572,12 +2573,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
|
||||
let representable = WebViewRepresentable(
|
||||
panel: panel,
|
||||
browserSearchState: nil,
|
||||
shouldAttachWebView: true,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0,
|
||||
paneDropZone: nil
|
||||
paneDropZone: nil,
|
||||
searchOverlay: nil,
|
||||
paneTopChromeHeight: 0
|
||||
)
|
||||
let coordinator = representable.makeCoordinator()
|
||||
coordinator.webView = panel.webView
|
||||
|
|
@ -8782,6 +8784,27 @@ final class BrowserPaneDropRoutingTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testTopChromeHeightPushesTopSplitThresholdIntoWebView() {
|
||||
let size = CGSize(width: 240, height: 180)
|
||||
|
||||
XCTAssertEqual(
|
||||
BrowserPaneDropRouting.zone(
|
||||
for: CGPoint(x: size.width * 0.5, y: 110),
|
||||
in: size,
|
||||
topChromeHeight: 36
|
||||
),
|
||||
.center
|
||||
)
|
||||
XCTAssertEqual(
|
||||
BrowserPaneDropRouting.zone(
|
||||
for: CGPoint(x: size.width * 0.5, y: 150),
|
||||
in: size,
|
||||
topChromeHeight: 36
|
||||
),
|
||||
.top
|
||||
)
|
||||
}
|
||||
|
||||
func testHitTestingCapturesOnlyForRelevantDragEvents() {
|
||||
XCTAssertTrue(
|
||||
BrowserPaneDropTargetView.shouldCaptureHitTesting(
|
||||
|
|
@ -8879,22 +8902,24 @@ final class WindowBrowserSlotViewTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() {
|
||||
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
||||
let slot = WindowBrowserSlotView(frame: container.bounds)
|
||||
container.addSubview(slot)
|
||||
let child = CapturingView(frame: slot.bounds)
|
||||
child.autoresizingMask = [.width, .height]
|
||||
slot.addSubview(child)
|
||||
|
||||
slot.setDropZoneOverlay(zone: .right)
|
||||
slot.layoutSubtreeIfNeeded()
|
||||
container.layoutSubtreeIfNeeded()
|
||||
|
||||
guard let overlay = slot.subviews.first(where: {
|
||||
$0 !== child && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
||||
guard let overlay = container.subviews.first(where: {
|
||||
$0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
||||
}) else {
|
||||
XCTFail("Expected browser slot drop-zone overlay")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(slot.subviews.last === overlay, "Overlay should stay above the hosted web view")
|
||||
XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view")
|
||||
XCTAssertFalse(overlay.isHidden)
|
||||
XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
|
||||
|
|
@ -8907,6 +8932,35 @@ final class WindowBrowserSlotViewTests: XCTestCase {
|
|||
advanceAnimations()
|
||||
XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay")
|
||||
}
|
||||
|
||||
func testTopDropZoneOverlayUsesFullBrowserContentHeight() {
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
||||
let slot = WindowBrowserSlotView(frame: container.bounds)
|
||||
container.addSubview(slot)
|
||||
|
||||
slot.setPaneTopChromeHeight(20)
|
||||
slot.setDropZoneOverlay(zone: .top)
|
||||
container.layoutSubtreeIfNeeded()
|
||||
|
||||
guard let overlay = container.subviews.first(where: {
|
||||
String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
||||
}) else {
|
||||
XCTFail("Expected browser slot drop-zone overlay")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertFalse(overlay.isHidden)
|
||||
XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5)
|
||||
XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY)
|
||||
XCTAssertEqual(slot.layer?.masksToBounds, true)
|
||||
|
||||
slot.setDropZoneOverlay(zone: nil)
|
||||
advanceAnimations()
|
||||
XCTAssertEqual(slot.layer?.masksToBounds, true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -10326,8 +10380,11 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
|||
}
|
||||
|
||||
private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? {
|
||||
slot.subviews.first(where: {
|
||||
$0 !== webView && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
||||
let candidates = slot.subviews + (slot.superview?.subviews ?? [])
|
||||
return candidates.first(where: {
|
||||
$0 !== slot &&
|
||||
$0 !== webView &&
|
||||
String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -10638,9 +10695,9 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
|||
portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right)
|
||||
slot.layoutSubtreeIfNeeded()
|
||||
XCTAssertFalse(overlay.isHidden)
|
||||
XCTAssertTrue(slot.subviews.last === overlay, "Overlay should remain above the hosted web view")
|
||||
XCTAssertEqual(overlay.frame.origin.x, 110, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
|
||||
XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view")
|
||||
XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5)
|
||||
XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5)
|
||||
|
||||
|
|
@ -10777,6 +10834,41 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
|||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
XCTAssertNil(webView.superview)
|
||||
}
|
||||
|
||||
func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
|
||||
contentView.addSubview(anchor)
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
|
||||
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
|
||||
advanceAnimations()
|
||||
|
||||
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
||||
XCTFail("Expected browser slot")
|
||||
return
|
||||
}
|
||||
XCTAssertFalse(slot.isHidden)
|
||||
|
||||
BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest")
|
||||
advanceAnimations()
|
||||
|
||||
XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment")
|
||||
XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue