Ariver
2026-06-11 0045898e8cd7b2d8972bb24c8ac27e514bdd8c70
Implement Quick Switch Space filtering
8 files modified
1 files added
1362 ■■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 334 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSessionController.swift 130 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift 99 ●●●●● patch | view | raw | blame | history
C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift 153 ●●●●● patch | view | raw | blame | history
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift 157 ●●●●● patch | view | raw | blame | history
C3.tools/round1-space-filter-fixture-qa.sh 436 ●●●●● patch | view | raw | blame | history
C3.tools/round1-three-zone-linkage-fixture-qa.sh 45 ●●●● patch | view | raw | blame | history
C1.source/Resources/Aligner-Info.plist
@@ -17,9 +17,9 @@
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>0.0.52</string>
    <string>0.0.53</string>
    <key>CFBundleVersion</key>
    <string>20260610.1717</string>
    <string>20260611.1607</string>
    <key>LSMinimumSystemVersion</key>
    <string>26.0</string>
    <key>NSHighResolutionCapable</key>
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -517,6 +517,10 @@
            return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .candidateFiltering)
        }
        if round1QuickSwitchOptions.fixtureSpaceFilter {
            return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .spaceFilter)
        }
        if let fixtureAppCount = round1QuickSwitchOptions.fixtureAppCount {
            return FixtureQuickSwitchSnapshotLoader(
                appCount: fixtureAppCount,
C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -78,8 +78,13 @@
    private var keyboardResponseLatencyMilliseconds: [Double] = []
    private var mouseResponseLatencyMilliseconds: [Double] = []
    private var hoverResponseLatencyMilliseconds: [Double] = []
    private var lastProjectionTransitionMovedLayerCount = 0
    private var lastProjectionTransitionFadeInLayerCount = 0
    private var lastProjectionTransitionFadeOutLayerCount = 0
    private var lastProjectionTransitionDurationMilliseconds: Double = 0
    var onEscape: (() -> Void)?
    var onCommitSelection: ((QuickSwitchSelection, QuickSwitchCommitSource) -> Void)?
    var onSpaceLaneClick: ((UInt64) -> Void)?
    var onRequestClose: ((QuickSwitchCloseRequest) -> Void)?
    var onCloseConfirmationDisabled: (() -> Void)?
@@ -256,6 +261,22 @@
        let stateLayer: CATextLayer
        let shineLayer: CAGradientLayer
        let closeButton: CloseButtonLayers
    }
    private struct ProjectionLayerSnapshot {
        let layer: CALayer
        let frame: CGRect
    }
    private struct ProjectionColumnSnapshot {
        let layer: CALayer
        let frame: CGRect
        let cardsByWindowID: [UInt32: ProjectionLayerSnapshot]
    }
    private struct ProjectionTransitionSnapshot {
        let appShelfItemsByAppGroupIndex: [Int: ProjectionLayerSnapshot]
        let waterfallColumnsByAppGroupIndex: [Int: ProjectionColumnSnapshot]
    }
    private enum AppShelfMetrics {
@@ -608,8 +629,44 @@
        }
    }
    func effectiveSelectionForProjection() -> QuickSwitchSelection? {
        effectiveSelection
    }
    func apply(viewModel: QuickSwitchViewModel?) {
        apply(viewModel: viewModel, preservingInteractionState: false, animatedProjection: false)
    }
    func applyProjected(viewModel: QuickSwitchViewModel?) {
        apply(viewModel: viewModel, preservingInteractionState: true, animatedProjection: true)
    }
    private func apply(
        viewModel: QuickSwitchViewModel?,
        preservingInteractionState: Bool,
        animatedProjection: Bool
    ) {
        let transitionSnapshot = animatedProjection ? captureProjectionTransitionSnapshot() : nil
        if !animatedProjection {
            resetProjectionTransitionReport()
        }
        currentViewModel = viewModel
        if preservingInteractionState {
            currentSelection = validSelection(currentSelection, in: viewModel) ?? viewModel?.initialSelection
            columnSelectionHistory = columnSelectionHistory.filter { appGroupIndex, _ in
                viewModel?.waterfallColumns.contains { $0.appGroupIndex == appGroupIndex } == true
            }
            if let currentSelection {
                columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
            }
            hoveredCloseTarget = nil
            pendingCloseTarget = nil
            pendingCloseRequest = nil
            pendingCloseOptOut = false
            closeConfirmationLayers.containerLayer.isHidden = true
            closeConfirmationArrowEdge = nil
        } else {
        screenshotSourcesByWindowID = [:]
        screenshotSkippedReasonsByWindowID = [:]
        currentSelection = viewModel?.initialSelection
@@ -637,6 +694,8 @@
        clearCloseFeedback()
        setHoverTarget(.none)
        hoveredAppGroupIndex = debugHoveredAppGroupIndex
        }
        if let currentSelection {
            columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
        }
@@ -646,6 +705,250 @@
        needsLayout = true
        ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: false))
        needsLayout = true
        if animatedProjection, let transitionSnapshot {
            layoutSubtreeIfNeeded()
            applyProjectionTransitionAnimations(from: transitionSnapshot)
        }
    }
    private func validSelection(
        _ selection: QuickSwitchSelection?,
        in viewModel: QuickSwitchViewModel?
    ) -> QuickSwitchSelection? {
        guard let selection, let viewModel else { return nil }
        for column in viewModel.waterfallColumns {
            if let card = column.windows.first(where: { $0.window.id == selection.windowID }) {
                return QuickSwitchSelection(
                    appGroupIndex: card.appGroupIndex,
                    windowIndex: card.windowIndex,
                    windowID: card.window.id
                )
            }
        }
        return nil
    }
    private func captureProjectionTransitionSnapshot() -> ProjectionTransitionSnapshot {
        ProjectionTransitionSnapshot(
            appShelfItemsByAppGroupIndex: Dictionary(
                uniqueKeysWithValues: appShelfItems.map { item in
                    (
                        item.item.appGroupIndex,
                        ProjectionLayerSnapshot(
                            layer: item.containerLayer,
                            frame: item.containerLayer.frame
                        )
                    )
                }
            ),
            waterfallColumnsByAppGroupIndex: Dictionary(
                uniqueKeysWithValues: waterfallColumns.map { column in
                    (
                        column.column.appGroupIndex,
                        ProjectionColumnSnapshot(
                            layer: column.containerLayer,
                            frame: column.containerLayer.frame,
                            cardsByWindowID: Dictionary(
                                uniqueKeysWithValues: column.cards.map { card in
                                    (
                                        card.item.window.id,
                                        ProjectionLayerSnapshot(
                                            layer: card.containerLayer,
                                            frame: card.containerLayer.frame
                                        )
                                    )
                                }
                            )
                        )
                    )
                }
            )
        )
    }
    private func applyProjectionTransitionAnimations(from snapshot: ProjectionTransitionSnapshot) {
        let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
        let duration: CFTimeInterval = reduceMotion ? 0.12 : 0.21
        let timing = CAMediaTimingFunction(name: reduceMotion ? .linear : .easeOut)
        var movedLayerCount = 0
        var fadeInLayerCount = 0
        var fadeOutLayerCount = 0
        for item in appShelfItems {
            guard let oldSnapshot = snapshot.appShelfItemsByAppGroupIndex[item.item.appGroupIndex] else {
                if addFadeInAnimation(to: item.containerLayer, duration: duration, timing: timing) {
                    fadeInLayerCount += 1
                }
                continue
            }
            if addPositionAnimation(to: item.containerLayer, from: oldSnapshot.frame, duration: duration, timing: timing) {
                movedLayerCount += 1
            }
        }
        let visibleAppGroupIndexes = Set(appShelfItems.map(\.item.appGroupIndex))
        for (appGroupIndex, oldSnapshot) in snapshot.appShelfItemsByAppGroupIndex
            where !visibleAppGroupIndexes.contains(appGroupIndex) {
            if addFadeOutSnapshot(
                oldSnapshot,
                to: appShelfContentLayer,
                frame: oldSnapshot.frame,
                duration: duration,
                timing: timing
            ) {
                fadeOutLayerCount += 1
            }
        }
        for column in waterfallColumns {
            guard let oldSnapshot = snapshot.waterfallColumnsByAppGroupIndex[column.column.appGroupIndex] else {
                if addFadeInAnimation(to: column.containerLayer, duration: duration, timing: timing) {
                    fadeInLayerCount += 1
                }
                continue
            }
            if addPositionAnimation(to: column.containerLayer, from: oldSnapshot.frame, duration: duration, timing: timing) {
                movedLayerCount += 1
            }
            for card in column.cards {
                guard let oldCardSnapshot = oldSnapshot.cardsByWindowID[card.item.window.id] else {
                    if addFadeInAnimation(to: card.containerLayer, duration: duration, timing: timing) {
                        fadeInLayerCount += 1
                    }
                    continue
                }
                if addPositionAnimation(to: card.containerLayer, from: oldCardSnapshot.frame, duration: duration, timing: timing) {
                    movedLayerCount += 1
                }
            }
        }
        let visibleColumnsByAppGroupIndex = Dictionary(
            uniqueKeysWithValues: waterfallColumns.map { ($0.column.appGroupIndex, $0) }
        )
        for (appGroupIndex, oldSnapshot) in snapshot.waterfallColumnsByAppGroupIndex {
            guard let visibleColumn = visibleColumnsByAppGroupIndex[appGroupIndex] else {
                if addFadeOutSnapshot(
                    ProjectionLayerSnapshot(layer: oldSnapshot.layer, frame: oldSnapshot.frame),
                    to: waterfallContentLayer,
                    frame: oldSnapshot.frame,
                    duration: duration,
                    timing: timing
                ) {
                    fadeOutLayerCount += 1
                }
                continue
            }
            let visibleWindowIDs = Set(visibleColumn.cards.map(\.item.window.id))
            for (windowID, oldCardSnapshot) in oldSnapshot.cardsByWindowID
                where !visibleWindowIDs.contains(windowID) {
                let absoluteFrame = oldCardSnapshot.frame.offsetBy(
                    dx: oldSnapshot.frame.minX,
                    dy: oldSnapshot.frame.minY
                )
                if addFadeOutSnapshot(
                    oldCardSnapshot,
                    to: waterfallContentLayer,
                    frame: absoluteFrame,
                    duration: duration,
                    timing: timing
                ) {
                    fadeOutLayerCount += 1
                }
            }
        }
        lastProjectionTransitionMovedLayerCount = movedLayerCount
        lastProjectionTransitionFadeInLayerCount = fadeInLayerCount
        lastProjectionTransitionFadeOutLayerCount = fadeOutLayerCount
        lastProjectionTransitionDurationMilliseconds = duration * 1_000
    }
    private func resetProjectionTransitionReport() {
        lastProjectionTransitionMovedLayerCount = 0
        lastProjectionTransitionFadeInLayerCount = 0
        lastProjectionTransitionFadeOutLayerCount = 0
        lastProjectionTransitionDurationMilliseconds = 0
    }
    @discardableResult
    private func addPositionAnimation(
        to layer: CALayer,
        from oldFrame: CGRect,
        duration: CFTimeInterval,
        timing: CAMediaTimingFunction
    ) -> Bool {
        let oldPosition = CGPoint(x: oldFrame.midX, y: oldFrame.midY)
        let newPosition = layer.position
        guard abs(oldPosition.x - newPosition.x) > 0.5 || abs(oldPosition.y - newPosition.y) > 0.5 else {
            return false
        }
        let animation = CABasicAnimation(keyPath: "position")
        animation.fromValue = oldPosition
        animation.toValue = newPosition
        animation.duration = duration
        animation.timingFunction = timing
        animation.isRemovedOnCompletion = true
        layer.add(animation, forKey: "quickSwitchProjectionPosition")
        return true
    }
    @discardableResult
    private func addFadeInAnimation(
        to layer: CALayer,
        duration: CFTimeInterval,
        timing: CAMediaTimingFunction
    ) -> Bool {
        let animation = CABasicAnimation(keyPath: "opacity")
        animation.fromValue = 0
        animation.toValue = layer.opacity
        animation.duration = duration
        animation.timingFunction = timing
        animation.isRemovedOnCompletion = true
        layer.add(animation, forKey: "quickSwitchProjectionFadeIn")
        return true
    }
    @discardableResult
    private func addFadeOutSnapshot(
        _ snapshot: ProjectionLayerSnapshot,
        to parentLayer: CALayer,
        frame: CGRect,
        duration: CFTimeInterval,
        timing: CAMediaTimingFunction
    ) -> Bool {
        let layer = snapshot.layer
        layer.removeAllAnimations()
        layer.removeFromSuperlayer()
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        layer.frame = frame
        layer.opacity = max(layer.opacity, 0.01)
        layer.zPosition = max(layer.zPosition, 240)
        parentLayer.addSublayer(layer)
        CATransaction.commit()
        let animation = CABasicAnimation(keyPath: "opacity")
        animation.fromValue = layer.opacity
        animation.toValue = 0
        animation.duration = duration
        animation.timingFunction = timing
        animation.isRemovedOnCompletion = true
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        layer.opacity = 0
        CATransaction.commit()
        layer.add(animation, forKey: "quickSwitchProjectionFadeOut")
        DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.05) { [weak layer] in
            layer?.removeFromSuperlayer()
        }
        return true
    }
    func reportDictionary() -> [String: Any] {
@@ -685,6 +988,8 @@
            "spaceLaneHoveredSpaceID": hoveredSpaceLaneID ?? NSNull(),
            "spaceLaneFocusedSpaceID": focusedSpaceID ?? NSNull(),
            "activeSpaceFocusID": activeSpaceFocusID ?? NSNull(),
            "spaceFilterLockedSpaceID": currentViewModel?.lockedSpaceID ?? NSNull(),
            "spaceFilterActive": currentViewModel?.lockedSpaceID != nil,
            "spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(),
            "appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(),
            "appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels,
@@ -732,6 +1037,10 @@
            "waterfallAlignmentTolerance": Double(WaterfallAlignmentMetrics.tolerance),
            "waterfallAlignmentAnimationDurationMilliseconds": Double(WaterfallAlignmentMetrics.animationDuration * 1_000),
            "waterfallAlignmentAnimationTimingName": "easeOut",
            "projectionTransitionMovedLayerCount": lastProjectionTransitionMovedLayerCount,
            "projectionTransitionFadeInLayerCount": lastProjectionTransitionFadeInLayerCount,
            "projectionTransitionFadeOutLayerCount": lastProjectionTransitionFadeOutLayerCount,
            "projectionTransitionDurationMilliseconds": lastProjectionTransitionDurationMilliseconds,
            "reduceMotionEffective": NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
            "selectedAppGroupIndex": effectiveSelection?.appGroupIndex ?? NSNull(),
            "selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
@@ -1021,16 +1330,8 @@
            "label": segment.space.label
        ])
        lastSpaceLaneClickSpaceID = segment.space.id
        guard let firstCard = firstWaterfallCard(inSpaceID: segment.space.id) else {
            DevelopmentDiagnostics.log("quickSwitch.view.clickSpaceLane.emptySpaceUnsupported", [
                "spaceID": segment.space.id,
                "label": segment.space.label
            ])
            return
        }
        commitWindowCard(firstCard, trigger: "spaceLane")
        onSpaceLaneClick?(segment.space.id)
        lastSpaceLaneClickSpaceID = segment.space.id
    }
    private func focusSpace(_ spaceID: UInt64, persistent: Bool, source: HoverTargetSource = .event) {
@@ -2118,6 +2419,7 @@
            segment.space.id == activeSpaceFocusID
                || appHoverAssociatedSpaceIDs.contains(segment.space.id)
                || segment.space.id == windowHoverAssociatedSpaceID
                || segment.space.id == currentViewModel?.lockedSpaceID
        }
        let hasCurrentSegment = group.display.spaces.contains { $0.isCurrent }
        group.containerLayer.backgroundColor = NSColor.windowBackgroundColor.cgColor
