diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f2d7bf6b..7fc4f75b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -274,7 +274,8 @@ var fileDropOverlayKey: UInt8 = 0 enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 - static let maxMountedWorkspacesDuringCycle = 3 + // During workspace cycling, keep only a minimal handoff pair (selected + retiring). + static let maxMountedWorkspacesDuringCycle = 2 static func nextMountedWorkspaceIds( current: [UUID], @@ -293,12 +294,6 @@ enum WorkspaceMountPolicy { ordered.insert(selected, at: 0) } - let prioritizedPinnedIds = pinnedIds.filter { existing.contains($0) && $0 != selected } - for pinnedId in prioritizedPinnedIds.reversed() { - ordered.removeAll { $0 == pinnedId } - ordered.insert(pinnedId, at: 0) - } - if isCycleHot, let selected { let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds) for id in warmIds.reversed() { @@ -307,6 +302,33 @@ enum WorkspaceMountPolicy { } } + if isCycleHot, + pinnedIds.isEmpty, + let selected { + ordered.removeAll { $0 != selected } + } + + // Ensure pinned ids (retiring handoff workspaces) are always retained at highest priority. + // This runs after warming to prevent neighbor warming from evicting the retiring workspace. + let prioritizedPinnedIds = pinnedIds + .filter { existing.contains($0) && $0 != selected } + .sorted { lhs, rhs in + let lhsIndex = orderedTabIds.firstIndex(of: lhs) ?? .max + let rhsIndex = orderedTabIds.firstIndex(of: rhs) ?? .max + return lhsIndex < rhsIndex + } + if let selected, existing.contains(selected) { + ordered.removeAll { $0 == selected } + ordered.insert(selected, at: 0) + } + var pinnedInsertionIndex = (selected != nil) ? 1 : 0 + for pinnedId in prioritizedPinnedIds { + ordered.removeAll { $0 == pinnedId } + let insertionIndex = min(pinnedInsertionIndex, ordered.count) + ordered.insert(pinnedId, at: insertionIndex) + pinnedInsertionIndex += 1 + } + if ordered.count > clampedMax { ordered.removeSubrange(clampedMax...) } @@ -315,18 +337,10 @@ enum WorkspaceMountPolicy { } private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] { - guard let selectedIndex = orderedTabIds.firstIndex(of: selected) else { - return [selected] - } - - var ids: [UUID] = [selected] - if selectedIndex > 0 { - ids.append(orderedTabIds[selectedIndex - 1]) - } - if selectedIndex + 1 < orderedTabIds.count { - ids.append(orderedTabIds[selectedIndex + 1]) - } - return ids + guard orderedTabIds.contains(selected) else { return [selected] } + // Keep warming focused to the selected workspace. Retiring/target workspaces are + // pinned by handoff logic, so warming adjacent neighbors here just adds layout work. + return [selected] } } @@ -465,10 +479,12 @@ struct ContentView: View { let isRetiringWorkspace = retiringWorkspaceId == tab.id let isInputActive = isSelectedWorkspace || isRetiringWorkspace let isVisible = isSelectedWorkspace || isRetiringWorkspace + let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( workspace: tab, isWorkspaceVisible: isVisible, - isWorkspaceInputActive: isInputActive + isWorkspaceInputActive: isInputActive, + workspacePortalPriority: portalPriority ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) @@ -821,7 +837,8 @@ struct ContentView: View { let effectiveSelectedId = selectedId ?? tabManager.selectedTabId let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] let isCycleHot = tabManager.isWorkspaceCycleHot - let baseMaxMounted = isCycleHot + let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty + let baseMaxMounted = shouldKeepHandoffPair ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle : WorkspaceMountPolicy.maxMountedWorkspaces let selectedCount = effectiveSelectedId == nil ? 0 : 1 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 68572571..5fe29ff4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3670,6 +3670,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let terminalSurface: TerminalSurface var isActive: Bool = true var isVisibleInUI: Bool = true + var portalZPriority: Int = 0 var showsInactiveOverlay: Bool = false var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 @@ -3713,6 +3714,8 @@ struct GhosttyTerminalView: NSViewRepresentable { // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true var desiredIsVisibleInUI: Bool = true + var desiredPortalZPriority: Int = 0 + var lastBoundHostId: ObjectIdentifier? weak var hostedView: GhosttySurfaceScrollView? } @@ -3729,23 +3732,30 @@ struct GhosttyTerminalView: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator +#if DEBUG let previousDesiredIsActive = coordinator.desiredIsActive +#endif let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI + let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI + coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG - if previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI { + if previousDesiredIsActive != isActive || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + - "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + + "active=\(isActive ? 1 : 0) z=\(portalZPriority)" ) } else { dlog( "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)" ) } } @@ -3774,28 +3784,36 @@ struct GhosttyTerminalView: NSViewRepresentable { TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, - visibleInUI: coordinator.desiredIsVisibleInUI + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority ) + coordinator.lastBoundHostId = ObjectIdentifier(host) hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) } - host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in - guard let host, let hostedView, let coordinator else { return } + host.onGeometryChanged = { [weak host, weak coordinator] in + guard let host, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI - ) + guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return } TerminalWindowPortalRegistry.synchronizeForAnchor(host) } if host.window != nil { - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI - ) + let hostId = ObjectIdentifier(host) + let shouldBindNow = + coordinator.lastBoundHostId != hostId || + hostedView.superview == nil || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority + if shouldBindNow { + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastBoundHostId = hostId + } TerminalWindowPortalRegistry.synchronizeForAnchor(host) } } @@ -3805,6 +3823,8 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.attachGeneration += 1 coordinator.desiredIsActive = false coordinator.desiredIsVisibleInUI = false + coordinator.desiredPortalZPriority = 0 + coordinator.lastBoundHostId = nil #if DEBUG if let hostedView = coordinator.hostedView { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 4411ed74..9049e166 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -7,6 +7,7 @@ struct PanelContentView: View { let isFocused: Bool let isSelectedInPane: Bool let isVisibleInUI: Bool + let portalPriority: Int let isSplit: Bool let appearance: PanelAppearance let notificationStore: TerminalNotificationStore @@ -22,6 +23,7 @@ struct PanelContentView: View { panel: terminalPanel, isFocused: isFocused, isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, isSplit: isSplit, appearance: appearance, notificationStore: notificationStore, diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index eaf2f0ba..4486d724 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -7,6 +7,7 @@ struct TerminalPanelView: View { @ObservedObject var panel: TerminalPanel let isFocused: Bool let isVisibleInUI: Bool + let portalPriority: Int let isSplit: Bool let appearance: PanelAppearance let notificationStore: TerminalNotificationStore @@ -19,6 +20,7 @@ struct TerminalPanelView: View { terminalSurface: panel.surface, isActive: isFocused, isVisibleInUI: isVisibleInUI, + portalZPriority: portalPriority, showsInactiveOverlay: isSplit && !isFocused, inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 7faf6949..5904a7aa 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -25,6 +25,7 @@ final class WindowTerminalPortal: NSObject { weak var hostedView: GhosttySurfaceScrollView? weak var anchorView: NSView? var visibleInUI: Bool + var zPriority: Int } private var entriesByHostedId: [ObjectIdentifier: Entry] = [:] @@ -61,13 +62,14 @@ final class WindowTerminalPortal: NSObject { NSLayoutConstraint.activate(installConstraints) installedContainerView = container installedReferenceView = reference - } else { + } 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 { + overlay.superview === container, + !Self.isView(overlay, above: hostView, in: container) { container.addSubview(overlay, positioned: .above, relativeTo: hostView) } @@ -105,6 +107,14 @@ final class WindowTerminalPortal: NSObject { 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 { @@ -115,11 +125,12 @@ final class WindowTerminalPortal: NSObject { } } - func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + 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 { detachHostedView(withId: previousHostedId) @@ -135,12 +146,25 @@ final class WindowTerminalPortal: NSObject { entriesByHostedId[hostedId] = Entry( hostedView: hostedView, anchorView: anchorView, - visibleInUI: visibleInUI + 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 hostedView.superview !== hostView { hostedView.removeFromSuperview() hostView.addSubview(hostedView) + } else if (didChangeAnchor || becameVisible || priorityIncreased), hostView.subviews.last !== hostedView { + // Refresh z-order only on meaningful transitions. Reordering on every bind call + // creates expensive reparent loops during SwiftUI update/layout churn. + hostedView.removeFromSuperview() + hostView.addSubview(hostedView) } synchronizeHostedView(withId: hostedId) @@ -347,7 +371,7 @@ enum TerminalWindowPortalRegistry { return portal } - static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard let window = anchorView.window else { return } let windowId = ObjectIdentifier(window) @@ -359,7 +383,7 @@ enum TerminalWindowPortalRegistry { portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId) } - nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI) + nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) hostedToWindowId[hostedId] = windowId pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds()) } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 92fc0a42..f977ea65 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -7,6 +7,7 @@ struct WorkspaceContentView: View { @ObservedObject var workspace: Workspace let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool + let workspacePortalPriority: Int @State private var config = GhosttyConfig.load() @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -44,6 +45,7 @@ struct WorkspaceContentView: View { isFocused: isFocused, isSelectedInPane: isSelectedInPane, isVisibleInUI: isVisibleInUI, + portalPriority: workspacePortalPriority, isSplit: isSplit, appearance: appearance, notificationStore: notificationStore, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3bb81093..0acac8b6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1803,7 +1803,7 @@ final class WorkspaceMountPolicyTests: XCTestCase { XCTAssertEqual(next, [a]) } - func testCycleHotModeWarmsSelectedAndImmediateNeighbors() { + func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() { let a = UUID() let b = UUID() let c = UUID() @@ -1819,7 +1819,7 @@ final class WorkspaceMountPolicyTests: XCTestCase { maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle ) - XCTAssertEqual(next, [c, b, d]) + XCTAssertEqual(next, [c]) } func testCycleHotModeRespectsMaxMountedLimit() { @@ -1837,7 +1837,7 @@ final class WorkspaceMountPolicyTests: XCTestCase { maxMounted: 2 ) - XCTAssertEqual(next, [b, a]) + XCTAssertEqual(next, [b]) } func testPinnedIdsAreRetainedAcrossReconcile() { @@ -1857,6 +1857,23 @@ final class WorkspaceMountPolicyTests: XCTestCase { XCTAssertEqual(next, [c, a]) } + + func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() { + let a = UUID() + let b = UUID() + let orderedTabIds: [UUID] = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + pinnedIds: [a], + orderedTabIds: orderedTabIds, + isCycleHot: true, + maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle + ) + + XCTAssertEqual(next, [b, a]) + } } @MainActor @@ -1903,6 +1920,35 @@ final class GhosttySurfaceOverlayTests: XCTestCase { @MainActor final class TerminalWindowPortalLifecycleTests: XCTestCase { + func testPortalHostInstallsAboveContentViewForVisibility() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + _ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), + let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { + XCTFail("Expected host/content views in same container") + return + } + + XCTAssertGreaterThan( + hostIndex, + contentIndex, + "Portal host must remain above content view so portal-hosted terminals stay visible" + ) + } + func testRegistryPrunesPortalWhenWindowCloses() { let baseline = TerminalWindowPortalRegistry.debugPortalCount() let window = NSWindow( @@ -1982,4 +2028,85 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Portal hit-testing should resolve the terminal view for Finder file drops" ) } + + func testVisibilityTransitionBringsHostedViewToFront() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180)) + let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1) + let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true) + portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true) + + let overlapInContent = NSPoint(x: 120, y: 100) + let overlapInWindow = contentView.convert(overlapInContent, to: nil) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2, + "Latest bind should be top-most before visibility transition" + ) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false) + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1, + "Becoming visible should refresh z-order for already-hosted view" + ) + } + + func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180)) + let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1) + let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1) + portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2) + + let overlapInContent = NSPoint(x: 120, y: 100) + let overlapInWindow = contentView.convert(overlapInContent, to: nil) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2, + "Higher-priority terminal should initially be top-most" + ) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1, + "Promoting z-priority should bring an already-visible terminal to front" + ) + } }