diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index d3f3b2221c..646e8aba6b 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -732,8 +732,8 @@ final class WindowBrowserHostView: NSView { return false } - let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide - let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide + let regionMinX = dividerX - SidebarResizeInteraction.sidebarSideHitWidth + let regionMaxX = dividerX + SidebarResizeInteraction.contentSideHitWidth return point.x >= regionMinX && point.x <= regionMaxX } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2cc83ee299..c20b05a7ec 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -336,11 +336,13 @@ final class SidebarState: ObservableObject { } enum SidebarResizeInteraction { - static let handleWidth: CGFloat = 6 - static let hitInset: CGFloat = 3 + // Keep a generous drag target inside the sidebar itself, but make the + // terminal-side overlap very small so column-0 text selection still wins. + static let sidebarSideHitWidth: CGFloat = 6 + static let contentSideHitWidth: CGFloat = 2 - static var hitWidthPerSide: CGFloat { - hitInset + (handleWidth / 2) + static var totalHitWidth: CGFloat { + sidebarSideHitWidth + contentSideHitWidth } } @@ -1788,8 +1790,12 @@ struct ContentView: View { case divider } - private var sidebarResizerHitWidthPerSide: CGFloat { - SidebarResizeInteraction.hitWidthPerSide + private var sidebarResizerSidebarHitWidth: CGFloat { + SidebarResizeInteraction.sidebarSideHitWidth + } + + private var sidebarResizerContentHitWidth: CGFloat { + SidebarResizeInteraction.contentSideHitWidth } private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { @@ -1865,8 +1871,8 @@ struct ContentView: View { private func dividerBandContains(pointInContent point: NSPoint, contentBounds: NSRect) -> Bool { guard point.y >= contentBounds.minY, point.y <= contentBounds.maxY else { return false } - let minX = sidebarWidth - sidebarResizerHitWidthPerSide - let maxX = sidebarWidth + sidebarResizerHitWidthPerSide + let minX = sidebarWidth - sidebarResizerSidebarHitWidth + let maxX = sidebarWidth + sidebarResizerContentHitWidth return point.x >= minX && point.x <= maxX } @@ -2046,7 +2052,7 @@ struct ContentView: View { GeometryReader { proxy in let totalWidth = max(0, proxy.size.width) let dividerX = min(max(sidebarWidth, 0), totalWidth) - let leadingWidth = max(0, dividerX - sidebarResizerHitWidthPerSide) + let leadingWidth = max(0, dividerX - sidebarResizerSidebarHitWidth) HStack(spacing: 0) { Color.clear @@ -2055,7 +2061,7 @@ struct ContentView: View { sidebarResizerHandleOverlay( .divider, - width: sidebarResizerHitWidthPerSide * 2, + width: SidebarResizeInteraction.totalHitWidth, availableWidth: totalWidth, accessibilityIdentifier: "SidebarResizer" ) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2ee395f0ca..90bf075495 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4454,6 +4454,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { updateSurfaceSize(size: size) } +#if DEBUG + fileprivate func debugPendingSurfaceSize() -> CGSize? { + pendingSurfaceSize + } +#endif + /// Force a full size reconciliation for the current bounds. /// Keep the drawable-size cache intact so redundant refresh paths do not /// reallocate Metal drawables when the pixel size is unchanged. @@ -6998,6 +7004,17 @@ final class GhosttySurfaceScrollView: NSView { ) { [weak self] _ in self?.synchronizeScrollView() }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScroller.preferredScrollerStyleDidChangeNotification, + object: nil, + // Match AppKit's geometry change immediately so the terminal width + // does not stay stuck behind a legacy scrollbar gutter. + queue: nil + ) { [weak self] _ in + self?.handlePreferredScrollerStyleChange() + }) + } required init?(coder: NSCoder) { @@ -8024,6 +8041,10 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.debugSimulateFileDrop(paths: paths) } + func debugPendingSurfaceSize() -> CGSize? { + surfaceView.debugPendingSurfaceSize() + } + func debugRegisteredDropTypes() -> [String] { surfaceView.debugRegisteredDropTypes() } @@ -9029,6 +9050,21 @@ final class GhosttySurfaceScrollView: NSView { synchronizeScrollView() } + private func handlePreferredScrollerStyleChange() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.handlePreferredScrollerStyleChange() + } + return + } + + // Retile just the scroll view so contentSize reflects the current + // scrollbar mode without perturbing viewport origin or hosted view + // geometry; the broader reconcile path caused visible content glitches. + scrollView.tile() + _ = synchronizeCoreSurface() + } + private func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height let cellHeight = surfaceView.cellSize.height diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index e8d73a3390..ff34d60779 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -5034,10 +5034,12 @@ struct WebViewRepresentable: NSViewRepresentable { // Origin-only frame churn is common while the surrounding split layout // settles. Reapplying the side-docked inspector at the same size fights // WebKit's own dock layout and shows up as visible flicker. - if !isHostedInspectorSideDockActive() && - !isHostedInspectorDividerDragActive && - !hasStoredHostedInspectorWidthPreference { - captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize") + if !isHostedInspectorDividerDragActive { + if hasStoredHostedInspectorWidthPreference { + reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout.sameSize") + } else if !isHostedInspectorSideDockActive() { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize") + } } updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize") notifyGeometryChangedIfNeeded() @@ -5049,7 +5051,9 @@ struct WebViewRepresentable: NSViewRepresentable { lastHostedInspectorLayoutBoundsSize = bounds.size if isHostedInspectorSideDockActive() { layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock") - } else if !hasStoredHostedInspectorWidthPreference { + } else if hasStoredHostedInspectorWidthPreference { + reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout") + } else { captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout") } updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") @@ -5127,26 +5131,24 @@ struct WebViewRepresentable: NSViewRepresentable { return nil } if let hostedInspectorHit { - let isSideDockHit = isHostedInspectorSideDockHit(hostedInspectorHit) if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) { #if DEBUG debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit) #endif - if !isSideDockHit || - (nativeHit !== hostedInspectorHit.inspectorView && - !hostedInspectorHit.inspectorView.isDescendant(of: nativeHit)) { + if nativeHit !== hostedInspectorHit.inspectorView && + !hostedInspectorHit.inspectorView.isDescendant(of: nativeHit) { return nativeHit } } #if DEBUG debugLogHitTest( - stage: isSideDockHit ? "hitTest.hostedInspectorManual" : "hitTest.hostedInspectorFallback", + stage: "hitTest.hostedInspectorManual", point: point, passThrough: false, - hitView: hostedInspectorHit.inspectorView + hitView: self ) #endif - return isSideDockHit ? self : hostedInspectorHit.inspectorView + return self } let hit = super.hitTest(point) #if DEBUG @@ -5157,8 +5159,7 @@ struct WebViewRepresentable: NSViewRepresentable { override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) - guard let hostedInspectorHit = hostedInspectorDividerHit(at: point), - isHostedInspectorSideDockHit(hostedInspectorHit) else { + guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else { super.mouseDown(with: event) return } @@ -5255,7 +5256,7 @@ struct WebViewRepresentable: NSViewRepresentable { ) ) #endif - layoutHostedInspectorSideDockIfNeeded(reason: "drag.end") + reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "drag.end") } super.mouseUp(with: event) } @@ -5270,7 +5271,7 @@ struct WebViewRepresentable: NSViewRepresentable { // Pass through a narrow leading-edge band so the shared sidebar divider // handle can receive hover/click even when WKWebView is attached here. // Keeping this deterministic avoids flicker from dynamic left-edge scans. - guard point.x >= 0, point.x <= SidebarResizeInteraction.hitWidthPerSide else { + guard point.x >= 0, point.x <= SidebarResizeInteraction.contentSideHitWidth else { return false } guard let window, let contentView = window.contentView else { @@ -5492,9 +5493,9 @@ struct WebViewRepresentable: NSViewRepresentable { guard let self else { return } self.hostedInspectorReapplyWorkItem = nil _ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() - if self.isHostedInspectorSideDockActive() { + if self.hasStoredHostedInspectorWidthPreference { self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason) - } else if !self.hasStoredHostedInspectorWidthPreference { + } else { self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) } } @@ -5555,7 +5556,6 @@ struct WebViewRepresentable: NSViewRepresentable { private func reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: String) { guard !isApplyingHostedInspectorLayout else { return } guard let hit = hostedInspectorDividerCandidate() else { return } - guard isHostedInspectorSideDockHit(hit) else { return } guard let preferredWidth = resolvedPreferredHostedInspectorWidth(in: hit.containerView.bounds) else { return } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 0518e37cca..4c4588a8a2 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -243,8 +243,8 @@ final class WindowTerminalHostView: NSView { return false } - let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide - let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide + let regionMinX = dividerX - SidebarResizeInteraction.sidebarSideHitWidth + let regionMaxX = dividerX + SidebarResizeInteraction.contentSideHitWidth return point.x >= regionMinX && point.x <= regionMaxX } diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 5c61be3d63..65db2bc531 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -547,7 +547,7 @@ final class WindowBrowserHostViewTests: XCTestCase { XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5) XCTAssertTrue( - abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide, + abs(dividerPointInHost.x - slot.frame.minX) <= 2, "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone" ) @@ -905,7 +905,7 @@ final class WindowBrowserHostViewTests: XCTestCase { let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide) + XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, 2) let dividerHit = host.hitTest(dividerPointInHost) XCTAssertTrue( isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index aca39b2467..2f2a987a7f 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1510,6 +1510,34 @@ final class WindowTerminalHostViewTests: XCTestCase { private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + private func makeHostedTerminalView(frame: NSRect) -> GhosttySurfaceScrollView { + let surfaceView = GhosttyNSView(frame: frame) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = frame + hostedView.autoresizingMask = [.width, .height] + return hostedView + } + + private func assertHitFallsInsideHostedTerminal( + _ hitView: NSView?, + hostedView: GhosttySurfaceScrollView, + message: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let hitView else { + XCTFail(message, file: file, line: line) + return + } + + XCTAssertTrue( + hitView === hostedView || hitView.isDescendant(of: hostedView), + message, + file: file, + line: line + ) + } + func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() { let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) @@ -1555,9 +1583,8 @@ final class WindowTerminalHostViewTests: XCTestCase { let host = WindowTerminalHostView(frame: contentView.bounds) host.autoresizingMask = [.width, .height] - let child = CapturingView(frame: host.bounds) - child.autoresizingMask = [.width, .height] - host.addSubview(child) + let hostedView = makeHostedTerminalView(frame: host.bounds) + host.addSubview(hostedView) contentView.addSubview(host) let dividerPointInSplit = NSPoint( @@ -1575,7 +1602,73 @@ final class WindowTerminalHostViewTests: XCTestCase { let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) let contentPointInHost = host.convert(contentPointInWindow, from: nil) - XCTAssertTrue(host.hitTest(contentPointInHost) === child) + assertHitFallsInsideHostedTerminal( + host.hitTest(contentPointInHost), + hostedView: hostedView, + message: "Terminal content should keep receiving hits after the divider region" + ) + } + + func testHostViewStopsSidebarPassThroughJustInsideTerminalContent() { + let terminalSideOverlapWidth: CGFloat = 2 + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + splitView.dividerStyle = .thin + let splitDelegate = BonsplitMockSplitDelegate() + splitView.delegate = splitDelegate + let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) + let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) + splitView.addSubview(first) + splitView.addSubview(second) + contentView.addSubview(splitView) + splitView.setPosition(1, ofDividerAt: 0) + splitView.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let host = WindowTerminalHostView(frame: contentView.bounds) + host.autoresizingMask = [.width, .height] + let hostedView = makeHostedTerminalView(frame: host.bounds) + host.addSubview(hostedView) + contentView.addSubview(host) + + let dividerPointInSplit = NSPoint( + x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), + y: splitView.bounds.midY + ) + let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + let resizeBandPoint = NSPoint( + x: dividerPointInHost.x + terminalSideOverlapWidth, + y: dividerPointInHost.y + ) + XCTAssertNil( + host.hitTest(resizeBandPoint), + "The narrow terminal-side overlap should still pass through to the sidebar resizer" + ) + + let textSelectionPoint = NSPoint( + x: dividerPointInHost.x + terminalSideOverlapWidth + 1, + y: dividerPointInHost.y + ) + assertHitFallsInsideHostedTerminal( + host.hitTest(textSelectionPoint), + hostedView: hostedView, + message: "Once the pointer moves past the reduced terminal-side overlap, terminal content should win hit-testing" + ) } } @@ -1690,6 +1783,115 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertTrue(state.isHidden) } + func testPreferredScrollerStyleChangeRecalculatesTerminalSurfaceWidth() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + guard let initialSurfaceSize = hostedView.debugPendingSurfaceSize() else { + XCTFail("Expected an initial terminal surface size") + return + } + + func assertPendingSurfaceWidth( + _ expectedWidth: CGFloat, + _ message: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let pendingSurfaceWidth = hostedView.debugPendingSurfaceSize()?.width else { + XCTFail("Expected a pending terminal surface size", file: file, line: line) + return + } + + XCTAssertEqual( + pendingSurfaceWidth, + expectedWidth, + accuracy: 0.5, + message, + file: file, + line: line + ) + } + + let initialContentWidth = scrollView.contentSize.width + XCTAssertEqual(initialSurfaceSize.width, initialContentWidth, accuracy: 0.5) + + scrollView.scrollerStyle = .legacy + scrollView.layoutSubtreeIfNeeded() + let legacyContentWidth = scrollView.contentSize.width + XCTAssertLessThan( + legacyContentWidth, + initialContentWidth, + "Legacy scrollbars should reserve width in the scroll view content area" + ) + assertPendingSurfaceWidth( + initialSurfaceSize.width, + "Changing the scroll view style alone should leave the terminal grid stale until the scroller-style observer runs" + ) + + NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertEqual(scrollView.scrollerStyle, .legacy) + assertPendingSurfaceWidth( + legacyContentWidth, + "Preferred scroller style changes should recalculate the terminal grid width immediately" + ) + + scrollView.scrollerStyle = .overlay + scrollView.layoutSubtreeIfNeeded() + let overlayContentWidth = scrollView.contentSize.width + XCTAssertGreaterThan( + overlayContentWidth, + legacyContentWidth, + "Overlay scrollbars should restore the full terminal content width" + ) + assertPendingSurfaceWidth( + legacyContentWidth, + "Changing the scroll view style alone should leave the terminal grid stale until the scroller-style observer runs" + ) + + NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertEqual(scrollView.scrollerStyle, .overlay) + assertPendingSurfaceWidth( + overlayContentWidth, + "Preferred scroller style changes should also restore the wider terminal grid when overlay scrollbars return" + ) + } + func testWindowResignKeyClearsFocusedTerminalFirstResponder() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),