Speed up workspace switching: reduce portal churn and enforce selected z-order

This commit is contained in:
Lawrence Chen 2026-02-18 22:13:40 -08:00
parent 442eb1f01d
commit ee4848c008
7 changed files with 240 additions and 46 deletions

View file

@ -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

View file

@ -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() {

View file

@ -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,

View file

@ -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,

View file

@ -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())
}

View file

@ -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,

View file

@ -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"
)
}
}