@@ -2133,11 +2435,12 @@
    private func applySpaceLaneVisualState(_ segment: SpaceLaneSegmentLayers) {
        let isHovered = segment.space.id == activeSpaceFocusID
        let isFocused = segment.space.id == activeSpaceFocusID
        let isLocked = segment.space.id == currentViewModel?.lockedSpaceID
        let isCurrent = segment.space.isCurrent
        let hasWindows = segment.space.windowCount > 0
        let isAppAssociated = appHoverAssociatedSpaceIDs.contains(segment.space.id)
        let isWindowAssociated = segment.space.id == windowHoverAssociatedSpaceID
        let isActive = isHovered || isFocused
        let isActive = isHovered || isFocused || isLocked
        let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive
        segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
@@ -2169,15 +2472,15 @@
                colors: spaceLaneIdleOccupiedGradientColors()
            )
        }
        segment.containerLayer.shadowOpacity = hasWindows
        segment.containerLayer.shadowOpacity = (hasWindows || isLocked)
            ? (isActive ? 0.26 : isAssociated ? 0.18 : isCurrent ? 0.14 : 0)
            : 0
        segment.containerLayer.shadowRadius = hasWindows
        segment.containerLayer.shadowRadius = (hasWindows || isLocked)
            ? (isActive ? 24 : isAssociated ? 18 : isCurrent ? 12 : 0)
            : 0
        segment.containerLayer.shadowOffset = CGSize(
            width: 0,
            height: hasWindows ? (isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0) : 0
            height: (hasWindows || isLocked) ? (isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0) : 0
        )
        segment.containerLayer.zPosition = isActive ? 30 : isAssociated ? 18 : isCurrent ? 10 : 0
        segment.containerLayer.transform = (isActive || isAssociated)
