diff --git a/Sources/OmniWM/Core/Ax/AXManager+FrameApplications.swift b/Sources/OmniWM/Core/Ax/AXManager+FrameApplications.swift index 33a51dde9..b95ecd2b4 100644 --- a/Sources/OmniWM/Core/Ax/AXManager+FrameApplications.swift +++ b/Sources/OmniWM/Core/Ax/AXManager+FrameApplications.swift @@ -92,7 +92,7 @@ extension AXManager { else { return frames } return frames.filter { let allowed = isFrameAllowedToWrite($0) - if !allowed { workspaceFrameSettlement?.reject($0) } + if !allowed { rejectInWorkspaceFrameSettlements($0) } return allowed } } diff --git a/Sources/OmniWM/Core/Ax/AXManager+FrameBatching.swift b/Sources/OmniWM/Core/Ax/AXManager+FrameBatching.swift index 4b6aa062d..f1d3aa23d 100644 --- a/Sources/OmniWM/Core/Ax/AXManager+FrameBatching.swift +++ b/Sources/OmniWM/Core/Ax/AXManager+FrameBatching.swift @@ -47,6 +47,26 @@ extension AXManager { } } + func rejectInWorkspaceFrameSettlements(_ target: AXFrameApplicationTarget) { + for settlement in workspaceFrameSettlements { + settlement.reject(target) + } + } + + private func workspaceFrameSettlementObserver( + for target: AXFrameApplicationTarget, + after observer: FrameApplicationTerminalObserver? + ) -> FrameApplicationTerminalObserver? { + let settlementObservers = workspaceFrameSettlements.compactMap { $0.observe(target) } + guard !settlementObservers.isEmpty else { return observer } + return { result in + observer?(result) + for settlementObserver in settlementObservers { + settlementObserver(result) + } + } + } + private func enqueueFrameApplicationsUsingBuffer( _ frames: [AXFrameApplicationTarget], options: AXFrameBatchOptions, @@ -65,19 +85,12 @@ extension AXManager { let pid = target.pid let windowId = target.windowId if inactiveWorkspaceWindowIds.contains(windowId) { - workspaceFrameSettlement?.reject(target) + rejectInWorkspaceFrameSettlements(target) continue } - let settlementObserver = options.isRetry ? nil : workspaceFrameSettlement?.observe(target) - let terminalObserver: FrameApplicationTerminalObserver? - if let settlementObserver { - terminalObserver = { result in - options.terminalObserver?(result) - settlementObserver(result) - } - } else { - terminalObserver = options.terminalObserver - } + let terminalObserver = options.isRetry + ? options.terminalObserver + : workspaceFrameSettlementObserver(for: target, after: options.terminalObserver) let decision = frameLedger.prepareFrameApplication( target, isRetry: options.isRetry, diff --git a/Sources/OmniWM/Core/Ax/AXManager.swift b/Sources/OmniWM/Core/Ax/AXManager.swift index 017ebf1ce..c1437cfd7 100644 --- a/Sources/OmniWM/Core/Ax/AXManager.swift +++ b/Sources/OmniWM/Core/Ax/AXManager.swift @@ -40,7 +40,7 @@ final class AXManager { let frameBatchBuffer = AXFrameBatchBuffer() let managedWindowBindings = AXManagedWindowBindings() let frameLedger = AXFrameApplicationLedger() - var workspaceFrameSettlement: AXFrameSettlement? + var workspaceFrameSettlements: [AXFrameSettlement] = [] var deferredParkRequests: [AXFrameApplicationRequest] = [] var deferredParkAwaitedWindowIds: Set = [] var deferredParkTimeout: Task? diff --git a/Sources/OmniWM/Core/Controller/LayoutRefreshController+DisplayLink.swift b/Sources/OmniWM/Core/Controller/LayoutRefreshController+DisplayLink.swift index 0f246f5bb..23e882073 100644 --- a/Sources/OmniWM/Core/Controller/LayoutRefreshController+DisplayLink.swift +++ b/Sources/OmniWM/Core/Controller/LayoutRefreshController+DisplayLink.swift @@ -52,16 +52,14 @@ extension LayoutRefreshController { } private func handleScreenParametersChanged() { - workspaceSwipe.cancel(reason: "display-change") + workspaceSwipe.cancelAll(reason: "display-change") detectRefreshRates() controller?.syncMonitorsToNiriEngine() controller?.surfaceReconciler.noteWorldChanged() } func cleanupForMonitorDisconnect(displayId: CGDirectDisplayID, migrateAnimations: Bool) { - if workspaceSwipe.flight?.preparation.monitor.displayId == displayId { - workspaceSwipe.cancel(reason: "display-disconnected") - } + workspaceSwipe.cancelFlights(onDisplay: displayId, reason: "display-disconnected") if let workspaceId = niriHandler.scrollAnimationByDisplay[displayId] { niriHandler.terminateViewportGesture( for: workspaceId, diff --git a/Sources/OmniWM/Core/Controller/LayoutRefreshController.swift b/Sources/OmniWM/Core/Controller/LayoutRefreshController.swift index e9bf7f377..6cc84f133 100644 --- a/Sources/OmniWM/Core/Controller/LayoutRefreshController.swift +++ b/Sources/OmniWM/Core/Controller/LayoutRefreshController.swift @@ -118,7 +118,7 @@ import QuartzCore } func resetState() { - workspaceSwipe.cancel(reason: "reset") + workspaceSwipe.cancelAll(reason: "reset") let discardedScratchpadIndices = revealGroups.indices() layoutState.activeRefreshTask?.cancel() layoutState.activeRefreshTask = nil diff --git a/Sources/OmniWM/Core/Controller/MouseEventHandler+TrackpadLifecycle.swift b/Sources/OmniWM/Core/Controller/MouseEventHandler+TrackpadLifecycle.swift index c42cb4ba6..6e260f905 100644 --- a/Sources/OmniWM/Core/Controller/MouseEventHandler+TrackpadLifecycle.swift +++ b/Sources/OmniWM/Core/Controller/MouseEventHandler+TrackpadLifecycle.swift @@ -47,17 +47,16 @@ extension MouseEventHandler { state.activeGestureMode = mode state.gesturePhase = .committed if case let .workspaceSwitch(axis) = mode { - controller.layoutRefreshController.workspaceSwipe.begin( + if controller.layoutRefreshController.workspaceSwipe.begin( axis: axis, cumulative: axis == .horizontal ? metrics.cumulativeX : metrics.cumulativeY, timestamp: timestamp, recognitionMovement: SwipeEvent( delta: Double(axis == .horizontal ? metrics.rawDeltaX : metrics.rawDeltaY), timestamp: metrics.previousTimestamp ) - ) - if controller.layoutRefreshController.workspaceSwipe.hasPresentation { state.workspaceSwipeFired = true } + ) { state.workspaceSwipeFired = true } } else { - controller.layoutRefreshController.workspaceSwipe.cancel(reason: "other-gesture") + controller.layoutRefreshController.workspaceSwipe.otherGestureRecognized(on: lockedContext.monitorId) } return true } diff --git a/Sources/OmniWM/Core/Controller/MouseEventHandler.swift b/Sources/OmniWM/Core/Controller/MouseEventHandler.swift index f5f6013cc..a066cd37b 100644 --- a/Sources/OmniWM/Core/Controller/MouseEventHandler.swift +++ b/Sources/OmniWM/Core/Controller/MouseEventHandler.swift @@ -171,7 +171,7 @@ final class MouseEventHandler { } func resetForMultitouchSourceReplacement() { - controller?.layoutRefreshController.workspaceSwipe.cancel(reason: "source-replaced") + controller?.layoutRefreshController.workspaceSwipe.cancelAll(reason: "source-replaced") resetGestureState() state.workspaceSwipeTracker.reset() clearGestureLatches() diff --git a/Sources/OmniWM/Core/Controller/WindowActionHandler.swift b/Sources/OmniWM/Core/Controller/WindowActionHandler.swift index d47872fe1..296885e15 100644 --- a/Sources/OmniWM/Core/Controller/WindowActionHandler.swift +++ b/Sources/OmniWM/Core/Controller/WindowActionHandler.swift @@ -94,13 +94,13 @@ final class WindowActionHandler { func toggleOverview() { guard controller?.settings.overview.enabled == true else { return } - controller?.layoutRefreshController.workspaceSwipe.cancel(reason: "overview") + controller?.layoutRefreshController.workspaceSwipe.cancelAll(reason: "overview") overviewController.toggle() } func openOverview() { guard controller?.settings.overview.enabled == true else { return } - controller?.layoutRefreshController.workspaceSwipe.cancel(reason: "overview") + controller?.layoutRefreshController.workspaceSwipe.cancelAll(reason: "overview") overviewController.input.beginGestureScrollSuppression() overviewController.open() } @@ -123,7 +123,7 @@ final class WindowActionHandler { func beginOverviewGesture() -> Bool { guard controller?.settings.overview.enabled == true else { return false } - controller?.layoutRefreshController.workspaceSwipe.cancel(reason: "overview") + controller?.layoutRefreshController.workspaceSwipe.cancelAll(reason: "overview") return overviewController.beginInteractiveTransition() } diff --git a/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler+WorkspaceSelection.swift b/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler+WorkspaceSelection.swift index 060a12896..6999be211 100644 --- a/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler+WorkspaceSelection.swift +++ b/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler+WorkspaceSelection.swift @@ -103,6 +103,7 @@ extension WorkspaceNavigationHandler { isNext: isNext, from: currentWorkspace.id, on: monitorId, createIfNeeded: true ) else { return } + controller.layoutRefreshController.workspaceSwipe.noteSwitchWithoutPresentation(on: monitorId) activateWorkspaceInOrder(targetWorkspace, from: currentWorkspace.id, on: monitorId) } diff --git a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Chaining.swift b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Chaining.swift new file mode 100644 index 000000000..fbd676db5 --- /dev/null +++ b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Chaining.swift @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Copyright (C) 2026 OmniNull — https://github.com/OmniNull/OmniWM + +import AppKit + +extension WorkspaceSwipePresentation { + func isCurrent(_ flight: Flight) -> Bool { + flights[flight.preparation.monitor.id] === flight + } + + func handOff( + from predecessor: Flight, into preparation: Preparation, toward destination: Workspace, + using preview: WorkspaceSwipePreview + ) -> Bool? { + guard preview.replace( + source: preparation.source.items, + destination: destination.items, + monitor: preparation.monitor, + workingFrame: preparation.frame + ) else { + return fallBackWithoutAnimation(from: predecessor) + } + if !predecessor.committing { commit(predecessor) } + guard isCurrent(predecessor), predecessor.committing else { return false } + cancel(predecessor, reason: "chained", keepingPreview: true) + return nil + } + + func prefetchChainTargets(after flight: Flight) { + let preparation = flight.preparation + guard flight.motion.target == 1, + let preview = previews[preparation.monitor.id], + let next = makePreparation(monitorId: preparation.monitor.id, chainedSource: flight.destination) + else { return } + preview.prefetch( + shown: preparation.source.items + flight.destination.items, + upcoming: (next.previous?.items ?? []) + (next.next?.items ?? []), + monitor: preparation.monitor, + workingFrame: preparation.frame + ) + } + + func refuseOrFallBack(from predecessor: Flight, axis: WorkspaceSwipeAxis, cumulative: Double) -> Bool { + guard let controller, + let isNext = TrackpadGestureIntent.isNextWorkspace( + axis: axis, displacement: cumulative, naturalDirection: controller.settings.gestures.invertDirection + ), + let target = controller.workspaceNavigationHandler.swipeTargetWorkspace( + isNext: isNext, + from: predecessor.destination.id, + on: predecessor.preparation.monitor.id, + createIfNeeded: false + ), + target.id != predecessor.destination.id + else { + trace("chain-refused") + return true + } + return fallBackWithoutAnimation(from: predecessor) + } + + func fallBackWithoutAnimation(from predecessor: Flight) -> Bool { + if !predecessor.committing { commit(predecessor) } + if isCurrent(predecessor), predecessor.committing { cancel(predecessor, reason: "chain-fallback") } + return false + } + + func createdDestination(isNext: Bool, from preparation: Preparation) -> Workspace? { + guard let controller, isNext, + let created = controller.workspaceNavigationHandler.swipeTargetWorkspace( + isNext: true, from: preparation.source.id, on: preparation.monitor.id, createIfNeeded: true + ) + else { return nil } + return makeWorkspace(created.id, monitor: preparation.monitor, active: false) + } +} diff --git a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Geometry.swift b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Geometry.swift index b437a6b85..6a7582111 100644 --- a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Geometry.swift +++ b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Geometry.swift @@ -5,14 +5,15 @@ import AppKit extension WorkspaceSwipePresentation { func warmPreviews() { - guard flight == nil, preparation == nil, + guard preparation == nil, let controller, controller.hasStartedServices, controller.motionPolicy.animationsEnabled, controller.settings.gestures.workspaceSwipeEnabled, !controller.isOverviewOpen(), let monitor = controller.monitorForInteraction(), + flights[monitor.id] == nil, let preparation = makePreparation(monitorId: monitor.id) else { return } - previewSurface(controller).warm( + previewSurface(controller, for: monitor.id).warm( source: preparation.source.items, destination: (preparation.previous?.items ?? []) + (preparation.next?.items ?? []), monitor: preparation.monitor, @@ -21,21 +22,25 @@ extension WorkspaceSwipePresentation { } func handleInvalidation(workspaceId: WorkspaceDescriptor.ID?, domains: InvalidationDomain) { - guard let flight else { return } - if flight.committing { - if !participantsAreCurrent(flight) { cancel(reason: "commit-superseded") } - } else if !domains.isDisjoint(with: .layoutCommit), - workspaceId == nil || workspaceId == flight.preparation.source.id || workspaceId == flight.destination - .id - { - cancel(reason: "world-invalidated") + for flight in Array(flights.values) { + if flight.committing { + if !participantsAreCurrent(flight) { cancel(flight, reason: "commit-superseded") } + } else if flight.chained { + if !participantsAreCurrent(flight) { cancel(flight, reason: "world-invalidated") } + } else if !domains.isDisjoint(with: .layoutCommit), + workspaceId == nil || workspaceId == flight.preparation.source.id + || workspaceId == flight.destination.id + { + cancel(flight, reason: "world-invalidated") + } } } - func makePreparation(monitorId: Monitor.ID) -> Preparation? { + func makePreparation(monitorId: Monitor.ID, chainedSource: Workspace? = nil) -> Preparation? { guard let controller, let monitor = controller.workspaceManager.monitor(byId: monitorId), - let current = controller.workspaceManager.activeWorkspaceOrFirst(on: monitorId), - let source = makeWorkspace(current.id, monitor: monitor, active: true) + let current = chainedSource.flatMap({ controller.workspaceManager.descriptor(for: $0.id) }) + ?? controller.workspaceManager.activeWorkspaceOrFirst(on: monitorId), + let source = chainedSource ?? makeWorkspace(current.id, monitor: monitor, active: true) else { return nil } let navigation = controller.workspaceNavigationHandler let previous = navigation.swipeTargetWorkspace( diff --git a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Handoff.swift b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Handoff.swift index d49561686..6fed57e9a 100644 --- a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Handoff.swift +++ b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Handoff.swift @@ -84,8 +84,9 @@ extension WorkspaceSwipePresentation { } func windowFrameChanged(_ windowId: UInt32) { - guard let flight, flight.handoff?.windowIds.contains(windowId) == true else { return } - readHandoffGeometry(flight) + for flight in flights.values where flight.handoff?.windowIds.contains(windowId) == true { + readHandoffGeometry(flight) + } } private func readHandoffGeometry(_ flight: Flight) { @@ -114,8 +115,8 @@ extension WorkspaceSwipePresentation { } private func finishHandoff(_ flight: Flight, confirmed: Bool) { - guard self.flight === flight else { return } + guard isCurrent(flight) else { return } let failed = flight.settlement?.failed == true - cancel(reason: failed ? "placement-failed" : confirmed ? "completed" : "handoff-unconfirmed") + cancel(flight, reason: failed ? "placement-failed" : confirmed ? "completed" : "handoff-unconfirmed") } } diff --git a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Previews.swift b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Previews.swift new file mode 100644 index 000000000..efce4a037 --- /dev/null +++ b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation+Previews.swift @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Copyright (C) 2026 OmniNull — https://github.com/OmniNull/OmniWM + +import AppKit + +extension WorkspaceSwipePresentation { + private var allPreviews: [WorkspaceSwipePreview] { + Array(previews.values) + (unclaimedPreview.map { [$0] } ?? []) + } + + func cancelFlights(onDisplay displayId: CGDirectDisplayID, reason: String) { + for flight in Array(flights.values) where flight.preparation.monitor.displayId == displayId { + cancel(flight, reason: reason, settlingFocus: false) + } + forgetNewestGesture(onDisplay: displayId) + for monitorId in previews.keys where monitorId.displayId == displayId { + previews.removeValue(forKey: monitorId)?.release() + } + } + + func windowRemoved(_ token: WindowToken) { + for preview in allPreviews { + preview.remove(token: token) + } + } + + func syncAvailability() { + guard let controller, !allPreviews.isEmpty, + !controller.motionPolicy.animationsEnabled || !controller.settings.gestures.workspaceSwipeEnabled + else { return } + cancelAll(reason: "disabled") + for preview in allPreviews { + preview.release() + } + previews.removeAll() + unclaimedPreview = nil + } + + func previewSurface(_ controller: WMController, for monitorId: Monitor.ID) -> WorkspaceSwipePreview { + if let preview = previews[monitorId] { return preview } + let preview = unclaimedPreview ?? previewFactory?(controller) + ?? WorkspaceSwipePreview(ownedWindowRegistry: controller.ownedWindowRegistry) + unclaimedPreview = nil + previews[monitorId] = preview + return preview + } + + func present(_ flight: Flight, at timestamp: TimeInterval) { + flight.progress = flight.motion.progress(at: timestamp) + flight.carried = flight.carry?(timestamp) ?? .zero + if flight.carried == .zero { flight.carry = nil } + previews[flight.preparation.monitor.id]?.update( + sourceOffset: flight.offset(destination: false), + destinationOffset: flight.offset(destination: true) + ) + } + + func trace(_ action: String, progress: Double = 0) { + guard TrackpadScrollTrace.shared.isActive else { return } + TrackpadScrollTrace.record(.workspacePresentation( + renderer: "preview", + action: action, + progress: progress, + velocity: flight?.motion.velocity(at: mediaTimeProvider()), + target: flight?.motion.target + )) + } +} diff --git a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation.swift b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation.swift index 71918a4f7..b5eb2a503 100644 --- a/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation.swift +++ b/Sources/OmniWM/Core/Controller/WorkspaceSwipePresentation.swift @@ -38,6 +38,10 @@ final class WorkspaceSwipePresentation { var settlement: AXFrameSettlement? var handoff: Handoff? + var carry: ((TimeInterval) -> CGVector?)? + var carried = CGVector.zero + var chained = false + var serial = 0 init( preparation: Preparation, @@ -66,16 +70,35 @@ final class WorkspaceSwipePresentation { } func offset(destination: Bool) -> CGVector { - let translation = (CGFloat(progress) - (destination ? 1 : 0)) * stride * visualSign - return axis == .horizontal ? CGVector(dx: -translation, dy: 0) : CGVector(dx: 0, dy: translation) + let own = vector((CGFloat(progress) - (destination ? 1 : 0)) * stride * visualSign) + return CGVector(dx: own.dx + carried.dx, dy: own.dy + carried.dy) + } + + func residual(at timestamp: TimeInterval) -> CGVector? { + let inherited = carry?(timestamp) + if inherited == nil { carry = nil } + guard inherited != nil || !motion.isComplete(at: timestamp) else { return nil } + let own = vector((CGFloat(motion.progress(at: timestamp)) - 1) * stride * visualSign) + return CGVector(dx: own.dx + (inherited?.dx ?? 0), dy: own.dy + (inherited?.dy ?? 0)) + } + + private func vector(_ translation: CGFloat) -> CGVector { + axis == .horizontal ? CGVector(dx: -translation, dy: 0) : CGVector(dx: 0, dy: translation) } } weak var refreshController: LayoutRefreshController? var preparation: Preparation? - private(set) var flight: Flight? - private var preview: WorkspaceSwipePreview? - private let mediaTimeProvider: () -> TimeInterval + private(set) var flights: [Monitor.ID: Flight] = [:] + private weak var gestureFlight: Flight? + private var chainMonitorId: Monitor.ID? + private var latestSerial = 0 + private var latestGestureMonitorId: Monitor.ID? + private var focusOwed = false + var previews: [Monitor.ID: WorkspaceSwipePreview] = [:] + var unclaimedPreview: WorkspaceSwipePreview? + let previewFactory: (@MainActor (WMController) -> WorkspaceSwipePreview)? + let mediaTimeProvider: () -> TimeInterval var handoffWindowInfo: @MainActor (Set) async throws -> [UInt32: WindowServerInfo]? = { try await SkyLight.shared.queryWindowInfoDeferred(windowIds: $0) } @@ -85,10 +108,12 @@ final class WorkspaceSwipePresentation { init( refreshController: LayoutRefreshController, previewSurface: WorkspaceSwipePreview? = nil, + previewFactory: (@MainActor (WMController) -> WorkspaceSwipePreview)? = nil, mediaTimeProvider: @escaping () -> TimeInterval = CACurrentMediaTime ) { self.refreshController = refreshController - preview = previewSurface + unclaimedPreview = previewSurface + self.previewFactory = previewFactory self.mediaTimeProvider = mediaTimeProvider } @@ -96,29 +121,45 @@ final class WorkspaceSwipePresentation { refreshController?.controller } + var flight: Flight? { + gestureFlight.flatMap { isCurrent($0) ? $0 : nil } + } + var hasPresentation: Bool { - flight != nil + !flights.isEmpty } func hasDisplayWork(_ displayId: CGDirectDisplayID) -> Bool { - flight?.preparation.monitor.displayId == displayId && flight?.committing == false - && flight?.motion.target != nil + flights.values.contains { hasDisplayWork($0, displayId) } + } + + private func hasDisplayWork(_ flight: Flight, _ displayId: CGDirectDisplayID) -> Bool { + flight.preparation.monitor.displayId == displayId && !flight.committing + && (flight.motion.target != nil || flight.carry != nil) } func prepare(monitorId: Monitor.ID, timestamp: TimeInterval) -> Bool { + gestureFlight = nil guard let controller, controller.motionPolicy.animationsEnabled else { return false } let mediaTime = mediaTimeProvider() - if let flight, flight.preparation.monitor.id == monitorId, !flight.committing, - flight.motion.target != nil, flight.motion.catchMotion( + let existing = flights[monitorId] + if let existing, existing.committing || existing.motion.target == 1 { + chainMonitorId = monitorId + return false + } + chainMonitorId = nil + if let existing, !existing.committing, + existing.motion.target != nil, existing.motion.catchMotion( cumulativeUnits: 0, timestamp: timestamp, animationTime: mediaTime ) { - flight.progress = flight.motion.progress(at: mediaTime) - flight.phase = .tracking - trace("caught", progress: flight.progress) + existing.progress = existing.motion.progress(at: mediaTime) + existing.phase = .tracking + gestureFlight = existing + trace("caught", progress: existing.progress) return true } - if flight != nil { cancel(reason: "new-contact") } + if let existing { cancel(existing, reason: "new-contact") } preparation = nil guard let preparation = makePreparation(monitorId: monitorId) else { stopPreparing() @@ -126,7 +167,7 @@ final class WorkspaceSwipePresentation { return false } self.preparation = preparation - previewSurface(controller).warm( + previewSurface(controller, for: monitorId).warm( source: preparation.source.items, destination: (preparation.previous?.items ?? []) + (preparation.next?.items ?? []), monitor: preparation.monitor, workingFrame: preparation.frame @@ -134,19 +175,37 @@ final class WorkspaceSwipePresentation { return false } + @discardableResult func begin( axis: WorkspaceSwipeAxis, cumulative: Double, timestamp: TimeInterval, recognitionMovement: SwipeEvent? = nil - ) { - guard let controller, let preparation, - controller.motionPolicy.animationsEnabled, + ) -> Bool { + guard let controller, controller.motionPolicy.animationsEnabled else { return false } + let (preparation, predecessor) = beginPreparation() + guard let preparation, let isNext = TrackpadGestureIntent.isNextWorkspace( axis: axis, displacement: cumulative, naturalDirection: controller.settings.gestures.invertDirection ), let destination = (isNext ? preparation.next : preparation.previous) ?? createdDestination(isNext: isNext, from: preparation), destination.id != preparation.source.id - else { return } + else { + guard let predecessor else { return false } + return refuseOrFallBack(from: predecessor, axis: axis, cumulative: cumulative) + } + let preview = previewSurface(controller, for: preparation.monitor.id) + if let predecessor, + let handled = handOff(from: predecessor, into: preparation, toward: destination, using: preview) + { + return handled + } + if self.preparation == nil { + self.preparation = preparation + preview.warm( + source: preparation.source.items, destination: destination.items, + monitor: preparation.monitor, workingFrame: preparation.frame + ) + } let flight = Flight( preparation: preparation, destination: destination, @@ -157,37 +216,25 @@ final class WorkspaceSwipePresentation { recognitionMovement: recognitionMovement ) guard participantsAreCurrent(flight) else { + if predecessor != nil { preview.stop() } trace("fallback-participants") - return + return false } - guard preview?.begin( - source: preparation.source.items, - destination: destination.items, - monitor: preparation.monitor, - workingFrame: preparation.frame - ) == true else { + guard predecessor != nil || show(preview, for: flight) else { trace("fallback-preview-unavailable") - return + return false } - preview?.prepare( - source: preparation.source.items, - destination: destination.items, - monitor: preparation.monitor, - workingFrame: preparation.frame - ) - refreshController?.stopScrollAnimation(for: preparation.monitor.displayId) - refreshController?.stopDwindleAnimation(for: preparation.monitor.displayId) - self.flight = flight - trace("began") + install(flight, after: predecessor) controller.surfaceReconciler.reconcileNow() present(flight, at: mediaTimeProvider()) + return true } @discardableResult func update(cumulative: Double, timestamp: TimeInterval) -> Bool { guard let flight, !flight.committing, flight.motion.target == nil else { return false } guard participantsAreCurrent(flight), controller?.motionPolicy.animationsEnabled == true else { - cancel(reason: "invalidated") + cancel(flight, reason: "invalidated") return true } flight.motion.update(cumulativeUnits: cumulative * flight.inputSign, timestamp: timestamp) @@ -202,67 +249,156 @@ final class WorkspaceSwipePresentation { timestamp: timestamp, allowFlick: allowFlick, animationTime: mediaTimeProvider(), motion: controller?.motionPolicy.snapshot() ?? .enabled ) else { - cancel(reason: "invalid-release") + cancel(flight, reason: "invalid-release") return true } flight.phase = .settling + if !activateDisplayLink(for: flight) { + cancel(flight, reason: "display-link-unavailable") + return true + } + prefetchChainTargets(after: flight) + return true + } + + private func activateDisplayLink(for flight: Flight) -> Bool { if refreshController? .displayLinkActivationForTests?(flight.preparation.monitor.displayId) == true { return true } guard let link = refreshController?.getOrCreateDisplayLink(for: flight.preparation.monitor.displayId) else { - cancel(reason: "display-link-unavailable") - return true + return false } link.add(to: .main, forMode: .common) return true } - private func createdDestination(isNext: Bool, from preparation: Preparation) -> Workspace? { - guard let controller, isNext, - let created = controller.workspaceNavigationHandler.swipeTargetWorkspace( - isNext: true, from: preparation.source.id, on: preparation.monitor.id, createIfNeeded: true - ) - else { return nil } - return makeWorkspace(created.id, monitor: preparation.monitor, active: false) + func otherGestureRecognized(on monitorId: Monitor.ID) { + noteNewestGesture(on: monitorId) + guard chainMonitorId == nil else { + chainMonitorId = nil + return + } + cancel(reason: "other-gesture") + } + + func noteSwitchWithoutPresentation(on monitorId: Monitor.ID) { + noteNewestGesture(on: monitorId) + } + + func forgetNewestGesture(onDisplay displayId: CGDirectDisplayID) { + guard latestGestureMonitorId?.displayId == displayId else { return } + let survivor = flights.values.max { $0.serial < $1.serial } + latestGestureMonitorId = survivor?.preparation.monitor.id + if let survivor { latestSerial = survivor.serial } + } + + private func noteNewestGesture(on monitorId: Monitor.ID) { + latestSerial += 1 + latestGestureMonitorId = monitorId } func tick(displayId: CGDirectDisplayID, timestamp: TimeInterval) { - guard hasDisplayWork(displayId), let flight else { return } - guard participantsAreCurrent(flight), controller?.motionPolicy.animationsEnabled == true else { - cancel(reason: "invalidated") - return - } - present(flight, at: timestamp) - if flight.motion.isComplete(at: timestamp) { - if flight.motion.target == 1 { commit(flight) } else { cancel(reason: "cancelled") } + for flight in flights.values where hasDisplayWork(flight, displayId) { + guard participantsAreCurrent(flight), controller?.motionPolicy.animationsEnabled == true else { + cancel(flight, reason: "invalidated") + continue + } + present(flight, at: timestamp) + if flight.motion.isComplete(at: timestamp) { + if flight.motion.target == 1 { commit(flight) } else { cancel(flight, reason: "cancelled") } + } } } func stopPreparing(warm: Bool = false) { + chainMonitorId = nil guard flight == nil else { return } preparation = nil - preview?.stop() + for (monitorId, preview) in previews where flights[monitorId] == nil { + preview.stop() + } if warm { refreshController?.collectUnusedWorkspacesIfIdle() warmPreviews() } } +} + +extension WorkspaceSwipePresentation { + private func beginPreparation() -> (Preparation?, predecessor: Flight?) { + guard let chainMonitorId else { return (preparation, nil) } + self.chainMonitorId = nil + if let existing = flights[chainMonitorId] { + return (makePreparation(monitorId: chainMonitorId, chainedSource: existing.destination), existing) + } + return (preparation ?? makePreparation(monitorId: chainMonitorId), nil) + } + + private func show(_ preview: WorkspaceSwipePreview, for flight: Flight) -> Bool { + let preparation = flight.preparation + guard preview.begin( + source: preparation.source.items, + destination: flight.destination.items, + monitor: preparation.monitor, + workingFrame: preparation.frame + ) else { return false } + preview.prepare( + source: preparation.source.items, + destination: flight.destination.items, + monitor: preparation.monitor, + workingFrame: preparation.frame + ) + return true + } + + private func install(_ flight: Flight, after predecessor: Flight?) { + let monitor = flight.preparation.monitor + refreshController?.stopScrollAnimation(for: monitor.displayId) + refreshController?.stopDwindleAnimation(for: monitor.displayId) + if let predecessor { + flight.chained = true + flight.carry = { predecessor.residual(at: $0) } + _ = activateDisplayLink(for: flight) + } + noteNewestGesture(on: monitor.id) + flight.serial = latestSerial + flights[monitor.id] = flight + gestureFlight = flight + trace(predecessor == nil ? "began" : "chained") + } func cancel(reason: String) { guard let flight else { stopPreparing() return } - self.flight = nil + cancel(flight, reason: reason) + } + + func cancelAll(reason: String) { + focusOwed = false + for flight in Array(flights.values) { + cancel(flight, reason: reason, settlingFocus: false) + } + stopPreparing() + } + + func cancel(_ flight: Flight, reason: String, keepingPreview: Bool = false, settlingFocus: Bool = true) { + let monitorId = flight.preparation.monitor.id + guard flights[monitorId] === flight else { return } + flights[monitorId] = nil flight.handoff?.cancel() flight.settlement?.onChange = nil - if controller?.axManager.workspaceFrameSettlement === flight.settlement { - controller?.axManager.workspaceFrameSettlement = nil - } - preview?.stop() - preparation = nil + controller?.axManager.workspaceFrameSettlements.removeAll { $0 === flight.settlement } + if !keepingPreview { previews[monitorId]?.stop() } + if preparation?.monitor.id == monitorId { preparation = nil } trace(reason, progress: flight.progress) controller?.surfaceReconciler.noteWorldChanged() refreshController?.stopDisplayLinkIfIdle(for: flight.preparation.monitor.displayId) + if settlingFocus, flight.serial == latestSerial, !flight.committing, reason != "chained", + focusOwed || flights.values.contains(where: { $0.preparation.monitor.id != monitorId }) + { + settleOwedFocus(on: flight.preparation.monitor) + } if ["completed", "placement-failed", "handoff-unconfirmed", "cancelled"].contains(reason) { refreshController?.collectUnusedWorkspacesIfIdle() warmPreviews() @@ -270,93 +406,93 @@ final class WorkspaceSwipePresentation { } func checkSettlement() { - guard let flight, flight.committing, let settlement = flight.settlement, settlement.isSettled else { return } - if settlement.tokens.contains(where: { - refreshController?.hasPendingRevealTransaction(for: $0.windowId) == true - }) { return } - confirmHandoff(flight) + for flight in Array(flights.values) { + guard flight.committing, let settlement = flight.settlement, settlement.isSettled else { continue } + if settlement.tokens.contains(where: { + refreshController?.hasPendingRevealTransaction(for: $0.windowId) == true + }) { continue } + confirmHandoff(flight) + } } func didSubmitPlacement() { - guard let flight, flight.committing else { return } + guard let flight else { return } + didSubmitPlacement(flight) + } + + private func didSubmitPlacement(_ flight: Flight) { + guard isCurrent(flight), flight.committing else { return } flight.settlement?.seal() } - private func commit(_ flight: Flight) { + func commit(_ flight: Flight) { + let monitor = flight.preparation.monitor guard let controller, let refreshController, - controller.workspaceManager.activeWorkspaceOrFirst(on: flight.preparation.monitor.id)?.id + controller.workspaceManager.activeWorkspaceOrFirst(on: monitor.id)?.id == flight.preparation.source.id else { - cancel(reason: "superseded") + cancel(flight, reason: "superseded") return } + let takesFocus = flight.serial == latestSerial || latestGestureMonitorId == monitor.id flight.phase = .committing let settlement = AXFrameSettlement(tokens: Set(controller.workspaceManager.entries(in: flight.destination.id).map(\.token))) flight.settlement = settlement settlement.onChange = { [weak self] in self?.checkSettlement() } - controller.axManager.workspaceFrameSettlement = settlement + controller.axManager.workspaceFrameSettlements.append(settlement) controller.workspaceNavigationHandler.saveNiriViewportState(for: flight.preparation.source.id) - guard controller.workspaceManager.setActiveWorkspace(flight.destination.id, on: flight.preparation.monitor.id) - else { + guard controller.workspaceManager.setActiveWorkspace( + flight.destination.id, on: monitor.id, updateInteractionMonitor: takesFocus + ) else { flight.phase = .settling - cancel(reason: "commit-failed") + cancel(flight, reason: "commit-failed") return } - trace("committed", progress: flight.progress) - controller.workspaceNavigationHandler.commitWorkspaceTransitionFocusHandoff( - targetWorkspaceId: flight.destination.id, monitor: flight.preparation.monitor, startScrollAnimation: false, - placementSubmitted: { [weak self, weak flight] in - guard let self, let flight, self.flight === flight else { return } - didSubmitPlacement() - }, - placementInvalidated: { [weak self, weak flight] in - guard let self, let flight, self.flight === flight else { return } - cancel(reason: "placement-invalidated") - } - ) + trace(takesFocus ? "committed" : "committed-background", progress: flight.progress) + let placementSubmitted: LayoutRefreshController.PostLayoutAction = { [weak self, weak flight] in + guard let self, let flight else { return } + didSubmitPlacement(flight) + } + let placementInvalidated: LayoutRefreshController.PostLayoutAction = { [weak self, weak flight] in + guard let self, let flight, isCurrent(flight) else { return } + cancel(flight, reason: "placement-invalidated") + } + if takesFocus { + focusOwed = false + controller.workspaceNavigationHandler.commitWorkspaceTransitionFocusHandoff( + targetWorkspaceId: flight.destination.id, monitor: monitor, startScrollAnimation: false, + placementSubmitted: placementSubmitted, placementInvalidated: placementInvalidated + ) + } else { + focusOwed = flights.values.contains { $0.serial > flight.serial } + refreshController.stopScrollAnimation(for: monitor.displayId) + refreshController.commitWorkspaceTransition( + postLayout: placementSubmitted, postLayoutInvalidated: placementInvalidated + ) + if !focusOwed { keepFocusOffHiddenWorkspace(flight.preparation.source.id) } + } flight.phase = .waitingForPlacement - refreshController.stopDisplayLinkIfIdle(for: flight.preparation.monitor.displayId) - } -} - -extension WorkspaceSwipePresentation { - func windowRemoved(_ token: WindowToken) { - preview?.remove(token: token) + refreshController.stopDisplayLinkIfIdle(for: monitor.displayId) } - func syncAvailability() { - guard let controller, preview != nil, - !controller.motionPolicy.animationsEnabled || !controller.settings.gestures.workspaceSwipeEnabled + private func keepFocusOffHiddenWorkspace(_ workspaceId: WorkspaceDescriptor.ID) { + guard let controller, let monitorId = latestGestureMonitorId, + let monitor = controller.workspaceManager.monitor(byId: monitorId), + let token = controller.workspaceManager.nativeManagedFocusToken, + controller.workspaceManager.entry(for: token)?.workspaceId == workspaceId else { return } - cancel(reason: "disabled") - preview?.release() - preview = nil + settleOwedFocus(on: monitor) } - func previewSurface(_ controller: WMController) -> WorkspaceSwipePreview { - if let preview { return preview } - let preview = WorkspaceSwipePreview(ownedWindowRegistry: controller.ownedWindowRegistry) - self.preview = preview - return preview - } - - private func present(_ flight: Flight, at timestamp: TimeInterval) { - flight.progress = flight.motion.progress(at: timestamp) - preview?.update( - sourceOffset: flight.offset(destination: false), - destinationOffset: flight.offset(destination: true) + private func settleOwedFocus(on monitor: Monitor) { + focusOwed = false + guard let controller, let workspace = controller.workspaceManager.activeWorkspaceOrFirst(on: monitor.id) + else { return } + trace("focus-owed") + _ = controller.workspaceManager.setInteractionMonitor(monitor.id) + controller.workspaceNavigationHandler.commitWorkspaceTransitionFocusHandoff( + targetWorkspaceId: workspace.id, monitor: monitor, startScrollAnimation: false ) } - - func trace(_ action: String, progress: Double = 0) { - guard TrackpadScrollTrace.shared.isActive else { return } - TrackpadScrollTrace.record(.workspacePresentation( - renderer: "preview", - action: action, - progress: progress, - velocity: flight?.motion.velocity(at: mediaTimeProvider()), - target: flight?.motion.target - )) - } } diff --git a/Sources/OmniWM/Core/Overview/OverviewThumbnailCapture.swift b/Sources/OmniWM/Core/Overview/OverviewThumbnailCapture.swift index 076686d23..2ee93eb1b 100644 --- a/Sources/OmniWM/Core/Overview/OverviewThumbnailCapture.swift +++ b/Sources/OmniWM/Core/Overview/OverviewThumbnailCapture.swift @@ -21,6 +21,7 @@ final class OverviewThumbnailCapture { let id: UInt64 let generation: UInt64 let request: OverviewPreviewRequest + var stopsAfterFirstFrame = false let requestedAt = CACurrentMediaTime() var status = Status.queued var published = false @@ -96,7 +97,8 @@ final class OverviewThumbnailCapture { visible: [OverviewPreviewRequest], prioritizing selectedHandle: WindowHandle? = nil, retainingUnrepresentedPreviews: Bool = false, - firstFrameOnly: Bool = false + firstFrameOnly: Bool = false, + firstFrameHandles: Set = [] ) { isPresenting = true stopsAfterFirstFrame = firstFrameOnly @@ -110,13 +112,8 @@ final class OverviewThumbnailCapture { onPreview(handle, nil) } var requests = collectRequests(represented: represented, visible: visible, selectedHandle: selectedHandle) - adoptSharedPreviews(for: &requests, droppingSatisfied: firstFrameOnly) - for (key, source) in sources where requests[key]?.token != source.request.token - || (!firstFrameOnly && source.status == .completed) - { - retire(source) - sources.removeValue(forKey: key) - } + adoptSharedPreviews(for: &requests) { firstFrameOnly || firstFrameHandles.contains($0) } + retireStaleSources(for: requests, firstFrameOnly: firstFrameOnly, firstFrameHandles: firstFrameHandles) guard !requests.isEmpty, hasCaptureAccess() else { for source in sources.values { retire(source) } sources.removeAll() @@ -128,6 +125,7 @@ final class OverviewThumbnailCapture { guard sources[key] == nil, let request = requests[key] else { continue } nextSourceId &+= 1 let source = Source(id: nextSourceId, generation: generation, request: request) + source.stopsAfterFirstFrame = firstFrameOnly || firstFrameHandles.contains(request.handle) sources[key] = source trace(.previewRequested, source: source) added = true @@ -144,6 +142,23 @@ final class OverviewThumbnailCapture { onReadinessChange() } + private func retireStaleSources( + for requests: [ObjectIdentifier: OverviewPreviewRequest], + firstFrameOnly: Bool, + firstFrameHandles: Set + ) { + for (key, source) in sources { + let request = requests[key] + let live = request.map { !firstFrameOnly && !firstFrameHandles.contains($0.handle) } ?? false + guard let request, request.token == source.request.token, !(live && source.status == .completed) else { + retire(source) + sources.removeValue(forKey: key) + continue + } + if firstFrameOnly || live { source.stopsAfterFirstFrame = !live } + } + } + func remove(handle: WindowHandle) { let key = ObjectIdentifier(handle) if let source = sources.removeValue(forKey: key) { retire(source) } @@ -250,7 +265,7 @@ final class OverviewThumbnailCapture { } private func completeFirstFrame(_ source: Source) { - guard stopsAfterFirstFrame, source.published, source.status != .completed else { return } + guard source.stopsAfterFirstFrame, source.published, source.status != .completed else { return } retire(source) source.status = .completed } @@ -259,7 +274,7 @@ final class OverviewThumbnailCapture { guard let source = sources[key], source.id == sourceId, isCurrent(source) else { return } source.output?.invalidate() source.status = .failed - if stopsAfterFirstFrame { source.control?.stop() } + if source.stopsAfterFirstFrame { source.control?.stop() } source.control = nil FallbackFiringRecorder.shared.note(.capture, "overviewStreamException") onReadinessChange() @@ -318,7 +333,7 @@ extension OverviewThumbnailCapture { private func adoptSharedPreviews( for requests: inout [ObjectIdentifier: OverviewPreviewRequest], - droppingSatisfied: Bool + droppingSatisfied: (WindowHandle) -> Bool ) { for (key, request) in requests where previewCache[request.handle]?.token != request.token { guard let frame = adoptSharedPreview( @@ -326,7 +341,7 @@ extension OverviewThumbnailCapture { satisfying: adoptsProvisionalPreviews ? nil : request ) else { continue } onPreview(request.handle, frame) - if droppingSatisfied, frame.covers(request) { requests.removeValue(forKey: key) } + if droppingSatisfied(request.handle), frame.covers(request) { requests.removeValue(forKey: key) } } } diff --git a/Sources/OmniWM/Core/Surface/WorkspaceSwipePreview.swift b/Sources/OmniWM/Core/Surface/WorkspaceSwipePreview.swift index 7b8a2e774..42235d45d 100644 --- a/Sources/OmniWM/Core/Surface/WorkspaceSwipePreview.swift +++ b/Sources/OmniWM/Core/Surface/WorkspaceSwipePreview.swift @@ -110,8 +110,19 @@ final class WorkspaceSwipePreview { reconcile(source: source, destination: destination, monitor: monitor, workingFrame: workingFrame, warming: true) } + func prefetch(shown: [Item], upcoming: [Item], monitor: Monitor, workingFrame: CGRect? = nil) { + let frame = workingFrame ?? monitor.visibleFrame + let missing = upcoming.filter { $0.frame.intersects(frame) && capture.preview(for: $0.handle) == nil } + guard panel != nil, !missing.isEmpty else { return } + reconcile( + source: shown, destination: missing, monitor: monitor, workingFrame: frame, warming: false, + firstFrameHandles: Set(missing.map(\.handle)) + ) + } + private func reconcile( - source: [Item], destination: [Item], monitor: Monitor, workingFrame: CGRect?, warming: Bool + source: [Item], destination: [Item], monitor: Monitor, workingFrame: CGRect?, warming: Bool, + firstFrameHandles: Set = [] ) { isWarming = false let frame = workingFrame ?? monitor.visibleFrame @@ -129,7 +140,8 @@ final class WorkspaceSwipePreview { represented: represented, visible: requested.map { $0.captureRequest(backingScale: scale) }, retainingUnrepresentedPreviews: true, - firstFrameOnly: warming + firstFrameOnly: warming, + firstFrameHandles: firstFrameHandles ) } @@ -193,6 +205,42 @@ final class WorkspaceSwipePreview { return true } + func replace(source: [Item], destination: [Item], monitor: Monitor, workingFrame: CGRect? = nil) -> Bool { + guard let root = panel?.contentView?.layer, hasCaptureAccess(), + let wallpaperImage = backdrop.image(for: monitor) + else { return false } + let frame = workingFrame ?? monitor.visibleFrame + let source = source.filter { $0.frame.intersects(frame) } + let destination = destination.filter { $0.frame.intersects(frame) } + guard (source + destination).allSatisfy({ capture.preview(for: $0.handle) != nil }) else { return false } + reconcile(source: source, destination: destination, monitor: monitor, workingFrame: frame, warming: false) + guard (source + destination).allSatisfy({ + tokens[$0.handle] == $0.handle.token && capture.preview(for: $0.handle) != nil + }) else { return false } + + let previous = contents.values.flatMap { $0.map(\.preview) } + contents.removeAll() + CATransaction.begin() + CATransaction.setDisableActions(true) + CATransaction.setCompletionBlock { withExtendedLifetime(previous) {} } + let scale = Self.scale(for: monitor) + let sourceLayer = makeWorkspaceLayer( + source, monitor: monitor, frame: frame, scale: scale, image: wallpaperImage + ) + let destinationLayer = makeWorkspaceLayer( + destination, monitor: monitor, frame: frame, scale: scale, image: wallpaperImage + ) + destinationLayer.isHidden = true + self.sourceLayer?.removeFromSuperlayer() + self.destinationLayer?.removeFromSuperlayer() + root.addSublayer(sourceLayer) + root.addSublayer(destinationLayer) + self.sourceLayer = sourceLayer + self.destinationLayer = destinationLayer + CATransaction.commit() + return true + } + func update(sourceOffset: CGVector, destinationOffset: CGVector) { guard panel != nil else { return } CATransaction.begin() diff --git a/Tests/OmniWMTests/WorkspaceSwipePresentationTests.swift b/Tests/OmniWMTests/WorkspaceSwipePresentationTests.swift index 5ad13ee02..8ec452d7a 100644 --- a/Tests/OmniWMTests/WorkspaceSwipePresentationTests.swift +++ b/Tests/OmniWMTests/WorkspaceSwipePresentationTests.swift @@ -188,7 +188,7 @@ final class WorkspaceSwipePresentationTests: XCTestCase { let settlement = AXFrameSettlement(tokens: [token]) flight.phase = .waitingForPlacement flight.settlement = settlement - controller.axManager.workspaceFrameSettlement = settlement + controller.axManager.workspaceFrameSettlements = [settlement] settlement.reject(.init(pid: token.pid, window: axRef, frame: targetFrame)) settlement.seal() XCTAssertTrue(settlement.failed) @@ -203,7 +203,7 @@ final class WorkspaceSwipePresentationTests: XCTestCase { )) swipe.checkSettlement() XCTAssertFalse(swipe.hasPresentation) - XCTAssertNil(controller.axManager.workspaceFrameSettlement) + XCTAssertTrue(controller.axManager.workspaceFrameSettlements.isEmpty) } func testUnrelatedEffectPlanDoesNotSealPlacementSettlement() throws { @@ -237,20 +237,21 @@ final class WorkspaceSwipePresentationTests: XCTestCase { XCTAssertFalse(swipe.hasPresentation) } - func testTouchCatchesSettlingAtCurrentProgressAndLiftCanCancel() throws { - var mediaTime = 1.06 + func testTouchCatchesCancellingSettleAtCurrentProgress() throws { + var mediaTime = 1.3 let (controller, swipe, monitor, source) = try fixture(mediaTimeProvider: { mediaTime }) _ = swipe.prepare(monitorId: monitor.id, timestamp: 1) swipe.begin(axis: .vertical, cumulative: 20, timestamp: 1.01) swipe.update(cumulative: 80, timestamp: 1.05) - swipe.release(timestamp: 1.06, allowFlick: true) - mediaTime = 1.07 + swipe.release(timestamp: 1.3, allowFlick: true) + XCTAssertEqual(swipe.flight?.motion.target, 0) + mediaTime = 1.31 let expected = try XCTUnwrap(swipe.flight).motion.progress(at: mediaTime) - XCTAssertTrue(swipe.prepare(monitorId: monitor.id, timestamp: 1.07)) + XCTAssertTrue(swipe.prepare(monitorId: monitor.id, timestamp: 1.31)) XCTAssertEqual(try XCTUnwrap(swipe.flight).progress, expected, accuracy: 0.000001) XCTAssertNil(swipe.flight?.motion.target) - mediaTime = 1.3 - swipe.release(timestamp: 1.3, allowFlick: true) + mediaTime = 1.6 + swipe.release(timestamp: 1.6, allowFlick: true) swipe.tick(displayId: monitor.displayId, timestamp: 3) XCTAssertEqual(controller.workspaceManager.activeWorkspace(on: monitor.id)?.id, source) XCTAssertFalse(swipe.hasPresentation) @@ -279,6 +280,632 @@ final class WorkspaceSwipePresentationTests: XCTestCase { swipe.cancel(reason: "test-complete") } + func testTouchDuringCommittingSettleLeavesTheSwitchRunning() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, _) = try fixture(mediaTimeProvider: { mediaTime }) + _ = swipe.prepare(monitorId: monitor.id, timestamp: 1) + swipe.begin(axis: .vertical, cumulative: 20, timestamp: 1.01) + swipe.update(cumulative: 80, timestamp: 1.05) + swipe.release(timestamp: 1.06, allowFlick: true) + let destination = try XCTUnwrap(swipe.flight).destination.id + mediaTime = 1.07 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.07)) + XCTAssertNil(swipe.flight) + XCTAssertEqual(swipe.flights[monitor.id]?.motion.target, 1) + swipe.stopPreparing(warm: true) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(controller.workspaceManager.activeWorkspace(on: monitor.id)?.id, destination) + } + + func testSwipeDuringCommittingSettleChainsFromTheDestination() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, _) = try fixture(mediaTimeProvider: { mediaTime }) + let manager = controller.workspaceManager + let third = try XCTUnwrap(manager.workspaceId(for: "3", createIfMissing: true)) + manager.assignWorkspaceToMonitor(third, monitorId: monitor.id) + _ = swipe.prepare(monitorId: monitor.id, timestamp: 1) + swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.01) + swipe.update(cumulative: -80, timestamp: 1.05) + swipe.release(timestamp: 1.06, allowFlick: true) + let first = try XCTUnwrap(swipe.flight) + XCTAssertEqual(first.motion.target, 1) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + let residual = try XCTUnwrap(first.residual(at: mediaTime)) + XCTAssertGreaterThan(residual.dx, 0) + + swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12) + + let second = try XCTUnwrap(swipe.flight) + XCTAssertFalse(second === first) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(second.preparation.source.id, first.destination.id) + XCTAssertEqual( + second.destination.id, + manager.nextWorkspaceInOrder(on: monitor.id, from: first.destination.id, wrapAround: true)?.id + ) + XCTAssertEqual(second.offset(destination: false).dx, residual.dx, accuracy: 0.000001) + XCTAssertTrue(swipe.hasDisplayWork(monitor.displayId)) + + XCTAssertTrue(swipe.update(cumulative: -260, timestamp: 1.2)) + mediaTime = 1.4 + XCTAssertTrue(swipe.release(timestamp: 1.4, allowFlick: true)) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, second.destination.id) + XCTAssertEqual(second.carried, .zero) + swipe.didSubmitPlacement() + XCTAssertFalse(swipe.hasPresentation) + } + + func testSwipeOnAnotherMonitorRunsAlongsideTheSettlingSwitch() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, source, otherSource) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + XCTAssertTrue(manager.setInteractionMonitor(other.id)) + swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12) + + let second = try XCTUnwrap(swipe.flight) + XCTAssertTrue(swipe.flights[monitor.id] === first) + XCTAssertTrue(swipe.flights[other.id] === second) + XCTAssertEqual(second.preparation.source.id, otherSource) + XCTAssertEqual(second.carried, .zero) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, source) + + swipe.tick(displayId: monitor.displayId, timestamp: 3) + + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(first.phase, .waitingForPlacement) + XCTAssertEqual(manager.interactionMonitorId, other.id) + XCTAssertEqual(manager.activeWorkspace(on: other.id)?.id, otherSource) + XCTAssertNil(second.motion.target) + + XCTAssertTrue(swipe.update(cumulative: -260, timestamp: 1.2)) + mediaTime = 1.4 + XCTAssertTrue(swipe.release(timestamp: 1.4, allowFlick: true)) + swipe.tick(displayId: other.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: other.id)?.id, second.destination.id) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.cancelAll(reason: "test-complete") + XCTAssertFalse(swipe.hasPresentation) + } + + func testCancelledNewerSwipeTakesTheFocusTheBackgroundSwitchSkipped() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, otherSource) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, monitor.id) + + swipe.update(cumulative: -30, timestamp: 1.5) + mediaTime = 2 + XCTAssertTrue(swipe.release(timestamp: 2, allowFlick: true)) + XCTAssertEqual(swipe.flight?.motion.target, 0) + swipe.tick(displayId: other.displayId, timestamp: 4) + + XCTAssertEqual(manager.activeWorkspace(on: other.id)?.id, otherSource) + XCTAssertNil(swipe.flights[other.id]) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.cancelAll(reason: "test-complete") + } + + func testInstantSwitchOnAnotherDisplayKeepsFocusFromTheSettlingSwitch() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, otherSource) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + + controller.workspaceNavigationHandler.switchWorkspaceBySwipe(isNext: true, monitorId: other.id) + + XCTAssertNotEqual(manager.activeWorkspace(on: other.id)?.id, otherSource) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.cancelAll(reason: "test-complete") + } + + func testSettlingSwitchStillTakesFocusWhenNothingNewerSwitched() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, _) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertTrue(manager.setInteractionMonitor(other.id)) + + swipe.tick(displayId: monitor.displayId, timestamp: 3) + + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, monitor.id) + swipe.cancelAll(reason: "test-complete") + } + + func testNewerSwipeCancellingBeforeTheOlderCommitTakesFocus() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, otherSource) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + swipe.update(cumulative: -30, timestamp: 1.5) + mediaTime = 2 + XCTAssertTrue(swipe.release(timestamp: 2, allowFlick: true)) + XCTAssertEqual(swipe.flight?.motion.target, 0) + + swipe.tick(displayId: other.displayId, timestamp: 4) + + XCTAssertNil(swipe.flights[other.id]) + XCTAssertEqual(manager.activeWorkspace(on: other.id)?.id, otherSource) + XCTAssertEqual(manager.interactionMonitorId, other.id) + XCTAssertTrue(swipe.flights[monitor.id] === first) + swipe.tick(displayId: monitor.displayId, timestamp: 4) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.cancelAll(reason: "test-complete") + } + + func testDisconnectedDisplayReleasesItsOverlay() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, _) = try twoMonitorFixture { mediaTime } + _ = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + XCTAssertNotNil(swipe.previews[other.id]) + + swipe.cancelFlights(onDisplay: other.displayId, reason: "display-disconnected") + + XCTAssertNil(swipe.flights[other.id]) + XCTAssertNil(swipe.previews[other.id]) + XCTAssertNotNil(swipe.flights[monitor.id]) + XCTAssertNotNil(swipe.previews[monitor.id]) + withExtendedLifetime(controller) { swipe.cancelAll(reason: "test-complete") } + } + + func testOtherGestureOnAnotherDisplayKeepsFocusFromTheSettlingSwitch() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, _) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + + swipe.otherGestureRecognized(on: other.id) + XCTAssertTrue(manager.setInteractionMonitor(other.id)) + + XCTAssertTrue(swipe.flights[monitor.id] === first) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.cancelAll(reason: "test-complete") + } + + func testOtherGestureLeavingFocusOnTheHiddenWorkspaceMovesFocusToItsDisplay() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, source, _) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let token = WindowToken(pid: 985_001, windowId: 985_002) + _ = WindowAdmissionTestSupport.track(token, in: source, controller: controller) + XCTAssertTrue(manager.confirmManagedFocus(token, in: source, activateWorkspaceOnMonitor: false)) + XCTAssertEqual(manager.nativeManagedFocusToken, token) + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + + swipe.otherGestureRecognized(on: other.id) + + XCTAssertEqual(manager.interactionMonitorId, monitor.id) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.cancelAll(reason: "test-complete") + } + + func testZeroProgressReleaseOnAnotherDisplayTakesFocusBeforeTheOlderCommit() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, otherSource) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + XCTAssertTrue(swipe.release(timestamp: 1.12, allowFlick: true)) + XCTAssertEqual(swipe.flight?.motion.target, 0) + swipe.tick(displayId: other.displayId, timestamp: 1.1) + + XCTAssertNil(swipe.flights[other.id]) + XCTAssertEqual(manager.activeWorkspace(on: other.id)?.id, otherSource) + XCTAssertEqual(manager.interactionMonitorId, other.id) + XCTAssertTrue(swipe.flights[monitor.id] === first) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, other.id) + swipe.cancelAll(reason: "test-complete") + } + + func testChainTowardUncapturedWindowsCommitsAndFallsBackToAnInstantSwitch() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, ids) = try chainFallbackFixture(targetLayout: .dwindle) { mediaTime } + let first = try settleForwardSwipe(swipe, on: monitor) + XCTAssertEqual(first.destination.id, ids["2"]) + let chained = try XCTUnwrap(swipe.makePreparation(monitorId: monitor.id, chainedSource: first.destination)) + XCTAssertEqual(chained.next?.id, ids["3"]) + XCTAssertFalse(try XCTUnwrap(chained.next).items.isEmpty) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + + XCTAssertFalse(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + assertChainFellBack(controller, swipe, monitor, from: first, to: try XCTUnwrap(ids["3"])) + } + + func testChainTowardAWorkspaceThatCannotBeBuiltCommitsAndFallsBack() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, ids) = try chainFallbackFixture(targetLayout: nil) { mediaTime } + let manager = controller.workspaceManager + let target = try XCTUnwrap(ids["3"]) + let token = try XCTUnwrap(manager.entries(in: target).first?.token) + manager.setLayoutReason(.nativeFullscreen, for: token) + let first = try settleForwardSwipe(swipe, on: monitor) + XCTAssertNil(swipe.makePreparation(monitorId: monitor.id, chainedSource: first.destination)?.next) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + + XCTAssertFalse(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + assertChainFellBack(controller, swipe, monitor, from: first, to: target) + } + + private func assertChainFellBack( + _ controller: WMController, + _ swipe: WorkspaceSwipePresentation, + _ monitor: Monitor, + from first: WorkspaceSwipePresentation.Flight, + to target: WorkspaceDescriptor.ID, + file: StaticString = #filePath, + line: UInt = #line + ) { + let manager = controller.workspaceManager + XCTAssertNil(swipe.flight, file: file, line: line) + XCTAssertNil(swipe.flights[monitor.id], file: file, line: line) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id, file: file, line: line) + XCTAssertNotEqual(swipe.previews[monitor.id]?.isVisible, true, file: file, line: line) + controller.workspaceNavigationHandler.switchWorkspaceBySwipe(isNext: true, monitorId: monitor.id) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, target, file: file, line: line) + } + + private func chainFallbackFixture( + targetLayout: LayoutType?, + mediaTimeProvider: @escaping () -> TimeInterval + ) throws -> (WMController, WorkspaceSwipePresentation, Monitor, [String: WorkspaceDescriptor.ID]) { + let (controller, swipe, monitor, _) = try fixture(mediaTimeProvider: mediaTimeProvider) + let manager = controller.workspaceManager + let dwindle = DwindleLayoutEngine() + dwindle.animationClock = controller.animationClock + controller.dwindleEngine = dwindle + controller.settings.workspaces.configurations = [ + WorkspaceConfiguration(name: "1"), + WorkspaceConfiguration(name: "2"), + targetLayout.map { WorkspaceConfiguration(name: "3", layoutType: $0) } ?? WorkspaceConfiguration(name: "3") + ] + manager.applySettings() + var ids: [String: WorkspaceDescriptor.ID] = [:] + for name in ["1", "2", "3"] { + ids[name] = try XCTUnwrap(manager.workspaceId(for: name, createIfMissing: true)) + } + let token = WindowToken(pid: 986_001, windowId: 986_002) + let target = try XCTUnwrap(ids["3"]) + _ = WindowAdmissionTestSupport.track(token, in: target, controller: controller) + if targetLayout == .dwindle { + _ = dwindle.addWindow(token: token, to: target, activeWindowFrame: nil) + } + XCTAssertTrue(manager.setActiveWorkspace(try XCTUnwrap(ids["1"]), on: monitor.id)) + return (controller, swipe, monitor, ids) + } + + func testCancellingEverySwipeLeavesFocusAlone() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, _) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + _ = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + XCTAssertEqual(manager.interactionMonitorId, monitor.id) + + swipe.cancelAll(reason: "overview") + + XCTAssertFalse(swipe.hasPresentation) + XCTAssertEqual(manager.interactionMonitorId, monitor.id) + } + + func testDisconnectingTheNewestSwipesDisplayLeavesFocusWithTheSurvivingSwitch() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, other, _, _) = try twoMonitorFixture { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: other.id, timestamp: 1.1)) + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + swipe.cancelFlights(onDisplay: other.displayId, reason: "display-disconnected") + + XCTAssertNil(swipe.flights[other.id]) + XCTAssertEqual(manager.interactionMonitorId, monitor.id) + XCTAssertTrue(manager.setInteractionMonitor(other.id)) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, first.destination.id) + XCTAssertEqual(manager.interactionMonitorId, monitor.id) + swipe.cancelAll(reason: "test-complete") + } + + private func settleForwardSwipe( + _ swipe: WorkspaceSwipePresentation, on monitor: Monitor + ) throws -> WorkspaceSwipePresentation.Flight { + _ = swipe.prepare(monitorId: monitor.id, timestamp: 1) + swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.01) + swipe.update(cumulative: -80, timestamp: 1.05) + swipe.release(timestamp: 1.06, allowFlick: true) + let flight = try XCTUnwrap(swipe.flight) + XCTAssertEqual(flight.motion.target, 1) + return flight + } + + private func twoMonitorFixture( + mediaTimeProvider: @escaping () -> TimeInterval + ) throws -> ( + WMController, WorkspaceSwipePresentation, Monitor, Monitor, WorkspaceDescriptor.ID, WorkspaceDescriptor.ID + ) { + let (controller, swipe, monitor, source) = try fixture(mediaTimeProvider: mediaTimeProvider) + let manager = controller.workspaceManager + let other = makeMonitor(2, x: 1600, y: 0) + controller.settings.workspaces.configurations = ["1", "2", "3", "4"].map { + WorkspaceConfiguration( + name: $0, + monitorAssignment: .specificDisplay(OutputId(from: Int($0)! <= 2 ? monitor : other)) + ) + } + manager.applyMonitorConfigurationChange([monitor, other]) + manager.applySettings() + let otherSource = try XCTUnwrap(manager.workspaceId(named: "3")) + XCTAssertTrue(manager.setActiveWorkspace(otherSource, on: other.id)) + XCTAssertTrue(manager.setActiveWorkspace(source, on: monitor.id)) + return (controller, swipe, monitor, other, source, otherSource) + } + + func testRefusedChainDetachesTheGestureAndLetsTheFirstSwitchFinish() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, _) = try fixture(mediaTimeProvider: { mediaTime }) + let manager = controller.workspaceManager + controller.settings.workspaces.configurations = ["1", "2"].map { WorkspaceConfiguration(name: $0) } + controller.settings.workspaceBar.hideEmptyWorkspaces = true + manager.applySettings() + let empty = try XCTUnwrap(manager.workspaceId(for: "1", createIfMissing: true)) + let occupied = try XCTUnwrap(manager.workspaceId(for: "2", createIfMissing: true)) + _ = WindowAdmissionTestSupport.track( + WindowToken(pid: 983_001, windowId: 983_002), in: occupied, controller: controller + ) + XCTAssertTrue(manager.setActiveWorkspace(empty, on: monitor.id)) + let first = try settleForwardSwipe(swipe, on: monitor) + XCTAssertEqual(first.destination.id, occupied) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + XCTAssertNil(swipe.flight) + XCTAssertFalse(swipe.release(timestamp: 1.15, allowFlick: true)) + XCTAssertTrue(swipe.flights[monitor.id] === first) + XCTAssertEqual(first.motion.target, 1) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, occupied) + XCTAssertEqual(first.phase, .waitingForPlacement) + swipe.cancelAll(reason: "test-complete") + } + + func testChainPastANoneEdgeDetachesAndKeepsTheFirstSwitch() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, ids) = try edgeFixture(.none, active: "1") { mediaTime } + let first = try settleForwardSwipe(swipe, on: monitor) + XCTAssertEqual(first.destination.id, ids["2"]) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + XCTAssertNil(swipe.flight) + XCTAssertFalse(swipe.release(timestamp: 1.15, allowFlick: true)) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(controller.workspaceManager.activeWorkspace(on: monitor.id)?.id, ids["2"]) + swipe.cancelAll(reason: "test-complete") + } + + func testChainFromOnePastsCreatedWorkspaceDetachesAndKeepsTheFirstSwitch() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, _) = try edgeFixture(.onePast, active: "2") { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + let created = try XCTUnwrap(manager.workspaceId(named: "3")) + XCTAssertEqual(first.destination.id, created) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + XCTAssertNil(swipe.flight) + XCTAssertNil(manager.workspaceId(named: "4")) + XCTAssertFalse(swipe.release(timestamp: 1.15, allowFlick: true)) + swipe.tick(displayId: monitor.displayId, timestamp: 3) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, created) + swipe.cancelAll(reason: "test-complete") + } + + func testChainIntoOnePastsCreatedWorkspaceAnimatesFromTheLastOccupied() throws { + var mediaTime = 1.06 + let (controller, swipe, monitor, ids) = try edgeFixture(.onePast, active: "1") { mediaTime } + let manager = controller.workspaceManager + let first = try settleForwardSwipe(swipe, on: monitor) + XCTAssertEqual(first.destination.id, ids["2"]) + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + let second = try XCTUnwrap(swipe.flight) + let created = try XCTUnwrap(manager.workspaceId(named: "3")) + XCTAssertEqual(manager.activeWorkspace(on: monitor.id)?.id, ids["2"]) + XCTAssertEqual(second.preparation.source.id, ids["2"]) + XCTAssertEqual(second.destination.id, created) + swipe.cancelAll(reason: "test-complete") + } + + private func edgeFixture( + _ behavior: WorkspaceSwipeEdgeBehavior, + active: String, + mediaTimeProvider: @escaping () -> TimeInterval + ) throws -> (WMController, WorkspaceSwipePresentation, Monitor, [String: WorkspaceDescriptor.ID]) { + let (controller, swipe, monitor, _) = try fixture(mediaTimeProvider: mediaTimeProvider) + let manager = controller.workspaceManager + controller.settings.workspaces.configurations = ["1", "2"].map { WorkspaceConfiguration(name: $0) } + controller.settings.gestures.workspaceSwipeEdgeBehavior = behavior + manager.applySettings() + var ids: [String: WorkspaceDescriptor.ID] = [:] + for (index, name) in ["1", "2"].enumerated() { + let id = try XCTUnwrap(manager.workspaceId(for: name, createIfMissing: true)) + ids[name] = id + _ = WindowAdmissionTestSupport.track( + WindowToken(pid: 984_001, windowId: 984_100 + index), in: id, controller: controller + ) + } + XCTAssertTrue(manager.setActiveWorkspace(try XCTUnwrap(ids[active]), on: monitor.id)) + return (controller, swipe, monitor, ids) + } + + func testChainKeepsTheOverlayAndWallpaperWithoutRecapturing() throws { + var captures = 0 + let image = try makeWallpaperImage() + let wallpaper = OverviewWallpaperCache() + wallpaper.desktopImageURL = { _ in nil } + wallpaper.captureWallpaper = { _ in + captures += 1 + return image + } + let preview = WorkspaceSwipePreview( + ownedWindowRegistry: OwnedWindowRegistry(), + backdrop: WorkspaceSwipeBackdrop(wallpaperCache: wallpaper), + hasCaptureAccess: { true } + ) + let overlayCloses = OverlayCloseCounter() + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: nil, queue: nil + ) { note in + if let window = note.object as? NSWindow, + String(describing: type(of: window)).contains("WorkspaceSwipePreviewPanel") + { + overlayCloses.increment() + } + } + defer { NotificationCenter.default.removeObserver(observer) } + var mediaTime = 1.06 + let (controller, swipe, monitor, _) = try fixture(previewSurface: preview, mediaTimeProvider: { mediaTime }) + let third = try XCTUnwrap(controller.workspaceManager.workspaceId(for: "3", createIfMissing: true)) + controller.workspaceManager.assignWorkspaceToMonitor(third, monitorId: monitor.id) + _ = try settleForwardSwipe(swipe, on: monitor) + XCTAssertTrue(preview.isVisible) + let capturesBeforeChain = captures + mediaTime = 1.1 + XCTAssertFalse(swipe.prepare(monitorId: monitor.id, timestamp: 1.1)) + + XCTAssertTrue(swipe.begin(axis: .horizontal, cumulative: -20, timestamp: 1.12)) + + XCTAssertNotNil(swipe.flight) + XCTAssertTrue(preview.isVisible) + XCTAssertEqual(captures, capturesBeforeChain, "chaining re-captured the wallpaper on the main thread") + XCTAssertEqual(overlayCloses.value, 0, "chaining closed the overlay window and opened a new one") + swipe.cancelAll(reason: "test-complete") + } + + private func makeWallpaperImage() throws -> CGImage { + let context = try XCTUnwrap(CGContext( + data: nil, width: 1600, height: 900, bitsPerComponent: 8, bytesPerRow: 1600 * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(CGColor(red: 0.2, green: 0.3, blue: 0.4, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 1600, height: 900)) + return try XCTUnwrap(context.makeImage()) + } + + func testOverlayReplaceRefusesUncapturedWindowsAndKeepsItsWindow() async throws { + let fixture = try previewFixture() + let preview = fixture.preview + let monitor = fixture.monitor + let item = WorkspaceSwipePreview.Item( + handle: fixture.handle, + frame: CGRect(x: 50, y: 50, width: 600, height: 500) + ) + XCTAssertTrue(preview.begin(source: [], destination: [], monitor: monitor)) + + XCTAssertFalse(preview.replace(source: [], destination: [item], monitor: monitor)) + + XCTAssertTrue(preview.isVisible) + preview.stop() + try await cachePreviewThroughContact(fixture) + XCTAssertTrue(preview.begin(source: [], destination: [], monitor: monitor)) + + XCTAssertTrue(preview.replace(source: [], destination: [item], monitor: monitor)) + + XCTAssertTrue(preview.isVisible) + } + + func testOverlayPrefetchKeepsLiveCapturesWhileFetchingUpcomingWindows() async throws { + let fixture = try previewFixture() + let preview = fixture.preview + let monitor = fixture.monitor + let manager = fixture.controller.workspaceManager + let neighbor = try XCTUnwrap(manager.workspaceId(for: "2", createIfMissing: true)) + let upcomingToken = WindowToken(pid: 987_001, windowId: 987_002) + _ = WindowAdmissionTestSupport.track(upcomingToken, in: neighbor, controller: fixture.controller) + let upcomingHandle = try XCTUnwrap(manager.handle(for: upcomingToken)) + let frame = CGRect(x: 50, y: 50, width: 600, height: 500) + let shown = WorkspaceSwipePreview.Item(handle: fixture.handle, frame: frame) + let upcoming = WorkspaceSwipePreview.Item(handle: upcomingHandle, frame: frame) + try await cachePreviewThroughContact(fixture) + XCTAssertTrue(preview.begin(source: [shown], destination: [], monitor: monitor)) + preview.prepare(source: [shown], destination: [], monitor: monitor) + try await fixture.driver.waitForStarts(2) + fixture.driver.completeAllStarts() + let live = fixture.driver.streams[1] + try await publish(try makeOverviewPreviewFrame(), through: live, into: fixture.capture) + XCTAssertFalse(preview.replace(source: [shown], destination: [upcoming], monitor: monitor)) + + preview.prefetch(shown: [shown], upcoming: [upcoming], monitor: monitor) + + XCTAssertFalse(preview.isWarming) + try await fixture.driver.waitForStarts(3) + fixture.driver.completeAllStarts() + let oneShot = fixture.driver.streams[2] + XCTAssertEqual(oneShot.request.handle, upcomingHandle) + try await publish(try makeOverviewPreviewFrame(), through: oneShot, into: fixture.capture) + try await fixture.driver.waitForStops(2) + XCTAssertEqual(live.stopCount, 0) + XCTAssertTrue(fixture.capture.isPresenting) + XCTAssertNotNil(fixture.capture.preview(for: upcomingHandle)) + XCTAssertTrue(preview.isVisible) + XCTAssertTrue(preview.replace(source: [shown], destination: [upcoming], monitor: monitor)) + XCTAssertEqual(live.stopCount, 0) + } + func testPreviousWorkspaceWrapsAsOneVisualStep() throws { let (controller, swipe, monitor, source) = try fixture() let last = controller.workspaceManager.workspaces(on: monitor.id).last?.id @@ -497,7 +1124,7 @@ final class WorkspaceSwipePresentationTests: XCTestCase { ownedWindowRegistry: OwnedWindowRegistry(), previewCapture: capture, backdrop: makeBackdrop(), hasCaptureAccess: { true } ) - let (controller, swipe, _, _) = try fixture(previewSurface: preview) + let (controller, swipe, monitor, _) = try fixture(previewSurface: preview) defer { swipe.cancel(reason: "test") preview.stop() @@ -527,7 +1154,7 @@ final class WorkspaceSwipePresentationTests: XCTestCase { disable(controller) XCTAssertEqual(capture.cachedByteCount, 0) - XCTAssertFalse(swipe.previewSurface(controller) === preview) + XCTAssertFalse(swipe.previewSurface(controller, for: monitor.id) === preview) } } @@ -562,9 +1189,16 @@ final class WorkspaceSwipePresentationTests: XCTestCase { ownedWindowRegistry: controller.ownedWindowRegistry, backdrop: makeBackdrop(), hasCaptureAccess: { true } ) + let backdrop = try makeBackdrop() let swipe = WorkspaceSwipePresentation( refreshController: controller.layoutRefreshController, - previewSurface: preview, mediaTimeProvider: mediaTimeProvider + previewSurface: preview, + previewFactory: { controller in + WorkspaceSwipePreview( + ownedWindowRegistry: controller.ownedWindowRegistry, backdrop: backdrop, hasCaptureAccess: { true } + ) + }, + mediaTimeProvider: mediaTimeProvider ) controller.layoutRefreshController.workspaceSwipe = swipe return (controller, swipe, monitor, source) @@ -678,16 +1312,23 @@ final class WorkspaceSwipePresentationTests: XCTestCase { } private func makeBackdrop() throws -> WorkspaceSwipeBackdrop { - let context = try XCTUnwrap(CGContext( - data: nil, width: 1600, height: 900, bitsPerComponent: 8, bytesPerRow: 1600 * 4, - space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - )) - context.setFillColor(CGColor(red: 0.2, green: 0.3, blue: 0.4, alpha: 1)) - context.fill(CGRect(x: 0, y: 0, width: 1600, height: 900)) - let image = try XCTUnwrap(context.makeImage()) + let image = try makeWallpaperImage() let wallpaper = OverviewWallpaperCache() wallpaper.desktopImageURL = { _ in nil } wallpaper.captureWallpaper = { _ in image } return WorkspaceSwipeBackdrop(wallpaperCache: wallpaper) } } + +private final class OverlayCloseCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.withLock { count } + } + + func increment() { + lock.withLock { count += 1 } + } +}