diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift index a5b096b2fef5..95bbbde8dcaa 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -96,7 +96,11 @@ public struct TerminalCatalogSection: SettingCatalogSection { /// instant. Extra off-screen renderers are reclaimed oldest first. public let rendererRealizationMaxWarmRenderers = DefaultsKey( id: "terminal.rendererRealization.maxWarmRenderers", - defaultValue: 1, + // Keep the last few hidden surfaces' renderers warm so switching + // between recently used workspaces presents retained pixels instantly + // (#1291). Hidden windows still release everything via window + // occlusion, and the idle threshold bounds the rest. + defaultValue: 4, userDefaultsKey: "terminal.rendererRealization.maxWarmRenderers" ) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 37ef7e8457d3..1a1cba051316 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -934,6 +934,7 @@ struct ContentView: View { @State private var previousSelectedWorkspaceId: UUID? @State private var retiringWorkspaceId: UUID? @State private var workspaceHandoffFallbackScheduler = MainActorDeferredActionScheduler() + @State private var workspaceHandoffFrameWatcher = WorkspaceHandoffFrameWatcher() @State private var didApplyUITestSidebarSelection = false @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @@ -3026,6 +3027,7 @@ struct ContentView: View { if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { self.retiringWorkspaceId = nil workspaceHandoffFallbackScheduler.cancel() + workspaceHandoffFrameWatcher.cancel() } if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) { self.previousSelectedWorkspaceId = tabManager.selectedTabId @@ -3571,11 +3573,13 @@ struct ContentView: View { tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") retiringWorkspaceId = nil workspaceHandoffFallbackScheduler.cancel() + workspaceHandoffFrameWatcher.cancel() return } retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackScheduler.cancel() + workspaceHandoffFrameWatcher.cancel() #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -3606,7 +3610,23 @@ struct ContentView: View { return } - workspaceHandoffFallbackScheduler.schedule(after: .milliseconds(150)) { + // Complete as soon as every incoming visible terminal renders a frame, + // so the retiring workspace's content covers the whole gap without a + // blank transition frame (#1291). The timeout below stays the ceiling. + if let workspace = tabManager.tabs.first(where: { $0.id == newSelectedId }) { + workspaceHandoffFrameWatcher.begin( + workspaceId: newSelectedId, + targets: workspace.handoffWatchTargets() + ) { + completeWorkspaceHandoff(reason: "first_frame") + } + } + + // The timeout is a liveness escape (dead PTY, wedged renderer), not + // the normal completion: frame-driven completion typically lands well + // under 100ms. Holding the retiring content a bit longer beats + // painting a blank frame. + workspaceHandoffFallbackScheduler.schedule(after: .milliseconds(500)) { completeWorkspaceHandoff(reason: "timeout") } } @@ -3614,6 +3634,10 @@ struct ContentView: View { private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) { guard focusedTabId == tabManager.selectedTabId else { return } guard retiringWorkspaceId != nil else { return } + // Focus can land on an incoming terminal before it renders its first + // frame; completing then hides the retiring content over an empty + // layer (#1291). Let the frame watcher (or the timeout) finish. + guard !workspaceHandoffFrameWatcher.isPending else { return } completeWorkspaceHandoff(reason: reason) } @@ -3623,11 +3647,18 @@ struct ContentView: View { workspace.browserPanel(for: focusedPanelId) != nil { return true } - return workspace.hasLoadedTerminalSurface() + guard workspace.hasLoadedTerminalSurface() else { return false } + // Surface existence is not presentation: a freshly mounted workspace + // has hidden, unrevealed portals, and hiding the old content at that + // point paints a frame with neither workspace's terminals (#1291). + // Complete immediately only when the incoming terminals are already + // presented on screen (e.g. the cycle-hot mounted pair). + return workspace.visibleTerminalsReadyForImmediateHandoff() } private func completeWorkspaceHandoff(reason: String) { workspaceHandoffFallbackScheduler.cancel() + workspaceHandoffFrameWatcher.cancel() let retiring = retiringWorkspaceId // Disable before clearing retiringWorkspaceId: unmount teardown does not diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 87218639c83f..0e16c527cb09 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5150,6 +5150,46 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos return terminalPanels.contains { $0.surface.surface != nil } } + /// The terminals the current layout model would show, as handoff watch + /// targets. The workspace handoff waits for each one's first rendered + /// frame and reveal before the retiring workspace's content is hidden + /// (#1291). + func handoffWatchTargets() -> [WorkspaceHandoffFrameWatcher.Target] { + let visiblePanelIds = expectedVisiblePanelIdsForLayout() + var targets: [WorkspaceHandoffFrameWatcher.Target] = [] + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + // Mirror-rendered window-tab panels are drawn by their split view, + // not this panel's surface (see the portal visibility reconcile). + if remoteTmuxWindowMirrors[terminalPanel.id] != nil { continue } + guard visiblePanelIds.contains(terminalPanel.id) else { continue } + targets.append(WorkspaceHandoffFrameWatcher.Target( + surface: terminalPanel.surface, + hostedView: terminalPanel.hostedView + )) + } + return targets + } + + /// Whether every rendered-visible terminal is already presented in the + /// window, so hiding the retiring workspace's content cannot expose a + /// frame with neither workspace's terminals (#1291). False for a freshly + /// mounted workspace whose portals have not revealed yet. + func visibleTerminalsReadyForImmediateHandoff() -> Bool { + let visiblePanelIds = expectedVisiblePanelIdsForLayout() + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + if remoteTmuxWindowMirrors[terminalPanel.id] != nil { continue } + guard visiblePanelIds.contains(terminalPanel.id) else { continue } + let hostedView = terminalPanel.hostedView + guard !hostedView.isHidden, + hostedView.superview != nil, + terminalPanel.surface.isViewInWindow, + terminalPanel.surface.isRendererPresented else { return false } + } + return true + } + func panelTitle(panelId: UUID) -> String? { if let remotePane = remoteTmuxControlPane(surfaceID: panelId) { return remotePane.pane.title @@ -11642,6 +11682,14 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos private func renderedVisiblePanelIdsForCurrentLayout() -> Set { guard portalRenderingEnabled else { return [] } + return expectedVisiblePanelIdsForLayout() + } + + /// The panel ids the current layout model would show, independent of + /// whether this workspace's portals are rendering yet. The workspace + /// handoff consults this for a workspace that is about to mount (#1291), + /// when `portalRenderingEnabled` is still false. + private func expectedVisiblePanelIdsForLayout() -> Set { // Canvas mode renders one panel per canvas pane — its selected tab. // Background tabs are unmounted, so reporting them as rendered makes // the terminal window portal float them at stale frames (chromeless diff --git a/Sources/WorkspaceHandoffFrameWatcher.swift b/Sources/WorkspaceHandoffFrameWatcher.swift new file mode 100644 index 000000000000..c8a46558c4fe --- /dev/null +++ b/Sources/WorkspaceHandoffFrameWatcher.swift @@ -0,0 +1,150 @@ +import AppKit +import CmuxFoundation +import CmuxTerminal + +/// Completes a workspace handoff when every incoming visible terminal is +/// presentable, so hiding the retiring workspace's content can never expose a +/// blank frame (#1291). +/// +/// Presentable means the hosted view is revealed (unhidden, in a window) and +/// the terminal layer holds pixels (`layer.contents != nil`). A warm surface +/// keeps its last IOSurface across hides, so it is presentable the moment the +/// portal reveals it; a surface whose renderer was reclaimed becomes +/// presentable when the rebuilt renderer publishes its first IOSurface. Both +/// transitions are observed (portal visibility notification, `isHidden` and +/// `contents` KVO); the caller's timeout stays the liveness backstop. +@MainActor +final class WorkspaceHandoffFrameWatcher { + struct Target { + let surface: TerminalSurface + let hostedView: GhosttySurfaceScrollView + } + + private var targets: [Target] = [] + private var observers: [NSObjectProtocol] = [] + private var kvoObservations: [NSKeyValueObservation] = [] + private var onReady: (() -> Void)? + private var workspaceId: UUID? + private let recheckScheduler = MainActorDeferredActionScheduler() + + /// CALayer contents changes bypass KVO for the core-owned terminal layer, + /// so cold reveals (reclaimed renderer republishing its IOSurface) are + /// re-checked on a short bounded cadence. The handoff's timeout ends the + /// loop; completion or cancel stops it earlier. + private static let recheckInterval: Duration = .milliseconds(32) + + /// True while incoming terminals are not yet presentable. Focus-driven + /// handoff completions defer to this so focus arriving ahead of pixels + /// cannot re-expose the blank transition (#1291). + var isPending: Bool { onReady != nil } + + /// Starts watching. With no targets this never fires `onReady`; callers + /// complete such handoffs through the immediate path instead. + func begin( + workspaceId: UUID, + targets: [Target], + onReady: @escaping () -> Void + ) { + cancel() + guard !targets.isEmpty else { return } +#if DEBUG + cmuxDebugLog( + "ws.handoff.frameWatch.begin ws=\(workspaceId.uuidString.prefix(5)) expected=\(targets.count)" + ) +#endif + self.workspaceId = workspaceId + self.targets = targets + self.onReady = onReady + + observers.append(NotificationCenter.default.addObserver( + forName: .terminalPortalVisibilityDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.completeIfReady() + }) + for target in targets { + // The portal reveal path can unhide a hosted view without posting + // a visibility notification; observe the hidden bit directly. + kvoObservations.append(target.hostedView.observe( + \.isHidden, options: [.new] + ) { [weak self] _, _ in + Task { @MainActor [weak self] in + self?.completeIfReady() + } + }) + // A reclaimed renderer publishes its first IOSurface by setting + // the terminal layer's contents; observe that for cold reveals. + if let layer = target.hostedView.surfaceView.layer { + kvoObservations.append(layer.observe( + \.contents, options: [.new] + ) { [weak self] _, _ in + Task { @MainActor [weak self] in + self?.completeIfReady() + } + }) + } + } + // The reveal may already be complete (cycle-hot warm pair). + completeIfReady() + if isPending { scheduleRecheck() } + } + + private func scheduleRecheck() { + recheckScheduler.schedule(after: Self.recheckInterval) { [weak self] in + guard let self, self.isPending else { return } + self.completeIfReady() + if self.isPending { self.scheduleRecheck() } + } + } + + func cancel() { +#if DEBUG + if onReady != nil { + for target in targets { + let view = target.hostedView + let layer = view.surfaceView.layer + cmuxDebugLog( + "ws.handoff.frameWatch.state surface=\(target.surface.id.uuidString.prefix(5)) " + + "hidden=\(view.isHidden ? 1 : 0) inWindow=\(view.window != nil ? 1 : 0) " + + "layer=\(layer.map { String(describing: type(of: $0)) } ?? "nil") " + + "contents=\((layer?.presentation() ?? layer)?.contents != nil ? 1 : 0)" + ) + } + } +#endif + recheckScheduler.cancel() + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers = [] + kvoObservations.forEach { $0.invalidate() } + kvoObservations = [] + targets = [] + onReady = nil + workspaceId = nil + } + + private func isPresentable(_ target: Target) -> Bool { + let view = target.hostedView + guard !view.isHidden, view.window != nil else { return false } + // The core publishes IOSurface contents off the main thread; the model + // layer can read nil while the presentation copy already has pixels + // (same idiom as the debug present-stats reader). + guard let layer = view.surfaceView.layer else { return false } + return (layer.presentation() ?? layer).contents != nil + } + + private func completeIfReady() { + guard onReady != nil else { return } + guard targets.allSatisfy(isPresentable) else { return } +#if DEBUG + cmuxDebugLog( + "ws.handoff.frameWatch.presentable ws=\(workspaceId?.uuidString.prefix(5) ?? "nil") targets=\(targets.count)" + ) +#endif + let ready = onReady + cancel() + // One main-queue turn so any contents commit queued behind this event + // lands before the retiring content is hidden. + DispatchQueue.main.async { ready?() } + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9294dd780525..5f73d3de81ec 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2756,6 +2756,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9A57401C9A57401C9A57401 /* WorkspaceGroupMoveToMenuStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */; }; 9164A0019164A0019164A001 /* WorkspaceGroupNumberedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9164A0029164A0029164A002 /* WorkspaceGroupNumberedSelectionTests.swift */; }; C9A57001C9A57001C9A57001 /* WorkspaceGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */; }; + D71291AB0000000000000001 /* WorkspaceHandoffFrameWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71291AB0000000000000002 /* WorkspaceHandoffFrameWatcher.swift */; }; E3B7A4000000000000000007 /* WorkspaceIndicatorStyle+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A4000000000000000008 /* WorkspaceIndicatorStyle+Display.swift */; }; A17C0A110000000000000001 /* WorkspaceInitialCommandLoginShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17C0A110000000000000002 /* WorkspaceInitialCommandLoginShell.swift */; }; B63E1257BB174740A44F458C /* WorkspaceIsStaleAgentHookBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B714B27E46014592B9CB2D3A /* WorkspaceIsStaleAgentHookBindingTests.swift */; }; @@ -5580,6 +5581,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMoveToMenuStateTests.swift; sourceTree = ""; }; 9164A0029164A0029164A002 /* WorkspaceGroupNumberedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupNumberedSelectionTests.swift; sourceTree = ""; }; C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupTests.swift; sourceTree = ""; }; + D71291AB0000000000000002 /* WorkspaceHandoffFrameWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceHandoffFrameWatcher.swift; sourceTree = ""; }; E3B7A4000000000000000008 /* WorkspaceIndicatorStyle+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceIndicatorStyle+Display.swift"; sourceTree = ""; }; A17C0A110000000000000002 /* WorkspaceInitialCommandLoginShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceInitialCommandLoginShell.swift; sourceTree = ""; }; B714B27E46014592B9CB2D3A /* WorkspaceIsStaleAgentHookBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceIsStaleAgentHookBindingTests.swift; sourceTree = ""; }; @@ -6745,6 +6747,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A11E5E1E0000000000000002 /* TerminalSelectionAccessibilityNotifier.swift */, A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */, D78971000000000000000002 /* GhosttyNotificationScrollRuntimeBridge.swift */, + D71291AB0000000000000002 /* WorkspaceHandoffFrameWatcher.swift */, D78972000000000000000002 /* GhosttyCurrentDirectoryActionDispatcher.swift */, D78975000000000000000002 /* GhosttyCurrentDirectoryAction.swift */, D78970000000000000000006 /* NotificationReplayRestoreContext.swift */, @@ -10907,6 +10910,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F0ACC0DE000000000000000B /* WorkspaceForkAgentConversationAvailability.swift in Sources */, C9A57203C9A57203C9A57203 /* WorkspaceGroupMenuSnapshot.swift in Sources */, C9A57206C9A57206C9A57206 /* WorkspaceGroupMoveToMenuState.swift in Sources */, + D71291AB0000000000000001 /* WorkspaceHandoffFrameWatcher.swift in Sources */, E3B7A4000000000000000007 /* WorkspaceIndicatorStyle+Display.swift in Sources */, A17C0A110000000000000001 /* WorkspaceInitialCommandLoginShell.swift in Sources */, CA52B0180000000000000000 /* WorkspaceLayoutMode.swift in Sources */, diff --git a/cmuxTests/RendererRealizationPlannerTests.swift b/cmuxTests/RendererRealizationPlannerTests.swift index 3ee4cca30326..5bc2fc9805b1 100644 --- a/cmuxTests/RendererRealizationPlannerTests.swift +++ b/cmuxTests/RendererRealizationPlannerTests.swift @@ -102,7 +102,7 @@ struct RendererRealizationPlannerTests { #expect(selected.contains(ids[4])) // oldest released } - @Test func defaultFiveTabBaselineReclaimsFourHiddenRenderers() throws { + @Test func defaultBaselineKeepsWarmCapAndReclaimsTheExcess() throws { let suiteName = "RendererRealizationPlannerTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -110,15 +110,17 @@ struct RendererRealizationPlannerTests { let now: TimeInterval = 1000 let visible = UUID() - let hidden = (0..<4).map { _ in UUID() } let settings = RendererRealizationSettings.values(defaults: defaults) + // One more hidden idle surface than the default warm cap: the planner + // keeps the cap's most recent renderers warm (instant, pixel-ready + // switching between recent workspaces, #1291) and reclaims the rest. + let hidden = (0..<(settings.maxWarmRenderers + 1)).map { offset in + (id: UUID(), idleFor: settings.idleSeconds + TimeInterval(offset)) + } let inputs = [ input(visible, visible: true, lastVisibleAt: now), ] + hidden.map { - input( - $0, - lastVisibleAt: now - settings.idleSeconds - ) + input($0.id, lastVisibleAt: now - $0.idleFor) } let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, @@ -126,10 +128,10 @@ struct RendererRealizationPlannerTests { now: now ) - #expect(selected == Set(hidden)) + #expect(selected == [hidden.last!.id]) } - @Test func defaultFiveTabBaselineSchedulesTheIdleDeadline() throws { + @Test func defaultBaselineSchedulesTheIdleDeadlineForTheExcess() throws { let suiteName = "RendererRealizationPlannerTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -137,8 +139,8 @@ struct RendererRealizationPlannerTests { let now: TimeInterval = 1000 let visible = UUID() - let hidden = (0..<4).map { _ in UUID() } let settings = RendererRealizationSettings.values(defaults: defaults) + let hidden = (0..<(settings.maxWarmRenderers + 1)).map { _ in UUID() } let inputs = [ input(visible, visible: true, lastVisibleAt: now), ] + hidden.map {