@@ -4164,6 +4467,9 @@
        if space.id == activeSpaceFocusID {
            states.append("focused")
        }
        if space.id == currentViewModel?.lockedSpaceID {
            states.append("locked")
        }
        if space.type == .fullscreen {
            states.append("fullscreen")
        }
C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -30,7 +30,11 @@
        debugOverlayWidth: debugOverlayWidth
    )
    private var currentSnapshot: QuickSwitchSnapshot?
    private var sourceViewModel: QuickSwitchViewModel?
    private var currentViewModel: QuickSwitchViewModel?
    private var lockedSpaceFilterID: UInt64?
    private var selectionBeforeSpaceFilter: QuickSwitchSelection?
    private var lastSpaceFilterAction: String?
    private var lastSnapshotError: String?
    private var snapshotTask: Task<Void, Never>?
    private var screenshotTask: Task<Void, Never>?
@@ -159,6 +163,9 @@
        lastCloseError = nil
        pendingCloseVerification = nil
        suppressedCloseTargets = []
        lockedSpaceFilterID = nil
        selectionBeforeSpaceFilter = nil
        lastSpaceFilterAction = nil
        lastDismissReason = nil
        lastSystemCriticalAction = nil
        lastSystemCriticalMonitorSource = nil
@@ -179,6 +186,9 @@
        }
        view.onCommitSelection = { [weak self] selection, source in
            self?.commitSelection(selection, source: source)
        }
        view.onSpaceLaneClick = { [weak self] spaceID in
            self?.handleSpaceLaneClick(spaceID)
        }
        view.onRequestClose = { [weak self] request in
            self?.requestClose(request)
@@ -264,6 +274,9 @@
            "initialSelectionWindowID": currentViewModel?.initialSelection?.windowID ?? NSNull(),
            "appShelfNames": currentViewModel?.appShelf.map(\.app.name) ?? [],
            "spaceLabels": currentViewModel?.displays.flatMap { $0.spaces.map(\.label) } ?? [],
            "spaceFilterLockedSpaceID": lockedSpaceFilterID ?? NSNull(),
            "spaceFilterActive": lockedSpaceFilterID != nil,
            "lastSpaceFilterAction": lastSpaceFilterAction ?? NSNull(),
            "overlayOpenElapsedMilliseconds": overlayOpenElapsedMilliseconds ?? NSNull(),
            "snapshotStartElapsedMilliseconds": snapshotStartElapsedMilliseconds ?? NSNull(),
            "snapshotDurationMilliseconds": snapshotDurationMilliseconds ?? NSNull(),
@@ -375,6 +388,7 @@
    private func clearSnapshotState() {
        currentSnapshot = nil
        sourceViewModel = nil
        currentViewModel = nil
        lastSnapshotError = nil
    }
@@ -446,7 +460,11 @@
        onSnapshotUpdated?()
    }
    private func commitSelection(_ selection: QuickSwitchSelection, source: QuickSwitchCommitSource) {
    private func commitSelection(
        _ selection: QuickSwitchSelection,
        source: QuickSwitchCommitSource,
        in viewModel: QuickSwitchViewModel? = nil
    ) {
        DevelopmentDiagnostics.log("quickSwitch.activation.commit.start", [
            "source": source.rawValue,
            "appGroupIndex": selection.appGroupIndex,
@@ -458,7 +476,7 @@
        lastActivationWindowID = selection.windowID
        lastActivationError = nil
        guard let window = window(for: selection) else {
        guard let window = window(for: selection, in: viewModel ?? currentViewModel) else {
            lastActivationResult = .windowNotFound
            DevelopmentDiagnostics.log("quickSwitch.activation.commit.windowNotFound", [
                "source": source.rawValue,
@@ -513,6 +531,77 @@
                "result": lastActivationResult.map(Self.activationResultString)
            ])
        }
        onSnapshotUpdated?()
    }
    private func handleSpaceLaneClick(_ spaceID: UInt64) {
        guard let sourceViewModel else {
            lastSpaceFilterAction = "blockedNoSourceViewModel"
            DevelopmentDiagnostics.log("quickSwitch.spaceFilter.click.blocked", [
                "spaceID": spaceID,
                "reason": "noSourceViewModel"
            ])
            onSnapshotUpdated?()
            return
        }
        if let fullscreenSelection = QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(
            inSpaceID: spaceID,
            viewModel: sourceViewModel
        ) {
            lockedSpaceFilterID = nil
            selectionBeforeSpaceFilter = nil
            lastSpaceFilterAction = "activateSingleFullscreen"
            let visibleViewModel = projectedViewModel(from: sourceViewModel)
            currentViewModel = visibleViewModel
            view.applyProjected(viewModel: visibleViewModel)
            DevelopmentDiagnostics.log("quickSwitch.spaceFilter.singleFullscreenActivate", [
                "spaceID": spaceID,
                "windowID": fullscreenSelection.windowID
            ])
            commitSelection(fullscreenSelection, source: .mouse, in: sourceViewModel)
            return
        }
        let preferredSelection: QuickSwitchSelection?
        if lockedSpaceFilterID == spaceID {
            lockedSpaceFilterID = nil
            preferredSelection = selectionBeforeSpaceFilter
            selectionBeforeSpaceFilter = nil
            lastSpaceFilterAction = "unlock"
        } else {
            if lockedSpaceFilterID == nil {
                selectionBeforeSpaceFilter = view.effectiveSelectionForProjection()
                lastSpaceFilterAction = "lock"
            } else {
                lastSpaceFilterAction = "switch"
            }
            lockedSpaceFilterID = spaceID
            preferredSelection = nil
        }
        let visibleViewModel = projectedViewModel(
            from: sourceViewModel,
            preferredSelection: preferredSelection
        )
        currentViewModel = visibleViewModel
        view.applyProjected(viewModel: visibleViewModel)
        DevelopmentDiagnostics.log("quickSwitch.spaceFilter.apply", [
            "spaceID": spaceID,
            "lockedSpaceID": lockedSpaceFilterID,
            "action": lastSpaceFilterAction,
            "visibleAppCount": visibleViewModel.appShelf.count,
            "visibleWindowCount": visibleViewModel.windowCount
        ])
        if disableScreenshotRefresh {
            view.markScreenshotsNotRequested(for: visibleViewModel, reason: "disabledByLaunchOption")
            screenshotTask?.cancel()
            screenshotTask = nil
        } else {
            scheduleScreenshotRefresh(for: visibleViewModel, generation: sessionGeneration)
        }
        onSnapshotUpdated?()
    }
@@ -618,10 +707,10 @@
            suppressedCloseTargets.append(target)
        }
        guard let currentViewModel else { return }
        let visibleViewModel = viewModel(currentViewModel, excluding: suppressedCloseTargets)
        guard let sourceViewModel else { return }
        let visibleViewModel = projectedViewModel(from: sourceViewModel, preferredSelection: view.effectiveSelectionForProjection())
        self.currentViewModel = visibleViewModel
        view.apply(viewModel: visibleViewModel)
        view.applyProjected(viewModel: visibleViewModel)
        DevelopmentDiagnostics.log("quickSwitch.close.optimisticRemove", [
            "targetKind": request.kindDescription,
            "appGroupIndex": request.appGroupIndex,
@@ -683,7 +772,20 @@
            displays: viewModel.displays,
            appShelf: appShelf,
            waterfallColumns: columns,
            initialSelection: initialSelection(in: columns, fallback: viewModel.initialSelection)
            initialSelection: initialSelection(in: columns, fallback: viewModel.initialSelection),
            lockedSpaceID: viewModel.lockedSpaceID
        )
    }
    private func projectedViewModel(
        from sourceViewModel: QuickSwitchViewModel,
        preferredSelection: QuickSwitchSelection? = nil
    ) -> QuickSwitchViewModel {
        let closeProjectedViewModel = viewModel(sourceViewModel, excluding: suppressedCloseTargets)
        return QuickSwitchSpaceFilterPolicy.projectedViewModel(
            from: closeProjectedViewModel,
            lockedSpaceID: lockedSpaceFilterID,
            preferredSelection: preferredSelection
        )
    }
@@ -743,8 +845,11 @@
        }
    }
    private func window(for selection: QuickSwitchSelection) -> AlignerWindow? {
        currentViewModel?
    private func window(
        for selection: QuickSwitchSelection,
        in viewModel: QuickSwitchViewModel?
    ) -> AlignerWindow? {
        viewModel?
            .waterfallColumns
            .flatMap(\.windows)
            .first { card in
@@ -772,7 +877,11 @@
        switch result {
        case .success(let success):
            currentSnapshot = success.snapshot
            let visibleViewModel = viewModel(success.viewModel, excluding: suppressedCloseTargets)
            sourceViewModel = success.viewModel
            let visibleViewModel = projectedViewModel(
                from: success.viewModel,
                preferredSelection: view.effectiveSelectionForProjection()
            )
            currentViewModel = visibleViewModel
            lastSnapshotError = nil
            snapshotStartElapsedMilliseconds = success.timing.startElapsedMilliseconds
@@ -808,6 +917,7 @@
            }
        case .failure(let failure):
            currentSnapshot = nil
            sourceViewModel = nil
            currentViewModel = nil
            lastSnapshotError = failure.message
            snapshotStartElapsedMilliseconds = failure.timing.startElapsedMilliseconds
@@ -1169,6 +1279,7 @@
    let fixtureMultiDisplay: Bool
    let fixtureCandidateFiltering: Bool
    let fixtureActivation: Bool
    let fixtureSpaceFilter: Bool
    let debugHoveredAppGroupIndex: Int?
    let debugOverlayWidth: CGFloat?
    let debugKeySequence: [String]
@@ -1195,6 +1306,7 @@
            fixtureMultiDisplay: arguments.contains("--round01-fixture-multi-display"),
            fixtureCandidateFiltering: arguments.contains("--round01-fixture-candidate-filtering"),
            fixtureActivation: arguments.contains("--round01-fixture-window-activation"),
            fixtureSpaceFilter: arguments.contains("--round01-fixture-space-filter"),
            debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments),
            debugOverlayWidth: cgFloatValue(for: "--round01-debug-overlay-width", in: arguments),
            debugKeySequence: stringListValue(for: "--round01-debug-key-sequence", in: arguments),
C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
@@ -14,6 +14,7 @@
    case layout
    case candidateFiltering
    case activation
    case spaceFilter
}
final class LiveQuickSwitchSnapshotLoader: QuickSwitchSnapshotLoading {
@@ -82,6 +83,9 @@
        }
        if mode == .activation {
            return activationSnapshot()
        }
        if mode == .spaceFilter {
            return spaceFilterSnapshot()
        }
        return layoutSnapshot()
@@ -395,4 +399,99 @@
            currentSpaceIDs: [1]
        )
    }
    private func spaceFilterSnapshot() -> QuickSwitchSnapshotLoad {
        let displayAUUID = "fixture-display-a"
        let displayBUUID = "fixture-display-b"
        let displays = [
            AlignerDisplay(
                uuid: displayAUUID,
                physical: true,
                spaces: [
                    AlignerSpace(id: 1, type: .user, displayUUID: displayAUUID, index: 1),
                    AlignerSpace(id: 2, type: .user, displayUUID: displayAUUID, index: 2),
                    AlignerSpace(id: 3, type: .user, displayUUID: displayAUUID, index: 3)
                ]
            ),
            AlignerDisplay(
                uuid: displayBUUID,
                physical: true,
                spaces: [
                    AlignerSpace(id: 4, type: .user, displayUUID: displayBUUID, index: 1),
                    AlignerSpace(id: 5, type: .fullscreen, displayUUID: displayBUUID, index: 2)
                ]
            )
        ]
        let alphaApp = AlignerApp(
            bundleIdentifier: "com.example.spacefilter.alpha",
            name: "Alpha Space App",
            category: .finder,
            processIdentifier: 50_001
        )
        let betaApp = AlignerApp(
            bundleIdentifier: "com.example.spacefilter.beta",
            name: "Beta Space App",
            category: .documentNotes,
            processIdentifier: 50_002
        )
        let fullscreenApp = AlignerApp(
            bundleIdentifier: "com.example.spacefilter.fullscreen",
            name: "Fullscreen Solo App",
            category: .generic,
            processIdentifier: 50_003
        )
        let noSpaceApp = AlignerApp(
            bundleIdentifier: "com.example.spacefilter.nospace",
            name: "No Space App",
            category: .generic,
            processIdentifier: 50_004
        )
        let windows = [
            AlignerWindow(
                id: 50_101,
                app: alphaApp,
                title: "Alpha A1 Primary",
                spaceIDs: [1]
            ),
            AlignerWindow(
                id: 50_102,
                app: alphaApp,
                title: "Alpha A1 Secondary",
                isMinimized: true,
                spaceIDs: [1]
            ),
            AlignerWindow(
                id: 50_201,
                app: betaApp,
                title: "Beta A1 Shared",
                spaceIDs: [1]
            ),
            AlignerWindow(
                id: 50_202,
                app: betaApp,
                title: "Beta A2 Only",
                spaceIDs: [2]
            ),
            AlignerWindow(
                id: 50_501,
                app: fullscreenApp,
                title: "Fullscreen B2 Solo",
                isFullscreen: true,
                spaceIDs: [5]
            ),
            AlignerWindow(
                id: 50_901,
                app: noSpaceApp,
                title: "No Space Diagnostic",
                spaceIDs: []
            )
        ]
        return QuickSwitchSnapshotLoad(
            snapshot: QuickSwitchSnapshotBuilder.snapshot(displays: displays, windows: windows),
            currentSpaceIDs: [1, 4]
        )
    }
}
C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
@@ -5,17 +5,20 @@
    public let appShelf: [QuickSwitchAppShelfItemViewModel]
    public let waterfallColumns: [QuickSwitchWaterfallColumnViewModel]
    public let initialSelection: QuickSwitchSelection?
    public let lockedSpaceID: UInt64?
    public init(
        displays: [QuickSwitchDisplayViewModel],
        appShelf: [QuickSwitchAppShelfItemViewModel],
        waterfallColumns: [QuickSwitchWaterfallColumnViewModel],
        initialSelection: QuickSwitchSelection?
        initialSelection: QuickSwitchSelection?,
        lockedSpaceID: UInt64? = nil
    ) {
        self.displays = displays
        self.appShelf = appShelf
        self.waterfallColumns = waterfallColumns
        self.initialSelection = initialSelection
        self.lockedSpaceID = lockedSpaceID
    }
    public var spaceCount: Int {
@@ -197,7 +200,8 @@
            displays: displays,
            appShelf: appShelf,
            waterfallColumns: columns,
            initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot)
            initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot),
            lockedSpaceID: nil
        )
    }
