* Keep split dividers visible in tiny panes * Fix collapsed split border resize hit-testing * Stabilize sidebar and split divider resize routing * Fix split divider resize cursor routing regressions * Fix stuck split resize cursor outside divider range
1111 lines
44 KiB
Swift
1111 lines
44 KiB
Swift
import AppKit
|
|
import ObjectiveC
|
|
#if DEBUG
|
|
import Bonsplit
|
|
#endif
|
|
|
|
private var cmuxWindowTerminalPortalKey: UInt8 = 0
|
|
private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0
|
|
|
|
#if DEBUG
|
|
private func portalDebugToken(_ view: NSView?) -> String {
|
|
guard let view else { return "nil" }
|
|
let ptr = Unmanaged.passUnretained(view).toOpaque()
|
|
return String(describing: ptr)
|
|
}
|
|
|
|
private func portalDebugFrame(_ rect: NSRect) -> String {
|
|
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
|
|
}
|
|
#endif
|
|
|
|
final class WindowTerminalHostView: NSView {
|
|
private struct DividerRegion {
|
|
let rectInWindow: NSRect
|
|
let isVertical: Bool
|
|
}
|
|
|
|
private enum DividerCursorKind: Equatable {
|
|
case vertical
|
|
case horizontal
|
|
|
|
var cursor: NSCursor {
|
|
switch self {
|
|
case .vertical: return .resizeLeftRight
|
|
case .horizontal: return .resizeUpDown
|
|
}
|
|
}
|
|
}
|
|
|
|
override var isOpaque: Bool { false }
|
|
private static let sidebarLeadingEdgeEpsilon: CGFloat = 1
|
|
private static let minimumVisibleLeadingContentWidth: CGFloat = 24
|
|
private var cachedSidebarDividerX: CGFloat?
|
|
private var sidebarDividerMissCount = 0
|
|
private var trackingArea: NSTrackingArea?
|
|
private var activeDividerCursorKind: DividerCursorKind?
|
|
#if DEBUG
|
|
private var lastDragRouteSignature: String?
|
|
#endif
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
if window == nil {
|
|
clearActiveDividerCursor(restoreArrow: false)
|
|
}
|
|
window?.invalidateCursorRects(for: self)
|
|
}
|
|
|
|
override func setFrameSize(_ newSize: NSSize) {
|
|
super.setFrameSize(newSize)
|
|
window?.invalidateCursorRects(for: self)
|
|
}
|
|
|
|
override func setFrameOrigin(_ newOrigin: NSPoint) {
|
|
super.setFrameOrigin(newOrigin)
|
|
window?.invalidateCursorRects(for: self)
|
|
}
|
|
|
|
override func resetCursorRects() {
|
|
super.resetCursorRects()
|
|
guard let window, let rootView = window.contentView else { return }
|
|
var regions: [DividerRegion] = []
|
|
Self.collectSplitDividerRegions(in: rootView, into: ®ions)
|
|
let expansion: CGFloat = 4
|
|
for region in regions {
|
|
var rectInHost = convert(region.rectInWindow, from: nil)
|
|
rectInHost = rectInHost.insetBy(
|
|
dx: region.isVertical ? -expansion : 0,
|
|
dy: region.isVertical ? 0 : -expansion
|
|
)
|
|
let clipped = rectInHost.intersection(bounds)
|
|
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue }
|
|
addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown)
|
|
}
|
|
}
|
|
|
|
override func updateTrackingAreas() {
|
|
if let trackingArea {
|
|
removeTrackingArea(trackingArea)
|
|
}
|
|
let options: NSTrackingArea.Options = [
|
|
.inVisibleRect,
|
|
.activeAlways,
|
|
.cursorUpdate,
|
|
.mouseMoved,
|
|
.mouseEnteredAndExited,
|
|
.enabledDuringMouseDrag,
|
|
]
|
|
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
|
|
addTrackingArea(next)
|
|
trackingArea = next
|
|
super.updateTrackingAreas()
|
|
}
|
|
|
|
override func cursorUpdate(with event: NSEvent) {
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
updateDividerCursor(at: point)
|
|
}
|
|
|
|
override func mouseMoved(with event: NSEvent) {
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
updateDividerCursor(at: point)
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
clearActiveDividerCursor(restoreArrow: true)
|
|
}
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
updateDividerCursor(at: point)
|
|
|
|
if shouldPassThroughToSidebarResizer(at: point) {
|
|
return nil
|
|
}
|
|
|
|
if shouldPassThroughToSplitDivider(at: point) {
|
|
return nil
|
|
}
|
|
|
|
let dragPasteboardTypes = NSPasteboard(name: .drag).types
|
|
let eventType = NSApp.currentEvent?.type
|
|
let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
|
|
pasteboardTypes: dragPasteboardTypes,
|
|
eventType: eventType
|
|
)
|
|
if shouldPassThrough {
|
|
#if DEBUG
|
|
logDragRouteDecision(
|
|
passThrough: true,
|
|
eventType: eventType,
|
|
pasteboardTypes: dragPasteboardTypes,
|
|
hitView: nil
|
|
)
|
|
#endif
|
|
return nil
|
|
}
|
|
|
|
let hitView = super.hitTest(point)
|
|
#if DEBUG
|
|
logDragRouteDecision(
|
|
passThrough: false,
|
|
eventType: eventType,
|
|
pasteboardTypes: dragPasteboardTypes,
|
|
hitView: hitView
|
|
)
|
|
#endif
|
|
return hitView === self ? nil : hitView
|
|
}
|
|
|
|
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
|
|
// The sidebar resizer handle is implemented in SwiftUI. When terminals
|
|
// are portal-hosted, this AppKit host can otherwise sit above the handle
|
|
// and steal hover/mouse events.
|
|
let visibleHostedViews = subviews.compactMap { $0 as? GhosttySurfaceScrollView }
|
|
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
|
|
|
|
// If content is flush to the leading edge, sidebar is effectively hidden.
|
|
// In that state, treating any internal split edge as a sidebar divider
|
|
// steals split-divider cursor/drag behavior.
|
|
let hasLeadingContent = visibleHostedViews.contains {
|
|
$0.frame.minX <= Self.sidebarLeadingEdgeEpsilon
|
|
&& $0.frame.maxX > Self.minimumVisibleLeadingContentWidth
|
|
}
|
|
if hasLeadingContent {
|
|
if cachedSidebarDividerX != nil {
|
|
sidebarDividerMissCount += 1
|
|
if sidebarDividerMissCount >= 2 {
|
|
cachedSidebarDividerX = nil
|
|
sidebarDividerMissCount = 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Ignore transient 0-origin hosts while layouts churn (e.g. workspace
|
|
// creation/switching). They can temporarily report minX=0 and would
|
|
// otherwise clear divider pass-through, causing hover flicker.
|
|
let dividerCandidates = visibleHostedViews
|
|
.map(\.frame.minX)
|
|
.filter { $0 > Self.sidebarLeadingEdgeEpsilon }
|
|
if let leftMostEdge = dividerCandidates.min() {
|
|
cachedSidebarDividerX = leftMostEdge
|
|
sidebarDividerMissCount = 0
|
|
} else if cachedSidebarDividerX != nil {
|
|
// Keep cache briefly for layout churn, but clear if we miss repeatedly
|
|
// so stale divider positions don't steal pointer routing.
|
|
sidebarDividerMissCount += 1
|
|
if sidebarDividerMissCount >= 4 {
|
|
cachedSidebarDividerX = nil
|
|
sidebarDividerMissCount = 0
|
|
}
|
|
}
|
|
|
|
guard let dividerX = cachedSidebarDividerX else {
|
|
return false
|
|
}
|
|
|
|
let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide
|
|
let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide
|
|
return point.x >= regionMinX && point.x <= regionMaxX
|
|
}
|
|
|
|
private func updateDividerCursor(at point: NSPoint) {
|
|
if shouldPassThroughToSidebarResizer(at: point) {
|
|
clearActiveDividerCursor(restoreArrow: false)
|
|
return
|
|
}
|
|
|
|
guard let nextKind = splitDividerCursorKind(at: point) else {
|
|
clearActiveDividerCursor(restoreArrow: true)
|
|
return
|
|
}
|
|
activeDividerCursorKind = nextKind
|
|
nextKind.cursor.set()
|
|
}
|
|
|
|
private func clearActiveDividerCursor(restoreArrow: Bool) {
|
|
guard activeDividerCursorKind != nil else { return }
|
|
window?.invalidateCursorRects(for: self)
|
|
activeDividerCursorKind = nil
|
|
if restoreArrow {
|
|
NSCursor.arrow.set()
|
|
}
|
|
}
|
|
|
|
private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? {
|
|
guard let window else { return nil }
|
|
let windowPoint = convert(point, to: nil)
|
|
guard let rootView = window.contentView else { return nil }
|
|
return Self.dividerCursorKind(at: windowPoint, in: rootView)
|
|
}
|
|
|
|
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
|
|
splitDividerCursorKind(at: point) != nil
|
|
}
|
|
|
|
private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? {
|
|
guard !view.isHidden else { return nil }
|
|
|
|
if let splitView = view as? NSSplitView {
|
|
let pointInSplit = splitView.convert(windowPoint, from: nil)
|
|
if splitView.bounds.contains(pointInSplit) {
|
|
// Keep divider interactions reliable even when portal-hosted terminal frames
|
|
// temporarily overlap divider edges during rapid layout churn.
|
|
let expansion: CGFloat = 5
|
|
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
|
|
for dividerIndex in 0..<dividerCount {
|
|
let first = splitView.arrangedSubviews[dividerIndex].frame
|
|
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
|
|
let thickness = splitView.dividerThickness
|
|
let dividerRect: NSRect
|
|
if splitView.isVertical {
|
|
// Keep divider hit-testing active even when one side is nearly collapsed,
|
|
// so users can drag the divider back out from the border.
|
|
// But ignore transient states where both panes are effectively 0-width.
|
|
guard first.width > 1 || second.width > 1 else { continue }
|
|
let x = max(0, first.maxX)
|
|
dividerRect = NSRect(
|
|
x: x,
|
|
y: 0,
|
|
width: thickness,
|
|
height: splitView.bounds.height
|
|
)
|
|
} else {
|
|
// Same behavior for horizontal splits with a near-zero-height pane.
|
|
guard first.height > 1 || second.height > 1 else { continue }
|
|
let y = max(0, first.maxY)
|
|
dividerRect = NSRect(
|
|
x: 0,
|
|
y: y,
|
|
width: splitView.bounds.width,
|
|
height: thickness
|
|
)
|
|
}
|
|
let expandedDividerRect = dividerRect.insetBy(dx: -expansion, dy: -expansion)
|
|
if expandedDividerRect.contains(pointInSplit) {
|
|
return splitView.isVertical ? .vertical : .horizontal
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for subview in view.subviews.reversed() {
|
|
if let kind = dividerCursorKind(at: windowPoint, in: subview) {
|
|
return kind
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) {
|
|
guard !view.isHidden else { return }
|
|
|
|
if let splitView = view as? NSSplitView {
|
|
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
|
|
for dividerIndex in 0..<dividerCount {
|
|
let first = splitView.arrangedSubviews[dividerIndex].frame
|
|
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
|
|
let thickness = splitView.dividerThickness
|
|
let dividerRect: NSRect
|
|
if splitView.isVertical {
|
|
guard first.width > 1 || second.width > 1 else { continue }
|
|
let x = max(0, first.maxX)
|
|
dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height)
|
|
} else {
|
|
guard first.height > 1 || second.height > 1 else { continue }
|
|
let y = max(0, first.maxY)
|
|
dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness)
|
|
}
|
|
let dividerRectInWindow = splitView.convert(dividerRect, to: nil)
|
|
guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue }
|
|
result.append(
|
|
DividerRegion(
|
|
rectInWindow: dividerRectInWindow,
|
|
isVertical: splitView.isVertical
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
for subview in view.subviews {
|
|
collectSplitDividerRegions(in: subview, into: &result)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
private func logDragRouteDecision(
|
|
passThrough: Bool,
|
|
eventType: NSEvent.EventType?,
|
|
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
|
hitView: NSView?
|
|
) {
|
|
let hasRelevantTypes = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|
|
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
|
|
guard passThrough || hasRelevantTypes else { return }
|
|
|
|
let targetClass = hitView.map { NSStringFromClass(type(of: $0)) } ?? "nil"
|
|
let signature = [
|
|
passThrough ? "1" : "0",
|
|
debugEventName(eventType),
|
|
debugPasteboardTypes(pasteboardTypes),
|
|
targetClass,
|
|
].joined(separator: "|")
|
|
guard lastDragRouteSignature != signature else { return }
|
|
lastDragRouteSignature = signature
|
|
|
|
dlog(
|
|
"portal.dragRoute passThrough=\(passThrough ? 1 : 0) " +
|
|
"event=\(debugEventName(eventType)) target=\(targetClass) " +
|
|
"types=\(debugPasteboardTypes(pasteboardTypes))"
|
|
)
|
|
}
|
|
|
|
private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String {
|
|
guard let types, !types.isEmpty else { return "-" }
|
|
return types.map(\.rawValue).joined(separator: ",")
|
|
}
|
|
|
|
private func debugEventName(_ eventType: NSEvent.EventType?) -> String {
|
|
guard let eventType else { return "none" }
|
|
switch eventType {
|
|
case .cursorUpdate: return "cursorUpdate"
|
|
case .appKitDefined: return "appKitDefined"
|
|
case .systemDefined: return "systemDefined"
|
|
case .applicationDefined: return "applicationDefined"
|
|
case .periodic: return "periodic"
|
|
case .mouseMoved: return "mouseMoved"
|
|
case .mouseEntered: return "mouseEntered"
|
|
case .mouseExited: return "mouseExited"
|
|
case .flagsChanged: return "flagsChanged"
|
|
case .leftMouseDragged: return "leftMouseDragged"
|
|
case .rightMouseDragged: return "rightMouseDragged"
|
|
case .otherMouseDragged: return "otherMouseDragged"
|
|
case .leftMouseDown: return "leftMouseDown"
|
|
case .leftMouseUp: return "leftMouseUp"
|
|
case .rightMouseDown: return "rightMouseDown"
|
|
case .rightMouseUp: return "rightMouseUp"
|
|
case .otherMouseDown: return "otherMouseDown"
|
|
case .otherMouseUp: return "otherMouseUp"
|
|
default: return "other(\(eventType.rawValue))"
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private final class SplitDividerOverlayView: NSView {
|
|
private struct DividerSegment {
|
|
let rect: NSRect
|
|
let color: NSColor
|
|
let isVertical: Bool
|
|
}
|
|
|
|
override var isOpaque: Bool { false }
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
super.draw(dirtyRect)
|
|
guard let window, let rootView = window.contentView else { return }
|
|
|
|
var dividerSegments: [DividerSegment] = []
|
|
collectDividerSegments(in: rootView, into: ÷rSegments)
|
|
guard !dividerSegments.isEmpty else { return }
|
|
let hostedFrames = hostedFramesLikelyToOccludeDividers()
|
|
let visibleSegments = dividerSegments.filter { shouldRenderOverlay(for: $0, hostedFrames: hostedFrames) }
|
|
guard !visibleSegments.isEmpty else { return }
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
defer { NSGraphicsContext.restoreGraphicsState() }
|
|
|
|
// Keep separators visible above portal-hosted surfaces while matching each split view's
|
|
// native divider color (avoids visible color shifts at tiny pane sizes).
|
|
for segment in visibleSegments where segment.rect.intersects(dirtyRect) {
|
|
segment.color.setFill()
|
|
let rect = segment.rect
|
|
let pixelAligned = NSRect(
|
|
x: floor(rect.origin.x),
|
|
y: floor(rect.origin.y),
|
|
width: max(1, round(rect.size.width)),
|
|
height: max(1, round(rect.size.height))
|
|
)
|
|
NSBezierPath(rect: pixelAligned).fill()
|
|
}
|
|
}
|
|
|
|
private func collectDividerSegments(in view: NSView, into result: inout [DividerSegment]) {
|
|
guard !view.isHidden else { return }
|
|
|
|
if let splitView = view as? NSSplitView {
|
|
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
|
|
let dividerColor = overlayDividerColor(for: splitView)
|
|
for dividerIndex in 0..<dividerCount {
|
|
let first = splitView.arrangedSubviews[dividerIndex].frame
|
|
let thickness = max(splitView.dividerThickness, 1)
|
|
let dividerRectInSplit: NSRect
|
|
if splitView.isVertical {
|
|
dividerRectInSplit = NSRect(
|
|
x: first.maxX,
|
|
y: 0,
|
|
width: thickness,
|
|
height: splitView.bounds.height
|
|
)
|
|
} else {
|
|
dividerRectInSplit = NSRect(
|
|
x: 0,
|
|
y: first.maxY,
|
|
width: splitView.bounds.width,
|
|
height: thickness
|
|
)
|
|
}
|
|
|
|
let dividerRectInWindow = splitView.convert(dividerRectInSplit, to: nil)
|
|
let dividerRectInOverlay = convert(dividerRectInWindow, from: nil)
|
|
if dividerRectInOverlay.intersects(bounds) {
|
|
result.append(
|
|
DividerSegment(
|
|
rect: dividerRectInOverlay,
|
|
color: dividerColor,
|
|
isVertical: splitView.isVertical
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for subview in view.subviews {
|
|
collectDividerSegments(in: subview, into: &result)
|
|
}
|
|
}
|
|
|
|
private func hostedFramesLikelyToOccludeDividers() -> [NSRect] {
|
|
guard let hostView = superview else { return [] }
|
|
return hostView.subviews.compactMap { subview -> NSRect? in
|
|
guard let hosted = subview as? GhosttySurfaceScrollView else { return nil }
|
|
guard !hosted.isHidden, hosted.window != nil else { return nil }
|
|
return hosted.frame
|
|
}
|
|
}
|
|
|
|
private func shouldRenderOverlay(for segment: DividerSegment, hostedFrames: [NSRect]) -> Bool {
|
|
// Draw only when a hosted surface actually intrudes across the divider centerline.
|
|
// This preserves tiny-pane visibility fixes without darkening regular dividers.
|
|
let axisEpsilon: CGFloat = 0.01
|
|
let axis = segment.isVertical ? segment.rect.midX : segment.rect.midY
|
|
let extentRect = segment.rect.insetBy(
|
|
dx: segment.isVertical ? 0 : -1,
|
|
dy: segment.isVertical ? -1 : 0
|
|
)
|
|
|
|
for frame in hostedFrames where frame.intersects(extentRect) {
|
|
if segment.isVertical {
|
|
if frame.minX < axis - axisEpsilon && frame.maxX > axis + axisEpsilon {
|
|
return true
|
|
}
|
|
} else if frame.minY < axis - axisEpsilon && frame.maxY > axis + axisEpsilon {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func overlayDividerColor(for splitView: NSSplitView) -> NSColor {
|
|
let divider = splitView.dividerColor.usingColorSpace(.deviceRGB) ?? splitView.dividerColor
|
|
let alpha = divider.alphaComponent
|
|
guard alpha < 0.999 else { return divider }
|
|
|
|
guard let bgColor = splitView.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)),
|
|
let bgRGB = bgColor.usingColorSpace(.deviceRGB) else {
|
|
return divider
|
|
}
|
|
|
|
let opaqueBG = bgRGB.withAlphaComponent(1)
|
|
let opaqueDivider = divider.withAlphaComponent(1)
|
|
return opaqueBG.blended(withFraction: alpha, of: opaqueDivider) ?? divider
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class WindowTerminalPortal: NSObject {
|
|
private weak var window: NSWindow?
|
|
private let hostView = WindowTerminalHostView(frame: .zero)
|
|
private let dividerOverlayView = SplitDividerOverlayView(frame: .zero)
|
|
private weak var installedContainerView: NSView?
|
|
private weak var installedReferenceView: NSView?
|
|
private var installConstraints: [NSLayoutConstraint] = []
|
|
private var hasDeferredFullSyncScheduled = false
|
|
|
|
private struct Entry {
|
|
weak var hostedView: GhosttySurfaceScrollView?
|
|
weak var anchorView: NSView?
|
|
var visibleInUI: Bool
|
|
var zPriority: Int
|
|
}
|
|
|
|
private var entriesByHostedId: [ObjectIdentifier: Entry] = [:]
|
|
private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
|
|
|
init(window: NSWindow) {
|
|
self.window = window
|
|
super.init()
|
|
hostView.wantsLayer = false
|
|
hostView.translatesAutoresizingMaskIntoConstraints = false
|
|
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
|
|
dividerOverlayView.autoresizingMask = [.width, .height]
|
|
_ = ensureInstalled()
|
|
}
|
|
|
|
private func ensureDividerOverlayOnTop() {
|
|
if dividerOverlayView.superview !== hostView {
|
|
dividerOverlayView.frame = hostView.bounds
|
|
hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil)
|
|
} else if hostView.subviews.last !== dividerOverlayView {
|
|
hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil)
|
|
}
|
|
|
|
if !Self.rectApproximatelyEqual(dividerOverlayView.frame, hostView.bounds) {
|
|
dividerOverlayView.frame = hostView.bounds
|
|
}
|
|
dividerOverlayView.needsDisplay = true
|
|
}
|
|
|
|
@discardableResult
|
|
private func ensureInstalled() -> Bool {
|
|
guard let window else { return false }
|
|
guard let (container, reference) = installationTarget(for: window) else { return false }
|
|
|
|
if hostView.superview !== container ||
|
|
installedContainerView !== container ||
|
|
installedReferenceView !== reference {
|
|
NSLayoutConstraint.deactivate(installConstraints)
|
|
installConstraints.removeAll()
|
|
|
|
hostView.removeFromSuperview()
|
|
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
|
|
|
installConstraints = [
|
|
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
|
|
hostView.trailingAnchor.constraint(equalTo: reference.trailingAnchor),
|
|
hostView.topAnchor.constraint(equalTo: reference.topAnchor),
|
|
hostView.bottomAnchor.constraint(equalTo: reference.bottomAnchor),
|
|
]
|
|
NSLayoutConstraint.activate(installConstraints)
|
|
installedContainerView = container
|
|
installedReferenceView = reference
|
|
} else if !Self.isView(hostView, above: reference, in: container) {
|
|
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
|
}
|
|
|
|
// Keep the drag/mouse forwarding overlay above portal-hosted terminal views.
|
|
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView,
|
|
overlay.superview === container,
|
|
!Self.isView(overlay, above: hostView, in: container) {
|
|
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
|
|
}
|
|
|
|
ensureDividerOverlayOnTop()
|
|
|
|
return true
|
|
}
|
|
|
|
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
|
|
guard let contentView = window.contentView else { return nil }
|
|
|
|
// If NSGlassEffectView wraps the original content view, install inside the glass view
|
|
// so terminals are above the glass background but below SwiftUI content.
|
|
if contentView.className == "NSGlassEffectView",
|
|
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
|
|
return (contentView, foreground)
|
|
}
|
|
|
|
guard let themeFrame = contentView.superview else { return nil }
|
|
return (themeFrame, contentView)
|
|
}
|
|
|
|
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
|
|
if view.isHidden { return true }
|
|
var current = view.superview
|
|
while let v = current {
|
|
if v.isHidden { return true }
|
|
current = v.superview
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
|
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
|
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
|
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
|
abs(lhs.size.height - rhs.size.height) <= epsilon
|
|
}
|
|
|
|
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
|
|
guard let viewIndex = container.subviews.firstIndex(of: view),
|
|
let referenceIndex = container.subviews.firstIndex(of: reference) else {
|
|
return false
|
|
}
|
|
return viewIndex > referenceIndex
|
|
}
|
|
|
|
func detachHostedView(withId hostedId: ObjectIdentifier) {
|
|
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
|
|
if let anchor = entry.anchorView {
|
|
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
|
|
}
|
|
#if DEBUG
|
|
let hadSuperview = (entry.hostedView?.superview === hostView) ? 1 : 0
|
|
dlog(
|
|
"portal.detach hosted=\(portalDebugToken(entry.hostedView)) " +
|
|
"anchor=\(portalDebugToken(entry.anchorView)) hadSuperview=\(hadSuperview)"
|
|
)
|
|
#endif
|
|
if let hostedView = entry.hostedView, hostedView.superview === hostView {
|
|
hostedView.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
/// Hide a portal entry without detaching it. Updates visibleInUI to false and
|
|
/// sets isHidden = true so subsequent synchronizeHostedView calls keep it hidden.
|
|
/// Used when a workspace is permanently unmounted (vs. transient bonsplit dismantles).
|
|
func hideEntry(forHostedId hostedId: ObjectIdentifier) {
|
|
guard var entry = entriesByHostedId[hostedId] else { return }
|
|
guard entry.visibleInUI else { return }
|
|
entry.visibleInUI = false
|
|
entriesByHostedId[hostedId] = entry
|
|
entry.hostedView?.isHidden = true
|
|
#if DEBUG
|
|
dlog("portal.hideEntry hosted=\(portalDebugToken(entry.hostedView)) reason=workspaceUnmount")
|
|
#endif
|
|
}
|
|
|
|
/// Update the visibleInUI flag on an existing entry without rebinding.
|
|
/// Used when a deferred bind is pending — this ensures synchronizeHostedView
|
|
/// won't hide a view that updateNSView has already marked as visible.
|
|
func updateEntryVisibility(forHostedId hostedId: ObjectIdentifier, visibleInUI: Bool) {
|
|
guard var entry = entriesByHostedId[hostedId] else { return }
|
|
entry.visibleInUI = visibleInUI
|
|
entriesByHostedId[hostedId] = entry
|
|
}
|
|
|
|
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
|
guard ensureInstalled() else { return }
|
|
|
|
let hostedId = ObjectIdentifier(hostedView)
|
|
let anchorId = ObjectIdentifier(anchorView)
|
|
let previousEntry = entriesByHostedId[hostedId]
|
|
|
|
if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId {
|
|
#if DEBUG
|
|
let previousToken = entriesByHostedId[previousHostedId]
|
|
.map { portalDebugToken($0.hostedView) }
|
|
?? String(describing: previousHostedId)
|
|
dlog(
|
|
"portal.bind.replace anchor=\(portalDebugToken(anchorView)) " +
|
|
"oldHosted=\(previousToken) newHosted=\(portalDebugToken(hostedView))"
|
|
)
|
|
#endif
|
|
detachHostedView(withId: previousHostedId)
|
|
}
|
|
|
|
if let oldEntry = entriesByHostedId[hostedId],
|
|
let oldAnchor = oldEntry.anchorView,
|
|
oldAnchor !== anchorView {
|
|
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
|
|
}
|
|
|
|
hostedByAnchorId[anchorId] = hostedId
|
|
entriesByHostedId[hostedId] = Entry(
|
|
hostedView: hostedView,
|
|
anchorView: anchorView,
|
|
visibleInUI: visibleInUI,
|
|
zPriority: zPriority
|
|
)
|
|
|
|
let didChangeAnchor: Bool = {
|
|
guard let previousAnchor = previousEntry?.anchorView else { return true }
|
|
return previousAnchor !== anchorView
|
|
}()
|
|
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
|
|
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
|
|
#if DEBUG
|
|
if previousEntry == nil || didChangeAnchor || becameVisible || priorityIncreased || hostedView.superview !== hostView {
|
|
dlog(
|
|
"portal.bind hosted=\(portalDebugToken(hostedView)) " +
|
|
"anchor=\(portalDebugToken(anchorView)) prevAnchor=\(portalDebugToken(previousEntry?.anchorView)) " +
|
|
"visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " +
|
|
"z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)"
|
|
)
|
|
}
|
|
#endif
|
|
|
|
if hostedView.superview !== hostView {
|
|
#if DEBUG
|
|
dlog(
|
|
"portal.reparent hosted=\(portalDebugToken(hostedView)) " +
|
|
"reason=attach super=\(portalDebugToken(hostedView.superview))"
|
|
)
|
|
#endif
|
|
hostView.addSubview(hostedView, positioned: .above, relativeTo: nil)
|
|
} else if (becameVisible || priorityIncreased), hostView.subviews.last !== hostedView {
|
|
// Refresh z-order only when a view becomes visible or gets a higher priority.
|
|
// Anchor-only churn is common during split tree updates; forcing remove/add there
|
|
// causes transient inWindow=0 -> 1 bounces that can flash black.
|
|
#if DEBUG
|
|
dlog(
|
|
"portal.reparent hosted=\(portalDebugToken(hostedView)) reason=raise " +
|
|
"didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " +
|
|
"priorityIncreased=\(priorityIncreased ? 1 : 0)"
|
|
)
|
|
#endif
|
|
hostView.addSubview(hostedView, positioned: .above, relativeTo: nil)
|
|
}
|
|
|
|
ensureDividerOverlayOnTop()
|
|
|
|
synchronizeHostedView(withId: hostedId)
|
|
pruneDeadEntries()
|
|
}
|
|
|
|
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
|
|
pruneDeadEntries()
|
|
let anchorId = ObjectIdentifier(anchorView)
|
|
let primaryHostedId = hostedByAnchorId[anchorId]
|
|
if let primaryHostedId {
|
|
synchronizeHostedView(withId: primaryHostedId)
|
|
}
|
|
|
|
// Failsafe: during aggressive divider drags/structural churn, one anchor can miss a
|
|
// geometry callback while another fires. Reconcile all mapped hosted views so no stale
|
|
// frame remains "stuck" onscreen until the next interaction.
|
|
synchronizeAllHostedViews(excluding: primaryHostedId)
|
|
scheduleDeferredFullSynchronizeAll()
|
|
}
|
|
|
|
private func scheduleDeferredFullSynchronizeAll() {
|
|
guard !hasDeferredFullSyncScheduled else { return }
|
|
hasDeferredFullSyncScheduled = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.hasDeferredFullSyncScheduled = false
|
|
self.synchronizeAllHostedViews(excluding: nil)
|
|
}
|
|
}
|
|
|
|
private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) {
|
|
guard ensureInstalled() else { return }
|
|
pruneDeadEntries()
|
|
let hostedIds = Array(entriesByHostedId.keys)
|
|
for hostedId in hostedIds {
|
|
if hostedId == hostedIdToSkip { continue }
|
|
synchronizeHostedView(withId: hostedId)
|
|
}
|
|
}
|
|
|
|
private func synchronizeHostedView(withId hostedId: ObjectIdentifier) {
|
|
guard ensureInstalled() else { return }
|
|
guard let entry = entriesByHostedId[hostedId] else { return }
|
|
guard let hostedView = entry.hostedView else {
|
|
entriesByHostedId.removeValue(forKey: hostedId)
|
|
return
|
|
}
|
|
guard let anchorView = entry.anchorView, let window else {
|
|
// Only hide if the entry is not marked visibleInUI. When a workspace is
|
|
// remounting, updateNSView sets visibleInUI=true before the deferred bind
|
|
// provides an anchor — hiding here would race with that and cause a flash.
|
|
if !entry.visibleInUI {
|
|
#if DEBUG
|
|
if !hostedView.isHidden {
|
|
dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow")
|
|
}
|
|
#endif
|
|
hostedView.isHidden = true
|
|
}
|
|
return
|
|
}
|
|
guard anchorView.window === window else {
|
|
#if DEBUG
|
|
if !hostedView.isHidden {
|
|
dlog(
|
|
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
|
|
"reason=anchorWindowMismatch anchorWindow=\(portalDebugToken(anchorView.window?.contentView))"
|
|
)
|
|
}
|
|
#endif
|
|
hostedView.isHidden = true
|
|
return
|
|
}
|
|
|
|
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
|
let frameInHost = hostView.convert(frameInWindow, from: nil)
|
|
let hasFiniteFrame =
|
|
frameInHost.origin.x.isFinite &&
|
|
frameInHost.origin.y.isFinite &&
|
|
frameInHost.size.width.isFinite &&
|
|
frameInHost.size.height.isFinite
|
|
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
|
|
let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1
|
|
let outsideHostBounds = !frameInHost.intersects(hostView.bounds)
|
|
let shouldHide =
|
|
!entry.visibleInUI ||
|
|
anchorHidden ||
|
|
tinyFrame ||
|
|
!hasFiniteFrame ||
|
|
outsideHostBounds
|
|
|
|
let oldFrame = hostedView.frame
|
|
#if DEBUG
|
|
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
|
|
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
|
|
if collapsedToTiny {
|
|
dlog(
|
|
"portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
|
|
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
|
|
)
|
|
} else if restoredFromTiny {
|
|
dlog(
|
|
"portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
|
|
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
|
|
)
|
|
}
|
|
#endif
|
|
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
hostedView.frame = frameInHost
|
|
CATransaction.commit()
|
|
|
|
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 ||
|
|
abs(oldFrame.size.height - frameInHost.size.height) > 0.5 {
|
|
hostedView.reconcileGeometryNow()
|
|
}
|
|
}
|
|
|
|
if hostedView.isHidden != shouldHide {
|
|
#if DEBUG
|
|
dlog(
|
|
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " +
|
|
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
|
|
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
|
|
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))"
|
|
)
|
|
#endif
|
|
hostedView.isHidden = shouldHide
|
|
}
|
|
|
|
ensureDividerOverlayOnTop()
|
|
}
|
|
|
|
private func pruneDeadEntries() {
|
|
let currentWindow = window
|
|
let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in
|
|
guard entry.hostedView != nil else { return hostedId }
|
|
guard let anchor = entry.anchorView else { return hostedId }
|
|
if anchor.window !== currentWindow || anchor.superview == nil {
|
|
return hostedId
|
|
}
|
|
if let reference = installedReferenceView,
|
|
!anchor.isDescendant(of: reference) {
|
|
return hostedId
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for hostedId in deadHostedIds {
|
|
detachHostedView(withId: hostedId)
|
|
}
|
|
|
|
let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in
|
|
entry.anchorView.map { ObjectIdentifier($0) }
|
|
})
|
|
hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) }
|
|
}
|
|
|
|
func hostedIds() -> Set<ObjectIdentifier> {
|
|
Set(entriesByHostedId.keys)
|
|
}
|
|
|
|
func tearDown() {
|
|
for hostedId in Array(entriesByHostedId.keys) {
|
|
detachHostedView(withId: hostedId)
|
|
}
|
|
NSLayoutConstraint.deactivate(installConstraints)
|
|
installConstraints.removeAll()
|
|
hostView.removeFromSuperview()
|
|
installedContainerView = nil
|
|
installedReferenceView = nil
|
|
}
|
|
|
|
#if DEBUG
|
|
func debugEntryCount() -> Int {
|
|
entriesByHostedId.count
|
|
}
|
|
|
|
func debugHostedSubviewCount() -> Int {
|
|
hostView.subviews.count
|
|
}
|
|
#endif
|
|
|
|
func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? {
|
|
guard ensureInstalled() else { return nil }
|
|
let point = hostView.convert(windowPoint, from: nil)
|
|
|
|
// Restrict hit-testing to currently mapped entries so stale detached views
|
|
// can't steal file-drop/mouse routing.
|
|
for subview in hostView.subviews.reversed() {
|
|
guard let hostedView = subview as? GhosttySurfaceScrollView else { continue }
|
|
let hostedId = ObjectIdentifier(hostedView)
|
|
guard entriesByHostedId[hostedId] != nil else { continue }
|
|
guard !hostedView.isHidden else { continue }
|
|
guard hostedView.frame.contains(point) else { continue }
|
|
let localPoint = hostedView.convert(point, from: hostView)
|
|
return hostedView.hitTest(localPoint) ?? hostedView
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
|
|
guard ensureInstalled() else { return nil }
|
|
let point = hostView.convert(windowPoint, from: nil)
|
|
|
|
for subview in hostView.subviews.reversed() {
|
|
guard let hostedView = subview as? GhosttySurfaceScrollView else { continue }
|
|
let hostedId = ObjectIdentifier(hostedView)
|
|
guard entriesByHostedId[hostedId] != nil else { continue }
|
|
guard !hostedView.isHidden else { continue }
|
|
guard hostedView.frame.contains(point) else { continue }
|
|
let localPoint = hostedView.convert(point, from: hostView)
|
|
if let terminal = hostedView.terminalViewForDrop(at: localPoint) {
|
|
return terminal
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
enum TerminalWindowPortalRegistry {
|
|
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
|
|
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
|
|
|
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
|
|
guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return }
|
|
let windowId = ObjectIdentifier(window)
|
|
let observer = NotificationCenter.default.addObserver(
|
|
forName: NSWindow.willCloseNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak window] _ in
|
|
MainActor.assumeIsolated {
|
|
if let window {
|
|
removePortal(for: window)
|
|
} else {
|
|
removePortal(windowId: windowId, window: nil)
|
|
}
|
|
}
|
|
}
|
|
objc_setAssociatedObject(
|
|
window,
|
|
&cmuxWindowTerminalPortalCloseObserverKey,
|
|
observer,
|
|
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
|
)
|
|
}
|
|
|
|
private static func removePortal(for window: NSWindow) {
|
|
removePortal(windowId: ObjectIdentifier(window), window: window)
|
|
}
|
|
|
|
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
|
|
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
|
|
portal.tearDown()
|
|
}
|
|
hostedToWindowId = hostedToWindowId.filter { $0.value != windowId }
|
|
|
|
guard let window else { return }
|
|
if let observer = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
|
|
}
|
|
|
|
private static func pruneHostedMappings(for windowId: ObjectIdentifier, validHostedIds: Set<ObjectIdentifier>) {
|
|
hostedToWindowId = hostedToWindowId.filter { hostedId, mappedWindowId in
|
|
mappedWindowId != windowId || validHostedIds.contains(hostedId)
|
|
}
|
|
}
|
|
|
|
private static func portal(for window: NSWindow) -> WindowTerminalPortal {
|
|
if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal {
|
|
portalsByWindowId[ObjectIdentifier(window)] = existing
|
|
installWindowCloseObserverIfNeeded(for: window)
|
|
return existing
|
|
}
|
|
|
|
let portal = WindowTerminalPortal(window: window)
|
|
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
|
|
portalsByWindowId[ObjectIdentifier(window)] = portal
|
|
installWindowCloseObserverIfNeeded(for: window)
|
|
return portal
|
|
}
|
|
|
|
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
|
guard let window = anchorView.window else { return }
|
|
|
|
let windowId = ObjectIdentifier(window)
|
|
let hostedId = ObjectIdentifier(hostedView)
|
|
let nextPortal = portal(for: window)
|
|
|
|
if let oldWindowId = hostedToWindowId[hostedId],
|
|
oldWindowId != windowId {
|
|
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
|
|
}
|
|
|
|
nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
|
|
hostedToWindowId[hostedId] = windowId
|
|
pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds())
|
|
}
|
|
|
|
static func synchronizeForAnchor(_ anchorView: NSView) {
|
|
guard let window = anchorView.window else { return }
|
|
let portal = portal(for: window)
|
|
portal.synchronizeHostedViewForAnchor(anchorView)
|
|
}
|
|
|
|
static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) {
|
|
let hostedId = ObjectIdentifier(hostedView)
|
|
guard let windowId = hostedToWindowId[hostedId],
|
|
let portal = portalsByWindowId[windowId] else { return }
|
|
portal.hideEntry(forHostedId: hostedId)
|
|
}
|
|
|
|
/// Update the visibleInUI flag on an existing portal entry without rebinding.
|
|
/// Called when a bind is deferred (host not yet in window) to prevent stale
|
|
/// portal syncs from hiding a view that is about to become visible.
|
|
static func updateEntryVisibility(for hostedView: GhosttySurfaceScrollView, visibleInUI: Bool) {
|
|
let hostedId = ObjectIdentifier(hostedView)
|
|
guard let windowId = hostedToWindowId[hostedId],
|
|
let portal = portalsByWindowId[windowId] else { return }
|
|
portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI)
|
|
}
|
|
|
|
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
|
|
let portal = portal(for: window)
|
|
return portal.viewAtWindowPoint(windowPoint)
|
|
}
|
|
|
|
static func terminalViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> GhosttyNSView? {
|
|
let portal = portal(for: window)
|
|
return portal.terminalViewAtWindowPoint(windowPoint)
|
|
}
|
|
|
|
#if DEBUG
|
|
static func debugPortalCount() -> Int {
|
|
portalsByWindowId.count
|
|
}
|
|
#endif
|
|
}
|