cmux/Sources/TerminalWindowPortal.swift

379 lines
14 KiB
Swift

import AppKit
import ObjectiveC
private var cmuxWindowTerminalPortalKey: UInt8 = 0
private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0
final class WindowTerminalHostView: NSView {
override var isOpaque: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
let hitView = super.hitTest(point)
return hitView === self ? nil : hitView
}
}
@MainActor
final class WindowTerminalPortal: NSObject {
private weak var window: NSWindow?
private let hostView = WindowTerminalHostView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var installConstraints: [NSLayoutConstraint] = []
private struct Entry {
weak var hostedView: GhosttySurfaceScrollView?
weak var anchorView: NSView?
var visibleInUI: Bool
}
private var entriesByHostedId: [ObjectIdentifier: Entry] = [:]
private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = false
hostView.translatesAutoresizingMaskIntoConstraints = false
_ = ensureInstalled()
}
@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 {
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 {
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
}
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
}
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 let hostedView = entry.hostedView, hostedView.superview === hostView {
hostedView.removeFromSuperview()
}
}
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) {
guard ensureInstalled() else { return }
let hostedId = ObjectIdentifier(hostedView)
let anchorId = ObjectIdentifier(anchorView)
if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId {
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
)
if hostedView.superview !== hostView {
hostedView.removeFromSuperview()
hostView.addSubview(hostedView)
}
synchronizeHostedView(withId: hostedId)
pruneDeadEntries()
}
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
pruneDeadEntries()
guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return }
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 {
hostedView.isHidden = true
return
}
guard anchorView.window === window else {
hostedView.isHidden = true
return
}
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
let shouldHide =
!entry.visibleInUI ||
Self.isHiddenOrAncestorHidden(anchorView) ||
frameInHost.width <= 1 ||
frameInHost.height <= 1
let oldFrame = hostedView.frame
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 {
hostedView.isHidden = shouldHide
}
}
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
}
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 let hitView = viewAtWindowPoint(windowPoint) else { return nil }
var current: NSView? = hitView
while let view = current {
if let terminal = view as? GhosttyNSView { return terminal }
current = view.superview
}
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) {
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)
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 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
}