Move terminal drop overlay outside hosted view

This commit is contained in:
austinpower1258 2026-03-11 18:30:14 -07:00
parent c7bbb72e09
commit 379150a6c1

View file

@ -3670,9 +3670,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
private func activeSurfaceResizeDeferralReason() -> String? {
if terminalSurface?.hostedView.hasActiveDropZoneOverlay == true {
return "dropOverlay"
}
return Self.shouldDeferSurfaceResizeForActiveDrag() ? "tabDrag" : nil
}
@ -5623,8 +5620,8 @@ final class GhosttySurfaceScrollView: NSView {
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
private var lastSearchOverlayStateID: ObjectIdentifier?
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
private var lastSentRow: Int?
private var isActive = true
private var lastFocusRefreshAt: CFTimeInterval = 0
@ -5649,13 +5646,14 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
private var lastDropZoneOverlayLogSignature: String?
private var lastDragGeometryLogSignature: String?
private var dragLayoutLogSequence: UInt64 = 0
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
private static var flashCounts: [UUID: Int] = [:]
private static var drawCounts: [UUID: Int] = [:]
private static var lastDrawTimes: [UUID: CFTimeInterval] = [:]
private static var presentCounts: [UUID: Int] = [:]
private static var flashCounts: [UUID: Int] = [:]
private static var drawCounts: [UUID: Int] = [:]
private static var lastDrawTimes: [UUID: CFTimeInterval] = [:]
private static var presentCounts: [UUID: Int] = [:]
private static var dropOverlayShowCounts: [UUID: Int] = [:]
private static var lastPresentTimes: [UUID: CFTimeInterval] = [:]
private static var lastContentsKeys: [UUID: String] = [:]
@ -5836,7 +5834,6 @@ final class GhosttySurfaceScrollView: NSView {
dropZoneOverlayView.layer?.borderWidth = 2
dropZoneOverlayView.layer?.cornerRadius = 8
dropZoneOverlayView.isHidden = true
addSubview(dropZoneOverlayView)
notificationRingOverlayView.wantsLayer = true
notificationRingOverlayView.layer?.backgroundColor = NSColor.clear.cgColor
notificationRingOverlayView.layer?.masksToBounds = false
@ -6007,6 +6004,7 @@ final class GhosttySurfaceScrollView: NSView {
#endif
observers.forEach { NotificationCenter.default.removeObserver($0) }
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
dropZoneOverlayView.removeFromSuperview()
cancelFocusRequest()
}
@ -6020,6 +6018,15 @@ final class GhosttySurfaceScrollView: NSView {
synchronizeGeometryAndContent()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
guard activeDropZone != nil || pendingDropZone != nil else { return }
attachDropZoneOverlayIfNeeded()
if let zone = activeDropZone ?? pendingDropZone {
applyDropZoneOverlayFrame(dropZoneOverlayFrame(for: zone, in: bounds.size))
}
}
/// Reconcile AppKit geometry with ghostty surface geometry synchronously.
/// Used after split topology mutations (close/split) to prevent a stale one-frame
/// IOSurface size from being presented after pane expansion.
@ -6063,6 +6070,7 @@ final class GhosttySurfaceScrollView: NSView {
_ = setFrameIfNeeded(documentView, to: targetDocumentFrame)
_ = setFrameIfNeeded(inactiveOverlayView, to: bounds)
if let zone = activeDropZone {
attachDropZoneOverlayIfNeeded()
_ = setFrameIfNeeded(
dropZoneOverlayView,
to: dropZoneOverlayFrame(for: zone, in: bounds.size)
@ -6105,6 +6113,42 @@ final class GhosttySurfaceScrollView: NSView {
abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon
}
private func dropZoneOverlayContainerView() -> NSView {
superview ?? self
}
private func attachDropZoneOverlayIfNeeded() {
// Keep the hover indicator outside the hosted terminal subtree so it stays purely additive
// and cannot invalidate the scroll/surface layout that Ghostty renders into.
let container = dropZoneOverlayContainerView()
if dropZoneOverlayView.superview !== container {
dropZoneOverlayView.removeFromSuperview()
if container === self {
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
} else {
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self)
}
#if DEBUG
logDropZoneOverlay(event: "attach", zone: activeDropZone ?? pendingDropZone, frame: dropZoneOverlayView.frame)
#endif
return
}
guard container !== self else { return }
guard let hostedIndex = container.subviews.firstIndex(of: self),
let overlayIndex = container.subviews.firstIndex(of: dropZoneOverlayView),
overlayIndex <= hostedIndex else { return }
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self)
}
private func applyDropZoneOverlayFrame(_ frame: CGRect) {
if Self.rectApproximatelyEqual(dropZoneOverlayView.frame, frame) { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
dropZoneOverlayView.frame = frame
CATransaction.commit()
}
#if DEBUG
private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool {
switch eventType {
@ -6115,6 +6159,36 @@ final class GhosttySurfaceScrollView: NSView {
}
}
private func hasActiveDragLoggingContext() -> Bool {
let pasteboardTypes = NSPasteboard(name: .drag).types
let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true
let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true
let eventType = NSApp.currentEvent?.type
return activeDropZone != nil ||
pendingDropZone != nil ||
((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType))
}
private func logDragGeometryChange(event: String, old: CGPoint, new: CGPoint) {
guard hasActiveDragLoggingContext() else { return }
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil"
let signature =
"\(event)|\(surface)|\(String(format: "%.1f,%.1f", old.x, old.y))|" +
"\(String(format: "%.1f,%.1f", new.x, new.y))|\(overlaySuperviewClass)|\(dropZoneOverlayView.isHidden ? 1 : 0)"
guard lastDragGeometryLogSignature != signature else { return }
lastDragGeometryLogSignature = signature
dlog(
"terminal.dragGeometry event=\(event) surface=\(surface) " +
"old=\(String(format: "%.1f,%.1f", old.x, old.y)) " +
"new=\(String(format: "%.1f,%.1f", new.x, new.y)) " +
"overlaySuper=\(overlaySuperviewClass) " +
"overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " +
"overlayHidden=\(dropZoneOverlayView.isHidden ? 1 : 0)"
)
}
private func logLayoutDuringActiveDrag(targetSize: CGSize) {
let pasteboardTypes = NSPasteboard(name: .drag).types
let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true
@ -6131,11 +6205,15 @@ final class GhosttySurfaceScrollView: NSView {
let activeZone = activeDropZone.map { String(describing: $0) } ?? "none"
let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none"
let event = eventType.map { String(describing: $0) } ?? "nil"
let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " +
"activeZone=\(activeZone) pendingZone=\(pendingZone) " +
"hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " +
"event=\(event) inWindow=\(window != nil ? 1 : 0) " +
"overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " +
"scrollOrigin=\(String(format: "%.1f,%.1f", scrollView.contentView.bounds.origin.x, scrollView.contentView.bounds.origin.y)) " +
"surfaceOrigin=\(String(format: "%.1f,%.1f", surfaceView.frame.origin.x, surfaceView.frame.origin.y)) " +
"bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))"
)
@ -6378,18 +6456,23 @@ final class GhosttySurfaceScrollView: NSView {
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
let localFrame: CGRect
switch zone {
case .center:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
localFrame = 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)
localFrame = 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)
localFrame = 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)
localFrame = 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)
localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
}
let container = dropZoneOverlayView.superview ?? superview
guard let container, container !== self else { return localFrame }
return container.convert(localFrame, from: self)
}
private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool {
@ -6419,15 +6502,15 @@ final class GhosttySurfaceScrollView: NSView {
activeDropZone = zone
pendingDropZone = nil
let previousFrame = dropZoneOverlayView.frame
if let zone {
#if DEBUG
if window == nil {
logDropZoneOverlay(event: "showNoWindow", zone: zone, frame: nil)
}
#endif
attachDropZoneOverlayIfNeeded()
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
let previousFrame = dropZoneOverlayView.frame
let isSameFrame = Self.rectApproximatelyEqual(previousFrame, targetFrame)
let needsFrameUpdate = !isSameFrame
let zoneChanged = previousZone != zone
@ -6440,7 +6523,7 @@ final class GhosttySurfaceScrollView: NSView {
dropZoneOverlayView.layer?.removeAllAnimations()
if dropZoneOverlayView.isHidden {
dropZoneOverlayView.frame = targetFrame
applyDropZoneOverlayFrame(targetFrame)
dropZoneOverlayView.alphaValue = 0
dropZoneOverlayView.isHidden = false
#if DEBUG
@ -6510,6 +6593,17 @@ final class GhosttySurfaceScrollView: NSView {
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let zoneText = zone.map { String(describing: $0) } ?? "none"
let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height)
let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil"
let scrollOriginText = String(
format: "%.1f,%.1f",
scrollView.contentView.bounds.origin.x,
scrollView.contentView.bounds.origin.y
)
let surfaceOriginText = String(
format: "%.1f,%.1f",
surfaceView.frame.origin.x,
surfaceView.frame.origin.y
)
let frameText: String
if let frame {
frameText = String(
@ -6519,12 +6613,16 @@ final class GhosttySurfaceScrollView: NSView {
} else {
frameText = "-"
}
let signature = "\(event)|\(surface)|\(zoneText)|\(boundsText)|\(frameText)|\(dropZoneOverlayView.isHidden ? 1 : 0)"
let signature =
"\(event)|\(surface)|\(zoneText)|\(boundsText)|\(frameText)|\(overlaySuperviewClass)|" +
"\(scrollOriginText)|\(surfaceOriginText)|\(dropZoneOverlayView.isHidden ? 1 : 0)"
guard lastDropZoneOverlayLogSignature != signature else { return }
lastDropZoneOverlayLogSignature = signature
dlog(
"terminal.dropOverlay event=\(event) surface=\(surface) zone=\(zoneText) " +
"hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText)"
"hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText) " +
"overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " +
"scrollOrigin=\(scrollOriginText) surfaceOrigin=\(surfaceOriginText)"
)
}
#endif
@ -7523,6 +7621,9 @@ final class GhosttySurfaceScrollView: NSView {
private func synchronizeSurfaceView() {
let visibleRect = scrollView.contentView.documentVisibleRect
guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return }
#if DEBUG
logDragGeometryChange(event: "surfaceOrigin", old: surfaceView.frame.origin, new: visibleRect.origin)
#endif
surfaceView.frame.origin = visibleRect.origin
}
@ -7617,6 +7718,13 @@ final class GhosttySurfaceScrollView: NSView {
CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight
let targetOrigin = CGPoint(x: 0, y: offsetY)
if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) {
#if DEBUG
logDragGeometryChange(
event: "scrollOrigin",
old: scrollView.contentView.bounds.origin,
new: targetOrigin
)
#endif
scrollView.contentView.scroll(to: targetOrigin)
didChangeGeometry = true
}