From 72e7a9de768cf8736730f216f64afb030ea83aea Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 11 Mar 2026 22:56:39 -0700 Subject: [PATCH] Adapt attached devtools to narrow panes --- Sources/Panels/BrowserPanelView.swift | 108 ++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 53 +++++++++ 2 files changed, 161 insertions(+) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 9dcb7ff5..74888012 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3715,6 +3715,17 @@ struct WebViewRepresentable: NSViewRepresentable { final class HostContainerView: NSView { private final class HostedInspectorSideDockContainerView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + override var isOpaque: Bool { false } override func resizeSubviews(withOldSize oldSize: NSSize) { @@ -3767,6 +3778,8 @@ struct WebViewRepresentable: NSViewRepresentable { private static let hostedInspectorDividerHitExpansion: CGFloat = 10 private static let minimumHostedInspectorWidth: CGFloat = 120 + private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240 + private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25 private var trackingArea: NSTrackingArea? private var activeDividerCursorKind: DividerCursorKind? private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? @@ -3780,6 +3793,7 @@ struct WebViewRepresentable: NSViewRepresentable { private var isApplyingHostedInspectorLayout = false private var hostedInspectorReapplyWorkItem: DispatchWorkItem? private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem? + private var adaptiveBottomDockRequestCooldownDeadline: Date? private var lastHostedInspectorLayoutBoundsSize: NSSize? #if DEBUG private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)? @@ -4177,6 +4191,64 @@ struct WebViewRepresentable: NSViewRepresentable { ) } + func normalizeHostedInspectorLayoutIfNeeded(reason: String) { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") { + return + } + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if isHostedInspectorSideDockActive() { + layoutHostedInspectorSideDockIfNeeded(reason: reason) + } else if !hasStoredHostedInspectorWidthPreference { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) + } + } + + private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool { + let containerWidth = max(0, hit.containerView.bounds.width) + guard containerWidth > 1 else { return false } + + let currentInspectorWidth = max(0, hit.inspectorView.frame.width) + let currentPageWidth = max(0, hit.pageView.frame.width) + let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth)) + let effectivePageWidth = min(currentPageWidth, remainingPageWidth) + + return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock + } + + @discardableResult + private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool { + let now = Date() + if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now { + return true + } + guard let hostedInspectorFrontendWebView else { return false } + + adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown) +#if DEBUG + dlog( + "browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " + + "host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))" + ) +#endif + hostedInspectorFrontendWebView.evaluateJavaScript( + "typeof WI !== 'undefined' ? WI._dockBottom() : null" + ) { [weak self] _, _ in + self?.scheduleHostedInspectorDockConfigurationSync( + reason: "\(reason).adaptiveBottomDock" + ) + } + return true + } + + @discardableResult + private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool { + guard let hit = hostedInspectorDividerCandidate(), + shouldForceHostedInspectorBottomDock(using: hit) else { + return false + } + return requestAdaptiveHostedInspectorBottomDock(reason: reason) + } + fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) { hostedInspectorDockConfigurationSyncWorkItem?.cancel() guard hostedInspectorFrontendWebView != nil else { return } @@ -4202,22 +4274,37 @@ struct WebViewRepresentable: NSViewRepresentable { case "left": hostedInspectorSideDockDockSide = .leading if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") { + return + } layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft") } else if let slotView = localInlineSlotView, let hit = hostedInspectorDividerCandidate(in: slotView), hit.dockSide == .leading { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft") + return + } activateHostedInspectorSideDockIfNeeded(using: hit) } case "right": hostedInspectorSideDockDockSide = .trailing if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") { + return + } layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight") } else if let slotView = localInlineSlotView, let hit = hostedInspectorDividerCandidate(in: slotView), hit.dockSide == .trailing { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight") + return + } activateHostedInspectorSideDockIfNeeded(using: hit) } default: + adaptiveBottomDockRequestCooldownDeadline = nil if isHostedInspectorSideDockActive() { deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) if dockConfiguration == "bottom" { @@ -4259,6 +4346,13 @@ struct WebViewRepresentable: NSViewRepresentable { override func layout() { super.layout() _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") { + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif + return + } if let previousSize = lastHostedInspectorLayoutBoundsSize, Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) { // Origin-only frame churn is common while the surrounding split layout @@ -4830,6 +4924,19 @@ struct WebViewRepresentable: NSViewRepresentable { CATransaction.commit() isApplyingHostedInspectorLayout = false + hit.pageView.needsDisplay = true + hit.pageView.setNeedsDisplay(hit.pageView.bounds) + hit.inspectorView.needsDisplay = true + hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds) + hit.containerView.needsDisplay = true + hit.containerView.setNeedsDisplay(hit.containerView.bounds) + if let localInlineSlotView { + localInlineSlotView.needsDisplay = true + localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds) + } + needsDisplay = true + setNeedsDisplay(bounds) + let isLiveDrag = reason == "drag" #if DEBUG dlog( @@ -5176,6 +5283,7 @@ struct WebViewRepresentable: NSViewRepresentable { webView.layoutSubtreeIfNeeded() slotView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() + host.normalizeHostedInspectorLayoutIfNeeded(reason: "localInline.update.immediate") host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync") DispatchQueue.main.async { [weak host, weak webView] in guard let host, let webView else { return } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 90560272..7dcecf5a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -9418,6 +9418,18 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { } } + private final class TrackingInspectorFrontendWebView: WKWebView { + private(set) var evaluatedJavaScript: [String] = [] + + override func evaluateJavaScript( + _ javaScriptString: String, + completionHandler: ((Any?, Error?) -> Void)? = nil + ) { + evaluatedJavaScript.append(javaScriptString) + completionHandler?(nil, nil) + } + } + private final class WKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil @@ -9861,6 +9873,47 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { ) } + func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)) + let inspectorView = TrackingInspectorFrontendWebView( + frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }), + "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout" + ) + } + func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),