@@ -339,3 +343,148 @@
        return "\(displayLabel(for: displayIndex))\(space.index)"
    }
}
public enum QuickSwitchSpaceFilterPolicy {
    public static func projectedViewModel(
        from viewModel: QuickSwitchViewModel,
        lockedSpaceID: UInt64?,
        preferredSelection: QuickSwitchSelection? = nil
    ) -> QuickSwitchViewModel {
        guard let lockedSpaceID else {
            return replacingInitialSelection(
                in: viewModel,
                lockedSpaceID: nil,
                preferredSelection: preferredSelection
            )
        }
        var nextGlobalIndex = 0
        let columns = viewModel.waterfallColumns.compactMap { column -> QuickSwitchWaterfallColumnViewModel? in
            let windows = column.windows.filter { $0.primarySpaceID == lockedSpaceID }
            guard !windows.isEmpty else { return nil }
            let reindexedWindows = windows.enumerated().map { windowIndex, card in
                defer { nextGlobalIndex += 1 }
                return QuickSwitchWindowCardViewModel(
                    appGroupIndex: column.appGroupIndex,
                    windowIndex: windowIndex,
                    globalIndex: nextGlobalIndex,
                    window: card.window,
                    primarySpaceID: card.primarySpaceID,
                    primarySpaceLabel: card.primarySpaceLabel
                )
            }
            return QuickSwitchWaterfallColumnViewModel(
                appGroupIndex: column.appGroupIndex,
                app: column.app,
                windows: reindexedWindows
            )
        }
        let columnsByAppGroupIndex = Dictionary(uniqueKeysWithValues: columns.map { ($0.appGroupIndex, $0) })
        let appShelf = viewModel.appShelf.compactMap { item -> QuickSwitchAppShelfItemViewModel? in
            guard let column = columnsByAppGroupIndex[item.appGroupIndex] else { return nil }
            return QuickSwitchAppShelfItemViewModel(
                appGroupIndex: item.appGroupIndex,
                app: item.app,
                windowCount: column.windows.count,
                primarySpaceIDs: orderedPrimarySpaceIDs(in: column.windows),
                hasMinimizedWindows: column.windows.contains { $0.window.isMinimized },
                hasFullscreenWindows: column.windows.contains { $0.window.isFullscreen }
            )
        }
        return QuickSwitchViewModel(
            displays: viewModel.displays,
            appShelf: appShelf,
            waterfallColumns: columns,
            initialSelection: initialSelection(in: columns, preferredSelection: preferredSelection),
            lockedSpaceID: lockedSpaceID
        )
    }
    public static func singleFullscreenSelection(
        inSpaceID spaceID: UInt64,
        viewModel: QuickSwitchViewModel
    ) -> QuickSwitchSelection? {
        guard viewModel.displays
            .flatMap(\.spaces)
            .contains(where: { $0.id == spaceID && $0.type == .fullscreen })
        else {
            return nil
        }
        let cards = viewModel.waterfallColumns
            .flatMap(\.windows)
            .filter { $0.primarySpaceID == spaceID }
        guard cards.count == 1,
              let card = cards.first,
              card.window.isFullscreen
        else {
            return nil
        }
        return QuickSwitchSelection(
            appGroupIndex: card.appGroupIndex,
            windowIndex: card.windowIndex,
            windowID: card.window.id
        )
    }
    private static func replacingInitialSelection(
        in viewModel: QuickSwitchViewModel,
        lockedSpaceID: UInt64?,
        preferredSelection: QuickSwitchSelection?
    ) -> QuickSwitchViewModel {
        QuickSwitchViewModel(
            displays: viewModel.displays,
            appShelf: viewModel.appShelf,
            waterfallColumns: viewModel.waterfallColumns,
            initialSelection: initialSelection(in: viewModel.waterfallColumns, preferredSelection: preferredSelection)
                ?? viewModel.initialSelection,
            lockedSpaceID: lockedSpaceID
        )
    }
    private static func initialSelection(
        in columns: [QuickSwitchWaterfallColumnViewModel],
        preferredSelection: QuickSwitchSelection?
    ) -> QuickSwitchSelection? {
        if let preferredSelection,
           let matchingCard = columns
            .flatMap(\.windows)
            .first(where: { $0.window.id == preferredSelection.windowID }) {
            return QuickSwitchSelection(
                appGroupIndex: matchingCard.appGroupIndex,
                windowIndex: matchingCard.windowIndex,
                windowID: matchingCard.window.id
            )
        }
        guard let firstCard = columns.first?.windows.first else { return nil }
        return QuickSwitchSelection(
            appGroupIndex: firstCard.appGroupIndex,
            windowIndex: firstCard.windowIndex,
            windowID: firstCard.window.id
        )
    }
    private static func orderedPrimarySpaceIDs(in cards: [QuickSwitchWindowCardViewModel]) -> [UInt64] {
        var seen = Set<UInt64>()
        var result: [UInt64] = []
        for card in cards {
            guard let spaceID = card.primarySpaceID,
                  !seen.contains(spaceID)
            else {
                continue
            }
            seen.insert(spaceID)
            result.append(spaceID)
        }
        return result
    }
}
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -1279,6 +1279,163 @@
        XCTAssertEqual(viewModel.waterfallColumns.flatMap { $0.windows.map(\.primarySpaceLabel) }, ["01", "02"])
    }
    func testQuickSwitchSpaceFilterProjectsAppShelfAndWaterfallWithoutChangingSpaceLane() {
        let alpha = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
        let beta = AlignerApp(bundleIdentifier: "com.example.Beta", name: "Beta", category: .generic)
        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
            displays: [makeDisplay(spaceIDs: [10, 11])],
            windows: [
                AlignerWindow(id: 1, app: alpha, title: "A1", spaceIDs: [10]),
                AlignerWindow(id: 2, app: alpha, title: "A2", spaceIDs: [11]),
                AlignerWindow(id: 3, app: beta, title: "B1", spaceIDs: [11]),
                AlignerWindow(id: 4, app: beta, title: "No Space")
            ]
        )
        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
        let filtered = QuickSwitchSpaceFilterPolicy.projectedViewModel(
            from: viewModel,
            lockedSpaceID: 11
        )
        XCTAssertEqual(filtered.lockedSpaceID, 11)
        XCTAssertEqual(filtered.displays.flatMap { $0.spaces.map(\.id) }, [10, 11])
        XCTAssertEqual(filtered.displays.flatMap { $0.spaces.map(\.windowCount) }, [1, 2])
        XCTAssertEqual(filtered.appShelf.map(\.app.name), ["Alpha", "Beta"])
        XCTAssertEqual(filtered.appShelf.map(\.windowCount), [1, 1])
        XCTAssertEqual(filtered.appShelf.flatMap(\.primarySpaceIDs), [11, 11])
        XCTAssertEqual(filtered.waterfallColumns.flatMap { $0.windows.map(\.window.id) }, [2, 3])
        XCTAssertEqual(filtered.waterfallColumns.flatMap { $0.windows.map(\.windowIndex) }, [0, 0])
        XCTAssertEqual(filtered.waterfallColumns.flatMap { $0.windows.map(\.globalIndex) }, [0, 1])
        XCTAssertEqual(filtered.initialSelection, QuickSwitchSelection(appGroupIndex: 0, windowIndex: 0, windowID: 2))
    }
    func testQuickSwitchSpaceFilterKeyboardNavigationStaysInsideFilteredCollection() {
        let alpha = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
        let beta = AlignerApp(bundleIdentifier: "com.example.Beta", name: "Beta", category: .generic)
        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
            displays: [makeDisplay(spaceIDs: [10, 11])],
            windows: [
                AlignerWindow(id: 1, app: alpha, title: "Alpha A1", spaceIDs: [10]),
                AlignerWindow(id: 2, app: alpha, title: "Alpha A2", spaceIDs: [11]),
                AlignerWindow(id: 3, app: beta, title: "Beta A2", spaceIDs: [11]),
                AlignerWindow(id: 4, app: beta, title: "Beta No Space")
            ]
        )
        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
        let filtered = QuickSwitchSpaceFilterPolicy.projectedViewModel(
            from: viewModel,
            lockedSpaceID: 11
        )
        let filteredSnapshot = QuickSwitchSnapshot(
            displays: snapshot.displays,
            appGroups: filtered.waterfallColumns.map { column in
                QuickSwitchAppGroup(
                    app: column.app,
                    windows: column.windows.map {
                        QuickSwitchWindowItem(window: $0.window, primarySpaceID: $0.primarySpaceID)
                    }
                )
            }
        )
        XCTAssertEqual(filtered.initialSelection, QuickSwitchSelection(appGroupIndex: 0, windowIndex: 0, windowID: 2))
        XCTAssertEqual(
            QuickSwitchFocusPolicy.nextSelection(
                from: filtered.initialSelection!,
                in: filteredSnapshot,
                direction: .right
            ),
            QuickSwitchSelection(appGroupIndex: 1, windowIndex: 0, windowID: 3)
        )
        XCTAssertEqual(
            QuickSwitchFocusPolicy.nextSelection(
                from: QuickSwitchSelection(appGroupIndex: 1, windowIndex: 0, windowID: 3),
                in: filteredSnapshot,
                direction: .right
            ),
            QuickSwitchSelection(appGroupIndex: 1, windowIndex: 0, windowID: 3)
        )
        XCTAssertFalse(filteredSnapshot.appGroups.flatMap(\.windows).contains { $0.window.id == 1 || $0.window.id == 4 })
    }
    func testQuickSwitchSpaceFilterCanLockEmptySpaceWithoutChangingSpaceLane() {
        let app = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
            displays: [makeDisplay(spaceIDs: [10, 11])],
            windows: [
                AlignerWindow(id: 1, app: app, title: "A1", spaceIDs: [10])
            ]
        )
        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
        let filtered = QuickSwitchSpaceFilterPolicy.projectedViewModel(
            from: viewModel,
            lockedSpaceID: 11
        )
        XCTAssertEqual(filtered.lockedSpaceID, 11)
        XCTAssertEqual(filtered.displays.flatMap { $0.spaces.map(\.id) }, [10, 11])
        XCTAssertTrue(filtered.appShelf.isEmpty)
        XCTAssertTrue(filtered.waterfallColumns.isEmpty)
        XCTAssertNil(filtered.initialSelection)
    }
    func testQuickSwitchSpaceFilterRestoresPreferredSelectionWhenUnlocking() {
        let app = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
            displays: [makeDisplay(spaceIDs: [10, 11])],
            windows: [
                AlignerWindow(id: 1, app: app, title: "A1", spaceIDs: [10]),
                AlignerWindow(id: 2, app: app, title: "A2", spaceIDs: [11])
            ]
        )
        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
        let preferred = QuickSwitchSelection(appGroupIndex: 0, windowIndex: 1, windowID: 2)
        let unlocked = QuickSwitchSpaceFilterPolicy.projectedViewModel(
            from: viewModel,
            lockedSpaceID: nil,
            preferredSelection: preferred
        )
        XCTAssertNil(unlocked.lockedSpaceID)
        XCTAssertEqual(unlocked.initialSelection, preferred)
    }
    func testQuickSwitchSpaceFilterDetectsOnlySingleFullscreenWindowSpacesAsDirectActivation() {
        let alpha = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
        let beta = AlignerApp(bundleIdentifier: "com.example.Beta", name: "Beta", category: .generic)
        let displayUUID = "display-a"
        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
            displays: [
                AlignerDisplay(
                    uuid: displayUUID,
                    physical: true,
                    spaces: [
                        AlignerSpace(id: 10, type: .user, displayUUID: displayUUID, index: 1),
                        AlignerSpace(id: 11, type: .fullscreen, displayUUID: displayUUID, index: 2),
                        AlignerSpace(id: 12, type: .fullscreen, displayUUID: displayUUID, index: 3)
                    ]
                )
            ],
            windows: [
                AlignerWindow(id: 1, app: alpha, title: "Normal", spaceIDs: [10]),
                AlignerWindow(id: 2, app: alpha, title: "Fullscreen", isFullscreen: true, spaceIDs: [11]),
                AlignerWindow(id: 3, app: beta, title: "Split A", isFullscreen: true, spaceIDs: [12]),
                AlignerWindow(id: 4, app: beta, title: "Split B", isFullscreen: true, spaceIDs: [12])
            ]
        )
        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
        XCTAssertEqual(
            QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID: 11, viewModel: viewModel),
            QuickSwitchSelection(appGroupIndex: 0, windowIndex: 1, windowID: 2)
        )
        XCTAssertNil(QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID: 10, viewModel: viewModel))
        XCTAssertNil(QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID: 12, viewModel: viewModel))
    }
    func testQuickSwitchFocusPolicySelectsFirstStableWindowInitially() {
        let app = makeApp()
        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
C3.tools/round1-space-filter-fixture-qa.sh
New file
@@ -0,0 +1,436 @@
#!/bin/bash
# Round01.1 Space filter fixture QA. It verifies click-to-lock filtering,
# unlock, switching locked Spaces, empty Space state, and single-fullscreen
# Space direct activation.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=build-output-paths.sh
source "$SCRIPT_DIR/build-output-paths.sh"
APP="$BUILD_CURRENT_APP"
REPORT_WAIT="${ALIGNER_ROUND1_SPACE_FILTER_REPORT_WAIT:-6.0}"
REPORT_PREFIX="$BUILD_REPORT_ROOT/round01-space-filter"
LOCK_DIR="$BUILD_LOCK_ROOT/round1-space-filter-fixture-qa.lock"
APP_PID=""
LOCK_HELD=0
fail() {
  echo "Round01.1 Space filter fixture QA failed: $*" >&2
  exit 1
}
acquire_lock() {
  if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    fail "another round1-space-filter-fixture-qa.sh appears to be running; run packaging QA scripts serially"
  fi
  LOCK_HELD=1
}
release_lock() {
  if [ "$LOCK_HELD" -eq 1 ]; then
    rmdir "$LOCK_DIR" 2>/dev/null || true
    LOCK_HELD=0
  fi
}
aligner_pids_for_current_app() {
  ps -axo pid=,args= | while read -r pid command; do
    case "$command" in
      "$APP/Contents/MacOS/Aligner"*) echo "$pid" ;;
    esac
  done
}
stop_current_aligner() {
  for pid in $(aligner_pids_for_current_app); do
    kill "$pid" 2>/dev/null || true
  done
  for _ in {1..30}; do
    [ -z "$(aligner_pids_for_current_app)" ] && return
    sleep 0.1
  done
  fail "current Aligner app did not exit before QA"
}
cleanup() {
  if [ -n "${APP_PID:-}" ]; then
    kill "$APP_PID" 2>/dev/null || true
    wait "$APP_PID" 2>/dev/null || true
    APP_PID=""
  fi
  stop_current_aligner
  release_lock
}
wait_for_visible_report() {
  local report="$1"
  local expected_last_mouse="$2"
  /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" <<'PY'
import json
import sys
import time
path = sys.argv[1]
timeout = float(sys.argv[2])
expected_last_mouse = sys.argv[3]
deadline = time.monotonic() + timeout
last_report = None
while time.monotonic() < deadline:
    try:
        with open(path, "r", encoding="utf-8") as file:
            report = json.load(file)
        last_report = report
        root = report.get("rootView", {})
        if (
            report.get("snapshotLoaded") is True
            and report.get("quickSwitchVisible") is True
            and root.get("lastMouseCommand") == expected_last_mouse
        ):
            sys.exit(0)
    except FileNotFoundError:
        pass
    except json.JSONDecodeError:
        pass
    time.sleep(0.2)
if last_report is not None:
    print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
print(f"visible report did not reach mouse command {expected_last_mouse!r} within {timeout:.1f}s", file=sys.stderr)
sys.exit(1)
PY
}
wait_for_activation_report() {
  local report="$1"
  local expected_last_mouse="$2"
  /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" <<'PY'
import json
import sys
import time
path = sys.argv[1]
timeout = float(sys.argv[2])
expected_last_mouse = sys.argv[3]
deadline = time.monotonic() + timeout
last_report = None
while time.monotonic() < deadline:
    try:
        with open(path, "r", encoding="utf-8") as file:
            report = json.load(file)
        last_report = report
        root = report.get("rootView", {})
        if (
            report.get("snapshotLoaded") is True
            and report.get("quickSwitchVisible") is False
            and report.get("lastSpaceFilterAction") == "activateSingleFullscreen"
            and report.get("lastActivationResult") is not None
            and root.get("lastMouseCommand") == expected_last_mouse
        ):
            sys.exit(0)
    except FileNotFoundError:
        pass
    except json.JSONDecodeError:
        pass
    time.sleep(0.2)
if last_report is not None:
    print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
print(f"activation report did not reach mouse command {expected_last_mouse!r} within {timeout:.1f}s", file=sys.stderr)
sys.exit(1)
PY
}
run_report() {
  local report="$1"
  local sequence="$2"
  local expected_last_mouse="$3"
  local quick_switch_expectation="${4:-visible}"
  rm -f "$report"
  "$APP/Contents/MacOS/Aligner" \
    --round0-skip-permissions \
    --round01-open-quick-switch \
    --round01-fixture-space-filter \
    --round01-disable-screenshot-refresh \
    --round01-debug-window-activation \
    --round01-debug-mouse-sequence="$sequence" \
    --round01-quick-switch-report="$report" &
  APP_PID=$!
  if [ "$quick_switch_expectation" = "hidden" ]; then
    wait_for_activation_report "$report" "$expected_last_mouse"
    swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
  elif [ "$quick_switch_expectation" = "visible" ]; then
    wait_for_visible_report "$report" "$expected_last_mouse"
    swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
  else
    fail "unknown quick switch expectation: $quick_switch_expectation"
  fi
  kill "$APP_PID" 2>/dev/null || true
  wait "$APP_PID" 2>/dev/null || true
  APP_PID=""
  sleep 0.3
  stop_current_aligner
}
assert_lock_a1_report() {
  /usr/bin/python3 - "$1" <<'PY'
import json
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
def require(condition, message):
    if not condition:
        print(message, file=sys.stderr)
        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
root = report.get("rootView", {})
items = root.get("appShelfItems", [])
columns = root.get("waterfallColumns", [])
segments = root.get("spaceLaneSegments", [])
cards = [card for column in columns for card in column.get("cards", [])]
segment_by_id = {segment.get("spaceID"): segment for segment in segments}
require(report.get("snapshotLoaded") is True, "snapshot must load")
require(report.get("quickSwitchVisible") is True, "Quick Switch must stay visible after filtering")
require(report.get("spaceFilterActive") is True, "session filter must be active")
require(report.get("spaceFilterLockedSpaceID") == 1, "session locked Space must be A1")
require(report.get("lastSpaceFilterAction") == "lock", "first click must lock")
require(root.get("spaceFilterActive") is True, "root filter must be active")
require(root.get("spaceFilterLockedSpaceID") == 1, "root locked Space must be A1")
require(report.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "App Shelf must only include A1 apps")
require(root.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "root App Shelf must only include A1 apps")
require(root.get("waterfallColumnNames") == ["Alpha Space App", "Beta Space App"], "Waterfall must only include A1 columns")
require(report.get("windowCount") == 3, "A1 must expose exactly three windows")
require(len(cards) == 3, "A1 card count must be three")
require(all(card.get("primarySpaceID") == 1 for card in cards), "every visible card must belong to A1")
require(50901 not in [card.get("windowID") for card in cards], "no-space window must not enter A1 filter")
require(root.get("selectedWindowID") == 50101, "lock should select first filtered window")
require(root.get("spaceLaneLabels") == ["A1", "A2", "A3", "B1", "B2"], "Space Lane must keep all Spaces")
require(len(segments) == 5, "Space Lane must keep all five fixture Spaces")
require(segment_by_id[1].get("windowCount") == 3, "A1 Space Lane global count must remain three")
require(segment_by_id[2].get("windowCount") == 1, "A2 Space Lane global count must remain one")
require(segment_by_id[3].get("windowCount") == 0, "A3 must remain visible as empty Space")
require(segment_by_id[4].get("windowCount") == 0, "B1 must remain visible as empty Space")
require(segment_by_id[5].get("windowCount") == 1, "B2 fullscreen Space must remain visible")
require("locked" in segment_by_id[1].get("visualStates", []), "locked Space must expose locked visual state")
require("locked" not in segment_by_id[5].get("visualStates", []), "single fullscreen Space must not be marked locked")
require(root.get("projectionTransitionFadeOutLayerCount", 0) >= 1, "filter transition must fade out removed visual layers")
require(1 <= root.get("projectionTransitionDurationMilliseconds", 0) <= 220, "filter transition duration must stay within P0 native-feel budget")
print(json.dumps({
    "case": "lock-a1",
    "apps": root.get("appShelfNames"),
    "columns": root.get("waterfallColumnNames"),
    "cardWindowIDs": [card.get("windowID") for card in cards],
    "lockedSpace": root.get("spaceFilterLockedSpaceID"),
    "fadeOutLayerCount": root.get("projectionTransitionFadeOutLayerCount"),
    "transitionDurationMilliseconds": root.get("projectionTransitionDurationMilliseconds")
}, indent=2, ensure_ascii=False))
PY
}
assert_switch_a2_report() {
  /usr/bin/python3 - "$1" <<'PY'
import json
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
def require(condition, message):
    if not condition:
        print(message, file=sys.stderr)
        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
cards = [card for column in columns for card in column.get("cards", [])]
require(report.get("spaceFilterActive") is True, "filter must remain active after switching Space")
require(report.get("spaceFilterLockedSpaceID") == 2, "locked Space must switch to A2")
require(report.get("lastSpaceFilterAction") == "switch", "second different Space click must switch")
require(root.get("appShelfNames") == ["Beta Space App"], "A2 should only show Beta app")
require(root.get("waterfallColumnNames") == ["Beta Space App"], "A2 should only show Beta column")
require(len(cards) == 1 and cards[0].get("windowID") == 50202, "A2 should only show the Beta A2 window")
require(cards[0].get("primarySpaceID") == 2, "A2 card must belong to Space 2")
require(root.get("selectedWindowID") == 50202, "switching lock should select first A2 window")
print(json.dumps({
    "case": "switch-a2",
    "apps": root.get("appShelfNames"),
    "cardWindowIDs": [card.get("windowID") for card in cards],
    "lockedSpace": root.get("spaceFilterLockedSpaceID")
}, indent=2, ensure_ascii=False))
PY
}
assert_unlock_report() {
  /usr/bin/python3 - "$1" <<'PY'
import json
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
def require(condition, message):
    if not condition:
        print(message, file=sys.stderr)
        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
root = report.get("rootView", {})
cards = [card for column in root.get("waterfallColumns", []) for card in column.get("cards", [])]
require(report.get("spaceFilterActive") is False, "same Space click must unlock session filter")
require(root.get("spaceFilterActive") is False, "same Space click must unlock root filter")
require(report.get("lastSpaceFilterAction") == "unlock", "same Space click must record unlock")
require(report.get("appShelfNames") == ["Alpha Space App", "Beta Space App", "Fullscreen Solo App", "No Space App"], "unlock must restore all fixture apps")
require(report.get("windowCount") == 6, "unlock must restore all six fixture windows")
require(50901 in [card.get("windowID") for card in cards], "no-space window should be visible again after unlock")
require(root.get("selectedWindowID") == 50101, "unlock should restore pre-filter selected window")
print(json.dumps({
    "case": "unlock-a1",
    "apps": report.get("appShelfNames"),
    "windowCount": report.get("windowCount"),
    "selectedWindowID": root.get("selectedWindowID")
}, indent=2, ensure_ascii=False))
PY
}
assert_empty_a3_report() {
  /usr/bin/python3 - "$1" <<'PY'
import json
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
def require(condition, message):
    if not condition:
        print(message, file=sys.stderr)
        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
root = report.get("rootView", {})
segments = root.get("spaceLaneSegments", [])
segment_by_id = {segment.get("spaceID"): segment for segment in segments}
require(report.get("spaceFilterActive") is True, "empty Space click must still lock filter")
require(report.get("spaceFilterLockedSpaceID") == 3, "empty Space lock must target A3")
require(report.get("lastSpaceFilterAction") == "lock", "empty Space first click must record lock")
require(report.get("appCount") == 0, "empty Space must show no App Shelf items")
require(report.get("columnCount") == 0, "empty Space must show no Waterfall columns")
require(report.get("windowCount") == 0, "empty Space must show no windows")
require(root.get("appShelfItemCount") == 0, "root App Shelf must be empty")
require(root.get("waterfallColumnCount") == 0, "root Waterfall must be empty")
require(root.get("spaceLaneLabels") == ["A1", "A2", "A3", "B1", "B2"], "Space Lane must stay global while empty Space is locked")
require("locked" in segment_by_id[3].get("visualStates", []), "empty locked Space must expose locked visual state")
require(segment_by_id[3].get("backgroundKind") == "clear", "empty locked Space must not use gray/gradient fill")
require(segment_by_id[4].get("backgroundKind") == "clear", "empty sibling Space must not inherit gray/gradient fill")
print(json.dumps({
    "case": "empty-a3",
    "lockedSpace": report.get("spaceFilterLockedSpaceID"),
    "appCount": report.get("appCount"),
    "columnCount": report.get("columnCount"),
    "a3BackgroundKind": segment_by_id[3].get("backgroundKind"),
    "b1BackgroundKind": segment_by_id[4].get("backgroundKind")
}, indent=2, ensure_ascii=False))
PY
}
assert_single_fullscreen_report() {
  local report="$1"
  local expected_previous_action="$2"
  /usr/bin/python3 - "$report" "$expected_previous_action" <<'PY'
import json
import sys
path = sys.argv[1]
expected_previous_action = sys.argv[2]
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
def require(condition, message):
    if not condition:
        print(message, file=sys.stderr)
        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
root = report.get("rootView", {})
require(report.get("quickSwitchVisible") is False, "single fullscreen Space click must close Quick Switch")
require(report.get("spaceFilterActive") is False, "single fullscreen Space must not keep filter active")
require(root.get("spaceFilterActive") is False, "root must show no filter after direct activation")
require(report.get("lastSpaceFilterAction") == "activateSingleFullscreen", "single fullscreen Space must use direct activation action")
require(report.get("lastCommittedWindowID") == 50501, "direct activation must commit fullscreen window")
require(report.get("lastActivationWindowID") == 50501, "direct activation target must be fullscreen window")
require(report.get("lastActivationResult") == "activated", "debug activation should activate fullscreen window")
require(report.get("lastCommitSource") == "mouse", "direct fullscreen activation must be mouse sourced")
require(root.get("lastMouseCommand") == "click-space:5", "last mouse command must target B2")
print(json.dumps({
    "case": expected_previous_action,
    "quickSwitchVisible": report.get("quickSwitchVisible"),
    "lastSpaceFilterAction": report.get("lastSpaceFilterAction"),
    "lastCommittedWindowID": report.get("lastCommittedWindowID"),
    "lastActivationResult": report.get("lastActivationResult")
}, indent=2, ensure_ascii=False))
PY
}
acquire_lock
trap cleanup EXIT
stop_current_aligner
"$SCRIPT_DIR/package-app.sh" >&2
LOCK_A1_REPORT="$REPORT_PREFIX-lock-a1-report.json"
SWITCH_A2_REPORT="$REPORT_PREFIX-switch-a2-report.json"
UNLOCK_REPORT="$REPORT_PREFIX-unlock-report.json"
EMPTY_A3_REPORT="$REPORT_PREFIX-empty-a3-report.json"
FULLSCREEN_REPORT="$REPORT_PREFIX-fullscreen-report.json"
LOCK_THEN_FULLSCREEN_REPORT="$REPORT_PREFIX-lock-then-fullscreen-report.json"
run_report "$LOCK_A1_REPORT" "click-space:1" "click-space:1" "visible"
assert_lock_a1_report "$LOCK_A1_REPORT"
run_report "$SWITCH_A2_REPORT" "click-space:1,click-space:2" "click-space:2" "visible"
assert_switch_a2_report "$SWITCH_A2_REPORT"
run_report "$UNLOCK_REPORT" "click-space:1,click-space:1" "click-space:1" "visible"
assert_unlock_report "$UNLOCK_REPORT"
run_report "$EMPTY_A3_REPORT" "click-space:3" "click-space:3" "visible"
assert_empty_a3_report "$EMPTY_A3_REPORT"
run_report "$FULLSCREEN_REPORT" "click-space:5" "click-space:5" "hidden"
assert_single_fullscreen_report "$FULLSCREEN_REPORT" "single-fullscreen"
run_report "$LOCK_THEN_FULLSCREEN_REPORT" "click-space:1,click-space:5" "click-space:5" "hidden"
assert_single_fullscreen_report "$LOCK_THEN_FULLSCREEN_REPORT" "lock-then-single-fullscreen"
trap - EXIT
cleanup
echo "Round01.1 Space filter fixture QA passed"
C3.tools/round1-three-zone-linkage-fixture-qa.sh
@@ -295,7 +295,7 @@
PY
}
assert_space_click_report() {
assert_space_click_filter_report() {
  /usr/bin/python3 - "$1" <<'PY'
import json
import sys
@@ -314,30 +314,33 @@
root = report.get("rootView", {})
columns = sorted(root.get("waterfallColumns", []), key=lambda column: column.get("appGroupIndex", 0))
target_space_id = 1
first_space_card = None
for column in columns:
    cards = sorted(column.get("cards", []), key=lambda card: card.get("windowIndex", 0))
    first_space_card = next((card for card in cards if card.get("primarySpaceID") == target_space_id), None)
    if first_space_card is not None:
        break
require(first_space_card is not None, "fixture must include at least one window in Space A1")
expected_window_id = first_space_card.get("windowID")
cards = [card for column in columns for card in column.get("cards", [])]
segments = root.get("spaceLaneSegments", [])
locked_segments = [segment for segment in segments if "locked" in segment.get("visualStates", [])]
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("quickSwitchVisible") is False, "click-space must close Quick Switch after committing a window in that Space")
require(report.get("quickSwitchVisible") is True, "click-space must keep Quick Switch visible after locking Space filter")
require(report.get("spaceFilterActive") is True, "click-space must enable session Space filter")
require(report.get("spaceFilterLockedSpaceID") == target_space_id, "click-space must lock the clicked Space")
require(report.get("lastSpaceFilterAction") == "lock", "click-space must record a lock action")
require(root.get("spaceFilterActive") is True, "root report must expose active Space filter")
require(root.get("spaceFilterLockedSpaceID") == target_space_id, "root report must expose the locked Space")
require(root.get("lastSpaceLaneClickSpaceID") == target_space_id, "click-space must record the clicked Space")
require(root.get("spaceLaneFocusedSpaceID") is None, "click-space must not reuse persistent focus as its behavior")
require(root.get("lastCommittedWindowID") == expected_window_id, "click-space must commit the first window in the clicked Space")
require(report.get("lastCommittedWindowID") == expected_window_id, "session must surface the Space click commit")
require(report.get("lastActivationWindowID") == expected_window_id, "activation window must match the first window in clicked Space")
require(report.get("lastActivationResult") == "activated", "debug activation must activate the clicked Space window")
require(report.get("lastCommitSource") == "mouse", "click-space commit source must be mouse")
require(root.get("spaceLaneFocusedSpaceID") is None, "click-space must not reuse persistent hover focus as its behavior")
require(report.get("lastCommittedWindowID") is None, "ordinary click-space must not commit a window")
require(report.get("lastActivationWindowID") is None, "ordinary click-space must not activate a window")
require(report.get("lastActivationResult") is None, "ordinary click-space must not report activation")
require(report.get("lastCommitSource") is None, "ordinary click-space must not report a commit source")
require(cards, "fixture must include filtered cards for Space A1")
require(all(card.get("primarySpaceID") == target_space_id for card in cards), "click-space filter must only keep windows in clicked Space")
require(len(locked_segments) == 1, "exactly one Space Lane segment must expose locked")
require(locked_segments[0].get("spaceID") == target_space_id, "locked segment must be the clicked Space")
print(json.dumps({
    "clickedSpaceID": root.get("lastSpaceLaneClickSpaceID"),
    "lastCommittedWindowID": root.get("lastCommittedWindowID"),
    "lastActivationResult": report.get("lastActivationResult")
    "lockedSpaceID": root.get("spaceFilterLockedSpaceID"),
    "visibleCardSpaceIDs": sorted({card.get("primarySpaceID") for card in cards}),
    "quickSwitchVisible": report.get("quickSwitchVisible")
}, indent=2, ensure_ascii=False))
PY
}
@@ -702,8 +705,8 @@
run_report "$CLEAR_REPORT" "hover-space:1,hover-card:0:0" "hover-card:0:0"
assert_space_focus_clear_report "$CLEAR_REPORT"
run_report "$SPACE_CLICK_REPORT" "click-space:1" "click-space:1" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden
assert_space_click_report "$SPACE_CLICK_REPORT"
run_report "$SPACE_CLICK_REPORT" "click-space:1" "click-space:1" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" visible
assert_space_click_filter_report "$SPACE_CLICK_REPORT"
run_report "$REMOTE_REPORT" "hover-space:1,click-app:$REMOTE_APP_INDEX" "click-app:$REMOTE_APP_INDEX" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden
assert_remote_click_report "$REMOTE_REPORT"