Fix splitter hitbox overlap and terminal scrollbar width resync (#1950)

* test: add splitter and scrollbar regressions

* fix: narrow sidebar overlap and resync terminal width

* test: unwrap pending surface width in scrollbar regression

* fix: restore hosted inspector divider drag path
This commit is contained in:
Austin Wang 2026-03-22 18:06:11 -07:00 committed by GitHub
parent 5ced3134d3
commit fd279bdcec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 283 additions and 39 deletions

View file

@ -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
}

View file

@ -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
}
}
@ -2025,8 +2027,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 {
@ -2102,8 +2108,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
}
@ -2283,7 +2289,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
@ -2292,7 +2298,7 @@ struct ContentView: View {
sidebarResizerHandleOverlay(
.divider,
width: sidebarResizerHitWidthPerSide * 2,
width: SidebarResizeInteraction.totalHitWidth,
availableWidth: totalWidth,
accessibilityIdentifier: "SidebarResizer"
)

View file

@ -4468,6 +4468,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.
@ -7032,6 +7038,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) {
@ -8061,6 +8078,10 @@ final class GhosttySurfaceScrollView: NSView {
surfaceView.debugSimulateFileDrop(paths: paths)
}
func debugPendingSurfaceSize() -> CGSize? {
surfaceView.debugPendingSurfaceSize()
}
func debugRegisteredDropTypes() -> [String] {
surfaceView.debugRegisteredDropTypes()
}
@ -9072,6 +9093,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

View file

@ -5037,10 +5037,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()
@ -5052,7 +5054,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")
@ -5130,26 +5134,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
@ -5160,8 +5162,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
}
@ -5258,7 +5259,7 @@ struct WebViewRepresentable: NSViewRepresentable {
)
)
#endif
layoutHostedInspectorSideDockIfNeeded(reason: "drag.end")
reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "drag.end")
}
super.mouseUp(with: event)
}
@ -5273,7 +5274,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 {
@ -5495,9 +5496,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)
}
}
@ -5558,7 +5559,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
}

View file

@ -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
}

View file

@ -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),

View file

@ -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),