diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index ddd7e23a93b5..cf1e14eee175 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -226,7 +226,7 @@ final class WindowBrowserHostView: NSView { private var cachedSidebarDividerX: CGFloat? private var sidebarDividerMissCount = 0 private var cachedSplitDividerRegions: [DividerRegion]? - private var cachedSplitDividerRootSubviewIds: [ObjectIdentifier]? + private weak var cachedSplitDividerRootView: NSView? private let splitDividerCacheInvalidator = PortalSplitDividerCacheInvalidator() private var splitDividerResizeObserver: NSObjectProtocol? private var trackingArea: NSTrackingArea? @@ -1034,15 +1034,23 @@ final class WindowBrowserHostView: NSView { return (pageFrame, inspectorFrame) } private func splitDividerRegions() -> [DividerRegion] { - guard let rootView = dividerSearchRootView() else { cachedSplitDividerRegions = []; cachedSplitDividerRootSubviewIds = nil; return [] } - let rootSubviewIds = rootView.subviews.map { ObjectIdentifier($0) } - if let regions = cachedSplitDividerRegions, cachedSplitDividerRootSubviewIds == rootSubviewIds, PortalSplitDividerRegion.allLive(regions) { return regions } + guard let rootView = dividerSearchRootView() else { + invalidateSplitDividerRegionCache() + return [] + } + if let regions = cachedSplitDividerRegions, + cachedSplitDividerRootView === rootView, + splitDividerCacheInvalidator.isHierarchyCurrent(for: rootView), + PortalSplitDividerRegion.allLive(regions) { + return regions + } let collected = PortalSplitDividerRegion.collect(in: rootView, hostView: self) cachedSplitDividerRegions = collected.regions - cachedSplitDividerRootSubviewIds = rootSubviewIds + cachedSplitDividerRootView = rootView splitDividerCacheInvalidator.observe( + rootView: rootView, geometryViews: collected.geometryObservedViews, - structureViews: collected.structureObservedViews + hierarchyNodes: collected.hierarchyNodes ) { [weak self] in guard let self else { return } self.invalidateSplitDividerRegionCache() @@ -1053,7 +1061,7 @@ final class WindowBrowserHostView: NSView { private func invalidateSplitDividerRegionCache() { cachedSplitDividerRegions = nil - cachedSplitDividerRootSubviewIds = nil + cachedSplitDividerRootView = nil splitDividerCacheInvalidator.invalidate() } @@ -1753,6 +1761,18 @@ final class WindowBrowserSlotView: NSView { overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } + var previousPriority = Int.min + var needsReordering = false + for subview in subviews { + let priority = interactionLayerPriority(of: subview) + if priority < previousPriority { + needsReordering = true + break + } + previousPriority = priority + } + guard needsReordering else { return } + let context = Unmanaged.passUnretained(self).toOpaque() sortSubviews({ lhs, rhs, context in guard let context else { return .orderedSame } diff --git a/Sources/PortalSplitDividerCacheInvalidator.swift b/Sources/PortalSplitDividerCacheInvalidator.swift index 2201c35fad41..a9bf69a600f2 100644 --- a/Sources/PortalSplitDividerCacheInvalidator.swift +++ b/Sources/PortalSplitDividerCacheInvalidator.swift @@ -1,25 +1,260 @@ import AppKit +import ObjectiveC + +private typealias PortalSubviewComparator = @convention(c) ( + NSView, + NSView, + UnsafeMutableRawPointer? +) -> ComparisonResult + +private extension NSView { + @objc(cmux_portalAddSubview:) + func cmux_portalAddSubview(_ subview: NSView) { + let previousWindow = subview.window + cmux_portalAddSubview(subview) + PortalViewHierarchyMutationTracker.recordInsertion( + parentView: self, + insertedView: subview, + previousWindow: previousWindow + ) + } + + @objc(cmux_portalAddSubview:positioned:relativeTo:) + func cmux_portalAddSubview( + _ subview: NSView, + positioned place: NSWindow.OrderingMode, + relativeTo otherView: NSView? + ) { + let previousWindow = subview.window + cmux_portalAddSubview(subview, positioned: place, relativeTo: otherView) + PortalViewHierarchyMutationTracker.recordInsertion( + parentView: self, + insertedView: subview, + previousWindow: previousWindow + ) + } + + @objc(cmux_portalSetSubviews:) + func cmux_portalSetSubviews(_ newSubviews: [NSView]) { + let replacementState = PortalViewHierarchyMutationTracker.subviewsBeforeReplacement( + parentView: self, + newSubviews: newSubviews + ) + cmux_portalSetSubviews(newSubviews) + guard let replacementState else { return } + PortalViewHierarchyMutationTracker.recordSubviewsReplacement( + parentView: self, + newSubviews: newSubviews, + replacementState: replacementState + ) + } + + @objc func cmux_portalRemoveFromSuperview() { + let oldSuperview = superview + let previousWindow = window + cmux_portalRemoveFromSuperview() + if let oldSuperview { + PortalViewHierarchyMutationTracker.recordRemoval( + parentView: oldSuperview, + removedView: self, + previousWindow: previousWindow + ) + } + } + + @objc func cmux_portalRemoveFromSuperviewWithoutNeedingDisplay() { + let oldSuperview = superview + let previousWindow = window + cmux_portalRemoveFromSuperviewWithoutNeedingDisplay() + if let oldSuperview { + PortalViewHierarchyMutationTracker.recordRemoval( + parentView: oldSuperview, + removedView: self, + previousWindow: previousWindow + ) + } + } + + @objc func cmux_portalReplaceSubview(_ oldView: NSView, with newView: NSView) { + let parentWindow = window + let newViewPreviousWindow = newView.window + cmux_portalReplaceSubview(oldView, with: newView) + PortalViewHierarchyMutationTracker.recordReplacement( + parentView: self, + oldView: oldView, + newView: newView, + newViewPreviousWindow: newViewPreviousWindow, + parentWindow: parentWindow + ) + } + + @objc func cmux_portalSortSubviews( + _ compare: PortalSubviewComparator, + context: UnsafeMutableRawPointer? + ) { + let parentWindow = window + let sortState = PortalViewHierarchyMutationTracker.subviewOrderBeforeSort( + parentView: self, + parentWindow: parentWindow + ) + cmux_portalSortSubviews(compare, context: context) + guard let sortState else { return } + PortalViewHierarchyMutationTracker.recordSortIfNeeded( + parentView: self, + sortState: sortState + ) + } +} + +private extension NSSplitView { + @objc(cmux_portalAddArrangedSubview:) + func cmux_portalAddArrangedSubview(_ view: NSView) { + let mutationState = PortalViewHierarchyMutationTracker.arrangedSubviewsBeforeMutation( + splitView: self + ) + cmux_portalAddArrangedSubview(view) + guard let mutationState else { return } + PortalViewHierarchyMutationTracker.recordArrangedSubviewsMutation( + splitView: self, + mutationState: mutationState + ) + } + + @objc(cmux_portalInsertArrangedSubview:atIndex:) + func cmux_portalInsertArrangedSubview(_ view: NSView, at index: Int) { + let mutationState = PortalViewHierarchyMutationTracker.arrangedSubviewsBeforeMutation( + splitView: self + ) + cmux_portalInsertArrangedSubview(view, at: index) + guard let mutationState else { return } + PortalViewHierarchyMutationTracker.recordArrangedSubviewsMutation( + splitView: self, + mutationState: mutationState + ) + } + + @objc(cmux_portalRemoveArrangedSubview:) + func cmux_portalRemoveArrangedSubview(_ view: NSView) { + let mutationState = PortalViewHierarchyMutationTracker.arrangedSubviewsBeforeMutation( + splitView: self + ) + cmux_portalRemoveArrangedSubview(view) + guard let mutationState else { return } + PortalViewHierarchyMutationTracker.recordArrangedSubviewsMutation( + splitView: self, + mutationState: mutationState + ) + } + + @objc(cmux_portalSetArrangesAllSubviews:) + func cmux_portalSetArrangesAllSubviews(_ arrangesAllSubviews: Bool) { + let mutationState = PortalViewHierarchyMutationTracker.arrangedSubviewsBeforeMutation( + splitView: self + ) + cmux_portalSetArrangesAllSubviews(arrangesAllSubviews) + guard let mutationState else { return } + PortalViewHierarchyMutationTracker.recordArrangedSubviewsMutation( + splitView: self, + mutationState: mutationState + ) + } +} @MainActor final class PortalSplitDividerCacheInvalidator { + /// AppKit does not document `subviews` as KVO-compliant, and an `NSSplitView` + /// can change its arranged panes without changing `subviews`. Hook both + /// authoritative mutation surfaces once so the window tracker can advance + /// its generation without a pointer-time tree walk. + private static let hierarchyMutationHooksInstalled: Bool = { + let selectorPairs: [(ownerClass: AnyClass, original: Selector, replacement: Selector)] = [ + (NSView.self, #selector(NSView.addSubview(_:)), #selector(NSView.cmux_portalAddSubview(_:))), + ( + NSView.self, + #selector(NSView.addSubview(_:positioned:relativeTo:)), + #selector(NSView.cmux_portalAddSubview(_:positioned:relativeTo:)) + ), + (NSView.self, #selector(setter: NSView.subviews), #selector(NSView.cmux_portalSetSubviews(_:))), + (NSView.self, #selector(NSView.removeFromSuperview), #selector(NSView.cmux_portalRemoveFromSuperview)), + ( + NSView.self, + #selector(NSView.removeFromSuperviewWithoutNeedingDisplay), + #selector(NSView.cmux_portalRemoveFromSuperviewWithoutNeedingDisplay) + ), + ( + NSView.self, + #selector(NSView.replaceSubview(_:with:)), + #selector(NSView.cmux_portalReplaceSubview(_:with:)) + ), + ( + NSView.self, + #selector(NSView.sortSubviews(_:context:)), + #selector(NSView.cmux_portalSortSubviews(_:context:)) + ), + ( + NSSplitView.self, + #selector(NSSplitView.addArrangedSubview(_:)), + #selector(NSSplitView.cmux_portalAddArrangedSubview(_:)) + ), + ( + NSSplitView.self, + #selector(NSSplitView.insertArrangedSubview(_:at:)), + #selector(NSSplitView.cmux_portalInsertArrangedSubview(_:at:)) + ), + ( + NSSplitView.self, + #selector(NSSplitView.removeArrangedSubview(_:)), + #selector(NSSplitView.cmux_portalRemoveArrangedSubview(_:)) + ), + ( + NSSplitView.self, + #selector(setter: NSSplitView.arrangesAllSubviews), + #selector(NSSplitView.cmux_portalSetArrangesAllSubviews(_:)) + ), + ] + + var methodPairs: [(original: Method, replacement: Method)] = [] + for selectors in selectorPairs { + guard let original = class_getInstanceMethod(selectors.ownerClass, selectors.original), + let replacement = class_getInstanceMethod(selectors.ownerClass, selectors.replacement) else { + assertionFailure("Unable to install portal view-hierarchy mutation hook for \(selectors.original)") + return false + } + methodPairs.append((original, replacement)) + } + for methods in methodPairs { + method_exchangeImplementations(methods.original, methods.replacement) + } + return true + }() + // Observer tokens are assigned/cleared from main-thread AppKit paths. Swift // deinit is nonisolated, so the teardown helper needs nonisolated access // after all main-thread use has ceased. private nonisolated(unsafe) var observations: [NSKeyValueObservation] = [] private nonisolated(unsafe) var notificationObservers: [NSObjectProtocol] = [] + private var hierarchyRegistration: PortalViewHierarchyMutationRegistration? + + init() { + _ = Self.hierarchyMutationHooksInstalled + } deinit { invalidateObservations() } func observe( + rootView: NSView, geometryViews: [NSView], - structureViews: [NSView], + hierarchyNodes: [(view: NSView, containsSplitView: Bool)], onChange: @escaping @MainActor () -> Void ) { invalidate() let geometryViews = Self.uniqueViews(geometryViews) - let subviewObservedViews = Self.uniqueViews(geometryViews + structureViews) + hierarchyRegistration = PortalViewHierarchyMutationTracker.register( + rootView: rootView, + hierarchyNodes: hierarchyNodes + ) for view in geometryViews { // These NSView flags are shared; do not restore them per observer or @@ -42,14 +277,6 @@ final class PortalSplitDividerCacheInvalidator { MainActor.assumeIsolated { onChange() } } } - // Nested splits can be inserted under known layout containers after cache - // warm-up. Keep this bounded to root/direct/split-related containers, not - // arbitrary descendants such as WebKit or terminal internals. - observations.append(contentsOf: subviewObservedViews.map { view in - view.observe(\.subviews, options: [.new]) { _, _ in - MainActor.assumeIsolated { onChange() } - } - }) } private static func uniqueViews(_ views: [NSView]) -> [NSView] { @@ -62,9 +289,15 @@ final class PortalSplitDividerCacheInvalidator { } func invalidate() { + hierarchyRegistration = nil invalidateObservations() } + func isHierarchyCurrent(for rootView: NSView) -> Bool { + guard Self.hierarchyMutationHooksInstalled else { return false } + return hierarchyRegistration?.isCurrent(for: rootView) == true + } + private nonisolated func invalidateObservations() { observations.removeAll() notificationObservers.forEach(NotificationCenter.default.removeObserver) diff --git a/Sources/PortalSplitDividerRegion.swift b/Sources/PortalSplitDividerRegion.swift index a63ba8d908fc..6f62003bbd70 100644 --- a/Sources/PortalSplitDividerRegion.swift +++ b/Sources/PortalSplitDividerRegion.swift @@ -139,18 +139,19 @@ final class PortalSplitDividerRegion { static func collect( in rootView: NSView, hostView: NSView? = nil - ) -> (regions: [PortalSplitDividerRegion], geometryObservedViews: [NSView], structureObservedViews: [NSView]) { + ) -> ( + regions: [PortalSplitDividerRegion], + geometryObservedViews: [NSView], + hierarchyNodes: [(view: NSView, containsSplitView: Bool)] + ) { var regions: [PortalSplitDividerRegion] = [] var geometryObservedViews: [NSView] = [] var geometryObservedIds = Set() - var structureObservedViews: [NSView] = [] - var structureObservedIds = Set() + var hierarchyNodes: [(view: NSView, containsSplitView: Bool)] = [] var ancestorStack: [NSView] = [] appendObserved(rootView, to: &geometryObservedViews, ids: &geometryObservedIds) - appendObserved(rootView, to: &structureObservedViews, ids: &structureObservedIds) for subview in rootView.subviews { appendObserved(subview, to: &geometryObservedViews, ids: &geometryObservedIds) - appendObserved(subview, to: &structureObservedViews, ids: &structureObservedIds) } collect( in: rootView, @@ -160,12 +161,12 @@ final class PortalSplitDividerRegion { into: ®ions, geometryObservedViews: &geometryObservedViews, geometryObservedIds: &geometryObservedIds, - structureObservedViews: &structureObservedViews, - structureObservedIds: &structureObservedIds + hierarchyNodes: &hierarchyNodes ) - return (regions, geometryObservedViews, structureObservedViews) + return (regions, geometryObservedViews, hierarchyNodes) } + @discardableResult private static func collect( in view: NSView, hostView: NSView?, @@ -174,21 +175,16 @@ final class PortalSplitDividerRegion { into result: inout [PortalSplitDividerRegion], geometryObservedViews: inout [NSView], geometryObservedIds: inout Set, - structureObservedViews: inout [NSView], - structureObservedIds: inout Set - ) { + hierarchyNodes: inout [(view: NSView, containsSplitView: Bool)] + ) -> Bool { let isHidden = ancestorHidden || view.isHidden + var containsSplitView = view is NSSplitView if let splitView = view as? NSSplitView { for ancestor in ancestorStack { appendObserved(ancestor, to: &geometryObservedViews, ids: &geometryObservedIds) - appendObserved(ancestor, to: &structureObservedViews, ids: &structureObservedIds) } appendObserved(splitView, to: &geometryObservedViews, ids: &geometryObservedIds) - appendObserved(splitView, to: &structureObservedViews, ids: &structureObservedIds) - for arrangedSubview in splitView.arrangedSubviews { - appendObserved(arrangedSubview, to: &structureObservedViews, ids: &structureObservedIds) - } if !isHidden { appendDividerRegions(for: splitView, hostView: hostView, into: &result) } @@ -198,7 +194,7 @@ final class PortalSplitDividerRegion { defer { ancestorStack.removeLast() } for subview in view.subviews { - collect( + if collect( in: subview, hostView: hostView, ancestorHidden: isHidden, @@ -206,10 +202,13 @@ final class PortalSplitDividerRegion { into: &result, geometryObservedViews: &geometryObservedViews, geometryObservedIds: &geometryObservedIds, - structureObservedViews: &structureObservedViews, - structureObservedIds: &structureObservedIds - ) + hierarchyNodes: &hierarchyNodes + ) { + containsSplitView = true + } } + hierarchyNodes.append((view: view, containsSplitView: containsSplitView)) + return containsSplitView } private static func appendObserved(_ view: NSView, to observedViews: inout [NSView], ids: inout Set) { diff --git a/Sources/PortalViewHierarchyMutationRegistration.swift b/Sources/PortalViewHierarchyMutationRegistration.swift new file mode 100644 index 000000000000..23807f4ee690 --- /dev/null +++ b/Sources/PortalViewHierarchyMutationRegistration.swift @@ -0,0 +1,27 @@ +import AppKit + +/// Weak lifetime token connecting one live divider cache to its window tracker. +@MainActor +final class PortalViewHierarchyMutationRegistration: NSObject { + private weak var tracker: PortalViewHierarchyMutationTracker? + private weak var rootView: NSView? + private let generation: UInt64 + + init( + tracker: PortalViewHierarchyMutationTracker, + rootView: NSView, + generation: UInt64 + ) { + self.tracker = tracker + self.rootView = rootView + self.generation = generation + } + + func isCurrent(for rootView: NSView) -> Bool { + tracker?.isCurrent( + registrationGeneration: generation, + registeredRootView: self.rootView, + requestedRootView: rootView + ) == true + } +} diff --git a/Sources/PortalViewHierarchyMutationTracker.swift b/Sources/PortalViewHierarchyMutationTracker.swift new file mode 100644 index 000000000000..c612d7d972ef --- /dev/null +++ b/Sources/PortalViewHierarchyMutationTracker.swift @@ -0,0 +1,457 @@ +import AppKit +import ObjectiveC + +/// Owns the divider-relevant structural generation for one AppKit window. +@MainActor +final class PortalViewHierarchyMutationTracker: NSObject { + typealias SubviewOrderBeforeSort = ( + tracker: PortalViewHierarchyMutationTracker, + subviews: [NSView] + ) + typealias SubviewsBeforeReplacement = ( + tracker: PortalViewHierarchyMutationTracker, + subviews: [NSView], + newSubviewWasInTrackedWindow: [Bool] + ) + typealias ArrangedSubviewsBeforeMutation = ( + tracker: PortalViewHierarchyMutationTracker, + arrangedSubviews: [NSView] + ) + + private static let windowAssociationKey = NSObject() + private static let nodeStateAssociationKey = NSObject() + + private weak var window: NSWindow? + private var generation: UInt64 = 0 + private let registrations = NSHashTable.weakObjects() + // Each entry exists only across one synchronous `sortSubviews` call and is + // removed by `finishSort`, including nested sorts of the same parent. + private var activeSortDepths: [ObjectIdentifier: Int] = [:] + + private init(window: NSWindow) { + self.window = window + } + + private var hasActiveCaches: Bool { + registrations.anyObject != nil + } + + private func beginRegistrationCycleIfNeeded() { + guard !hasActiveCaches else { return } + // Mutation hooks intentionally skip windows without a live cache. + // Revoke every proof from the prior active interval before indexing + // the first new cache, so dormant structural changes cannot revive it. + generation &+= 1 + } + + static func register( + rootView: NSView, + hierarchyNodes: [(view: NSView, containsSplitView: Bool)] + ) -> PortalViewHierarchyMutationRegistration? { + guard let window = rootView.window, + let tracker = tracker(for: window, createIfNeeded: true) else { + return nil + } + tracker.beginRegistrationCycleIfNeeded() + tracker.index(hierarchyNodes, rootView: rootView) + let registration = PortalViewHierarchyMutationRegistration( + tracker: tracker, + rootView: rootView, + generation: tracker.generation + ) + tracker.registrations.add(registration) + return registration + } + + /// Records an insertion after AppKit has attached the child. Unknown + /// prebuilt subtrees fail closed, while a leaf under an indexed no-split + /// branch stays on the fast path without a full cache rebuild. + static func recordInsertion( + parentView: NSView, + insertedView: NSView, + previousWindow: NSWindow? + ) { + let parentWindow = parentView.window + guard let tracker = mutationTracker( + for: parentView, + parentWindow: parentWindow + ), + tracker.hasActiveCaches else { + return + } + tracker.recordInsertion( + parentView: parentView, + insertedView: insertedView, + cameFromSameWindow: parentWindow != nil && previousWindow === parentWindow + ) + } + + static func recordRemoval( + parentView: NSView, + removedView: NSView, + previousWindow: NSWindow? + ) { + guard let window = previousWindow, + let tracker = tracker(for: window, createIfNeeded: false), + tracker.hasActiveCaches else { + return + } + tracker.recordRemoval(parentView: parentView, removedView: removedView) + } + + static func recordReplacement( + parentView: NSView, + oldView: NSView, + newView: NSView, + newViewPreviousWindow: NSWindow?, + parentWindow: NSWindow? + ) { + guard let tracker = mutationTracker( + for: parentView, + parentWindow: parentWindow + ), + tracker.hasActiveCaches else { + return + } + tracker.recordReplacement( + parentView: parentView, + oldView: oldView, + newView: newView, + newViewCameFromSameWindow: parentWindow != nil && newViewPreviousWindow === parentWindow + ) + } + + static func subviewsBeforeReplacement( + parentView: NSView, + newSubviews: [NSView] + ) -> SubviewsBeforeReplacement? { + let parentWindow = parentView.window + guard let tracker = mutationTracker( + for: parentView, + parentWindow: parentWindow + ), + tracker.hasActiveCaches else { + return nil + } + return ( + tracker: tracker, + subviews: parentView.subviews, + newSubviewWasInTrackedWindow: newSubviews.map { + parentWindow != nil && $0.window === parentWindow + } + ) + } + + static func recordSubviewsReplacement( + parentView: NSView, + newSubviews: [NSView], + replacementState: SubviewsBeforeReplacement + ) { + replacementState.tracker.recordSubviewsReplacement( + parentView: parentView, + oldSubviews: replacementState.subviews, + newSubviews: newSubviews, + newSubviewWasInTrackedWindow: replacementState.newSubviewWasInTrackedWindow + ) + } + + static func arrangedSubviewsBeforeMutation( + splitView: NSSplitView + ) -> ArrangedSubviewsBeforeMutation? { + // A split-bearing indexed subtree cannot become windowless while its + // registration stays current: `recordRemoval` advances the generation + // at that boundary. Only in-window arranged mutations need a snapshot. + guard let window = splitView.window, + let tracker = tracker(for: window, createIfNeeded: false), + tracker.hasActiveCaches, + tracker.currentNodeState(for: splitView)?.containsSplitView == true else { + return nil + } + return ( + tracker: tracker, + arrangedSubviews: splitView.arrangedSubviews + ) + } + + static func recordArrangedSubviewsMutation( + splitView: NSSplitView, + mutationState: ArrangedSubviewsBeforeMutation + ) { + let tracker = mutationState.tracker + guard tracker.hasActiveCaches, + tracker.currentNodeState(for: splitView)?.containsSplitView == true, + !haveSameIdentityOrder(mutationState.arrangedSubviews, splitView.arrangedSubviews) else { + return + } + tracker.markDirty() + } + + static func subviewOrderBeforeSort( + parentView: NSView, + parentWindow: NSWindow? + ) -> SubviewOrderBeforeSort? { + // A parent whose index says it contains a split view dirties the + // generation when detached, before a nil-window sort can occur. + guard let window = parentWindow, + let tracker = tracker(for: window, createIfNeeded: false), + tracker.hasActiveCaches, + let parentState = tracker.currentNodeState(for: parentView), + parentState.containsSplitView else { + return nil + } + tracker.beginSort(parentView: parentView) + return (tracker: tracker, subviews: parentView.subviews) + } + + static func recordSortIfNeeded( + parentView: NSView, + sortState: SubviewOrderBeforeSort + ) { + let tracker = sortState.tracker + guard tracker.finishSort(parentView: parentView) else { + return + } + guard tracker.hasActiveCaches, + let parentState = tracker.currentNodeState(for: parentView), + parentState.containsSplitView, + !haveSameIdentityOrder(sortState.subviews, parentView.subviews) else { return } + tracker.markDirty() + } + + private static func haveSameIdentityOrder(_ lhs: [NSView], _ rhs: [NSView]) -> Bool { + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { $0 === $1 } + } + + private static func tracker( + for window: NSWindow, + createIfNeeded: Bool + ) -> PortalViewHierarchyMutationTracker? { + let key = Unmanaged.passUnretained(windowAssociationKey).toOpaque() + if let tracker = objc_getAssociatedObject(window, key) as? PortalViewHierarchyMutationTracker { + return tracker + } + guard createIfNeeded else { return nil } + + let tracker = PortalViewHierarchyMutationTracker(window: window) + objc_setAssociatedObject(window, key, tracker, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return tracker + } + + /// An indexed subtree can keep its split-free proof while detached. Route + /// mutations through that proof's tracker even before the subtree rejoins + /// a window, so a fresh wrapper cannot conceal detached structural changes. + private static func mutationTracker( + for parentView: NSView, + parentWindow: NSWindow? + ) -> PortalViewHierarchyMutationTracker? { + if let parentWindow { + return tracker(for: parentWindow, createIfNeeded: false) + } + return associatedNodeState(for: parentView)?.tracker + } + + func isCurrent( + registrationGeneration: UInt64, + registeredRootView: NSView?, + requestedRootView: NSView + ) -> Bool { + registeredRootView === requestedRootView + && requestedRootView.window === window + && registrationGeneration == generation + && currentNodeState(for: requestedRootView)?.registeredRootGeneration == generation + } + + private func index( + _ nodes: [(view: NSView, containsSplitView: Bool)], + rootView: NSView + ) { + for node in nodes { + guard let state = nodeState(for: node.view, createIfNeeded: true) else { continue } + state.tracker = self + state.generation = generation + state.containsSplitView = node.containsSplitView + } + currentNodeState(for: rootView)?.registeredRootGeneration = generation + } + + private func recordInsertion( + parentView: NSView, + insertedView: NSView, + cameFromSameWindow: Bool + ) { + guard !isSorting(parentView: parentView) else { return } + guard let parentState = currentNodeState(for: parentView) else { + // Crossing from an indexed cache root into an unindexed window + // branch breaks provenance for the entire moved subtree. Advancing + // the generation revokes every descendant proof in constant time. + if currentNodeState(for: insertedView) != nil { + markDirty() + } + return + } + if parentState.containsSplitView || insertedView is NSSplitView { + markDirty() + return + } + + // A node-state proof is reusable only while the subtree stayed in this + // window, where every hierarchy mutation passed through this tracker. + if cameFromSameWindow, + let insertedState = currentNodeState(for: insertedView) { + if insertedState.containsSplitView { + markDirty() + } + return + } + + // `subviews` bridges AppKit's retained array and `isEmpty` reads only + // its count. Never recurse through an untrusted prebuilt subtree here. + guard insertedView.subviews.isEmpty else { + markDirty() + return + } + guard let insertedState = nodeState(for: insertedView, createIfNeeded: true) else { return } + insertedState.tracker = self + insertedState.generation = generation + insertedState.containsSplitView = false + } + + private func recordRemoval(parentView: NSView, removedView: NSView) { + guard !isSorting(parentView: parentView) else { return } + guard let parentState = currentNodeState(for: parentView) else { + if isCurrentRegisteredRoot(removedView) { + markDirty() + } + return + } + if parentState.containsSplitView || currentNodeState(for: removedView)?.containsSplitView == true { + markDirty() + } + } + + private func recordReplacement( + parentView: NSView, + oldView: NSView, + newView: NSView, + newViewCameFromSameWindow: Bool + ) { + guard !isSorting(parentView: parentView) else { return } + guard currentNodeState(for: parentView) != nil else { + if isCurrentRegisteredRoot(oldView) + || currentNodeState(for: newView) != nil { + markDirty() + } + return + } + recordInsertion( + parentView: parentView, + insertedView: newView, + cameFromSameWindow: newViewCameFromSameWindow + ) + } + + private func recordSubviewsReplacement( + parentView: NSView, + oldSubviews: [NSView], + newSubviews: [NSView], + newSubviewWasInTrackedWindow: [Bool] + ) { + guard !isSorting(parentView: parentView), + hasActiveCaches, + !Self.haveSameIdentityOrder(oldSubviews, newSubviews) else { return } + guard let parentState = currentNodeState(for: parentView) else { + let touchesRegisteredRoot = oldSubviews.contains(where: isCurrentRegisteredRoot) + let movesIndexedProofOutsideRoot = newSubviews.contains { + currentNodeState(for: $0) != nil + } + if touchesRegisteredRoot || movesIndexedProofOutsideRoot { + markDirty() + } + return + } + guard !parentState.containsSplitView else { + markDirty() + return + } + + for (newSubview, wasInTrackedWindow) in zip(newSubviews, newSubviewWasInTrackedWindow) { + if newSubview is NSSplitView { + markDirty() + return + } + if wasInTrackedWindow, + let newSubviewState = currentNodeState(for: newSubview) { + if newSubviewState.containsSplitView { + markDirty() + return + } + continue + } + guard newSubview.subviews.isEmpty else { + markDirty() + return + } + guard let newSubviewState = nodeState(for: newSubview, createIfNeeded: true) else { + markDirty() + return + } + newSubviewState.tracker = self + newSubviewState.generation = generation + newSubviewState.containsSplitView = false + } + } + + private func isCurrentRegisteredRoot(_ view: NSView) -> Bool { + currentNodeState(for: view)?.registeredRootGeneration == generation + } + + private func beginSort(parentView: NSView) { + activeSortDepths[ObjectIdentifier(parentView), default: 0] += 1 + } + + private func finishSort(parentView: NSView) -> Bool { + let id = ObjectIdentifier(parentView) + guard let depth = activeSortDepths[id], depth > 0 else { return false } + if depth == 1 { + activeSortDepths.removeValue(forKey: id) + } else { + activeSortDepths[id] = depth - 1 + } + return true + } + + private func isSorting(parentView: NSView) -> Bool { + activeSortDepths[ObjectIdentifier(parentView)] != nil + } + + private func currentNodeState(for view: NSView) -> PortalViewHierarchyNodeState? { + let state = nodeState(for: view, createIfNeeded: false) + guard state?.tracker === self, state?.generation == generation else { return nil } + return state + } + + private func nodeState(for view: NSView, createIfNeeded: Bool) -> PortalViewHierarchyNodeState? { + if let state = Self.associatedNodeState(for: view) { + return state + } + guard createIfNeeded else { return nil } + + let state = PortalViewHierarchyNodeState() + let key = Unmanaged.passUnretained(Self.nodeStateAssociationKey).toOpaque() + objc_setAssociatedObject(view, key, state, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return state + } + + private static func associatedNodeState(for view: NSView) -> PortalViewHierarchyNodeState? { + let key = Unmanaged.passUnretained(nodeStateAssociationKey).toOpaque() + return objc_getAssociatedObject(view, key) as? PortalViewHierarchyNodeState + } + + private func markDirty() { + // Cache owners compare this lazily on their next pointer lookup. Keeping + // the mutation boundary callback-free prevents work from scaling with + // the number of terminal and browser portals in the window. + generation &+= 1 + } +} diff --git a/Sources/PortalViewHierarchyNodeState.swift b/Sources/PortalViewHierarchyNodeState.swift new file mode 100644 index 000000000000..eac3afd93d0a --- /dev/null +++ b/Sources/PortalViewHierarchyNodeState.swift @@ -0,0 +1,10 @@ +import AppKit + +/// Cache-miss split-presence index associated directly with one AppKit view. +@MainActor +final class PortalViewHierarchyNodeState: NSObject { + weak var tracker: PortalViewHierarchyMutationTracker? + var generation: UInt64 = 0 + var registeredRootGeneration: UInt64? + var containsSplitView = false +} diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 5001fed0dd29..b6f9aa5437fd 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -19,7 +19,7 @@ final class WindowTerminalHostView: NSView { private var cachedSidebarDividerX: CGFloat? private var sidebarDividerMissCount = 0 private var cachedSplitDividerRegions: [DividerRegion]? - private var cachedSplitDividerRootSubviewIds: [ObjectIdentifier]? + private weak var cachedSplitDividerRootView: NSView? private let splitDividerCacheInvalidator = PortalSplitDividerCacheInvalidator() private var splitDividerResizeObserver: NSObjectProtocol? private var trackingArea: NSTrackingArea? @@ -371,15 +371,23 @@ final class WindowTerminalHostView: NSView { } private func splitDividerRegions() -> [DividerRegion] { - guard let window, let rootView = window.contentView else { cachedSplitDividerRegions = []; cachedSplitDividerRootSubviewIds = nil; return [] } - let rootSubviewIds = rootView.subviews.map { ObjectIdentifier($0) } - if let regions = cachedSplitDividerRegions, cachedSplitDividerRootSubviewIds == rootSubviewIds, PortalSplitDividerRegion.allLive(regions) { return regions } + guard let window, let rootView = window.contentView else { + invalidateSplitDividerRegionCache() + return [] + } + if let regions = cachedSplitDividerRegions, + cachedSplitDividerRootView === rootView, + splitDividerCacheInvalidator.isHierarchyCurrent(for: rootView), + PortalSplitDividerRegion.allLive(regions) { + return regions + } let collected = PortalSplitDividerRegion.collect(in: rootView) cachedSplitDividerRegions = collected.regions - cachedSplitDividerRootSubviewIds = rootSubviewIds + cachedSplitDividerRootView = rootView splitDividerCacheInvalidator.observe( + rootView: rootView, geometryViews: collected.geometryObservedViews, - structureViews: collected.structureObservedViews + hierarchyNodes: collected.hierarchyNodes ) { [weak self] in guard let self else { return } self.invalidateSplitDividerRegionCache() @@ -390,7 +398,7 @@ final class WindowTerminalHostView: NSView { private func invalidateSplitDividerRegionCache() { cachedSplitDividerRegions = nil - cachedSplitDividerRootSubviewIds = nil + cachedSplitDividerRootView = nil splitDividerCacheInvalidator.invalidate() } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bc91bfa7809f..3cb6e554a7d8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1545,11 +1545,15 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 8672F0018672F0018672F001 /* PiFeedOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8672F0028672F0028672F002 /* PiFeedOwnershipTests.swift */; }; 8672F0068672F0068672F006 /* PiFeedV2CallResultBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8672F0058672F0058672F005 /* PiFeedV2CallResultBox.swift */; }; B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */; }; + 8580D7A1B2C3D4E5F607182B /* PortalDetachedHierarchyMutationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8580D7A1B2C3D4E5F607182A /* PortalDetachedHierarchyMutationTests.swift */; }; 5B0C00010000000000000005 /* PortalDividerCursorOcclusionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */; }; B1D5CEE0407545B6B57E3B0E /* PortalHitTestingPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */; }; D0C658660000000000000004 /* PortalSplitDividerCacheInvalidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C658660000000000000003 /* PortalSplitDividerCacheInvalidator.swift */; }; D0C658660000000000000002 /* PortalSplitDividerRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C658660000000000000001 /* PortalSplitDividerRegion.swift */; }; D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */; }; + D0C658660000000000000006 /* PortalViewHierarchyMutationRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C658660000000000000005 /* PortalViewHierarchyMutationRegistration.swift */; }; + D5ABCBB88D85477FA6D8D08B /* PortalViewHierarchyMutationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2F1C5A776AF414C8925A80D /* PortalViewHierarchyMutationTracker.swift */; }; + D0C658660000000000000008 /* PortalViewHierarchyNodeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C658660000000000000007 /* PortalViewHierarchyNodeState.swift */; }; B79500310000000000000001 /* PortLsofScanResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500310000000000000002 /* PortLsofScanResult.swift */; }; B79500030000000000000001 /* PortScanCoordination.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500030000000000000002 /* PortScanCoordination.swift */; }; B79500070000000000000001 /* PortScanner+PanelKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500070000000000000002 /* PortScanner+PanelKey.swift */; }; @@ -4199,11 +4203,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8672F0028672F0028672F002 /* PiFeedOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiFeedOwnershipTests.swift; sourceTree = ""; }; 8672F0058672F0058672F005 /* PiFeedV2CallResultBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiFeedV2CallResultBox.swift; sourceTree = ""; }; B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiVaultAgentPersistenceTests.swift; sourceTree = ""; }; + 8580D7A1B2C3D4E5F607182A /* PortalDetachedHierarchyMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalDetachedHierarchyMutationTests.swift; sourceTree = ""; }; 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalDividerCursorOcclusionTests.swift; sourceTree = ""; }; 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalHitTestingPerformanceTests.swift; sourceTree = ""; }; D0C658660000000000000003 /* PortalSplitDividerCacheInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalSplitDividerCacheInvalidator.swift; sourceTree = ""; }; D0C658660000000000000001 /* PortalSplitDividerRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalSplitDividerRegion.swift; sourceTree = ""; }; D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalTabDragRoutingTests.swift; sourceTree = ""; }; + D0C658660000000000000005 /* PortalViewHierarchyMutationRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalViewHierarchyMutationRegistration.swift; sourceTree = ""; }; + A2F1C5A776AF414C8925A80D /* PortalViewHierarchyMutationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalViewHierarchyMutationTracker.swift; sourceTree = ""; }; + D0C658660000000000000007 /* PortalViewHierarchyNodeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalViewHierarchyNodeState.swift; sourceTree = ""; }; B79500310000000000000002 /* PortLsofScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortLsofScanResult.swift; sourceTree = ""; }; B79500030000000000000002 /* PortScanCoordination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanCoordination.swift; sourceTree = ""; }; B79500070000000000000002 /* PortScanner+PanelKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PortScanner+PanelKey.swift"; sourceTree = ""; }; @@ -6363,6 +6371,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001531 /* TerminalWindowPortal.swift */, D0C658660000000000000003 /* PortalSplitDividerCacheInvalidator.swift */, D0C658660000000000000001 /* PortalSplitDividerRegion.swift */, + D0C658660000000000000007 /* PortalViewHierarchyNodeState.swift */, + D0C658660000000000000005 /* PortalViewHierarchyMutationRegistration.swift */, + A2F1C5A776AF414C8925A80D /* PortalViewHierarchyMutationTracker.swift */, C750500000000000000000B2 /* TerminalSurfaceRuntimeWiring.swift */, 606600020000000000000002 /* WindowTerminalHostViewHitTesting.swift */, D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */, @@ -7693,6 +7704,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */, 804100000000000000000004 /* ReorderShortcutActionTests.swift */, D7AB3605C10DEF0000000002 /* WorkspaceCloseTabsContextMenuTests.swift */, + 8580D7A1B2C3D4E5F607182A /* PortalDetachedHierarchyMutationTests.swift */, 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */, D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */, C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */, @@ -9499,6 +9511,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B79500210000000000000001 /* PIDPresence.swift in Sources */, D0C658660000000000000004 /* PortalSplitDividerCacheInvalidator.swift in Sources */, D0C658660000000000000002 /* PortalSplitDividerRegion.swift in Sources */, + D0C658660000000000000006 /* PortalViewHierarchyMutationRegistration.swift in Sources */, + D5ABCBB88D85477FA6D8D08B /* PortalViewHierarchyMutationTracker.swift in Sources */, + D0C658660000000000000008 /* PortalViewHierarchyNodeState.swift in Sources */, B79500310000000000000001 /* PortLsofScanResult.swift in Sources */, B79500030000000000000001 /* PortScanCoordination.swift in Sources */, B79500070000000000000001 /* PortScanner+PanelKey.swift in Sources */, @@ -10897,6 +10912,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8672F0018672F0018672F001 /* PiFeedOwnershipTests.swift in Sources */, 8672F0068672F0068672F006 /* PiFeedV2CallResultBox.swift in Sources */, B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */, + 8580D7A1B2C3D4E5F607182B /* PortalDetachedHierarchyMutationTests.swift in Sources */, 5B0C00010000000000000005 /* PortalDividerCursorOcclusionTests.swift in Sources */, B1D5CEE0407545B6B57E3B0E /* PortalHitTestingPerformanceTests.swift in Sources */, D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */, diff --git a/cmuxTests/PortalDetachedHierarchyMutationTests.swift b/cmuxTests/PortalDetachedHierarchyMutationTests.swift new file mode 100644 index 000000000000..7de726c293e6 --- /dev/null +++ b/cmuxTests/PortalDetachedHierarchyMutationTests.swift @@ -0,0 +1,100 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +private func makeRegisteredPortalHierarchy() throws -> ( + window: NSWindow, + rootView: NSView, + splitView: NSSplitView, + panes: [NSView], + siblingView: NSView, + invalidator: PortalSplitDividerCacheInvalidator +) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let rootView = try #require(window.contentView) + let splitView = NSSplitView(frame: rootView.bounds) + splitView.isVertical = true + let panes = [ + NSView(frame: NSRect(x: 0, y: 0, width: 120, height: rootView.bounds.height)), + NSView(frame: NSRect(x: 121, y: 0, width: 199, height: rootView.bounds.height)), + ] + for pane in panes { + splitView.addSubview(pane) + } + let siblingView = NSView(frame: NSRect(x: 1, y: 0, width: 1, height: 1)) + rootView.addSubview(splitView) + rootView.addSubview(siblingView) + + let invalidator = PortalSplitDividerCacheInvalidator() + let collected = PortalSplitDividerRegion.collect(in: rootView) + invalidator.observe( + rootView: rootView, + geometryViews: collected.geometryObservedViews, + hierarchyNodes: collected.hierarchyNodes + ) {} + #expect(invalidator.isHierarchyCurrent(for: rootView)) + return (window, rootView, splitView, panes, siblingView, invalidator) +} + +@MainActor +@Suite(.serialized) +struct PortalDetachedArrangedSubviewBoundaryTests { + @Test + func detachingRegisteredRootRevokesCacheBeforeArrangedSubviewReorder() throws { + let fixture = try makeRegisteredPortalHierarchy() + defer { fixture.window.orderOut(nil) } + defer { fixture.invalidator.invalidate() } + + fixture.window.contentView = NSView(frame: fixture.rootView.frame) + #expect(fixture.splitView.window == nil) + #expect( + !fixture.invalidator.isHierarchyCurrent(for: fixture.rootView), + "Removing a registered split-bearing root must revoke its cache before detached mutations." + ) + + fixture.splitView.removeArrangedSubview(fixture.panes[1]) + fixture.splitView.insertArrangedSubview(fixture.panes[1], at: 0) + #expect(fixture.splitView.arrangedSubviews.first === fixture.panes[1]) + + fixture.window.contentView = fixture.rootView + #expect(!fixture.invalidator.isHierarchyCurrent(for: fixture.rootView)) + } +} + +@MainActor +@Suite(.serialized) +struct PortalDetachedSubviewSortBoundaryTests { + @Test + func detachingRegisteredRootRevokesCacheBeforeSubviewSort() throws { + let fixture = try makeRegisteredPortalHierarchy() + defer { fixture.window.orderOut(nil) } + defer { fixture.invalidator.invalidate() } + + fixture.window.contentView = NSView(frame: fixture.rootView.frame) + #expect(fixture.rootView.window == nil) + #expect( + !fixture.invalidator.isHierarchyCurrent(for: fixture.rootView), + "Removing a registered split-bearing root must revoke its cache before detached mutations." + ) + + fixture.rootView.sortSubviews({ lhs, rhs, _ in + if lhs.frame.minX == rhs.frame.minX { return .orderedSame } + return lhs.frame.minX < rhs.frame.minX ? .orderedDescending : .orderedAscending + }, context: nil) + #expect(fixture.rootView.subviews.first === fixture.siblingView) + + fixture.window.contentView = fixture.rootView + #expect(!fixture.invalidator.isHierarchyCurrent(for: fixture.rootView)) + } +} diff --git a/cmuxTests/PortalHitTestingPerformanceTests.swift b/cmuxTests/PortalHitTestingPerformanceTests.swift index 9937c65546e0..8b98488d7ccf 100644 --- a/cmuxTests/PortalHitTestingPerformanceTests.swift +++ b/cmuxTests/PortalHitTestingPerformanceTests.swift @@ -40,6 +40,43 @@ struct PortalHitTestingPerformanceTests { } } + private final class SubviewReadCountingView: NSView { + private(set) var subviewReadCount = 0 + + override var subviews: [NSView] { + get { + subviewReadCount += 1 + return super.subviews + } + set { + super.subviews = newValue + } + } + } + + private final class SuperviewReadCountingView: NSView { + private(set) var superviewReadCount = 0 + + override var superview: NSView? { + superviewReadCount += 1 + return super.superview + } + } + + private final class CursorInvalidationCountingWindow: NSWindow { + private(set) var cursorInvalidationCount = 0 + + override func invalidateCursorRects(for view: NSView) { + cursorInvalidationCount += 1 + super.invalidateCursorRects(for: view) + } + } + + private final class NonForwardingHierarchyCallbackView: NSView { + override func didAddSubview(_ subview: NSView) {} + override func willRemoveSubview(_ subview: NSView) {} + } + private final class SplitDelegate: NSObject, NSSplitViewDelegate {} private func makeMouseEvent(type: NSEvent.EventType, at locationInWindow: NSPoint, window: NSWindow) -> NSEvent { @@ -59,6 +96,34 @@ struct PortalHitTestingPerformanceTests { return event } + private func makePrebuiltSplitSubtree(frame: NSRect) -> ( + root: SubviewReadCountingView, + scaleContainers: [SubviewReadCountingView], + splitView: CountingSplitView, + splitDelegate: SplitDelegate + ) { + let root = SubviewReadCountingView(frame: frame) + let scaleContainers = (0..<1_000).map { _ in SubviewReadCountingView(frame: .zero) } + for scaleContainer in scaleContainers { + root.addSubview(scaleContainer) + } + let splitView = CountingSplitView(frame: root.bounds) + splitView.isVertical = true + let splitDelegate = SplitDelegate() + splitView.delegate = splitDelegate + splitView.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 200, height: root.bounds.height))) + splitView.addSubview(NSView(frame: NSRect(x: 201, y: 0, width: 119, height: root.bounds.height))) + root.addSubview(splitView) + return (root, scaleContainers, splitView, splitDelegate) + } + + private func subviewReadCount( + root: SubviewReadCountingView, + scaleContainers: [SubviewReadCountingView] + ) -> Int { + root.subviewReadCount + scaleContainers.reduce(0) { $0 + $1.subviewReadCount } + } + @Test func mouseMovedTabBarPassThroughUsesOnlyRegisteredRegions() throws { let window = NSWindow( @@ -124,6 +189,10 @@ struct PortalHitTestingPerformanceTests { let unrelatedLeaf = NSView(frame: contentView.bounds) unrelatedMiddle.addSubview(unrelatedLeaf) unrelatedContainer.addSubview(unrelatedMiddle) + let scaleContainers = (0..<1_000).map { _ in SubviewReadCountingView(frame: .zero) } + for scaleContainer in scaleContainers { + unrelatedContainer.addSubview(scaleContainer) + } contentView.addSubview(unrelatedContainer) let host = WindowTerminalHostView(frame: contentView.bounds) @@ -140,6 +209,7 @@ struct PortalHitTestingPerformanceTests { let initialRectConversionCount = splitView.rectConversionCount #expect(host.performHitTest(at: dividerPointInHost, currentEvent: event) == nil) + let subviewReadCountAfterCacheWarmup = scaleContainers.reduce(0) { $0 + $1.subviewReadCount } #expect(host.performHitTest(at: dividerPointInHost, currentEvent: event) == nil) #expect( splitView.rectConversionCount - initialRectConversionCount == 2, @@ -149,12 +219,30 @@ struct PortalHitTestingPerformanceTests { splitView.pointConversionCount == 0, "Repeated pointer moves should hit cached divider rectangles instead of converting through each split view." ) + #expect( + scaleContainers.reduce(0) { $0 + $1.subviewReadCount } == subviewReadCountAfterCacheWarmup, + "A cache hit should not re-walk a large unrelated view hierarchy." + ) unrelatedLeaf.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 1, height: 1))) #expect(host.performHitTest(at: dividerPointInHost, currentEvent: event) == nil) #expect( splitView.rectConversionCount - initialRectConversionCount == 2, "Unrelated deep subtree mutations should not rebuild the cached divider geometry." ) + #expect( + scaleContainers.reduce(0) { $0 + $1.subviewReadCount } == subviewReadCountAfterCacheWarmup, + "Unrelated mutations should not make the next pointer hit re-walk the hierarchy." + ) + unrelatedLeaf.subviews = [NSView(frame: NSRect(x: 0, y: 0, width: 2, height: 2))] + #expect(host.performHitTest(at: dividerPointInHost, currentEvent: event) == nil) + #expect( + splitView.rectConversionCount - initialRectConversionCount == 2, + "Replacing split-free descendants should not rebuild the cached divider geometry." + ) + #expect( + scaleContainers.reduce(0) { $0 + $1.subviewReadCount } == subviewReadCountAfterCacheWarmup, + "A split-free subviews replacement should not make the next pointer hit re-walk the hierarchy." + ) } @Test @@ -293,6 +381,578 @@ struct PortalHitTestingPerformanceTests { #expect(host.performHitTest(at: host.convert(insertedDividerPointInWindow, from: nil), currentEvent: insertedEvent) == nil) } + @Test + func terminalSplitDividerCacheRefreshesAfterDeepContainerSubviewInsertion() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try #require(window.contentView) + let outerContainer = NSView(frame: contentView.bounds) + contentView.addSubview(outerContainer) + let innerContainer = NonForwardingHierarchyCallbackView(frame: outerContainer.bounds) + outerContainer.addSubview(innerContainer) + + let hostedView = CapturingView(frame: contentView.bounds) + let host = WindowTerminalHostView(frame: contentView.bounds) + host.addSubview(hostedView) + contentView.addSubview(host) + + let warmPointInWindow = contentView.convert(NSPoint(x: 20, y: 20), to: nil) + let warmEvent = makeMouseEvent(type: .mouseMoved, at: warmPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(warmPointInWindow, from: nil), currentEvent: warmEvent) === hostedView) + + // The inner container is two levels below the content root, holds no split + // when the cache warms up, and intentionally omits `super` in AppKit's + // hierarchy callbacks. A split inserted there must still invalidate the cache. + let insertedSplitView = CountingSplitView(frame: innerContainer.bounds) + insertedSplitView.isVertical = true + let insertedSplitDelegate = SplitDelegate() + insertedSplitView.delegate = insertedSplitDelegate + insertedSplitView.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 200, height: innerContainer.bounds.height))) + insertedSplitView.addSubview(NSView(frame: NSRect(x: 201, y: 0, width: 119, height: innerContainer.bounds.height))) + innerContainer.addSubview(insertedSplitView) + + let insertedDividerPointInWindow = insertedSplitView.convert( + NSPoint(x: insertedSplitView.arrangedSubviews[0].frame.maxX + (insertedSplitView.dividerThickness * 0.5), y: insertedSplitView.bounds.midY), + to: nil + ) + let insertedEvent = makeMouseEvent(type: .mouseMoved, at: insertedDividerPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(insertedDividerPointInWindow, from: nil), currentEvent: insertedEvent) == nil) + } + + @Test + func hierarchyMutationWorkIsBoundedAcrossCachesAndPrebuiltSubtree() throws { + let window = CursorInvalidationCountingWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let controlWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { controlWindow.orderOut(nil) } + + let rootView = try #require(window.contentView) + let outerContainer = NSView(frame: rootView.bounds) + let unobservedContainer = NSView(frame: outerContainer.bounds) + outerContainer.addSubview(unobservedContainer) + rootView.addSubview(outerContainer) + + let hosts = (0..<128).map { _ in + let host = WindowTerminalHostView(frame: rootView.bounds) + host.addSubview(CapturingView(frame: host.bounds)) + rootView.addSubview(host) + return host + } + let warmPointInWindow = rootView.convert(NSPoint(x: 20, y: 20), to: nil) + let warmEvent = makeMouseEvent(type: .mouseMoved, at: warmPointInWindow, window: window) + for host in hosts { + #expect(host.performHitTest(at: host.convert(warmPointInWindow, from: nil), currentEvent: warmEvent) != nil) + } + + let controlRootView = try #require(controlWindow.contentView) + let controlContainer = NSView(frame: controlRootView.bounds) + controlRootView.addSubview(controlContainer) + let controlSubtree = makePrebuiltSplitSubtree(frame: controlContainer.bounds) + let controlReadsBeforeInsertion = subviewReadCount( + root: controlSubtree.root, + scaleContainers: controlSubtree.scaleContainers + ) + controlContainer.addSubview(controlSubtree.root) + let appKitAttachmentReads = subviewReadCount( + root: controlSubtree.root, + scaleContainers: controlSubtree.scaleContainers + ) - controlReadsBeforeInsertion + #expect( + appKitAttachmentReads > 0, + "The no-cache control must observe AppKit's subtree attachment traversal." + ) + + let insertedSubtree = makePrebuiltSplitSubtree(frame: unobservedContainer.bounds) + let subtreeReadsBeforeInsertion = subviewReadCount( + root: insertedSubtree.root, + scaleContainers: insertedSubtree.scaleContainers + ) + let cursorInvalidationsBeforeInsertion = window.cursorInvalidationCount + + unobservedContainer.addSubview(insertedSubtree.root) + + let portalAttachmentReads = subviewReadCount( + root: insertedSubtree.root, + scaleContainers: insertedSubtree.scaleContainers + ) - subtreeReadsBeforeInsertion + #expect( + portalAttachmentReads - appKitAttachmentReads <= 2, + "Portal tracking must add only constant work beyond AppKit's own subtree attachment traversal." + ) + #expect( + window.cursorInvalidationCount - cursorInvalidationsBeforeInsertion <= 2, + "One hierarchy mutation must not synchronously notify every active portal cache." + ) + + let dividerPointInWindow = insertedSubtree.splitView.convert( + NSPoint( + x: insertedSubtree.splitView.arrangedSubviews[0].frame.maxX + + (insertedSubtree.splitView.dividerThickness * 0.5), + y: insertedSubtree.splitView.bounds.midY + ), + to: nil + ) + let dividerEvent = makeMouseEvent(type: .mouseMoved, at: dividerPointInWindow, window: window) + for host in hosts { + #expect(host.performHitTest( + at: host.convert(dividerPointInWindow, from: nil), + currentEvent: dividerEvent + ) == nil) + } + withExtendedLifetime(controlSubtree.splitDelegate) {} + withExtendedLifetime(insertedSubtree.splitDelegate) {} + } + + @Test + func hierarchyMutationHooksGateBeforeWalkingAnUnrelatedWindow() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let rootView = try #require(window.contentView) + var parent: NSView = rootView + let scaleContainers = (0..<1_000).map { _ in SuperviewReadCountingView(frame: .zero) } + for scaleContainer in scaleContainers { + parent.addSubview(scaleContainer) + parent = scaleContainer + } + + // Constructing an invalidator installs the process-wide AppKit hooks, but + // this window intentionally has no active portal cache. + let invalidator = PortalSplitDividerCacheInvalidator() + let readsBeforeInsertion = scaleContainers.reduce(0) { $0 + $1.superviewReadCount } + + parent.addSubview(NSView(frame: .zero)) + + #expect( + scaleContainers.reduce(0) { $0 + $1.superviewReadCount } - readsBeforeInsertion <= 1, + "A mutation in an unrelated window must return after its constant-time tracker lookup." + ) + withExtendedLifetime(invalidator) {} + } + + @Test + func hierarchyMutationInvalidatesAfterPureSubviewReorder() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let rootView = try #require(window.contentView) + let firstView = NSSplitView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) + let secondView = NSView(frame: NSRect(x: 1, y: 0, width: 1, height: 1)) + rootView.addSubview(firstView) + rootView.addSubview(secondView) + + let invalidator = PortalSplitDividerCacheInvalidator() + let collected = PortalSplitDividerRegion.collect(in: rootView) + invalidator.observe( + rootView: rootView, + geometryViews: collected.geometryObservedViews, + hierarchyNodes: collected.hierarchyNodes + ) {} + #expect(invalidator.isHierarchyCurrent(for: rootView)) + + rootView.sortSubviews({ lhs, rhs, _ in + if lhs.frame.minX == rhs.frame.minX { return .orderedSame } + return lhs.frame.minX < rhs.frame.minX ? .orderedAscending : .orderedDescending + }, context: nil) + + #expect(rootView.subviews.first === firstView) + #expect( + invalidator.isHierarchyCurrent(for: rootView), + "A sort that preserves identity order must not invalidate the hierarchy cache." + ) + + rootView.sortSubviews({ lhs, rhs, _ in + if lhs.frame.minX == rhs.frame.minX { return .orderedSame } + return lhs.frame.minX < rhs.frame.minX ? .orderedDescending : .orderedAscending + }, context: nil) + + #expect(rootView.subviews.first === secondView) + #expect(!invalidator.isHierarchyCurrent(for: rootView)) + withExtendedLifetime(invalidator) {} + } + + @Test + func hierarchyRegistrationRevokesProofsAcrossInactivePeriod() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let rootView = try #require(window.contentView) + let container = NSView(frame: rootView.bounds) + let detachedContainer = NSView(frame: container.bounds) + container.addSubview(detachedContainer) + rootView.addSubview(container) + + let invalidator = PortalSplitDividerCacheInvalidator() + func observeCurrentHierarchy() { + let collected = PortalSplitDividerRegion.collect(in: rootView) + invalidator.observe( + rootView: rootView, + geometryViews: collected.geometryObservedViews, + hierarchyNodes: collected.hierarchyNodes + ) {} + #expect(invalidator.isHierarchyCurrent(for: rootView)) + } + + observeCurrentHierarchy() + invalidator.invalidate() + detachedContainer.removeFromSuperview() + + let detachedWrapper = NSView(frame: detachedContainer.frame) + detachedWrapper.addSubview(detachedContainer) + let splitView = NSSplitView(frame: detachedContainer.bounds) + splitView.addSubview(NSView(frame: .zero)) + splitView.addSubview(NSView(frame: .zero)) + detachedContainer.addSubview(splitView) + + let windowContainer = try #require(rootView.superview) + let parkingView = NSView(frame: rootView.frame) + windowContainer.addSubview(parkingView, positioned: .below, relativeTo: rootView) + parkingView.addSubview(detachedWrapper) + + // No cache was registered during the detached mutation. The next + // registration must revoke proofs retained from that inactive period. + observeCurrentHierarchy() + container.addSubview(detachedContainer) + + #expect(!invalidator.isHierarchyCurrent(for: rootView)) + } + + @Test + func addingArrangedSubviewInvalidatesHierarchyRegistration() throws { + try expectArrangedSubviewMutationInvalidates( + prepare: { splitView, panes in + splitView.arrangesAllSubviews = false + splitView.addArrangedSubview(panes[0]) + }, + mutation: { splitView, panes in + splitView.addArrangedSubview(panes[1]) + } + ) + } + + @Test + func removingArrangedSubviewInvalidatesHierarchyRegistration() throws { + try expectArrangedSubviewMutationInvalidates( + prepare: { splitView, panes in + splitView.arrangesAllSubviews = false + for pane in panes { + splitView.addArrangedSubview(pane) + } + }, + mutation: { splitView, panes in + splitView.removeArrangedSubview(panes[0]) + }, + cleanup: { _, panes in + // Complete AppKit's documented two-step removal while + // `arrangesAllSubviews` is false. + panes[0].removeFromSuperview() + } + ) + } + + @Test + func insertingArrangedSubviewInvalidatesHierarchyRegistration() throws { + try expectArrangedSubviewMutationInvalidates( + prepare: { splitView, panes in + splitView.arrangesAllSubviews = false + for pane in panes { + splitView.addArrangedSubview(pane) + } + }, + mutation: { splitView, panes in + splitView.insertArrangedSubview(panes[1], at: 0) + } + ) + } + + @Test + func arrangingAllSubviewsInvalidatesHierarchyRegistration() throws { + try expectArrangedSubviewMutationInvalidates( + prepare: { splitView, panes in + splitView.arrangesAllSubviews = false + for pane in panes { + splitView.addSubview(pane) + } + }, + mutation: { splitView, _ in + splitView.arrangesAllSubviews = true + } + ) + } + + private func expectArrangedSubviewMutationInvalidates( + prepare: (NSSplitView, [NSView]) -> Void, + mutation: (NSSplitView, [NSView]) -> Void, + cleanup: (NSSplitView, [NSView]) -> Void = { _, _ in } + ) throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let rootView = try #require(window.contentView) + let splitView = NSSplitView(frame: rootView.bounds) + splitView.arrangesAllSubviews = true + let panes = [ + NSView(frame: NSRect(x: 0, y: 0, width: 200, height: rootView.bounds.height)), + NSView(frame: NSRect(x: 201, y: 0, width: 119, height: rootView.bounds.height)), + ] + prepare(splitView, panes) + rootView.addSubview(splitView) + defer { cleanup(splitView, panes) } + + let invalidator = PortalSplitDividerCacheInvalidator() + defer { invalidator.invalidate() } + + let collected = PortalSplitDividerRegion.collect(in: rootView) + invalidator.observe( + rootView: rootView, + geometryViews: collected.geometryObservedViews, + hierarchyNodes: collected.hierarchyNodes + ) {} + #expect(invalidator.isHierarchyCurrent(for: rootView)) + + mutation(splitView, panes) + #expect(!invalidator.isHierarchyCurrent(for: rootView)) + } + + @Test + func terminalSplitDividerCacheInvalidatesWhenContentRootIsReplaced() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let oldRoot = try #require(window.contentView) + let container = try #require(oldRoot.superview) + + let host = WindowTerminalHostView(frame: container.convert(oldRoot.bounds, from: oldRoot)) + let hostedView = CapturingView(frame: host.bounds) + host.addSubview(hostedView) + container.addSubview(host, positioned: .above, relativeTo: oldRoot) + + let warmPointInWindow = oldRoot.convert(NSPoint(x: 20, y: 20), to: nil) + let warmEvent = makeMouseEvent(type: .mouseMoved, at: warmPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(warmPointInWindow, from: nil), currentEvent: warmEvent) === hostedView) + + // Swap in a fresh content root while the old one stays alive and unmutated. + // A snapshot that never checks which root it was built from still validates + // against the detached tree and keeps returning the stale empty regions. + let newRoot = NSView(frame: oldRoot.frame) + window.contentView = newRoot + + let splitView = CountingSplitView(frame: newRoot.bounds) + splitView.isVertical = true + let splitDelegate = SplitDelegate() + splitView.delegate = splitDelegate + splitView.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 200, height: newRoot.bounds.height))) + splitView.addSubview(NSView(frame: NSRect(x: 201, y: 0, width: 119, height: newRoot.bounds.height))) + newRoot.addSubview(splitView) + + let dividerPointInWindow = splitView.convert( + NSPoint(x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), y: splitView.bounds.midY), + to: nil + ) + let event = makeMouseEvent(type: .mouseMoved, at: dividerPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(dividerPointInWindow, from: nil), currentEvent: event) == nil) + withExtendedLifetime(oldRoot) {} + } + + @Test + func terminalSplitDividerCacheDoesNotReviveAfterDetachedRootMutation() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let cachedRoot = try #require(window.contentView) + let container = try #require(cachedRoot.superview) + let hostedView = CapturingView(frame: cachedRoot.bounds) + let host = WindowTerminalHostView(frame: container.convert(cachedRoot.bounds, from: cachedRoot)) + host.addSubview(hostedView) + container.addSubview(host, positioned: .above, relativeTo: cachedRoot) + + let warmPointInWindow = cachedRoot.convert(NSPoint(x: 20, y: 20), to: nil) + let warmEvent = makeMouseEvent(type: .mouseMoved, at: warmPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(warmPointInWindow, from: nil), currentEvent: warmEvent) === hostedView) + + window.contentView = NSView(frame: cachedRoot.frame) + + let splitView = CountingSplitView(frame: cachedRoot.bounds) + splitView.isVertical = true + let splitDelegate = SplitDelegate() + splitView.delegate = splitDelegate + splitView.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 200, height: cachedRoot.bounds.height))) + splitView.addSubview(NSView(frame: NSRect(x: 201, y: 0, width: 119, height: cachedRoot.bounds.height))) + splitView.setPosition(200, ofDividerAt: 0) + splitView.adjustSubviews() + cachedRoot.addSubview(splitView) + + window.contentView = cachedRoot + + let dividerPointInWindow = splitView.convert( + NSPoint(x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), y: splitView.bounds.midY), + to: nil + ) + let event = makeMouseEvent(type: .mouseMoved, at: dividerPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(dividerPointInWindow, from: nil), currentEvent: event) == nil) + withExtendedLifetime(splitDelegate) {} + } + + @Test + func terminalSplitDividerCacheRevalidatesDetachedNestedSubtree() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let rootView = try #require(window.contentView) + let container = NSView(frame: rootView.bounds) + let detachedContainer = NSView(frame: container.bounds) + container.addSubview(detachedContainer) + rootView.addSubview(container) + + let hostedView = CapturingView(frame: rootView.bounds) + let host = WindowTerminalHostView(frame: rootView.bounds) + host.addSubview(hostedView) + rootView.addSubview(host) + + let warmPointInWindow = rootView.convert(NSPoint(x: 20, y: 20), to: nil) + let warmEvent = makeMouseEvent(type: .mouseMoved, at: warmPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(warmPointInWindow, from: nil), currentEvent: warmEvent) === hostedView) + + let windowContainer = try #require(rootView.superview) + let parkingView = NSView(frame: rootView.frame) + windowContainer.addSubview(parkingView, positioned: .below, relativeTo: rootView) + detachedContainer.removeFromSuperview() + + let splitView = CountingSplitView(frame: detachedContainer.bounds) + splitView.isVertical = true + let splitDelegate = SplitDelegate() + splitView.delegate = splitDelegate + splitView.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 200, height: detachedContainer.bounds.height))) + splitView.addSubview(NSView(frame: NSRect(x: 201, y: 0, width: 119, height: detachedContainer.bounds.height))) + splitView.setPosition(200, ofDividerAt: 0) + splitView.adjustSubviews() + detachedContainer.addSubview(splitView) + + // Re-enter the tracked window through a branch outside the cached root. + // The later same-window move must not revive the pre-detachment proof. + parkingView.addSubview(detachedContainer) + container.addSubview(detachedContainer) + + let dividerPointInWindow = splitView.convert( + NSPoint(x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), y: splitView.bounds.midY), + to: nil + ) + let event = makeMouseEvent(type: .mouseMoved, at: dividerPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(dividerPointInWindow, from: nil), currentEvent: event) == nil) + withExtendedLifetime(splitDelegate) {} + } + + @Test + func terminalSplitDividerCacheDoesNotReviveAfterWrappedDetachedMutation() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let rootView = try #require(window.contentView) + let container = NSView(frame: rootView.bounds) + let detachedContainer = NSView(frame: container.bounds) + container.addSubview(detachedContainer) + rootView.addSubview(container) + + let hostedView = CapturingView(frame: rootView.bounds) + let host = WindowTerminalHostView(frame: rootView.bounds) + host.addSubview(hostedView) + rootView.addSubview(host) + + let warmPointInWindow = rootView.convert(NSPoint(x: 20, y: 20), to: nil) + let warmEvent = makeMouseEvent(type: .mouseMoved, at: warmPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(warmPointInWindow, from: nil), currentEvent: warmEvent) === hostedView) + + let windowContainer = try #require(rootView.superview) + let parkingView = NSView(frame: rootView.frame) + windowContainer.addSubview(parkingView, positioned: .below, relativeTo: rootView) + detachedContainer.removeFromSuperview() + + // A fresh wrapper has no indexed node state. Mutating the indexed child + // while this whole branch is detached must still revoke its old proof. + let detachedWrapper = NSView(frame: detachedContainer.frame) + detachedWrapper.addSubview(detachedContainer) + + let splitView = CountingSplitView(frame: detachedContainer.bounds) + splitView.isVertical = true + let splitDelegate = SplitDelegate() + splitView.delegate = splitDelegate + splitView.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 200, height: detachedContainer.bounds.height))) + splitView.addSubview(NSView(frame: NSRect(x: 201, y: 0, width: 119, height: detachedContainer.bounds.height))) + splitView.setPosition(200, ofDividerAt: 0) + splitView.adjustSubviews() + detachedContainer.addSubview(splitView) + + // Re-enter through the unindexed wrapper, then move only the formerly + // indexed child back to the cache root in the same window. + parkingView.addSubview(detachedWrapper) + container.addSubview(detachedContainer) + + let dividerPointInWindow = splitView.convert( + NSPoint(x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), y: splitView.bounds.midY), + to: nil + ) + let event = makeMouseEvent(type: .mouseMoved, at: dividerPointInWindow, window: window) + #expect(host.performHitTest(at: host.convert(dividerPointInWindow, from: nil), currentEvent: event) == nil) + withExtendedLifetime(splitDelegate) {} + } + @Test func terminalSplitDividerCacheRefreshesWhenNestedSplitBecomesVisible() throws { let window = NSWindow(