Fix browser portal anchor churn during pane drag
This commit is contained in:
parent
9b215eddab
commit
2427a2a736
4 changed files with 408 additions and 70 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import ObjectiveC
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
private var cmuxWindowBrowserPortalKey: UInt8 = 0
|
||||
|
|
@ -359,6 +360,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
|
||||
|
|
@ -419,16 +428,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
|
||||
|
|
@ -437,6 +453,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,
|
||||
|
|
@ -556,7 +613,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,
|
||||
|
|
@ -612,7 +673,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
|
||||
|
|
@ -669,11 +734,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
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
|
|
@ -691,7 +758,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)
|
||||
|
|
@ -705,6 +771,12 @@ final class WindowBrowserSlotView: NSView {
|
|||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
super.viewDidMoveToSuperview()
|
||||
attachDropZoneOverlayIfNeeded()
|
||||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
func setDropZoneOverlay(zone: DropZone?) {
|
||||
forwardedDropZone = zone
|
||||
applyResolvedDropZoneOverlay()
|
||||
|
|
@ -719,9 +791,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()
|
||||
}
|
||||
|
||||
|
|
@ -729,6 +854,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) {
|
||||
|
|
@ -764,6 +900,7 @@ final class WindowBrowserSlotView: NSView {
|
|||
}
|
||||
return
|
||||
}
|
||||
attachDropZoneOverlayIfNeeded()
|
||||
|
||||
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
|
||||
let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame)
|
||||
|
|
@ -805,7 +942,6 @@ final class WindowBrowserSlotView: NSView {
|
|||
|
||||
private func interactionLayerPriority(of view: NSView) -> Int {
|
||||
if view === paneDropTargetView { return 1 }
|
||||
if view === dropZoneOverlayView { return 2 }
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
@ -817,8 +953,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()
|
||||
|
|
@ -841,19 +980,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 {
|
||||
|
|
@ -884,6 +1017,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
|
||||
}
|
||||
|
||||
|
|
@ -1142,10 +1278,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)) " +
|
||||
|
|
@ -1281,6 +1421,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 }
|
||||
|
||||
|
|
@ -1296,6 +1455,9 @@ final class WindowBrowserPortal: NSObject {
|
|||
zPriority: 0,
|
||||
dropZone: nil,
|
||||
paneDropContext: nil,
|
||||
searchOverlay: nil,
|
||||
paneTopChromeHeight: 0,
|
||||
transientRecoveryReason: nil,
|
||||
transientRecoveryRetriesRemaining: 0
|
||||
),
|
||||
webView: webView
|
||||
|
|
@ -1329,6 +1491,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
|
||||
)
|
||||
|
||||
|
|
@ -1446,7 +1611,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
|
||||
}
|
||||
|
|
@ -1457,9 +1623,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
|
||||
|
|
@ -1494,15 +1669,31 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
return
|
||||
}
|
||||
guard let anchorView = entry.anchorView, let window else {
|
||||
if entry.visibleInUI {
|
||||
_ = scheduleTransientRecoveryRetryIfNeeded(
|
||||
forWebViewId: webViewId,
|
||||
entry: &entry,
|
||||
webView: webView,
|
||||
reason: "missingAnchorOrWindow"
|
||||
func scheduleTransientDetachRecovery(reason: String) -> Bool {
|
||||
guard entry.visibleInUI else { return false }
|
||||
let didSchedule = scheduleTransientRecoveryRetryIfNeeded(
|
||||
forWebViewId: webViewId,
|
||||
entry: &entry,
|
||||
webView: webView,
|
||||
reason: reason
|
||||
)
|
||||
let shouldPreserve = didSchedule && !containerView.isHidden
|
||||
#if DEBUG
|
||||
if shouldPreserve {
|
||||
dlog(
|
||||
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=\(reason) frame=\(browserPortalDebugFrame(containerView.frame))"
|
||||
)
|
||||
} else {
|
||||
}
|
||||
#endif
|
||||
return shouldPreserve
|
||||
}
|
||||
guard let anchorView = entry.anchorView, let window else {
|
||||
if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") {
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
return
|
||||
}
|
||||
if !entry.visibleInUI {
|
||||
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
|
||||
}
|
||||
#if DEBUG
|
||||
|
|
@ -1513,11 +1704,17 @@ 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.setDropZoneOverlay(zone: nil)
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
if !containerView.isHidden {
|
||||
dlog(
|
||||
|
|
@ -1527,16 +1724,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
|
||||
|
|
@ -1617,6 +1809,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
} else {
|
||||
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
|
||||
}
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
containerView.isHidden = true
|
||||
if entry.visibleInUI {
|
||||
|
|
@ -1629,6 +1822,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
} else {
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
}
|
||||
containerView.setPaneTopChromeHeight(0)
|
||||
return
|
||||
}
|
||||
let oldFrame = containerView.frame
|
||||
|
|
@ -1788,6 +1982,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")
|
||||
|
|
@ -2011,6 +2207,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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -306,6 +307,8 @@ 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
|
||||
|
|
@ -317,17 +320,6 @@ struct BrowserPanelView: View {
|
|||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay {
|
||||
if let searchState = panel.searchState {
|
||||
BrowserSearchOverlay(
|
||||
panelId: panel.id,
|
||||
searchState: searchState,
|
||||
onNext: { panel.findNext() },
|
||||
onPrevious: { panel.findPrevious() },
|
||||
onClose: { panel.hideFind() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
|
||||
OmnibarSuggestionsView(
|
||||
|
|
@ -354,6 +346,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 }
|
||||
|
|
@ -495,6 +490,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)
|
||||
|
|
@ -739,7 +743,17 @@ struct BrowserPanelView: View {
|
|||
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.
|
||||
|
|
@ -1935,6 +1949,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 {
|
||||
|
|
@ -3039,6 +3061,8 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
let isPanelFocused: Bool
|
||||
let portalZPriority: Int
|
||||
let paneDropZone: DropZone?
|
||||
let searchOverlay: BrowserPortalSearchOverlayConfiguration?
|
||||
let paneTopChromeHeight: CGFloat
|
||||
|
||||
final class Coordinator {
|
||||
weak var panel: BrowserPanel?
|
||||
|
|
@ -3199,6 +3223,18 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
host.onGeometryChanged = nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
|
||||
guard let host = nsView as? HostContainerView else { return }
|
||||
|
||||
|
|
@ -3210,25 +3246,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)
|
||||
}
|
||||
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 {
|
||||
|
|
@ -3245,15 +3291,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)
|
||||
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
|
||||
|
|
@ -3269,10 +3321,15 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
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()
|
||||
|
||||
|
|
@ -3391,7 +3448,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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2450,7 +2450,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0,
|
||||
paneDropZone: nil
|
||||
paneDropZone: nil,
|
||||
searchOverlay: nil,
|
||||
paneTopChromeHeight: 0
|
||||
)
|
||||
let coordinator = representable.makeCoordinator()
|
||||
coordinator.webView = panel.webView
|
||||
|
|
@ -2487,7 +2489,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0,
|
||||
paneDropZone: nil
|
||||
paneDropZone: nil,
|
||||
searchOverlay: nil,
|
||||
paneTopChromeHeight: 0
|
||||
)
|
||||
let coordinator = representable.makeCoordinator()
|
||||
coordinator.webView = panel.webView
|
||||
|
|
@ -7776,6 +7780,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(
|
||||
|
|
@ -7873,22 +7898,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)
|
||||
|
|
@ -7901,6 +7928,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue