From 0045898e8cd7b2d8972bb24c8ac27e514bdd8c70 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 11 Jun 2026 16:46:31 +0800
Subject: [PATCH] Implement Quick Switch Space filtering
---
C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift | 99 ++++
C3.tools/round1-space-filter-fixture-qa.sh | 436 ++++++++++++++++++
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 388 ++++++++++++++-
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 4
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 130 +++++
C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift | 153 ++++++
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | 157 ++++++
C3.tools/round1-three-zone-linkage-fixture-qa.sh | 45 +
9 files changed, 1,341 insertions(+), 75 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index cbb85aa..19cd854 100644
--- a/C1.source/Resources/Aligner-Info.plist
+++ b/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>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 3b968f2..b2d0fa9 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/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,
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 0d3590c..dba70d2 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/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,35 +629,73 @@
}
}
+ 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
- screenshotSourcesByWindowID = [:]
- screenshotSkippedReasonsByWindowID = [:]
- currentSelection = viewModel?.initialSelection
- columnSelectionHistory = [:]
- keyboardCommandsApplied = []
- lastKeyboardCommand = nil
- tabIgnoredCount = 0
- lastCommittedSelection = nil
- lastCommitSource = nil
- mouseCommandsApplied = []
- lastMouseCommand = nil
- lastClickedAppGroupIndex = nil
- lastAppShelfClickChangedSelection = nil
- lastClickedWindowID = nil
- lastSpaceFocusedAppGroupIndex = nil
- lastSpaceLaneClickSpaceID = nil
- lastAppShelfScrollSyncedAppGroupIndex = nil
- focusedSpaceID = nil
- hoveredCloseTarget = nil
- pendingCloseTarget = nil
- pendingCloseRequest = nil
- pendingCloseOptOut = false
- closeConfirmationLayers.containerLayer.isHidden = true
- closeConfirmationArrowEdge = nil
- clearCloseFeedback()
- setHoverTarget(.none)
- hoveredAppGroupIndex = debugHoveredAppGroupIndex
+
+ 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
+ columnSelectionHistory = [:]
+ keyboardCommandsApplied = []
+ lastKeyboardCommand = nil
+ tabIgnoredCount = 0
+ lastCommittedSelection = nil
+ lastCommitSource = nil
+ mouseCommandsApplied = []
+ lastMouseCommand = nil
+ lastClickedAppGroupIndex = nil
+ lastAppShelfClickChangedSelection = nil
+ lastClickedWindowID = nil
+ lastSpaceFocusedAppGroupIndex = nil
+ lastSpaceLaneClickSpaceID = nil
+ lastAppShelfScrollSyncedAppGroupIndex = nil
+ focusedSpaceID = nil
+ hoveredCloseTarget = nil
+ pendingCloseTarget = nil
+ pendingCloseRequest = nil
+ pendingCloseOptOut = false
+ closeConfirmationLayers.containerLayer.isHidden = true
+ closeConfirmationArrowEdge = nil
+ 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")
}
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 11aa41d..5a36765 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/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),
diff --git a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
index 8d45239..fa8d37a 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
+++ b/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]
+ )
+ }
}
diff --git a/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift b/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
index 1686294..1bec9df 100644
--- a/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
+++ b/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
+ }
+}
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index a6c3447..e583820 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/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(
diff --git a/C3.tools/round1-space-filter-fixture-qa.sh b/C3.tools/round1-space-filter-fixture-qa.sh
new file mode 100755
index 0000000..cc1b1a2
--- /dev/null
+++ b/C3.tools/round1-space-filter-fixture-qa.sh
@@ -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"
diff --git a/C3.tools/round1-three-zone-linkage-fixture-qa.sh b/C3.tools/round1-three-zone-linkage-fixture-qa.sh
index b32d5b8..8486406 100755
--- a/C3.tools/round1-three-zone-linkage-fixture-qa.sh
+++ b/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"
--
Gitblit v1.9.3